Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion pcapkit/toolkit/scapy.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,12 @@ def ipv4_reassembly(packet: 'Packet', *, count: 'int' = -1) -> 'IP_Packet[IPv4Ad
Enum_TransType.get(ipv4.proto), # payload protocol type
),
num=count, # original packet range number
fo=ipv4.frag, # fragment offset
# NOTE: Scapy reports ``IP.frag`` in on-wire 8-octet units
# (:rfc:`791#section-3.1`), but the reassembly machinery indexes the
# datagram buffer with ``fo`` in octets, so it must be scaled -- the
# same scaling this module's own IPv6 path already applies to
# ``IPv6ExtHdrFragment.offset`` below.
fo=ipv4.frag * 8, # fragment offset
ihl=ipv4.ihl * 4, # internet header length
mf=bool(ipv4.flags.MF), # more fragment flag
tl=ipv4.len, # total length, header includes
Expand Down
57 changes: 57 additions & 0 deletions tests/toolkit/test_scapy_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,11 @@ def test_ipv4_and_ipv6_reassembly(self) -> None:
self.assertEqual(reassembled.header, bytes(ipv4)[:20])
self.assertEqual(bytes(reassembled.payload), bytes(ipv4.payload))
self.assertTrue(reassembled.mf)
# Scapy's ``frag`` is in on-wire 8-octet units (:rfc:`791#section-3.1`),
# but ``fo`` indexes the reassembly datagram buffer in octets, so one
# unit must become 8 octets -- see #483.
self.assertEqual(ipv4.frag, 2)
self.assertEqual(reassembled.fo, 16)

self.assertIsNone(toolkit.ipv4_reassembly(self._make_ether_raw(), count=1))
self.assertIsNone(toolkit.ipv4_reassembly(self._make_ipv4_fragment(df=True), count=1))
Expand All @@ -165,6 +170,58 @@ def test_ipv4_and_ipv6_reassembly(self) -> None:
self.assertIsNone(toolkit.ipv6_reassembly(self._make_ipv4_tcp_packet(), count=1))
self.assertIsNone(toolkit.ipv6_reassembly(self._make_ipv6_tcp_packet(), count=1))

def test_ipv4_reassembly_scales_fragment_offset_through_the_reassembler(self) -> None:
# Regression test for #483: an unscaled ``fo`` does not just report a
# wrong number, it makes the reassembler write the second fragment's
# payload *inside* the first fragment's span instead of after it -- so
# the defect has to be shown through an actual reassembly, not by
# asserting on ``fo`` in isolation (a unit fix could get that right
# while some other adapter/consumer mismatch still corrupted the
# datagram, and a suite total alone cannot tell the two apart).
from scapy.layers.inet import IP
from scapy.layers.l2 import Ether
from scapy.packet import Raw

from pcapkit.foundation.reassembly.ipv4 import IPv4
from pcapkit.toolkit import scapy as toolkit

# Fragment 1: offset 0, 40 octets of payload -- a multiple of 8, so
# fragment 2's on-wire ``frag=5`` is meant to land at byte offset 40
# (5 * 8), immediately after fragment 1's data.
frag1 = Ether(**self._ether_kwargs()) / \
IP(src='192.0.2.1', dst='198.51.100.1', id=1234, flags='MF', frag=0) / \
Raw(b'A' * 40)
frag1 = Ether(bytes(frag1))

# Fragment 2: final fragment, on-wire ``frag=5`` -> byte offset 40.
frag2 = Ether(**self._ether_kwargs()) / \
IP(src='192.0.2.1', dst='198.51.100.1', id=1234, flags=0, frag=5) / \
Raw(b'B' * 8)
frag2 = Ether(bytes(frag2))

packet1 = toolkit.ipv4_reassembly(frag1, count=1)
packet2 = toolkit.ipv4_reassembly(frag2, count=2)
assert packet1 is not None and packet2 is not None
self.assertEqual(packet1.fo, 0)
# this is the assertion that fails without the ``* 8`` scaling: an
# unscaled ``fo`` reports 5, not the byte offset 40
self.assertEqual(packet2.fo, 40)

reasm = IPv4()
reasm(packet1)
reasm(packet2)

datagram, = reasm.datagram
# only ``Completion.COMPLETE`` is truthy
self.assertTrue(datagram.completed)
# under the defect this comes back truncated to 13 octets
# (``b'AAAAABBBBBBBB'``): fragment 2 overwrote bytes 5-12 of
# fragment 1's span instead of being appended at byte 40, and the
# datagram's declared total length is computed from the corrupted
# (unscaled) offset of the final fragment
self.assertEqual(len(datagram.payload), 48)
self.assertEqual(bytes(datagram.payload), b'A' * 40 + b'B' * 8)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking suggestion: this test proves the payload-corruption half of #483's defect (the wrong bytes at the wrong offset), but it doesn't check the other symptom that the same corruption would have produced through #482's new conflict machinery.

I traced IP.reassembly() / IP._detect_conflicts() in pcapkit/foundation/reassembly/ip.py (unchanged by this PR) against this exact fixture under the pre-fix, unscaled fo:

  • Fragment 1 (fo=0, 40-octet payload) sets RCVBT bits for blocks 0-4 (FO // 8 through FO // 8 + (TL - IHL + 7) // 8 = 0..5) and writes datagram[0:40] = b'A' * 40.
  • Fragment 2, with an unscaled fo=5 instead of the correct 40, writes at datagram[5:13]. _detect_conflicts runs before that write, over [5, 13): every one of those positions falls in an already-RCVBT-set block (0 and 1, both fully claimed by fragment 1), and tdl is still -1 at that point (fragment 2's own MF=0 update happens after), so the tdl < 0 branch of the guard is satisfied everywhere. datagram[pos] is 'A' and payload[index] is 'B' for the whole span, so this reports a single conflicting run, i.e. conflict == ((5, 12),) -- a spurious conflict manufactured entirely by the unscaled offset, on data that never actually overlapped on the wire.

So a fix that scaled fo correctly but left some other consumer of the raw value unscaled would still be caught by the existing payload-bytes assertion, but a regression that reintroduced the offset bug would show up two ways: corrupted payload bytes and a non-empty conflict tuple on otherwise non-overlapping fragments. Since this is precisely the interaction #482 and #483 have with each other, asserting self.assertEqual(datagram.conflict, ()) alongside the existing payload assertion would pin that down directly rather than leaving it to be inferred from the payload check.

Not asking for a change before merge -- the existing assertions already conclusively demonstrate the fix works -- just flagging the gap since it's exactly the interaction worth covering here.


def test_tcp_reassembly_and_traceflow(self) -> None:
from scapy.layers.inet import TCP
from scapy.packet import Raw
Expand Down
Loading