Skip to content

corekit: reject short dynamic field buffers - #571

Closed
lux-liang wants to merge 1 commit into
JarryShaw:mainfrom
lux-liang:fix/field-unpack-length-554
Closed

lux-liang wants to merge 1 commit into
JarryShaw:mainfrom
lux-liang:fix/field-unpack-length-554

Conversation

@lux-liang

Copy link
Copy Markdown

Fixes #554.

Summary

  • reject short buffers when a field's length is resolved from packet context
  • preserve fixed-width short-read padding used by higher-level parser diagnostics
  • cover both byte buffers and streams, plus the existing compatibility behavior

Why

FieldBase.unpack() padded every short read to the declared field length. For a
callable length derived from packet data, that could create bytes that never
arrived. The parser now raises the existing FieldValueError before padding a
short dynamically sized field.

Verification

  • regression-first check on the original code: 2 failed, 1 passed
  • focused tests: 4 passed
  • corekit and schema tests: 128 passed, 125 subtests passed
  • unit tier: 1014 passed, 114 skipped, 2588 subtests passed
  • python -m compileall on both changed files
  • git diff --check

Stop callable field lengths from padding beyond the bytes that were read. Preserve the existing fixed-width padding behavior used by higher-level parser diagnostics.
@JarryShaw

Copy link
Copy Markdown
Owner

Thanks for this, and for spotting that a naive len(buffer) < length guard would break the library — you're right that fixed-width short reads have to keep padding, and building the discriminator around that is the correct instinct. Unfortunately the specific discriminator doesn't hold, and I don't think it can be made to.

The blocker: a truncated option stops parsing

Discriminating on whether the length came from packet context doesn't separate the attack from the legitimate short read, because both are wire-derived. A capture cut short by the snapshot length declares an over-long option length in exactly the same way a hostile one does.

OptionField.unpack rewinds and calls schema.unpack(...) at collections.py:420, which unpacks the option's data field — and that field is callable-length: BytesField(length=lambda pkt: pkt['length'] - 2) at schema/transport/tcp.py:321 and schema/internet/ipv4.py:259. So the guard fires on it.

A TCP segment with data offset 7, unassigned option kind 0x4f declaring length 12 with 6 data octets present — on main:

PARSED ok; options= [(<Option.Reserved_79: 79>, 12)]
SchemaWarning: packet length < 0: -4

That warning is the library noting the shortfall and carrying on, which is what #431 established. On this branch the same segment raises FieldValueError: Field data requires 10 octets, but only 6 are available., escaping TCP(...) unwrapped. Same at declared length 32.

The full suite stays green either way, which is why this wasn't obvious — that path has no test at all. I've filed that gap as #572, independently of this PR.

What's right

Your exception choice is correct: FieldValueError is the house pick for length mismatches in corekit/fields/. And your message spelling {self.name} is better than what I had — plain is 8/8 across pcapkit/corekit/, including five lines away in the same file. I'm taking that.

Where this leaves it

I'm going with #569, which bounds on magnitude instead: it rejects only when a declared length exceeds both the buffer and 262,144 octets (libpcap's MAXIMUM_SNAPLEN, and this package's own snaplen default), so every short read stays under the ceiling and keeps padding. So I'm closing this one — but the reasoning in it was sound and the short-read constraint you identified is exactly the thing that makes this fix difficult.

Two smaller notes for next time: tests for a field.py change belong in tests/corekit/test_fields_field.py rather than test_fields_strings.py, and CHANGELOG.md is generated from docs/source/changelog/1.5.0.rst by util/changelog_md.py. Also, your run showing 114 skipped suggests the sample captures weren't generated — python examples/generators/make_samples.py first, then it's 17.

Please do send more.

