Skip to content

Commit f984cb7

Browse files
committed
fix(protocols)!: a declared length running past the data is accepted and zero-filled on rebuild
OptionField.unpack reads each option through a proxy that notes short reads, and raises ProtocolError when an option that began with octets present then reads past the end of its data. An option starting at the end of the data keeps the end-of-option-list handling. Inside a parent layer the octets are kept as captured and rebuild byte for byte. BREAKING CHANGE: a truncated IPv4 or TCP option no longer parses with zero-filled data; the layer raises ProtocolError, and a parent keeps it as raw octets. The #431/#572 pins and the #1394 raw-item cases are rewritten to match. PCAP-NG is a follow-up after #1378. Refs #1325
1 parent 970fed9 commit f984cb7

6 files changed

Lines changed: 319 additions & 131 deletions

File tree

‎pcapkit/corekit/fields/collections.py‎

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from pcapkit.corekit.fields.numbers import NumberField
1010
from pcapkit.corekit.multidict import OrderedMultiDict
1111
from pcapkit.utilities.compat import List
12-
from pcapkit.utilities.exceptions import FieldValueError
12+
from pcapkit.utilities.exceptions import FieldValueError, ProtocolError
1313

1414
__all__ = [
1515
'ListField', 'OptionField',
@@ -29,6 +29,44 @@
2929
_TS = TypeVar('_TS', bound='Schema')
3030

3131

32+
class _ShortReadTracker:
33+
"""Stream proxy recording whether a read came back short.
34+
35+
Args:
36+
file: Stream to read from.
37+
38+
Only the ``read``, ``seek`` and ``tell`` calls that
39+
:meth:`Schema.unpack <pcapkit.protocols.schema.schema.Schema.unpack>` and
40+
:meth:`OptionField.unpack` make are delegated.
41+
42+
"""
43+
44+
__slots__ = ('_file', 'short')
45+
46+
def __init__(self, file: 'IO[bytes]') -> 'None':
47+
self._file = file
48+
#: bool: Whether a read since the last reset returned fewer octets than
49+
#: it asked for.
50+
self.short = False
51+
52+
def read(self, size: 'Optional[int]' = -1) -> 'bytes':
53+
"""Read ``size`` octets, noting a short read."""
54+
if size is None:
55+
size = -1
56+
data = self._file.read(size)
57+
if 0 <= size and len(data) < size:
58+
self.short = True
59+
return data
60+
61+
def seek(self, offset: 'int', whence: 'int' = io.SEEK_SET) -> 'int':
62+
"""Seek the underlying stream."""
63+
return self._file.seek(offset, whence)
64+
65+
def tell(self) -> 'int':
66+
"""Tell the underlying stream's position."""
67+
return self._file.tell()
68+
69+
3270
class ListField(FieldBase[List[_TL]], Generic[_TL]):
3371
"""Field list for protocol fields.
3472
@@ -362,6 +400,8 @@ def unpack(self, buffer: 'bytes | IO[bytes]', packet: 'dict[str, Any]') -> 'list
362400
Raises:
363401
FieldValueError: If an option consumes nothing from ``buffer``, since
364402
the loop below has then no way to get past it.
403+
ProtocolError: If an option that begins with octets present reads
404+
past the end of the data.
365405
366406
"""
367407
length = self._length
@@ -421,8 +461,24 @@ def unpack(self, buffer: 'bytes | IO[bytes]', packet: 'dict[str, Any]') -> 'list
421461
# number of octets to rewind to get back to the front of the option.
422462
type_field = self._type_field
423463

464+
# NOTE: An option whose declared length runs past the octets behind it
465+
# reads short, and every field reading off the end pads the missing
466+
# octets with zeros, so the option would rebuild octets that were never
467+
# there: a HIP ``ACK`` declaring 256 octets with 4 present, or an MH
468+
# ``PadN`` declaring 3 with none. Nothing downstream can tell, because
469+
# the schema records the zeros as values. C.f. #1325.
470+
#
471+
# So the stream is read through a proxy that notes any short read, and
472+
# an option that began with at least one octet present and then read
473+
# short is rejected. An option that begins at the end of the data reads
474+
# nothing at all and is left to the end-of-option-list and progress
475+
# handling below, which is what an option area declared longer than its
476+
# octets relies on.
477+
tracker = file = _ShortReadTracker(file) # type: ignore[assignment]
478+
424479
temp = [] # type: list[_TS]
425480
while length > 0:
481+
tracker.short = False
426482
if type_field is None:
427483
# unpack option type using base schema
428484
meta = self._base_schema.unpack(file, length, packet) # type: ignore[call-arg,misc,var-annotated]
@@ -443,6 +499,13 @@ def unpack(self, buffer: 'bytes | IO[bytes]', packet: 'dict[str, Any]') -> 'list
443499

444500
# unpack option using option schema
445501
data = schema.unpack(file, length, packet) # type: ignore[call-arg,misc,var-annotated]
502+
if tracker.short and consumed > 0:
503+
raise ProtocolError(
504+
f'Field {self.name} has an option that runs past the end of '
505+
f'the data: {code!r} at offset {offset - start} of '
506+
f'{self._length}, with only {file.tell() - offset} octet(s) '
507+
f'of it present'
508+
)
446509
new_packet[self.name].add(code, data)
447510
temp.append(data)
448511

‎tests/protocols/internet/test_ipv4_unit.py‎

Lines changed: 26 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1992,82 +1992,49 @@ def test_an_option_area_longer_than_the_datagram_still_parses(self) -> None:
19921992
)
19931993

