Skip to content

inet: choose the payload class from reassembled fragments - #5143

Open
KernelClint wants to merge 2 commits into
secdev:masterfrom
KernelClint:inet-payload-class-from-reassembled
Open

inet: choose the payload class from reassembled fragments#5143
KernelClint wants to merge 2 commits into
secdev:masterfrom
KernelClint:inet-payload-class-from-reassembled

Conversation

@KernelClint

Copy link
Copy Markdown
Contributor

_defrag_ip_pkt() puts a fragmented IPv4 datagram back together and then has to decide what the
reassembled bytes are. At
scapy/layers/inet.py:1467-1505
it reuses the class Scapy guessed for the first fragment's payload:

pay_class = p[IP].payload.__class__

The first fragment is often too short to classify correctly — with a small enough first fragment
Scapy has not seen a complete TCP header, so the guess is whatever it managed from those bytes. The
complete datagram is then forced into that class regardless of what the assembled bytes actually
are. Through TCPSession, an HTTP response that arrived in valid ordered fragments comes back
without its application layer, while the byte-identical unfragmented response comes back correctly.

The change asks the reassembled IP layer to classify the complete bytes, the same way ordinary
dissection does:

-            pay_class = p[IP].payload.__class__
+            pay_class = p[IP].guess_payload_class(data)
...
-            p /= pay_class(data)
+            p[IP].add_payload(pay_class(data))

Every valid fragment layout is still reassembled; what changes is that the result is classified from
what was actually received.

The added regression reassembles the same HTTP response at two fragment sizes, including one whose
first fragment is too small to classify, and asserts the response body is delivered in both. Without
the source change it fails.

Performance was measured on one computer, before and after the fix: reassembling a valid ordered
datagram took 232.0 µs before and 176.3 µs after — 24% faster. Repeat runs moved by less than
that, so it is a real difference. Dispatching normally avoids the work the old path spent forcing
bytes into a class that could not parse them.

@codecov

codecov Bot commented Sep 1, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.50000% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 80.81%. Comparing base (b3bbcc8) to head (5406515).
⚠️ Report is 22 commits behind head on master.

Files with missing lines Patch % Lines
scapy/layers/inet.py 87.50% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #5143      +/-   ##
==========================================
+ Coverage   80.63%   80.81%   +0.18%     
==========================================
  Files         390      390              
  Lines       96936    96972      +36     
==========================================
+ Hits        78168    78372     +204     
+ Misses      18768    18600     -168     
Files with missing lines Coverage Δ
scapy/layers/inet.py 71.98% <87.50%> (+0.04%) ⬆️

... and 22 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@gpotter2

gpotter2 commented Sep 3, 2026

Copy link
Copy Markdown
Member

The first fragment is often too short to classify correctly — with a small enough first fragment
Scapy has not seen a complete TCP header, so the guess is whatever it managed from those bytes.

Can you give a real world example of that? (not something too artificial)

@KernelClint

Copy link
Copy Markdown
Contributor Author

Scapy's own fragment() produces one. Send the fragments through bytes, as a capture does, and defragment() cannot reassemble them below a fragment size of 24:

>>> pkt = IP(id=1, src="10.0.0.1", dst="10.0.0.2")/TCP(sport=1234, dport=80)/Raw(b"B"*100)
>>> for size in (8, 16, 24, 40):
...     print(size, TCP in defragment([IP(raw(f)) for f in fragment(pkt, size)])[0])
8 False
16 False
24 True
40 True

The threshold is 20 bytes. The first fragment has to carry the fixed TCP header before Scapy can read it as TCP, and truncated options are tolerated, so a 40-byte header behaves the same. 24 is the smallest fragment size that is both a multiple of 8 and at least 20. Below it the first fragment comes back as Raw, and Raw is then what the whole reassembled payload gets.

The other is the tiny-fragment attack in RFC 1858. The sender picks a fragment size small enough to push the TCP flags into the second fragment, so a filter matching on those flags cannot see them. Reading traffic like that is a reason to reach for Scapy, and today the reassembled packet has no TCP layer at all.

@KernelClint

Copy link
Copy Markdown
Contributor Author

I have pushed a second commit. Taking the class from proto means the reassembled bytes might not dissect — two fragments adding up to less than a TCP header, for one — and that raised out of defragment(). Under IPSession it closed the socket and ended the capture. It now falls back to Raw the way do_dissect_payload does, and still raises under conf.debug_dissector.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants