fix(pcapng): bound an option's payload to the area its block declares (#594) - #676
Conversation
Closes the residual the #573 entry above records under "What this does not close": the 16-bit band, where a shortfall of 65,536 octets or fewer is padded unconditionally and charged to nothing, so an option declaring 65,535 octets could be repeated per block without limit. `util/changelog_md.py` regenerated `CHANGELOG.md`; `--check` exits 0.
|
The truncation gap in Found and deliberately not fixed is now filed as #678, with the 101-level measurement and the |
Adversarial probes against the bound, before and afterThe PR body reports one vector. Here are five more, each an attempt to get past the bound, measured
Worst ratio achieved after the fix across every probe: 0.862x. That is the bound behaving as
A correction to my own measurement, since it bears on trusting the restMy first run of this comparison was wrong and I am reporting it rather than quietly re-running: the The earlier evidence in the PR body is unaffected — those runs all predate the commit, when Two probes that were inconclusive, not clean
A third robustness gap, pre-existing and left alone
Python 3.10
CI is still QUEUED across the whole matrix — nothing here rests on it. |
Completeness: every variable-length field in the file, accounted for"All 15 option and record payloads" is a claim about coverage, so here is the mechanical check rather Option and record area spans (7).
Block-level Already Fixed-length (6).
So the set of option and record payload fields is exactly the 15 that are wrapped, and Two citations in the PR body, re-verified directly rather than taken from notes: |
…#594) A PCAP-NG option's length is a 16-bit wire field, so a four-octet option header can declare 65,535 octets of payload. Nothing bounded that against the option area the block frames, and the field layer pads every shortfall inside a 16-bit length unconditionally -- deliberately, so a snapshot-truncated capture still parses -- so repeating such an option across blocks amplified without limit. * `bounded_option()` clamps an option or record payload to the octets its area has left at that field, and warns (`SchemaWarning`) when it does. Applied to all 15 variable-width option and record payloads in the schema. * `bounded_area()` clamps a packet block's option area to the octets the block itself holds, less the trailing Block Total Length. Without it a block could declare 1,000,000 octets while holding 36, size its area from the lie, and synthesise 65,535 octets anyway -- 1,820x. A no-op on well-formed blocks, where the two are equal by construction. * The bound comes from this layer because the field layer cannot see it: what separates the crafted case from a legitimate one is inconsistency with the block's own declared framing, not the shortfall's size. * Clamping, not refusing: a block read has no catch point above `FieldBase.unpack`, so one refusal aborts the whole extraction. It reads only the block's own framing, so it is history-independent. * The negative-remainder guard is load-bearing on the unpack path, where `__length__` can already be past zero: without it a ten-octet area raises `struct.error` from a `'-2s'` template. * Block-level payload fields are left alone; #593 already budgets that 32-bit band, and the five non-packet option areas keep the framing assumption, which #678 is the general fix for. Crafted capture of 2,000 Enhanced Packet Blocks in 80,048 octets: 131,070,000 octets of padding and 1637.393x before, 0 octets and 0.000x after, with all 2,000 frames and 2,000 options still parsed. Worst ratio over five adversarial shapes after the fix is 0.862x, against 1637.393x/960.360x/224.067x before. Zero clamps fire across all six PCAP-NG fixtures (338 options), and 501 truncation levels of `dhcp.pcapng` are byte-identical. 117 tests and 742 subtests pass across the PCAP-NG and contract suites; 9 of the 15 new tests fail on `main` (exit 1 to 0). Fixes #594
158badc to
df67d72
Compare
GOOD TO GOCross-review by a subagent on It ran independently — its own clone, its own crafted captures built from #594's description rather Finding 1 — the bound rested on an unvalidated wire field. DISPUTED, and now fixed.The reviewer's most valuable output, and it was right. My bound was:
The third link is unvalidated. My test suite could not have caught this: Fixed rather than documented, with a new That Two new tests: one pinning the over-declared block at zero payload, and The reviewer also noted this is currently masked end-to-end by #678's crash, and that it would become Finding 2 — one row of my discriminator table was false. Corrected, with a real replacement.The reviewer implemented all six wrong rules. Five were caught:
So my claim that "pack an 8-octet option with But the guard is load-bearing — on the unpack path, where A ten-octet option area. The first option takes eight, leaving two — not a multiple of four — so The control parses either way, so the input isolates the guard rather than the clamp. Added as The seventh rule, Finding 3 — my truncation claim was over-general. Corrected.I wrote that all truncation levels raise the same Our counts differ because the ranges differ — the reviewer cut deeper, into the Section Header Block, Verified without dispute
What it could not resolve, stated rather than glossedWhether Finding 1's bypass can be made to produce a silent, non-crashing amplification through some It removed its worktree and scratch clone, confirmed via Two line-number citations of mine, corrected while I was at it
Re-verified after the amend and the rebase onto |
The entry described only the payload bound. The cross-review of #676 found that the option *area* is sized from a Block Total Length nothing checks against the file, so a block declaring 1,000,000 octets while holding 36 synthesised 65,535 anyway -- 1,820x, unwarned. `bounded_area` closes that and the entry now says so. Also corrects two claims the same review disproved: the negative-remainder skip is load-bearing on the unpacking path rather than the packing one, and the truncation sweep is not uniformly one exception type. `util/changelog_md.py` regenerated `CHANGELOG.md`; `--check` exits 0.
Fixes #594
The band, measured
#593 bounds the 32-bit padding band. The residual #594 records is a different
shape: a 16-bit declared length repeated across many blocks. An option's
length is a 16-bit wire field, so a four-octet option header can declare 65,535
octets of payload, and nothing bounded that against the option area the block
frames.
Reproduced on
0c7f2b7c9with a crafted capture built to the issue's owndescription — 2,000 Enhanced Packet Blocks in 80,048 octets, each carrying one
option that declares 65,535 octets against none present:
The
beforerow matches the issue's figures to three decimal places(1637.393x, 125.00 MiB). Every frame and every option still parses after the
fix; only the synthesised padding is gone.
Where #593's budget lives, and why this band escaped it
The mechanism is in
pcapkit/corekit/fields/field.py: acontextvars.ContextVarledger (
_zero_pad_ledger,:168-171) charged insideFieldBase.unpack(
:391-528), against_MAX_ZERO_PAD_LENGTH = 0x40_000(:64),_MAX_ZERO_PAD_SHORTFALL = 0x10_000(:104) and_ZERO_PAD_BUDGET_RATIO = 0x10(:125).The band escapes it at
field.py:494:A shortfall of 65,536 octets or fewer is never charged to the ledger at all,
so repeating it accumulates without limit. That is deliberate, not an oversight:
65,536 is the full span of a 16-bit wire length, and
tests/corekit/test_fields_field.py::test_a_shortfall_within_a_16_bit_length_is_never_refused(
:377) pins it, because a capture cut short by its snapshot length must stillparse. #571 was declined for proposing exactly the rejection that would break
that.
So the existing mechanism genuinely cannot cover this case at its own layer:
at
FieldBase.unpackthere is nothing to distinguish a crafted shortfall from alegitimate one. Lowering the threshold reintroduces the history dependence #593
removed.
_zero_pad_budget()(field.py:174-199) exists for scoping but haszero production call sites — five call sites, all in
tests/corekit/test_fields_field.py— and scoping alone would only shrink theratio, not bound it.
What this change does, and where the bound comes from
bounded_option()clamps an option or record payload to the octets its area hasleft at that field, and emits a
SchemaWarningwhen it does. It is appliedto all 15 variable-width option and record payloads in the PCAP-NG schema.
bounded_area()then clamps a packet block's option area to the octets theblock itself holds, less the trailing Block Total Length. This closes a hole the
cross-review found:
BlockType.post_processchecks Block Total Length onlyagainst its own trailing copy, never against the file, so a block declaring
1,000,000 octets while holding 36 sized its area at 999,964 and an option
declaring 65,535 was under that and never clamped — 1,820x, with no warning.
It is a no-op on well-formed blocks, where the octets left of the block are
exactly the area plus the trailing length's four. See the verdict comment below
for the measurement and for why the
- 4is load-bearing rather than cosmetic.The bound has to come from this layer, and the information is already here. What
separates the crafted case from the legitimate one is not the shortfall's size —
both sit inside a 16-bit length — but whether the option is inconsistent with
the framing the block itself declares. Block Total Length is authoritative and
cross-checked against its own trailing copy (
BlockType.post_process), so theoption area is
lengthless the fixed fields,captured_len, andcaptured_len's padding. An option declaring more payload than that area hasleft is malformed however complete the file behind it is. A snapshot-truncated
capture says so through
captured_leninstead and leaves its options whole, soit never trips this.
On the
captured_lencorrection in #594: it stands and this change relies onit.
captured_lenis not inert telemetry; it drives three formulas insideEnhancedPacketBlock—packet_dataat:1022,padding_dataat:1024, andthe
optionsarea at:1031on0c7f2b7c9(re-verified; now:1100,:1102and
:1109after the insertion above them). This diff does not touch any ofthe three. The area expression at
:1031/:1109is precisely what suppliesthe bound, reaching the payload through
OptionField.unpack'sschema.unpack(file, length, packet)(
pcapkit/corekit/fields/collections.py:448) andprepare'spacket['__length__'] = length(pcapkit/utilities/decorators.py:274).Clamp, not raise, and not truncate silently
Three choices were on the table for the band. The change clamps and warns:
above
FieldBase.unpack, so one refusal aborts the whole extraction ratherthan one block — a truncated capture would stop reporting at the cut instead
of reporting the frames before it.
rules out. Hence the
SchemaWarning, which names the declared length, theoctets remaining, and what was read.
framing, never a running total, so byte-identical input answers identically
regardless of what preceded it. That is the property fix(corekit): bound the total zero padding a parse may synthesise (#573) #593's own notes record a
naive threshold destroying.
Block-level payload fields (
UnknownBlock.body,CustomBlock.data,SystemdJournalExportBlock.entry) are deliberately left alone: those are the32-bit band #593 already budgets, and clamping them would change block-level
truncation behaviour, which is its own review. The five non-packet option
areas — Section Header, Interface Description, Name Resolution, Interface
Statistics, Decryption Secrets — are also left unclamped, since each computes its
span with a different offset and the area-equals-remainder-less-four equality has
to be re-established per block rather than assumed. They keep the framing
assumption; #678 is the general fix.
House precedent for the shape:
pcapkit/protocols/schema/transport/sctp.py:886is
length=lambda pkt: max(pkt['__length__'], 0), and itsbounded()does thesame
__length__clamp for SCTP list fields.The
remaining < 0guard is there for the unpack path, where__length__canalready be past zero because
Schema.unpackwarns and carries on when an earlierfield over-consumed. Without it, a ten-octet area gives the field a
'-2s'template and unpack raises
struct.error. It is not needed for the pack path:the cross-review established that both
BytesField.pre_process(
pcapkit/corekit/fields/strings.py:91-93) andStringField.pre_process(
:148-150) already repair a negative width tolen(value), so an earlierversion of this paragraph had the justification wrong.
Which inputs discriminate a correct fix from a plausible wrong one
This is the part the repo has been bitten on four times, so each test names the
wrong rule it rules out.
__length__reaches -2 at the payloadremaining < 0guard: the field's template becomes'-2s'and unpack raisesstruct.error__length__absentThe first and fifth rows are the ones that pass both before and after: they
exist to catch a fix that is too aggressive, which is the failure mode a
single-crafted-input test cannot see.
The bound is asserted as a property, not as one example
test_the_payload_never_exceeds_the_area_for_any_declared_lengthsweeps everycombination of 1-3 options against declared lengths
(0, 1, 4, 5, 8, 12, 0x100, 0x1000, 0xFFFF)— 27 subtests — and asserts theoctets a block's options report holding never exceed the area, and never exceed
the block's own declared length. That is the invariant that bounds the
amplification, since the block's declared length is what the reader advances the
file by (
pcapkit/protocols/misc/pcapng.py:968,977).test_the_amplification_does_not_grow_with_the_block_countthen asserts theratio is flat in the block count (1, 8, 64, 512 blocks), which is what
distinguishes a bound from a smaller constant: #594's vector was linear in the
block count and therefore unbounded in the input size.
Nine of the 27 sweep subtests fail without the fix, at declared lengths 256,
4,096 and 65,535.
No false positives on real captures
Every PCAP-NG fixture, committed and generated, parsed with warnings captured:
Separately, 101 EOF-truncation levels of
dhcp.pcapng(every 4 octets down to-400) were swept before and after: byte-identical results, including the
failures.
Failing then passing, exit codes read from files
The 15 new tests, run against the pristine
HEADcopy of the schema file andthen against the modified one (same selection, same command):
Nine of the 15 test functions fail without the fix:
test_every_clamped_payload_is_bounded_and_none_was_missedfails all 15subtests without the fix — one per clamped site — which is what shows each of
the 15 was genuinely unbounded rather than incidentally safe.
Regression runs
And the contract tests, before and after, same command:
EXPECTED_FAILURES: no entry moved. It was read by importing the module(
**unpacking makes it ungreppable) — 45 entries, 35 PCAP-NG-related. Theround-trip module enforces both halves (an unrecorded failure fails, and a
recorded gap that starts passing fails with "delete its
EXPECTED_FAILURESentry",
test_option_roundtrip_unit.py:676-717), so the byte-identical85-passed/528-subtest result before and after is the evidence that none moved.
No entry was deleted.
pcapkit.__file__for every measurement:/local/home/jarryx/GitHub/PyPCAPKit/.claude/worktrees/agent-a8c3daad0b5a55dd4/pcapkit/__init__.py— asserted before any other import, with
__editable__finders stripped fromsys.meta_pathand the worktree atsys.path[0]. Crafted inputs were capped andparsed in a subprocess under
resource.setrlimitwithRLIMIT_AS.Coverage and lint
Coverage does not go backwards: it stays at 100% while adding 9 statements and 2
branches, all executed -- 21 statements and 6 branches, with the area bound. PCAP-NG
subtests 189 to 251.
pylintandmypyon thechanged file, diffed before against after: zero new findings, zero resolved
(364 pre-existing pylint findings and 1 pre-existing
unused-ignoreidentical inboth runs).
CI is pending, not green — the Actions queue is backed up, and any single
passing check on this PR is
pyup.io/safety-ci, a StatusContext rather than anActions job. Everything above is local.
Labels, and the
breakingargumentApplied:
fix,test.breakingdeliberately not applied, and hereis the argument so it can be overridden.
at all; the clamp is not a rejection. Every fixture, every truncation level and
every crafted input that parsed before still parses, with the same frame and
option counts.
framing declares malformed — an option claiming more payload than its own
block's declared area has left. That payload used to be zero-padded to the
declared length and is now the octets actually in the area.
option.datafrom amalformed capture and depends on its length matching
option.lengthsees ashorter value.
option.lengthitself is unchanged, so the two can nowdisagree, and the new
SchemaWarningis the signal that they do. If thatcounts as a public-attribute change in the sense Frame.len and Frame.cap_len are populated from opposite wire fields by the PCAP and PCAP-NG readers #618 was labelled
breakingfor,
breakingshould be added — it is additive, and I would not argueagainst it.
Interaction with #646
#646 (every PCAP-NG packet block loses its payload octets) is neither
improved nor worsened by this change. Its defect is
packetbeing computedcorrectly in
PCAPNG.unpack(pcapkit/protocols/misc/pcapng.py:902-909) andthen overwritten at
pcapkit/protocols/protocol.py:647, becauseProtocolBase.packettreatsPCAPNG.length— the Block Total Length — as apre-payload header length. That is the
packet_datapayload path, sized fromcaptured_lenat:1022. This change touches only option and recordpayloads, never
packet_data, nevercaptured_len, and nothing inprotocol.py(which #640 owns). #646 does not mention padding, and its own fixlands in files this diff does not open.
Found and deliberately not fixed
granularity, 501 truncation levels of
dhcp.pcapnggive 497 uncaughtValueError: read length must be non-negative or -1frompcapkit/protocols/schema/schema.py:857, 2struct.error: bad char in struct format, and 2 that parse. (An earlier revision of this body said all levelsraised the same
ValueError; the cross-review caught that over-generalisation.)The
ValueErrorarisesbecause
pcapng_block_selectorpassesSchemaField(length=packet['__length__'])(
pcapkit/protocols/schema/misc/pcapng.py:228) and__length__can benegative when a block's declared length has run past the file.
sctp.pyalreadyuses
max(pkt['__length__'], 0)for the same hazard. Identical before and afterthis change. It is a block-level robustness gap, changes frame counts on
truncated captures, and wants its own review and its own
breakingargument —filing separately rather than folding it in here.
what the file holds) would bypass an area-derived bound in principle. In
practice it hits the
ValueErrorabove on the current tree, before any paddingis synthesised, so it is not a usable route — measured byte-identical before
and after. Closing the gap properly depends on the item above.