19941994
def test_a_truncated_option_still_parses_its_declared_length(self) -> None:
1995-
"""A capture cut short mid-option is tolerated, not just mid-header. C.f. #431, #572.
1995+
"""A capture cut short mid-option is refused alone, and kept as captured in a frame. C.f. #431, #572, #1325.
19961996
19971997
:meth:`test_an_option_area_longer_than_the_datagram_still_parses` above
19981998
pins the *empty*-tail half of the #431 accommodation: an option area
1999-
that runs out before it starts. Nothing pinned the other half -- an
2000-
option that *does* start, declares more data than the capture actually
2001-
holds, and runs out partway through, the shape of a datagram cut short
2002-
by the snapshot length rather than one with no options at all. A
2003-
candidate fix for #554 (PR #571) turned that into an unwrapped
2004-
``FieldValueError`` while the rest of the suite stayed green, because
2005-
nothing exercised it. See
2006-
``TCPUDPUnitTests.test_a_truncated_option_still_parses_its_declared_length``
2007-
for the same case on TCP, where the shortfall is simpler to reach.
1999+
that runs out before it starts. This is the other half -- an option
2000+
that *does* start, declares more data than the capture holds, and runs
2001+
out partway through.
20082002
20092003
The header below sets ``ihl`` to 9 -- 16 declared octets of options --
2010-
for an unassigned option (code 31) declaring ``length=12``, which asks
2011-
:class:`~pcapkit.protocols.schema.internet.ipv4.UnassignedOption`'s
2012-
``data`` field (``BytesField(length=lambda pkt: pkt['length'] - 2)``,
2013-
10 octets here) for more than the 6 octets actually behind it, and
2014-
:meth:`FieldBase.unpack <pcapkit.corekit.fields.field.FieldBase.unpack>`
2015-
pads the short read with zero octets rather than raising -- the
2016-
same accommodation as the TCP case, reached the same way. The zeros go
2017-
on the *tail*, where the octets that were never read would have been;
2018-
before #604 they were placed at the front, which for a numeric field
2019-
corrupted the value outright.
2020-
2021-
``ihl`` has to declare *more* than the 8 octets actually present,
2022-
though, which the TCP case does not need. Unlike TCP,
2023-
:meth:`~pcapkit.protocols.internet.ipv4.IPv4._read_ipv4_options` sums
2024-
each option's self-*declared* ``length`` -- not what it actually
2025-
consumed -- and raises ``IPv4: invalid format`` once its loop over the
2026-
parsed options finishes, if that sum exceeds the declared option
2027-
area. Declaring exactly 8 does not skip the accommodation -- the short
2028-
``data`` field is still read and tail-padded as above -- it just trips
2029-
that check afterwards, discarding the result before the test can
2030-
assert on it. Declaring 16 leaves headroom, at the cost of a second
2031-
effect: once the option loop's 16-octet budget outlives the 8 octets
2032-
its one real option consumed, the loop reads one further, fully
2033-
exhausted phantom option, decodes it as end-of-option-list (the same
2034-
mechanism the test above pins), and :meth:`Schema.unpack
2035-
<pcapkit.protocols.schema.schema.Schema.unpack>` hands the same 8
2036-
octets to the schema a second time as padding, via the
2037-
``option_padding`` rewind that #371 added -- code that predates #431
2038-
and lives outside :meth:`OptionField.unpack
2039-
<pcapkit.corekit.fields.collections.OptionField.unpack>`. That is why
2040-
``bytes(proto.__header__)`` does not round-trip to ``raw`` here and is
2041-
not asserted -- immaterial to what this test pins: the option's
2042-
declared ``length``, the full parsed options list, and the ``data``
2043-
field's short-read reconstruction.
2004+
for an unassigned option (code 31) declaring ``length=12``, with only
2005+
6 of its 10 data octets behind it. Those 4 missing octets used to be
2006+
read as zeros and rebuilt as if captured. :meth:`OptionField.unpack
2007+
<pcapkit.corekit.fields.collections.OptionField.unpack>` now refuses
2008+
the option with :exc:`~pcapkit.utilities.exceptions.ProtocolError`
2009+
instead (#1325). Inside a frame the datagram is kept as the octets
2010+
captured, a :class:`~pcapkit.protocols.misc.raw.Raw` payload carrying
2011+
that error, and rebuilds byte for byte.
20442012
20452013
"""
20462014
from pcapkit.const.ipv4.option_number import OptionNumber
20472015
from pcapkit.protocols.internet.ipv4 import IPv4
2016+
from pcapkit.protocols.link.ethernet import Ethernet
2017+
from pcapkit.protocols.misc.raw import Raw
2018+
from pcapkit.utilities.exceptions import ProtocolError
20482019
from tests._support import time_limit
20492020

20502021
custom = OptionNumber.get(31)
20512022
trailing = bytes.fromhex('aabbccddeeff')
20522023
raw = (bytes.fromhex('4900001c00010000400600000a0000010a000002') +
20532024
bytes([custom, 12]) + trailing)
2054-
with time_limit():
2055-
proto = IPv4(raw, len(raw))
2025+
with time_limit(), warnings.catch_warnings():
2026+
warnings.simplefilter('ignore')
2027+
with self.assertRaisesRegex(ProtocolError, 'runs past the end of the data'):
2028+
IPv4(raw, len(raw))
20562029

2057-
self.assertEqual(proto.info.hdr_len, 36)
2058-
self.assertEqual(
2059-
[(code, opt.length) for code, opt in proto.info.options.items(multi=True)],
2060-
[(custom, 12), (OptionNumber.EOOL, 1)],
2061-
)
2062-
unassigned = next(opt for code, opt in proto.info.options.items(multi=True)
2063-
if code == custom)
2064-
# the six octets actually behind the option come first, where they were
2065-
# read, and the four synthesised for the ones that were not follow them.
2066-
# Before #604 they arrived the other way round. See #604 and
2067-
# ``FieldBaseShortReadPaddingSideTests`` in
2068-
# ``tests/corekit/test_fields_field.py``.
2069-
self.assertEqual(unassigned.data, trailing + b'\x00\x00\x00\x00')
2030+
frame = bytes.fromhex('0123456789ab' 'fedcba987654' '0800') + raw
2031+
proto = Ethernet(frame, len(frame))
20702032

2033+
self.assertIsInstance(proto.payload, Raw)
2034+
self.assertIn('runs past the end of the data', str(proto.payload.info.error))
2035+
# the option octets are kept as captured, with nothing synthesised
2036+
self.assertEqual(proto.payload.data, raw)
2037+
self.assertEqual(Ethernet.from_data(proto.info).data, frame)
20712038

20722039
if __name__ == '__main__':
20732040
unittest.main()
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
# -*- coding: utf-8 -*-
2+
"""An option declaring more octets than are present is rejected, not zero-filled.
3+
4+
GitHub issue #1325: :meth:`OptionField.unpack
5+
<pcapkit.corekit.fields.collections.OptionField.unpack>` let an option schema
6+
read past the end of its data, and every field reading off the end padded the
7+
missing octets with zeros. A HIP ``ACK`` declaring 256 octets with 4 present
8+
parsed as 64 update IDs and rebuilt to 304 octets; an MH ``PadN`` declaring 3
9+
octets with none present rebuilt 24 octets from 16.
10+
11+
Such an option now raises :exc:`~pcapkit.utilities.exceptions.ProtocolError`.
12+
Inside a parent layer the octets are kept as captured -- raw extension data
13+
under IPv6, a :class:`~pcapkit.protocols.misc.raw.Raw` payload elsewhere -- and
14+
the parent rebuilds byte for byte.
15+
16+
An option that begins at the end of the data reads nothing at all, and is still
17+
left to the end-of-option-list handling an over-long option area relies on.
18+
19+
Every case builds its own octets in memory and reads no capture.
20+
21+
"""
22+
23+
import struct
24+
import unittest
25+
import warnings
26+
27+
from tests._support import reimport_once_per_class
28+
29+
30+
def _hip(params: bytes) -> bytes:
31+
"""A HIP ``I1`` header (40 octets) carrying ``params``."""
32+
return (bytes([59, (40 + len(params)) // 8 - 1, 1, 0x11, 0, 0, 0, 0])
33+
+ b'\x11' * 16 + b'\x22' * 16 + params)
34+
35+
36+
def _ext6(opts: bytes) -> bytes:
37+
"""An IPv6 options extension header carrying ``opts``."""
38+
return bytes([59, (2 + len(opts)) // 8 - 1]) + opts
39+
40+
41+
def _sctp(chunks: bytes) -> bytes:
42+
"""An SCTP common header carrying ``chunks``."""
43+
return struct.pack('>HHII', 1, 2, 0, 0) + chunks
44+
45+
46+
def _tcp(opts: bytes) -> bytes:
47+
"""A TCP header carrying ``opts``."""
48+
return struct.pack('>HHIIBBHHH', 1, 2, 0, 0, (5 + len(opts) // 4) << 4, 0x10, 0, 0, 0) + opts
49+
50+
51+
def _ipv4(opts: bytes, ihl: int, proto: int = 253, payload: bytes = b'') -> bytes:
52+
"""An IPv4 header declaring ``ihl`` and carrying ``opts`` and ``payload``."""
53+
return (bytes([0x40 | ihl, 0]) + struct.pack('>H', 20 + len(opts) + len(payload))
54+
+ bytes.fromhex('00010000 40') + bytes([proto]) + bytes(2)
55+
+ bytes.fromhex('0a000001 0a000002') + opts + payload)
56+
57+
58+
def _ipv6(proto: int, payload: bytes) -> bytes:
59+
"""An IPv6 header carrying ``payload`` as next header ``proto``."""
60+
return (bytes.fromhex('60000000') + struct.pack('>H', len(payload)) + bytes([proto, 64])
61+
+ b'\x11' * 16 + b'\x22' * 16 + payload)
62+
63+
64+
#: Option areas whose last option declares more octets than the area holds.
65+
OVERRUNS = {
66+
# issue #1325: ACK declaring 0x0100 octets, 4 present
67+
'HIP ACK': ('pcapkit.protocols.internet.hip', 'HIP', _hip(bytes.fromhex('01c10100 00000000'))),
68+
# issue #1325: PadN declaring 3 octets, none present
69+
'MH PadN': ('pcapkit.protocols.internet.mh', 'MH',
70+
bytes.fromhex('3b010000 00000000 010400000000 0103')),
71+
'HOPOPT PadN': ('pcapkit.protocols.internet.hopopt', 'HOPOPT', _ext6(bytes.fromhex('010700000000'))),
72+
'IPv6-Opts PadN': ('pcapkit.protocols.internet.ipv6_opts', 'IPv6_Opts', _ext6(bytes.fromhex('010700000000'))),
73+
'SCTP INIT parameter': ('pcapkit.protocols.transport.sctp', 'SCTP', _sctp(
74+
bytes.fromhex('01000020 00000001 00010000 00010001 00000001 000c0010 00050000'))),
75+
# an unassigned kind declaring 12 octets, 8 present
76+
'TCP unassigned': ('pcapkit.protocols.transport.tcp', 'TCP', _tcp(bytes.fromhex('4f0caabb ccddeeff'))),
77+
# an unassigned option declaring 12 octets, 8 present in a 16-octet area
78+
'IPv4 unassigned': ('pcapkit.protocols.internet.ipv4', 'IPv4',
79+
_ipv4(bytes.fromhex('1f0caabb ccddeeff'), ihl=9)),
80+
}
81+
82+
#: The same option areas with every option complete.
83+
CONTROLS = {
84+
'HIP ACK': ('pcapkit.protocols.internet.hip', 'HIP', _hip(bytes.fromhex('01c10004 00000001'))),
85+
'MH PadN': ('pcapkit.protocols.internet.mh', 'MH',
86+
bytes.fromhex('3b010000 00000000 0106000000000000')),
87+
'HOPOPT PadN': ('pcapkit.protocols.internet.hopopt', 'HOPOPT', _ext6(bytes.fromhex('010400000000'))),
88+
'IPv6-Opts PadN': ('pcapkit.protocols.internet.ipv6_opts', 'IPv6_Opts', _ext6(bytes.fromhex('010400000000'))),
89+
'SCTP INIT parameter': ('pcapkit.protocols.transport.sctp', 'SCTP', _sctp(
90+
bytes.fromhex('0100001c 00000001 00010000 00010001 00000001 000c0008 00050006'))),
91+
'TCP unassigned': ('pcapkit.protocols.transport.tcp', 'TCP', _tcp(bytes.fromhex('4f08aabb ccddeeff'))),
92+
'IPv4 unassigned': ('pcapkit.protocols.internet.ipv4', 'IPv4',
93+
_ipv4(bytes.fromhex('1f08aabb ccddeeff'), ihl=7)),
94+
}
95+
96+
#: Each overrun inside a parent layer: (parent module, parent class, frame).
97+
IN_PARENT = {
98+
'HIP ACK': ('pcapkit.protocols.internet.ipv6', 'IPv6', _ipv6(139, OVERRUNS['HIP ACK'][2])),
99+
'MH PadN': ('pcapkit.protocols.internet.ipv6', 'IPv6', _ipv6(135, OVERRUNS['MH PadN'][2])),
100+
'HOPOPT PadN': ('pcapkit.protocols.internet.ipv6', 'IPv6', _ipv6(0, OVERRUNS['HOPOPT PadN'][2])),
101+
'IPv6-Opts PadN': ('pcapkit.protocols.internet.ipv6', 'IPv6', _ipv6(60, OVERRUNS['IPv6-Opts PadN'][2])),
102+
'SCTP INIT parameter': ('pcapkit.protocols.internet.ipv4', 'IPv4',
103+
_ipv4(b'', ihl=5, proto=132, payload=OVERRUNS['SCTP INIT parameter'][2])),
104+
'TCP unassigned': ('pcapkit.protocols.internet.ipv4', 'IPv4',
105+
_ipv4(b'', ihl=5, proto=6, payload=OVERRUNS['TCP unassigned'][2])),
106+
'IPv4 unassigned': ('pcapkit.protocols.link.ethernet', 'Ethernet',
107+
bytes.fromhex('0123456789ab fedcba987654 0800') + OVERRUNS['IPv4 unassigned'][2]),
108+
}
109+
110+
111+
class TestDeclaredLengthOverrun(unittest.TestCase):
112+
"""Pin the rejection of an option running past the end of its data."""
113+
114+
def setUp(self) -> None:
115+
reimport_once_per_class(self)
116+
117+
@staticmethod
118+
def _protocol(module: str, name: str) -> type:
119+
import importlib
120+
121+
return getattr(importlib.import_module(module), name)
122+
123+
def test_an_overrunning_option_raises_protocol_error(self) -> None:
124+
from pcapkit.utilities.exceptions import ProtocolError
125+
126+
for case, (module, name, data) in OVERRUNS.items():
127+
with self.subTest(case=case):
128+
protocol = self._protocol(module, name)
129+
with warnings.catch_warnings():
130+
warnings.simplefilter('ignore')
131+
with self.assertRaisesRegex(ProtocolError, 'runs past the end of the data'):
132+
protocol(data, len(data))
133+
134+
def test_a_complete_option_still_rebuilds_byte_for_byte(self) -> None:
135+
for case, (module, name, data) in CONTROLS.items():
136+
with self.subTest(case=case):
137+
protocol = self._protocol(module, name)
138+
parsed = protocol(data, len(data))
139+
self.assertEqual(protocol.from_data(parsed.info).data, data)
140+
141+
def test_an_overrunning_layer_is_kept_as_captured_by_its_parent(self) -> None:
142+
from pcapkit.protocols.misc.raw import Raw
143+
144+
for case, (module, name, frame) in IN_PARENT.items():
145+
with self.subTest(case=case):
146+
protocol = self._protocol(module, name)
147+
with warnings.catch_warnings():
148+
warnings.simplefilter('ignore')
149+
parsed = protocol(frame, len(frame))
150+
151+
if name == 'IPv6':
152+
# an extension header is kept as raw extension data
153+
self.assertIn('runs past the end of the data', str(parsed.info.ext.error))
154+
else:
155+
# any other layer is kept as a raw payload, as captured
156+
self.assertIsInstance(parsed.payload, Raw)
157+
self.assertIn('runs past the end of the data', str(parsed.payload.info.error))
158+
self.assertEqual(parsed.payload.data, OVERRUNS[case][2])
159+
self.assertEqual(protocol.from_data(parsed.info).data, frame)
160+
161+
def test_an_option_area_declared_past_the_data_still_ends_at_eool(self) -> None:
162+
from pcapkit.protocols.internet.ipv4 import IPv4
163+
164+
# ``ihl`` declares 20 octets of options with none present: the first
165+
# option begins at the end of the data, so it is end-of-option-list.
166+
data = bytes.fromhex('4a00001800010000400600000a0000010a000002')
167+
with warnings.catch_warnings():
168+
warnings.simplefilter('ignore')
169+
parsed = IPv4(data, len(data))
170+
self.assertEqual(parsed.info.src.compressed, '10.0.0.1')
171+
172+
173+
if __name__ == '__main__':
174+
unittest.main()

0 commit comments

Comments
 (0)