Skip to content

toolkit(scapy): scale IPv4 fragment offset to octets before reassembly - #484

Merged
JarryShaw merged 2 commits into
mainfrom
fix-483-scapy-fragment-offset-scaling
Sep 18, 2026
Merged

JarryShaw merged 2 commits into
mainfrom
fix-483-scapy-fragment-offset-scaling

Conversation

@JarryShaw

Copy link
Copy Markdown
Owner

Closes #483

What

pcapkit/toolkit/scapy.py's ipv4_reassembly passed Scapy's raw 13-bit
ipv4.frag field straight through as fo, the byte offset the reassembler
indexes its buffer with. Scapy's frag is in on-wire 8-octet units per
:rfc:791#section-3.1, not bytes — so fo came out 8x too small for every
fragment except the first.

Every other adapter already gets this right:

  • dpkt.py: fo=ipv4.offset * 8
  • pypcapfile.py: fo=ipv4.off * 8
  • pcap.py / pcapng.py: fo=ipv4_info.offset, where pcapkit's own
    ipv4.py already scaled it (offset=int(schema.flags['offset']) * 8)
  • this same file's IPv6 path, a few lines below: fo=ipv6_frag.offset * 8

Only the scapy IPv4 path was missing the * 8.

Reproduction (before the fix)

At the field level:

>>> import scapy.all as sc
>>> bytes(sc.IP(frag=5, flags='MF'))[6:8].hex()
'2005'

0x2005 decodes as flags 001 (MF) + a 13-bit offset of 5 — the raw unit
count, not a byte offset of 40.

End to end, through the public API (pcapkit.toolkit.scapy.ipv4_reassembly
into pcapkit.foundation.reassembly.ipv4.IPv4): feeding a frag=0/MF
fragment carrying 40 octets of A, then a frag=5 final fragment carrying
8 octets of B, reports fo=5 for the second fragment (expected byte
offset 40). The reassembler writes it inside the first fragment's span
instead of after it, and returns a completed datagram truncated to 13
octets: b'AAAAABBBBBBBB', instead of the full 48 octets
(b'A'*40 + b'B'*8).

After the fix (fo=ipv4.frag * 8), the second fragment reports fo=40 and
the datagram reassembles to the full 48 octets.

The fix

One line in pcapkit/toolkit/scapy.py's ipv4_reassembly:

fo=ipv4.frag * 8,                          # fragment offset

with a comment recording the 8-octet-unit fact, mirroring the comment
already present on the IPv6 path in the same file.

Tests

tests/toolkit/test_scapy_unit.py:

  • Extended the existing test_ipv4_and_ipv6_reassembly with an fo
    assertion at the field level (mirroring the IPv6 assertion already there).
  • Added test_ipv4_reassembly_scales_fragment_offset_through_the_reassembler,
    which drives the actual reassembler with two crafted fragments and asserts
    on the reassembled payload — not just on fo in isolation — since a
    fragment-offset-only check can't distinguish a real fix from one that
    merely relocates the same corruption elsewhere.

Both fail without the * 8 scaling (2 != 16 and 5 != 40 respectively)
and pass with it.

Scope

Does not touch pcapkit/foundation/reassembly/ip.py, its data model, or
tests/foundation/reassembly/test_ip.py — those belong to #482. Does not
change any other toolkit adapter; none showed the same defect.

Test plan

  • pytest tests/toolkit/test_scapy_unit.py -v — 5 passed (confirmed the
    two touched assertions fail without the fix, pass with it)
  • pytest tests/toolkit tests/foundation/reassembly tests/foundation/engines tests/interface -q — 230 passed, 15 skipped
  • pytest tests -q (full suite) — 1035 passed, 17 skipped
  • mypy --config-file mypy.ini pcapkit/toolkit/scapy.py — same 6
    pre-existing errors as on origin/main, none introduced by this change
  • scapy 2.7.0 is importable in the repo venv; scapy-dependent tests ran
    (not skipped) in this environment

#483)

