Filtering Illegal TCP Flag Combinations at the Ingestion Edge
Flow telemetry describing packets with impossible TCP flag combinations is a reliable fingerprint of reconnaissance and evasion — and a reliable way to poison a fault-correlation platform. A SYN+FIN packet cannot occur in a legitimate handshake; a null packet with no flags set and an Xmas packet with FIN, PSH, and URG all lit are classic stealth-scan probes crafted to slip past stateless filters. When flow records carrying these combinations reach correlation unfiltered, they inflate connection-anomaly baselines, generate false interface-error incidents, and bury the genuine scan signal under noise the NOC then has to triage. That is a direct hit to mean time to resolution (MTTR): the platform spends cycles on manufactured anomalies while a real reconnaissance sweep proceeds unremarked. Filtering illegal flag combinations at the ingestion edge — with a cheap bitmask check before anything reaches correlation — converts that noise into a deterministic, alertable rejection.
Schema Alignment and Taxonomy Anchor
This reference is a security-perimeter application of the routing layer described in Security Boundary Mapping, the parent stage that assigns each event a trust zone before dispatch. That parent stage assumes the events it routes are already well-formed at the field level; this page owns the narrower job of proving a flow record’s tcp_flags byte is physically possible before the record is allowed to influence correlation. A packet that could never exist on the wire must never seed an incident.
The bitmask logic here is the standalone, hardened version of the single SYN+FIN guard shown inline in Validating NetFlow Events with Pydantic, which also rejects the SYN+FIN combination as part of its broader flow schema under Event Schema Design. This page extends that single check into a full illegal-combination filter — null, Xmas, SYN+FIN, and SYN+RST — while keeping the same reject-at-the-edge posture, so a validated flow record carries the same guarantees whether it enters through the NetFlow validator or this dedicated flag gate.
Production Code: Bitmask Validation
The filter encodes each illegal pattern as a mask-and-match rule over the 8-bit flags field. A null packet is flags == 0; SYN+FIN is flags & (SYN | FIN) == (SYN | FIN); Xmas is FIN, PSH, and URG all set. Every check is a single integer operation, so the whole gate is O(1) per record. The flags field is modeled with Pydantic V2 (ConfigDict, field_validator classmethods) so an out-of-range byte is rejected before the pattern logic runs. Note the ECN bits (ECE, CWR) are never treated as illegal — they are legitimate and must not be filtered.
import asyncio
import logging
from pydantic import BaseModel, ConfigDict, Field, field_validator
logger = logging.getLogger("tcp_flag_guard")
# Single-bit masks for the low six TCP control flags.
FIN, SYN, RST, PSH, ACK, URG = 0x01, 0x02, 0x04, 0x08, 0x10, 0x20
# ECE (0x40) and CWR (0x80) are valid ECN signalling — intentionally not flagged.
def classify_illegal(flags: int) -> str | None:
"""Return the name of the illegal pattern, or None if the flags are legal."""
if flags == 0:
return "null_scan" # no flags set at all
if (flags & (SYN | FIN)) == (SYN | FIN):
return "syn_fin" # connect and close at once
if (flags & (SYN | RST)) == (SYN | RST):
return "syn_rst" # open and reset at once
if (flags & (FIN | PSH | URG)) == (FIN | PSH | URG):
return "xmas_scan" # tree fully lit
return None
class FlowFlags(BaseModel):
model_config = ConfigDict(strict=True, frozen=True, extra="forbid")
event_id: str
src_ip: str
dst_ip: str
tcp_flags: int = Field(ge=0, le=255) # a single unsigned byte
timestamp_ns: int = Field(gt=0)
@field_validator("tcp_flags")
@classmethod
def _byte_range(cls, v: int) -> int:
# Anything outside 0..255 is a truncated or corrupt template field.
if not 0 <= v <= 255:
raise ValueError(f"tcp_flags out of byte range: {v}")
return v
class FlagGuard:
def __init__(self, dlq: asyncio.Queue) -> None:
self._dlq = dlq
async def validate(self, rec: FlowFlags) -> FlowFlags | None:
pattern = classify_illegal(rec.tcp_flags)
if pattern is not None:
payload = {"event_id": rec.event_id, "src_ip": rec.src_ip,
"dst_ip": rec.dst_ip, "tcp_flags": f"{rec.tcp_flags:#04x}",
"pattern": pattern}
await self._dlq.put(payload)
logger.warning("illegal TCP flags rejected", extra=payload)
return None
return rec # legal — safe to hand downstreamEncoding the flags in hex on rejection (0x03 for SYN+FIN) keeps the dead-letter record readable in a NOC dashboard without re-deriving the bits by hand. Because the ECN bits are deliberately excluded from every mask, a modern host negotiating explicit congestion notification is never mistaken for a scanner — a common false-positive trap in naive filters.
Async Ingestion Hook
The classifier is pure integer arithmetic, so it slots into the async pipeline as a coroutine draining a batch and forwarding only legal records. Rejected records divert to the dead-letter queue without blocking the consumer.
import asyncio
from pydantic import ValidationError
async def flag_worker(in_q: asyncio.Queue, out_q: asyncio.Queue,
guard: FlagGuard) -> None:
while True:
raw = await in_q.get()
try:
rec = FlowFlags.model_validate(raw)
except ValidationError as exc:
await guard._dlq.put({"raw": raw, "errors": exc.errors(include_url=False)})
in_q.task_done()
continue
legal = await guard.validate(rec)
if legal is not None:
await out_q.put(legal) # backpressure when correlation is busy
in_q.task_done()The only awaited operations are queue puts and gets, so a busy correlation stage throttles this worker through backpressure rather than causing dropped telemetry — the same non-blocking discipline used across the Security Boundary Mapping stage.
Mitigation and Hardening
When a record carries impossible flags, the system rejects it deterministically rather than trying to interpret it. These are the concrete failure paths to implement:
- Dead-letter isolation with the pattern name. Every rejected record carries its illegal-pattern label (
null_scan,syn_fin,syn_rst,xmas_scan), the hex flags, and the source and destination addresses. Alert when the illegal-flag rate exceeds 0.5% over a five-minute window — a spike concentrated on one source is a reconnaissance sweep, while a spike spread across many is more likely a broken exporter template. - Baseline protection. Illegal-flag records never reach connection-anomaly baselines, so a scan cannot teach the detector that SYN+FIN traffic is normal. This is the same posture that keeps spoofed origins out of routing baselines in Detecting Spoofed ASN in BGP Telemetry, applied at the transport layer.
- Reconnaissance signal preservation. Rejection is not the same as ignoring. The dead-letter stream of illegal-flag records is itself a high-value security feed — aggregated per source it names likely scanners — so route it to the security review lane rather than discarding it, exactly as Mitigating DDoS Telemetry Poisoning preserves signal while shielding the correlation engine.
- ECN safety. Never fold the ECE or CWR bits into an illegal mask. Treating congestion-notification traffic as a scan produces a steady stream of false rejections against perfectly healthy modern hosts, so the masks target only the low six control bits.
Operational Hardening Notes
Keep the per-record cost flat under storm load. Each classification is at most four integer AND-and-compare operations, so the classifier adds negligible latency even at line rate; the only structure that grows is the dead-letter queue, which is bounded and drained asynchronously. Validate at the consumer-batch level (500–1000 records per poll) so the event loop yields between batches rather than between records, and keep the classifier free of any per-packet payload inspection — the flags byte alone decides the verdict. Flag-guard workers are stateless and horizontally scalable, so a node under memory pressure fails over without dropping telemetry, holding the same sub-2-second SLA ticket-creation budget as the rest of the taxonomy.
Frequently Asked Questions
Why is SYN plus FIN illegal when the individual flags are normal?
SYN opens a connection and FIN closes it, so setting both in one segment asks to open and close simultaneously, which never happens in a legitimate handshake. Stack implementations differ in how they respond, so scanners send SYN plus FIN to probe hosts while evading filters that only inspect a lone SYN. A cheap bitmask check catches every one of these at the edge.
What exactly are null and Xmas packets?
A null packet has no TCP flags set at all, and an Xmas packet has FIN, PSH, and URG all set so the flags field looks lit up like a tree. Both are crafted probes that no normal connection produces, used to elicit distinguishing responses from a target stack. Filtering flags equal to zero and the FIN plus PSH plus URG combination rejects them without any payload inspection.
Does filtering these flags ever reject legitimate traffic?
Not if the masks target only the low six control bits. The two high bits, ECE and CWR, carry explicit congestion notification and are perfectly valid, so they are deliberately excluded from every illegal pattern. A filter that folded the ECN bits into a mask would produce a steady stream of false rejections against healthy modern hosts.
Should rejected records just be discarded?
No, route them to a dead-letter and security review lane. The stream of illegal-flag records is a high-value reconnaissance signal: aggregated per source it names likely scanners. Discarding it throws away that intelligence, so the records are kept out of correlation baselines but preserved as a measurable, alertable security feed.
Related
- Up to: Security Boundary Mapping — the routing layer this edge filter protects
- Detecting Spoofed ASN in BGP Telemetry — a sibling edge filter for spoofed routing origins
- Mitigating DDoS Telemetry Poisoning — preserving signal while shielding correlation from floods
- Validating NetFlow Events with Pydantic — the flow schema whose inline SYN plus FIN guard this page extends
- Event Schema Design — the canonical event contract these fields conform to