Skip to content

Commit 98d7d80

Browse files
authored
fix(reassembly): only the final fragment's partial block counts, and an oversized datagram never completes (#1566, #1567) (#1582)
1 parent bfb0e99 commit 98d7d80

8 files changed

Lines changed: 499 additions & 52 deletions

File tree

‎docs/source/pcapkit/foundation/reassembly/ip/index.rst‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,11 @@ Algorithm
9393
Because completion frees the buffer (:rfc:`791#section-3.2`), a fragment that
9494
arrives after its datagram has completed opens a new buffer, which is later
9595
reported as a separate incomplete datagram; this is intended (:issue:`1507`).
96+
97+
Two departures from the procedure above:
98+
99+
* Only the final fragment's partial last ``RCVBT`` block is set. A non-final
100+
fragment carries a multiple of 8 octets, so one ending mid-block was cut or
101+
is malformed, and setting its last block would complete the datagram with
102+
octets nobody sent (:issue:`1567`).
103+
* A datagram longer than the data buffer is never complete (:issue:`1566`).

‎docs/source/pcapkit/foundation/reassembly/ip/ipv6.rst‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,17 @@ Terminology
6363
back off. All four adapters (``pcap``, ``pcapng``, ``dpkt`` and
6464
``scapy``) agree on the three fields.
6565

66+
.. note::
67+
68+
``tl`` is derived from the captured payload, not from the Payload
69+
Length field, so a fragment the snapshot length cut does not show it
70+
in ``tl``. A non-final fragment must carry a multiple of 8 octets
71+
(:rfc:`8200#section-4.5`), so reassembly does not count the partial
72+
last block of one that ends mid-block as received: the datagram
73+
stays incomplete, rather than completing with zeros where the cut
74+
octets were (:issue:`1567`). A cut *final* fragment cannot be told
75+
from a whole one.
76+
6677
.. note::
6778

6879
The reassembly key is the Fragment header's *Identification*

‎pcapkit/foundation/reassembly/ip.py‎

