Skip to content

Commit bd82b76

Browse files
authored
fix(protocols): record a cut option area or SCTP padding where the data ends (#2680) (#2682)
Schema.unpack gave back a cut OptionField area's remainder by its declared width, keeping and rewinding octets never read, and charged the cut to the field before it parsed; #2679's SCTP.unpack override worked round that for SCTP alone and could not read a stream that does not seek. padding_length capped SCTP padding at the octets left, so a chunk cut inside it packed at full width: 34 octets in, 36 out. - Schema.unpack gives back no more of the remainder than it read, and charges the cut to the option field only when none is given back. - ChunkListField reports its remainder in declared terms; the override is gone. - padding_length returns the padding the Length calls for. OptionField.unpack asks _accepts_short_read before refusing a short option (#1325), and refuses by default, since a general padding rule accepts the PadN overruns #1325 refuses; PaddedOptionField accepts SCTP's, which the Length leaves out. - Tests: SCTP items cut at every padding offset, alone, nested and bundled, on bytes, streams and envelopes; a TCP option area cut at every offset; SCTP on a stream that cannot seek; an SCTP item overrunning its chunk, still refused; an option reporting more than it read. #2679's tests pass unchanged. Every unittest leg passes; captures parse identically to main. Closes #2680 Closes #2685
1 parent 37355fe commit bd82b76

6 files changed

Lines changed: 604 additions & 83 deletions

File tree

‎docs/source/pcapkit/protocols/transport/sctp.rst‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,10 @@ Header Schemas
323323
:members:
324324
:show-inheritance:
325325

326+
.. autoclass:: pcapkit.protocols.schema.transport.sctp.PaddedOptionField
327+
:members:
328+
:show-inheritance:
329+
326330
.. autoclass:: pcapkit.protocols.schema.transport.sctp.ChunkListField
327331
:members:
328332
:show-inheritance:

‎pcapkit/corekit/fields/collections.py‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -449,7 +449,8 @@ def unpack(self, buffer: 'bytes | IO[bytes]', packet: 'dict[str, Any]') -> 'list
449449
FieldValueError: If an option consumes nothing from ``buffer``, since
450450
the loop below has then no way to get past it.
451451
ProtocolError: If an option that begins with octets present reads
452-
past the end of the data.
452+
past the end of the data, unless :meth:`_accepts_short_read`
453+
accepts it.
453454
454455
.. versionchanged:: 1.5.0
455456
@@ -553,7 +554,7 @@ def unpack(self, buffer: 'bytes | IO[bytes]', packet: 'dict[str, Any]') -> 'list
553554

554555
# unpack option using option schema
555556
data = schema.unpack(file, length, packet) # type: ignore[call-arg,misc,var-annotated]
556-
if tracker.short and consumed > 0:
557+
if tracker.short and consumed > 0 and not self._accepts_short_read(data):
557558
raise ProtocolError(
558559
f'Field {self.name} has an option that runs past the end of '
559560
f'the data: {code!r} at offset {offset - start} of '
@@ -598,3 +599,21 @@ def unpack(self, buffer: 'bytes | IO[bytes]', packet: 'dict[str, Any]') -> 'list
598599

599600
self._option_padding = length
600601
return temp
602+
603+
def _accepts_short_read(self, option: '_TS') -> 'bool': # pylint: disable=unused-argument
604+
"""Whether an option that read past the end of the data may stand.
605+
606+
Args:
607+
option: Option just unpacked, which read short.
608+
609+
Returns:
610+
:data:`False`, so that :meth:`unpack` refuses the option
611+
(:issue:`1325`): padding cut short may be content the option's own
612+
length declares, as a ``PadN`` option's is. A list whose items'
613+
lengths leave out their trailing padding may override this, as
614+
:class:`~pcapkit.protocols.schema.transport.sctp.PaddedOptionField`
615+
does; :meth:`Schema.pack <pcapkit.protocols.schema.schema.Schema.pack>`
616+
then writes back only the octets read.
617+
618+
"""
619+
return False

‎pcapkit/protocols/schema/schema.py‎

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -732,9 +732,10 @@ def unpack(cls, data: 'bytes | IO[bytes]',
732732
declares the size of the whole area it may read, but stops at the
733733
end-of-option-list marker and reports the unconsumed remainder
734734
through ``__option_padding__``. Since that remainder has not been
735-
parsed, we rewind ``data`` by it, so that the fields which size
736-
themselves from ``__option_padding__`` read the remainder itself
737-
rather than the same number of octets from beyond it.
735+
parsed, we rewind ``data`` by as much of it as was read, so that the
736+
fields which size themselves from ``__option_padding__`` read the
737+
remainder itself rather than the same number of octets from beyond
738+
it, and record a cut inside it (:issue:`2685`).
738739
739740
"""
740741
# force cast arg type since decorator changed their signatures
@@ -798,7 +799,7 @@ def unpack(cls, data: 'bytes | IO[bytes]',
798799
# NOTE: bounded as the payload read above is (#1975).
799800
byte = data.read(min(length, sys.maxsize))
800801
self.__buffer__[field.name] = byte
801-
if not isinstance(field, ForwardMatchField):
802+
if not isinstance(field, (ForwardMatchField, OptionField)):
802803
self._note_short_read(field.name, length, byte)
803804

804805
value = field.unpack(byte, packet.copy())
@@ -842,15 +843,22 @@ def unpack(cls, data: 'bytes | IO[bytes]',
842843
data.seek(-len(byte), io.SEEK_CUR)
843844
self.__buffer__[field.name] = b''
844845
elif isinstance(field, OptionField) and field.option_padding > 0:
845-
# the option list ended before the declared field length was
846-
# exhausted; give the unconsumed remainder back to ``data``
847-
# so that the following fields can read it
848-
data.seek(-field.option_padding, io.SEEK_CUR)
846+
# NOTE: The option list ended before the declared area did, and
847+
# ``option_padding`` is the rest of that area; give back as much
848+
# of it as was read, so that the following fields read it and
849+
# record a cut inside it themselves (#2685).
849850
consumed = length - field.option_padding
851+
kept = min(consumed, len(byte))
852+
if kept < len(byte):
853+
data.seek(kept - len(byte), io.SEEK_CUR)
850854

851-
self.__buffer__[field.name] = byte[:consumed]
855+
self.__buffer__[field.name] = byte[:kept]
852856
packet['__length__'] -= consumed
857+
if kept == len(byte):
858+
self._note_short_read(field.name, length, byte)
853859
else:
860+
if isinstance(field, OptionField):
861+
self._note_short_read(field.name, length, byte)
854862
packet['__length__'] -= length
855863

856864
if packet['__length__'] < 0:

‎pcapkit/protocols/schema/transport/sctp.py‎

Lines changed: 62 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"""header schema for stream control transmission protocol"""
44

55
import io
6-
from typing import TYPE_CHECKING, cast
6+
from typing import TYPE_CHECKING
77

88
from pcapkit.const.reg.apptype import AppType as Enum_AppType
99
from pcapkit.const.reg.apptype import TransportProtocol as Enum_TransportProtocol
@@ -50,9 +50,7 @@
5050

5151
if TYPE_CHECKING:
5252
from ipaddress import IPv4Address, IPv6Address
53-
from typing import IO, Any, Callable, Optional
54-
55-
from typing_extensions import Self
53+
from typing import IO, Any, Callable
5654

5755
from pcapkit.protocols.protocol import ProtocolBase
5856

@@ -96,13 +94,15 @@ def padding_length(pkt: 'dict[str, Any]') -> 'int':
9694
pkt: Packet data.
9795
9896
Returns:
99-
Number of padding bytes, clamped to the number of bytes left in the
100-
enclosing structure, so that a final chunk captured without (all of)
101-
its padding still parses, although :rfc:`9260#section-3.2` has the
102-
sender pad every chunk. When packing, it is clamped as well
103-
to the ``__padding__`` width :func:`pre_pack_padding` hands over, so a
104-
final chunk captured without (all of) its padding rebuilds without it
105-
(:issue:`1474`).
97+
Number of padding bytes the ``length`` calls for. A final chunk captured
98+
without (all of) its padding, although :rfc:`9260#section-3.2` has the
99+
sender pad every chunk, reads short, which :meth:`Schema.unpack
100+
<pcapkit.protocols.schema.schema.Schema.unpack>` records so that
101+
:meth:`Schema.pack <pcapkit.protocols.schema.schema.Schema.pack>` writes
102+
back only the octets read, and :class:`PaddedOptionField` lets it parse
103+
(:issue:`2680`). When packing, it is clamped to the ``__padding__``
104+
width :func:`pre_pack_padding` hands over, so such a chunk rebuilt from
105+
its data model is short of its padding as well (:issue:`1474`).
106106
107107
"""
108108
length = pkt.get('length') or 0
@@ -111,11 +111,7 @@ def padding_length(pkt: 'dict[str, Any]') -> 'int':
111111
width = pkt.get('__padding__')
112112
if isinstance(width, int) and width >= 0:
113113
padding = min(padding, width)
114-
115-
remaining = pkt.get('__length__')
116-
if not isinstance(remaining, int) or remaining < 0:
117-
return padding
118-
return min(padding, remaining)
114+
return padding
119115

120116

121117
def pre_pack_padding(schema: 'Schema', packet: 'dict[str, Any]') -> 'None':
@@ -195,7 +191,8 @@ def nested_length(base: 'int') -> 'Callable[[dict[str, Any]], int]':
195191
``__option_padding__``. The last chunk of a packet may omit that padding
196192
altogether, so the chunks wrap this callback in :func:`bounded`: the span
197193
then ends with the data, and the final parameter or cause reads only the
198-
padding that is there (:issue:`1479`).
194+
padding that is there (:issue:`1479`), recording the cut
195+
(:issue:`2680`).
199196
* On construction, a separate padding field could not compute its own size:
200197
:meth:`Schema.pack <pcapkit.protocols.schema.schema.Schema.pack>` passes
201198
one shared ``packet`` mapping down into the nested schemas, and each
@@ -213,6 +210,39 @@ def callback(pkt: 'dict[str, Any]') -> 'int':
213210
return callback
214211

215212

213+
class PaddedOptionField(OptionField):
214+
"""List of chunks, parameters or error causes, the last of which may be cut
215+
inside its trailing padding.
216+
217+
:meth:`OptionField.unpack <pcapkit.corekit.fields.collections.OptionField.unpack>`
218+
refuses an item that reads past the end of the data (:issue:`1325`). The
219+
last chunk of a packet may lack (some of) its padding, though, and so may
220+
the last parameter or cause of that chunk, which share it
221+
(:func:`nested_length`). An item's ``length`` leaves its padding out and
222+
the receiver ignores it (:rfc:`9260#section-3.2`), and
223+
:class:`ChunkListField` hands on only chunks whose ``length`` octets are
224+
all present, so an item short of its padding alone is cut rather than
225+
overrun. It records that as a short read of its ``padding``, and is kept
226+
(:issue:`2680`).
227+
228+
"""
229+
230+
def _accepts_short_read(self, option: 'Schema') -> 'bool':
231+
"""Whether ``option`` read short inside its trailing padding alone.
232+
233+
Args:
234+
option: Item just unpacked, which read short.
235+
236+
Returns:
237+
Whether the short read :meth:`Schema.unpack
238+
<pcapkit.protocols.schema.schema.Schema.unpack>` recorded is of the
239+
item's ``padding``.
240+
241+
"""
242+
record = option.__dict__.get('__short_read__')
243+
return record is not None and record[0] == 'padding'
244+
245+
216246
class ErrorCause(EnumSchema[Enum_CauseCode]):
217247
"""Header schema for SCTP error causes."""
218248

@@ -644,7 +674,7 @@ class INITChunk(Chunk, code=Enum_Chunk.Initiation):
644674
init_tsn: 'int' = UInt32Field()
645675
#: Optional and variable-length parameters, including the chunk's own
646676
#: trailing padding; see :func:`nested_length`.
647-
parameters: 'list[Parameter]' = OptionField(
677+
parameters: 'list[Parameter]' = PaddedOptionField(
648678
length=bounded(nested_length(20)),
649679
base_schema=Parameter,
650680
type_name='type',
@@ -674,7 +704,7 @@ class INITACKChunk(Chunk, code=Enum_Chunk.Initiation_Acknowledgement):
674704
init_tsn: 'int' = UInt32Field()
675705
#: Optional and variable-length parameters, including the chunk's own
676706
#: trailing padding; see :func:`nested_length`.
677-
parameters: 'list[Parameter]' = OptionField(
707+
parameters: 'list[Parameter]' = PaddedOptionField(
678708
length=bounded(nested_length(20)),
679709
base_schema=Parameter,
680710
type_name='type',
@@ -729,7 +759,7 @@ class HeartbeatChunk(Chunk, code=Enum_Chunk.Heartbeat_Request):
729759

730760
#: Heartbeat information parameters, including the chunk's own trailing
731761
#: padding; see :func:`nested_length`.
732-
parameters: 'list[Parameter]' = OptionField(
762+
parameters: 'list[Parameter]' = PaddedOptionField(
733763
length=bounded(nested_length(4)),
734764
base_schema=Parameter,
735765
type_name='type',
@@ -747,7 +777,7 @@ class HeartbeatACKChunk(Chunk, code=Enum_Chunk.Heartbeat_Acknowledgement):
747777

748778
#: Heartbeat information parameters, including the chunk's own trailing
749779
#: padding; see :func:`nested_length`.
750-
parameters: 'list[Parameter]' = OptionField(
780+
parameters: 'list[Parameter]' = PaddedOptionField(
751781
length=bounded(nested_length(4)),
752782
base_schema=Parameter,
753783
type_name='type',
@@ -770,7 +800,7 @@ class AbortChunk(Chunk, code=Enum_Chunk.Abort):
770800
})
771801
#: Zero or more error causes, including the chunk's own trailing padding;
772802
#: see :func:`nested_length`.
773-
error: 'list[ErrorCause]' = OptionField(
803+
error: 'list[ErrorCause]' = PaddedOptionField(
774804
length=bounded(nested_length(4)),
775805
base_schema=ErrorCause,
776806
type_name='code',
@@ -813,7 +843,7 @@ class ErrorChunk(Chunk, code=Enum_Chunk.Operation_Error):
813843

814844
#: One or more error causes, including the chunk's own trailing padding;
815845
#: see :func:`nested_length`.
816-
error: 'list[ErrorCause]' = OptionField(
846+
error: 'list[ErrorCause]' = PaddedOptionField(
817847
length=bounded(nested_length(4)),
818848
base_schema=ErrorCause,
819849
type_name='code',
@@ -875,7 +905,7 @@ def whole_chunks_length(data: 'bytes') -> 'int':
875905
Returns:
876906
Number of octets up to the end of the last chunk whose declared
877907
``length`` octets are all present, with as much of its padding as there
878-
is, see :func:`padding_length`. A ``length`` below the four-octet chunk
908+
is, see :class:`PaddedOptionField`. A ``length`` below the four-octet chunk
879909
header (:rfc:`9260#section-3.2`) delimits nothing, so the run stops
880910
there too; four zero octets would otherwise read as a DATA chunk.
881911
@@ -890,17 +920,20 @@ def whole_chunks_length(data: 'bytes') -> 'int':
890920
return end
891921

892922

893-
class ChunkListField(OptionField):
923+
class ChunkListField(PaddedOptionField):
894924
"""Chunk list that stops at the last whole chunk.
895925
896926
SCTP has no packet length field, so octets after the last whole chunk --
897927
fewer than a chunk header, or a chunk whose ``length`` runs past the data --
898928
cannot be told apart from a chunk cut short. They are not parsed but
899929
reported as option padding, which :meth:`Schema.unpack
900930
<pcapkit.protocols.schema.schema.Schema.unpack>` hands back to the stream for
901-
:attr:`SCTP.trailer` to keep as captured (:issue:`1468`). The area reaches no
902-
further than the octets present, as :meth:`SCTP.unpack` sizes it
903-
(:issue:`2673`).
931+
:attr:`SCTP.trailer` to keep as captured (:issue:`1468`). The padding is the
932+
rest of the area declared, as for any
933+
:class:`~pcapkit.corekit.fields.collections.OptionField`, so an area the data
934+
ends inside hands back only the octets present (:issue:`2685`). The last
935+
whole chunk may be cut inside its padding, and is kept as
936+
:class:`PaddedOptionField` keeps one (:issue:`2680`).
904937
905938
"""
906939

@@ -928,7 +961,7 @@ def unpack(self, buffer: 'bytes | IO[bytes]', packet: 'dict[str, Any]') -> 'list
928961
chunks = super().unpack(data[:whole], packet)
929962
finally:
930963
self._length = area
931-
self._option_padding += len(data) - whole
964+
self._option_padding += max(area, 0) - whole
932965
return chunks
933966

934967

@@ -967,49 +1000,6 @@ class SCTP(Schema):
9671000
#: and :attr:`IPv4.trailer <pcapkit.protocols.schema.internet.ipv4.IPv4.trailer>`.
9681001
trailer: 'bytes' = PayloadField(default=b'')
9691002

970-
@classmethod
971-
def unpack(cls, data: 'bytes | IO[bytes]', # type: ignore[override]
972-
length: 'Optional[int]' = None, packet: 'Optional[dict[str, Any]]' = None) -> 'Self':
973-
"""Unpack :obj:`bytes` into :class:`SCTP`, no further than the octets present.
974-
975-
Args:
976-
data: Packed data.
977-
length: Length of data, as the enclosing layer declares it.
978-
packet: Unpacked data.
979-
980-
Returns:
981-
Unpacked data as :class:`SCTP`.
982-
983-
SCTP has no packet length field (:rfc:`9260#section-3`), so a ``length``
984-
past the octets present, as an IPv4 Total Length the capture cut short of
985-
declares, is cut to them. Sized past them, :attr:`chunks` would record the
986-
data as ending inside it while :class:`ChunkListField` hands the octets
987-
after the last whole chunk to :attr:`trailer`, so :meth:`Schema.pack
988-
<pcapkit.protocols.schema.schema.Schema.pack>` would drop them and
989-
``bytes(schema)`` count them twice (:issue:`2673`).
990-
991-
When no octet follows the last whole chunk, which the capture may have cut
992-
inside its padding, the data does end inside :attr:`chunks`, so the cut is
993-
recorded there as :meth:`Schema.unpack
994-
<pcapkit.protocols.schema.schema.Schema.unpack>` records a short read, and
995-
packing writes back only the octets read.
996-
997-
"""
998-
cut = False
999-
if length is not None:
1000-
if isinstance(data, bytes):
1001-
present = len(data)
1002-
else:
1003-
start = data.tell()
1004-
present = data.seek(0, io.SEEK_END) - start
1005-
data.seek(start, io.SEEK_SET)
1006-
cut, length = length > present, min(length, present)
1007-
1008-
schema = cast('Self', super().unpack(data, length, packet)) # type: ignore[call-arg,misc]
1009-
if cut and not schema.__buffer__['trailer'] and '__short_read__' not in schema.__dict__:
1010-
schema.__dict__['__short_read__'] = ('chunks', len(schema.__buffer__['chunks']))
1011-
return schema
1012-
10131003
if TYPE_CHECKING:
10141004
def __init__(self, srcport: 'Enum_AppType | int', dstport: 'Enum_AppType | int',
10151005
vtag: 'int', chksum: 'bytes',

0 commit comments

Comments
 (0)