JarryShaw added a commit that referenced this pull request Sep 21, 2026
…ing (#554)

length is frequently wire-derived -- resolved by a Field's
_length_callback against the packet under parse, or built by a schema
selector from a value it just read off the wire (e.g.
DecryptionSecretsBlock's secrets_data field) -- and so attacker- or
corruption-controlled. buffer[:length].rjust(length, b'\x00') zero-padded
straight up to that length regardless of how little data buffer actually
held, so a ~40-octet PCAP-NG Decryption Secrets Block with a bogus inner
length could force a multi-gigabyte allocation.

- Raise FieldValueError when a declared length exceeds both the buffer's
  actual size and a 262144-octet (0x40_000) ceiling -- libpcap's own
  MAXIMUM_SNAPLEN, and this package's own default snaplen -- before the
  rjust() that would otherwise allocate and zero-fill on the packet's
  own say-so. FieldValueError, not BoolError ("must be a bool"), matches
  the precedent for an invalid/insufficient length in ListField.unpack.
- The ceiling is deliberate rather than "any shortfall is bogus":
  ListField.unpack and OptionField.unpack (collections.py) depend on a
  short, sometimes empty, tail read past a truncated option area
  decoding as zero, so an over-long ihl or a capture cut short by its
  snapshot length reads as end-of-option-list/Pad1 instead of raising
  (#431). An unconditional reject broke 15 existing tests exercising
  that mechanism; every one of those fields is a handful of octets,
  far under the ceiling, and is untouched.
- Add tests/corekit/test_fields_field.py: small-field short reads still
  zero-pad, the ceiling boundary in both directions, a full buffer past
  the ceiling is accepted, and a 16 GiB declared length against a
  2-octet buffer is rejected under a 5s deadline without allocating.
  Verified against three mutants (loosened comparison, shifted ceiling
  operator, wrong constant) to confirm the bound is exact.
- Update docs/source/changelog/1.5.0.rst and regenerate CHANGELOG.md.

Build: pytest tests -- 1271 passed, 17 skipped, 2850 subtests passed, 0
failed.

Spell the error message's field name plain rather than quoted, matching the 8/8 precedent across `pcapkit/corekit/` including `field.py:207` five lines away. Adopted from @lux-liang's #571, which proposed the same correction.
JarryShaw added a commit that referenced this pull request Sep 21, 2026
…ing (#554) (#569)

length is frequently wire-derived -- resolved by a Field's
_length_callback against the packet under parse, or built by a schema
selector from a value it just read off the wire (e.g.
DecryptionSecretsBlock's secrets_data field) -- and so attacker- or
corruption-controlled. buffer[:length].rjust(length, b'\x00') zero-padded
straight up to that length regardless of how little data buffer actually
held, so a ~40-octet PCAP-NG Decryption Secrets Block with a bogus inner
length could force a multi-gigabyte allocation.

- Raise FieldValueError when a declared length exceeds both the buffer's
  actual size and a 262144-octet (0x40_000) ceiling -- libpcap's own
  MAXIMUM_SNAPLEN, and this package's own default snaplen -- before the
  rjust() that would otherwise allocate and zero-fill on the packet's
  own say-so. FieldValueError, not BoolError ("must be a bool"), matches
  the precedent for an invalid/insufficient length in ListField.unpack.
- The ceiling is deliberate rather than "any shortfall is bogus":
  ListField.unpack and OptionField.unpack (collections.py) depend on a
  short, sometimes empty, tail read past a truncated option area
  decoding as zero, so an over-long ihl or a capture cut short by its
  snapshot length reads as end-of-option-list/Pad1 instead of raising
  (#431). An unconditional reject broke 15 existing tests exercising
  that mechanism; every one of those fields is a handful of octets,
  far under the ceiling, and is untouched.
- Add tests/corekit/test_fields_field.py: small-field short reads still
  zero-pad, the ceiling boundary in both directions, a full buffer past
  the ceiling is accepted, and a 16 GiB declared length against a
  2-octet buffer is rejected under a 5s deadline without allocating.
  Verified against three mutants (loosened comparison, shifted ceiling
  operator, wrong constant) to confirm the bound is exact.
- Update docs/source/changelog/1.5.0.rst and regenerate CHANGELOG.md.

Build: pytest tests -- 1271 passed, 17 skipped, 2850 subtests passed, 0
failed.

Spell the error message's field name plain rather than quoted, matching the 8/8 precedent across `pcapkit/corekit/` including `field.py:207` five lines away. Adopted from @lux-liang's #571, which proposed the same correction.
JarryShaw added a commit that referenced this pull request Sep 21, 2026
#572) (#578)

- Add TCP coverage (test_tcp_udp_unit.py): a segment with an unassigned
  option kind declaring length 12 (and 32), with only 6 data octets
  behind it, still parses -- FieldBase.unpack left-pads the short read
  with zero octets rather than raising.
- Add the IPv4 equivalent (test_ipv4_unit.py), which needs the option
  area declared larger than the octets physically present, since
  IPv4._read_ipv4_options gates on the sum of *declared* option lengths
  where TCP gates on what was actually consumed.
- Only the empty-tail half of #431 had coverage before this
  (test_an_option_area_longer_than_the_{segment,datagram}_still_parses);
  a candidate fix for #554 (#571) regressed the truncated-data half
  while the whole suite stayed green, which is the gap #572 tracks.
- Add a changelog entry and regenerate CHANGELOG.md.

Verified the new tests fail against a deliberately over-strict guard in
FieldBase.unpack (raise whenever len(buffer) < length) with the same
FieldValueError message #571 would produce, then pass again once the
guard is reverted. No source file is touched in this commit.

Build/test: full unit tier (pytest -q --ignore=tests/integration
--ignore-glob='*_runtime.py' --ignore-glob='*_regression.py') --
1118 passed, 8 skipped, 2670 subtests passed.
@lux-liang

Copy link
Copy Markdown
Author

Thanks for the detailed analysis. I agree that snapshot truncation and hostile lengths are indistinguishable at that point in the wire-derived input, so the discriminator in #571 cannot be made sound without breaking the established short-read padding behavior.

I’ll leave the bounded-magnitude approach in #569 to address the original issue, and I’ll keep an eye on #572 for the missing truncated-option coverage. I’ve also noted the test-file and generated-changelog guidance for future field changes. Thanks again for the careful review — and I’ll send more focused fixes.

@JarryShaw

Copy link
Copy Markdown
Owner

Following up now that #554 is actually fixed: the defect you reported is closed, by a different mechanism than this PR proposed.

Two commits did it:

Your instinct was right and your specific guard was not, and it is worth being precise about the difference. A len(buffer) < length rejection cannot be used, because at the point of the read a snapshot-truncated capture and a hostile declared length are indistinguishable, and the library deliberately accommodates short reads (#431). You had already accepted that when we discussed it.

What we did not know then, and now do, is that the obvious next design — a single running budget over all padding — also fails, and for a subtler reason. Measured: with one budget, the same legitimate 54-octet frame declaring an IPv4 total length of 65,535 parsed to four different results across 40 byte-identical calls, refusing on calls 26, 33 and 39 and differing only by position in the run, with beholder swallowing the refusal into a silently different Raw layer. A guard whose answer depends on parse history is worse than the amplification it prevents. That is why the shipped fix has two bands rather than one.

The part your PR was reaching for that remains genuinely open is now #594: a 16-bit shortfall band is still padded unconditionally, so a crafted capture amplifies 1,637x — and a legitimately truncated offload frame amplifies by the same ratio, 1637.375x versus 1637.393x, so nothing at the field layer can tell them apart. Separating them needs the frame's own incl_len/orig_len from the PCAP-NG layer. If that problem interests you, #594 has the measurements and the constraints.

So this PR can be closed as superseded. Thank you for raising it — it is the reason the family got looked at properly, and four issues came out of it.

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

None yet

Development

Successfully merging this pull request may close these issues.

Unbounded allocation in FieldBase.unpack: a wire-declared length drives rjust() with no bound

2 participants