`pcapkit/toolkit/scapy.py`'s `ipv4_reassembly` passed `ipv4.frag` straight
through as `fo`. Scapy reports that field in its on-wire 13-bit form --
8-octet units per RFC 791 3.1 -- but `IP.reassembly()` (`ip.py`) indexes the
datagram buffer with `fo` in octets, dividing by 8 only for the RCVBT bit
table. Every other adapter already scales: `dpkt.py` does `ipv4.offset * 8`,
`pypcapfile.py` does `ipv4.off * 8`, and `pcap.py`/`pcapng.py` pass pcapkit's
own already-scaled `ipv4.offset`. This module's own IPv6 path (`fo=
ipv6_frag.offset * 8`, a few lines down) does the same scaling scapy's IPv4
path was missing.

Reproduced before the fix, through the public API: `scapy.all.IP(frag=5,
flags='MF')` puts raw `5` on the wire (`0x2005`), and feeding a `frag=0`/MF
fragment (40 octets of `A`) followed by a `frag=5` final fragment (8 octets
of `B`) through `ipv4_reassembly` into `pcapkit.foundation.reassembly.ipv4.
IPv4` reports `fo=5` for the second fragment instead of byte offset 40. The
reassembler then writes it inside the first fragment's span and returns a
completed 13-octet datagram, `b'AAAAABBBBBBBB'`, instead of the full 48
octets. After `fo=ipv4.frag * 8`, the same input reports `fo=40` and
reassembles to `b'A'*40 + b'B'*8`.

Added a regression test to `tests/toolkit/test_scapy_unit.py`: an `fo`
assertion in the existing field-level test, plus a new test that drives the
actual reassembler end to end and asserts on the datagram payload, since a
bare `fo` check can't distinguish a real fix from one that merely relocates
the same bug. Both fail without the `* 8` and pass with it.

Does not touch `pcapkit/foundation/reassembly/ip.py` or its data model,
which PR #482 owns.
@JarryShaw
JarryShaw merged commit 0761757 into main Sep 18, 2026
22 checks passed
@JarryShaw
JarryShaw deleted the fix-483-scapy-fragment-offset-scaling branch September 18, 2026 21:00
# 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.

@JarryShaw

JarryShaw commented Sep 18, 2026 •

Copy link
Copy Markdown
Owner Author

Review summary

Reviewed at 67dad967e51a811377536ffb5d242184af6831d1 (the tip of this PR's own commit, and the merge-base with the branch's current head 5e78093e0482ab73bcd505f1c2b0e5e0e7a6811f -- the branch has since had main merged into it, but pcapkit/toolkit/scapy.py and tests/toolkit/test_scapy_unit.py are byte-identical between the two shas, so this review covers the PR's actual content either way).

What changed

pcapkit/toolkit/scapy.py's ipv4_reassembly now passes fo=ipv4.frag * 8 instead of the raw ipv4.frag, with a NOTE: comment, plus two new/extended tests in tests/toolkit/test_scapy_unit.py.

Checks performed

  1. Only one consumer of ipv4.frag in this module. grep -n frag pcapkit/toolkit/scapy.py shows exactly one use of ipv4.frag (the fixed line) and the IPv6 path's own ipv6_frag.offset * 8, which already had the scaling. No sibling was left unscaled.

  2. Verified the reassembler's octet contract by reading pcapkit/foundation/reassembly/ip.py's reassembly() directly (unchanged by this PR -- identical between fcd853032 and 67dad967e): line 134-135 indexes the datagram buffer with start = FO; stop = TL - IHL + FO directly in octets, while lines 154-156 index RCVBT with start = FO // 8; stop = FO // 8 + (TL - IHL + 7) // 8. So the buffer wants octets and only RCVBT divides by 8, confirming the fix's contract is correct, not backwards.

  3. Interaction with reassembly: record IP fragment overlap conflicts instead of losing them silently #482's conflict feature: ip.py (where conflict was added) is untouched by this PR. No existing test anywhere in tests/ encodes the old buggy offset as expected behaviour (grep -rln conflict tests/ finds only test_ip.py, test_infoclass.py, and reassembly/data/test_models.py, none of which reference scapy). The new regression test drives the real IPv4() reassembler and asserts on reassembled payload bytes (b'A' * 40 + b'B' * 8), not just on fo in isolation -- so it's a real regression test, not a tautology. I left one non-blocking inline suggestion: the test doesn't assert datagram.conflict == (), and by tracing _detect_conflicts against this exact fixture I could show the pre-fix unscaled offset would have produced a spurious conflict == ((5, 12),) on data that never actually overlapped on the wire -- worth asserting directly given it's the exact reassembly: record IP fragment overlap conflicts instead of losing them silently #482/scapy toolkit adapter passes raw 13-bit fragment offset unscaled to IP reassembly #483 interaction, but not required since the payload-bytes assertion already conclusively pins the fix.

  4. Test adequacy. test_ipv4_and_ipv6_reassembly now asserts both the raw ipv4.frag == 2 and the scaled reassembled.fo == 16, which shows the multiplication actually happened rather than restating a single number. The new test_ipv4_reassembly_scales_fragment_offset_through_the_reassembler exercises a real two-fragment reassembly and checks payload bytes. Not covered, and worth having eventually but not blocking here since the fix is a single scalar multiply independent of fragment count/order: a three-fragment datagram, out-of-order arrival, and a final fragment whose length isn't itself a multiple of 8 (a genuinely partial RCVBT tail block).

  5. scapy actually ran. Confirmed scapy 2.7.0 importable in this environment and pcapkit.__file__ resolving to my worktree before running anything. tests/toolkit/test_scapy_unit.py ran for real (no skips):

    PYTHONPATH=<worktree> .venv/bin/python -m pytest tests/toolkit/test_scapy_unit.py -v
    5 passed in 4.12s
    

    Broader selection (tests/toolkit tests/foundation/reassembly tests/foundation/engines tests/integration):

    305 passed, 17 skipped, 232 subtests passed in 250.19s
    

    No failures in either run.

  6. The comment and RFC citation. The comment's :rfc: role points at RFC 791 section 3.1, which is the IPv4 header format section that defines the Fragment Offset field in 8-octet units -- accurate. The comment explains why (the reassembler indexes in octets while the wire field is in 8-octet units, mirroring the IPv6 path) rather than just restating * 8.

CI

gh api repos/JarryShaw/PyPCAPKit/commits/67dad967e/check-runs --paginate --jq ...
success=21 skipped=2 unfinished=0 failed=0

The 2 skips are Gate (full suite, Python 3.14) and Docs test gate, which skip by design on pull_request -- fully green otherwise. All Python 3.10-3.15, Compat, Integration, CodeQL/Analyze, and deploy-pages checks passed.

Verdict

GOOD TO MERGE 67dad967e51a811377536ffb5d242184af6831d1

One non-blocking inline suggestion posted above (strengthening the regression test with a conflict == () assertion); nothing blocking found.

JarryShaw added a commit that referenced this pull request Sep 18, 2026
…conflict

#484's review noted that the #483 regression test checks the reassembled
payload but never checks #482's conflict record, even though the defect
is precisely an overlap. Measured on main with the ``* 8`` scaling
reverted, the datagram comes back:

  payload  b'AAAAABBBBBBBB'  (13 octets, not 48)
  conflict ((5, 12),)

so fragment 2 landing inside fragment 1's span is directly observable in
conflict, and the fixed code reports (). Asserting the record is empty
pins the absence of an overlap rather than only the payload that results
from there being none -- a later change could restore the right bytes by
another route while still overwriting.

Worth being precise about what this does and does not prove: reverting
the scaling makes the earlier ``packet2.fo == 40`` assertion fail first,
so this new assertion is not independently demonstrated to catch the
regression on its own. Its discriminating power is the measurement above,
((5, 12),) against (), rather than a revert-proof. It is defence in depth
behind the existing guard, not a replacement for it.

Test only; tests/toolkit/test_scapy_unit.py 5 passed.
@JarryShaw JarryShaw added the fix Pull requests that fix a defect (fix: subject prefix) label Sep 22, 2026
@JarryShaw JarryShaw added this to the 1.5 milestone Oct 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fix Pull requests that fix a defect (fix: subject prefix)

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

scapy toolkit adapter passes raw 13-bit fragment offset unscaled to IP reassembly

1 participant