Lines changed: 72 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,16 @@ class IP(ReassemblyBase[Packet[_AT], Datagram[_AT], BufferID, Buffer[_AT]], Gene
3535
3636
Args:
3737
strict: if :data:`True`, report a datagram that is not completely
38-
reassembled as the tuple of its received runs; otherwise as one
39-
contiguous payload, its holes zero-filled -- or, while the total
40-
length is unknown, only the prefix up to the first hole. Receipt
41-
is tracked in 8-octet blocks, so the partial last block of a
42-
snaplen-truncated fragment is not counted as received: it is left
43-
out of the runs and ends the prefix, but once the total length is
44-
known the contiguous payload keeps its real octets, with only the
45-
rest of the hole zero-filled
38+
reassembled as the tuple of its received runs, empty if none was;
39+
otherwise as one contiguous payload, its holes zero-filled -- or,
40+
while the total length is unknown, only the prefix up to the first
41+
hole. Receipt is tracked in 8-octet blocks, and only the final
42+
fragment's partial last block counts as received -- a non-final
43+
fragment's, or a snaplen-truncated or buffer-clipped fragment's,
44+
does not: it is left out of the runs and ends the prefix, but once
45+
the total length is known the contiguous payload keeps its real
46+
octets, with only the rest of the hole zero-filled. A datagram
47+
longer than the data buffer is never complete
4648
store: if store reassembled datagram in memory, i.e.,
4749
:attr:`self._dtgram <pcapkit.foundation.reassembly.reassembly.Reassembly._dtgram>`
4850
(if not, datagram will be discarded after callback)
@@ -168,12 +170,12 @@ def reassembly(self, info: 'Packet[_AT]') -> 'None':
168170
# grows to hold such a datagram rather than clipping it, ``RCVBT`` in
169171
# step with it.
170172
#
171-
# With nothing declared, a snaplen cut cannot be seen in the length.
172-
# A fragment with **MF** set must carry a multiple of 8 octets, so its
173-
# partial last block is a cut and is left unmarked below. Any other cut
174-
# -- an unfragmented frame's, or a final fragment's -- cannot be told
175-
# from a whole one, since the frame's original length is not part of
176-
# the input, and the datagram comes out ``COMPLETE`` but short.
173+
# With nothing declared, a snaplen cut cannot be seen in the length. A
174+
# non-final fragment's cut still shows, as a partial last block, which
175+
# is left unmarked below. Any other cut -- an unfragmented frame's, or
176+
# a final fragment's -- cannot be told from a whole one, since the
177+
# frame's original length is not part of the input, and the datagram
178+
# comes out ``COMPLETE`` but short.
177179
if TL == 0:
178180
length = len(info.payload)
179181
extent = FO + length
@@ -206,13 +208,23 @@ def reassembly(self, info: 'Packet[_AT]') -> 'None':
206208
buf.datagram[start:stop] = payload
207209

208210
# Set RCVBT bits (in 8 octets) for the blocks this fragment actually
209-
# filled. A fragment held in full marks its last block even when it is
210-
# short, as the final fragment's is; a truncated one leaves its partial
211-
# last block clear, since the octets after the cut are a real hole --
212-
# as does a non-final fragment with Total Length 0, whose partial last
213-
# block can only be a cut.
211+
# filled. Only the final fragment, held in full, marks a partial last
212+
# block: the octets after its end are past the datagram's. Every other
213+
# partial last block is left clear, as a hole --
214+
#
215+
# * a truncated fragment's, since the octets after the cut are missing.
216+
# That includes one clipped by the data buffer's end, whose octets
217+
# past it cannot be held, so it counts as cut there (:issue:`1566`).
218+
# * a non-final fragment's. :rfc:`791#section-3.2` and
219+
# :rfc:`8200#section-4.5` make every fragment but the last carry a
220+
# multiple of 8 octets, so one that ends mid-block was cut or is
221+
# malformed, and nobody sent the rest of its block. Marking it would
222+
# complete the datagram with zeros in their place. This is the only
223+
# way a cut shows when the length is not declared: under Total
224+
# Length 0, and on IPv6, whose adapters derive it from the captured
225+
# payload (:issue:`1567`).
214226
start = FO // 8
215-
if held == length and not (TL == 0 and MF):
227+
if held == length and not MF:
216228
stop = (FO + held + 7) // 8
217229
else:
218230
stop = (FO + held) // 8
@@ -224,16 +236,33 @@ def reassembly(self, info: 'Packet[_AT]') -> 'None':
224236
# fragment with MF clear has arrived, and ``0`` is a valid length
225237
if not MF:
226238
buf.__update__(TDL=length + FO)
227-
TDL = buf.TDL
228239

229240
# when datagram is reassembled in whole
230-
start = 0
231-
stop = (TDL + 7) // 8
232-
if TDL >= 0 and all(buf.RCVBT[start:stop]):
241+
if self._is_complete(buf):
233242
self._dtgram.extend(
234243
self.submit(self._buffer.pop(BUFID), bufid=BUFID, checked=True)
235244
)
236245

246+
@staticmethod
247+
def _is_complete(buf: 'Buffer[_AT]') -> 'bool':
248+
"""Tell whether a buffer holds its whole datagram.
249+
250+
Arguments:
251+
buf: buffer to check
252+
253+
Returns:
254+
Whether the total data length is known, fits the data buffer, and
255+
every 8-octet block up to it is marked received.
256+
257+
A datagram longer than the data buffer cannot be held, so it is never
258+
complete. Checking ``RCVBT`` alone does not show that: slicing it past
259+
its end stops silently, so the blocks past the buffer would read as
260+
received (:issue:`1566`).
261+
262+
"""
263+
TDL = buf.TDL
264+
return 0 <= TDL <= len(buf.datagram) and all(buf.RCVBT[:(TDL + 7) // 8])
265+
237266
@staticmethod
238267
def _detect_conflicts(rcvbt: 'bytearray', tdl: 'int', datagram: 'bytearray', payload: 'bytearray',
239268
start: 'int', stop: 'int') -> 'list[tuple[int, int]]':
@@ -331,9 +360,7 @@ def submit(self, buf: 'Buffer[_AT]', *, bufid: 'tuple[_AT, _AT, int, TransType]'
331360
datagram = buf.datagram
332361
conflict = tuple(buf.conflict)
333362

334-
start = 0
335-
stop = (TDL + 7) // 8
336-
flag = checked or (TDL >= 0 and all(RCVBT[start:stop]))
363+
flag = checked or self._is_complete(buf)
337364
ret = [] # type: list[Datagram[_AT]]
338365

339366
# How completely this datagram came out, and why it stopped. Derived once,
@@ -361,23 +388,24 @@ def submit(self, buf: 'Buffer[_AT]', *, bufid: 'tuple[_AT, _AT, int, TransType]'
361388
# the last run may reach the end of the bit table
362389
if byte:
363390
data.append(bytes(byte))
364-
# strip empty packets
365-
if data or header:
366-
packet = Datagram(
367-
completed=completion,
368-
id=DatagramID(
369-
src=bufid[0],
370-
dst=bufid[1],
371-
id=bufid[2],
372-
proto=bufid[3],
373-
),
374-
index=tuple(index),
375-
header=header,
376-
payload=tuple(data),
377-
packet=None,
378-
conflict=conflict,
379-
)
380-
ret.append(packet)
391+
# Report it even with no run received, its payload ``()``, as
392+
# ``strict=False`` reports it with an empty payload: ``index``
393+
# still says which frames were its fragments (:issue:`1566`).
394+
packet = Datagram(
395+
completed=completion,
396+
id=DatagramID(
397+
src=bufid[0],
398+
dst=bufid[1],
399+
id=bufid[2],
400+
proto=bufid[3],
401+
),
402+
index=tuple(index),
403+
header=header,
404+
payload=tuple(data),
405+
packet=None,
406+
conflict=conflict,
407+
)
408+
ret.append(packet)
381409
# if datagram is reassembled in whole -- or if it is not, and ``strict``
382410
# asked for one contiguous payload rather than the received runs
383411
else:

‎tests/foundation/reassembly/test_ip.py‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -98,16 +98,18 @@ class TestIP(IP):
9898
self.assertEqual(pending._buffer[self._packet(num=0, fo=0, mf=True, payload=b'').bufid].header,
9999
b'updated')
100100

101+
# a buffer with no run received is still reported, as strict=False
102+
# reports it (#1566)
101103
empty = TestIP()
102104
src = ip_address('192.0.2.1')
103105
dst = ip_address('198.51.100.2')
104-
self.assertEqual(
105-
empty.submit(
106-
Buffer(-1, bytearray(b'\x00\x00'), [], b'', bytearray(b''), 1000.0, []),
107-
bufid=(src, dst, 42, TransType.UDP),
108-
),
109-
[],
106+
datagram, = empty.submit(
107+
Buffer(-1, bytearray(b'\x00\x00'), [], b'', bytearray(b''), 1000.0, []),
108+
bufid=(src, dst, 42, TransType.UDP),
110109
)
110+
self.assertFalse(datagram.completed)
111+
self.assertEqual(datagram.payload, ())
112+
self.assertIsNone(datagram.packet)
111113

112114

113115
@unittest.skipUnless(HAS_RUNTIME, 'runtime dependencies not installed')
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
# -*- coding: utf-8 -*-
2+
"""A non-final IP fragment that ends mid-block leaves a hole, not zeros.
3+
4+
GitHub issue #1567. The IPv6 adapters derive ``tl`` from the captured payload,
5+
so a fragment the snapshot length cut declares exactly what was captured, and
6+
the reassembler marked its partial last block received. A 3-fragment datagram
7+
with its first fragment cut from 64 octets to 60 then came out ``COMPLETE``,
8+
octets 60-63 zero-filled; cut to 52, strict mode's first run carried 4
9+
zero-filled octets.
10+
11+
Every fragment but the last carries a multiple of 8 octets
12+
(:rfc:`8200#section-4.5`, :rfc:`791#section-3.2`), so only the final fragment's
13+
partial last block is now counted received. That covers an IPv4 fragment whose
14+
Total Length declares a non-multiple of 8 on a non-final fragment as well.
15+
16+
Every case builds its fragments in memory and reads no capture. Classes are
17+
imported inside each test, after :func:`~tests._support.reimport_once_per_class`.
18+
19+
"""
20+
21+
from ipaddress import ip_address
22+
from itertools import permutations
23+
import unittest
24+
25+
from tests._support import reimport_once_per_class
26+
27+
#: The datagram, with no zero octet in it, so a hole shows.
28+
DATA = bytes((i * 7 + 3) % 251 + 1 for i in range(200))
29+
30+
#: Its three fragments, as ``(fragment offset, more fragments, payload)``.
31+
FRAGMENTS = ((0, True, DATA[0:64]), (64, True, DATA[64:128]), (128, False, DATA[128:200]))
32+
33+
34+
class TestIPReassemblyCutNonFinalFragment(unittest.TestCase):
35+
"""Pin that a cut non-final fragment leaves the datagram incomplete."""
36+
37+
def setUp(self) -> None:
38+
reimport_once_per_class(self)
39+
40+
def _packet(self, *, num: int, fo: int, mf: bool, payload: bytes, tl: 'int | None' = None):
41+
"""A fragment whose ``tl`` is, as the IPv6 adapters pass it, its captured length."""
42+
from pcapkit.const.reg.transtype import TransType
43+
from pcapkit.foundation.reassembly.data.ip import Packet
44+
45+
bufid = (ip_address('2001:db8::1'), ip_address('2001:db8::2'), 9, TransType.UDP)
46+
return Packet(bufid, num, fo, 40, mf, 40 + len(payload) if tl is None else tl,
47+
b'H' * 40, bytearray(payload), 1000.0 + num)
48+
49+
def _reassemble(self, cls, packets, strict: bool):
50+
reasm = cls(strict=strict)
51+
for packet in packets:
52+
reasm(packet)
53+
datagram, = reasm.datagram
54+
return datagram
55+
56+
def test_a_cut_first_ipv6_fragment_leaves_a_hole(self) -> None:
57+
from pcapkit.foundation.reassembly.data.data import Completion
58+
from pcapkit.foundation.reassembly.ipv6 import IPv6
59+
60+
for cut in (60, 52):
61+
for strict in (True, False):
62+
with self.subTest(cut=cut, strict=strict):
63+
packets = [self._packet(num=num, fo=fo, mf=mf, payload=payload[:cut] if fo == 0 else payload)
64+
for num, (fo, mf, payload) in enumerate(FRAGMENTS, 1)]
65+
datagram = self._reassemble(IPv6, packets, strict)
66+
self.assertIs(datagram.completed, Completion.PARTIAL)
67+
self.assertEqual(datagram.index, (1, 2, 3))
68+
if strict:
69+
# the cut fragment's whole blocks only
70+
self.assertEqual(datagram.payload, (DATA[:cut // 8 * 8], DATA[64:]))
71+
else:
72+
# its real octets kept, the rest of the hole zero-filled
73+
self.assertEqual(datagram.payload, DATA[:cut] + bytes(64 - cut) + DATA[64:])
74+
75+
def test_a_non_final_ipv4_fragment_ending_mid_block_leaves_a_hole(self) -> None:
76+
# Total Length 100 declares 60 octets on a non-final fragment, all of
77+
# them captured: malformed rather than cut, but nobody sent 60-63.
78+
from pcapkit.foundation.reassembly.data.data import Completion
79+
from pcapkit.foundation.reassembly.ipv4 import IPv4
80+
81+
for strict in (True, False):
82+
with self.subTest(strict=strict):
83+
packets = [self._packet(num=num, fo=fo, mf=mf, payload=payload[:60] if fo == 0 else payload)
84+
for num, (fo, mf, payload) in enumerate(FRAGMENTS, 1)]
85+
datagram = self._reassemble(IPv4, packets, strict)
86+
self.assertIs(datagram.completed, Completion.PARTIAL)
87+
self.assertEqual(datagram.payload,
88+
(DATA[:56], DATA[64:]) if strict else DATA[:60] + bytes(4) + DATA[64:])
89+
90+
def test_an_uncut_datagram_completes_byte_exact_in_any_order(self) -> None:
91+
from pcapkit.foundation.reassembly.data.data import Completion
92+
from pcapkit.foundation.reassembly.ipv4 import IPv4
93+
from pcapkit.foundation.reassembly.ipv6 import IPv6
94+
95+
for cls in (IPv4, IPv6):
96+
for order in permutations(range(3)):
97+
for strict in (True, False):
98+
with self.subTest(cls=cls.__name__, order=order, strict=strict):
99+
packets = [self._packet(num=index + 1, fo=FRAGMENTS[index][0], mf=FRAGMENTS[index][1],
100+
payload=FRAGMENTS[index][2]) for index in order]
101+
datagram = self._reassemble(cls, packets, strict)
102+
self.assertIs(datagram.completed, Completion.COMPLETE)
103+
self.assertEqual(datagram.payload, DATA)
104+
self.assertEqual(datagram.conflict, ())
105+
106+
107+
if __name__ == '__main__':
108+
unittest.main()

‎tests/foundation/reassembly/test_ip_last_fragment_offset_1565_unit.py‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,13 @@ def test_a_lone_fragment_at_the_largest_offset_is_buffered(self) -> None:
6565
datagram, = reasm.datagram
6666
self.assertIs(datagram.completed, Completion.PARTIAL)
6767
self.assertEqual(datagram.index, (1,))
68-
if strict:
68+
if strict and mf:
69+
# a non-final fragment ending mid-block was cut
70+
# or is malformed, so its block is not received
71+
# (#1567) -- and the datagram is still reported
72+
# (#1566)
73+
self.assertEqual(datagram.payload, ())
74+
elif strict:
6975
run, = datagram.payload
7076
self.assertEqual(run[:size], tail)
7177
elif mf:
@@ -112,7 +118,9 @@ def test_a_non_final_fragment_at_the_largest_offset_leaves_the_datagram_open(sel
112118
run, = datagram.payload
113119
else:
114120
run = datagram.payload
115-
self.assertEqual(run[:LAST + size], BODY + tail)
121+
# the tail ends mid-block on a non-final fragment, so
122+
# its block is not received (#1567)
123+
self.assertEqual(run, BODY)
116124

117125
def test_a_tso_fragment_at_the_largest_offset_composes_with_the_buffer_growth(self) -> None:
118126
# Total Length 0 (#1555): 7 octets end exactly at the preallocated

0 commit comments

Comments
 (0)