Skip to content

Commit 2f4ba1d

Browse files
DHCP: implement RFC 3396 encoding of long options (fixes #4642)
Split DHCP options longer than 255 bytes into multiple consecutive TLV entries during serialization (i2m), and concatenate consecutive options with the same code during dissection (m2i), as specified by RFC 3396. Also add support for option overload (code 52) in getfield, building the aggregate option buffer from the options, file and sname fields as described in RFC 3396 section 5.
1 parent 5b81ba1 commit 2f4ba1d

2 files changed

Lines changed: 118 additions & 13 deletions

File tree

scapy/layers/dhcp.py

Lines changed: 82 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
- rfc951 - BOOTSTRAP PROTOCOL (BOOTP)
1111
- rfc1542 - Clarifications and Extensions for the Bootstrap Protocol
1212
- rfc1533 - DHCP Options and BOOTP Vendor Extensions
13+
- rfc3396 - Encoding Long Options in DHCPv4
1314
"""
1415

1516
try:
@@ -436,8 +437,62 @@ def i2repr(self, pkt, x):
436437
s.append(sane(v))
437438
return "[%s]" % (" ".join(s))
438439

440+
def _find_overload(self, x):
441+
"""
442+
Quickly scans the raw options buffer to find the value
443+
of the Option Overload option (code 52).
444+
Returns 0 if the option is not present.
445+
"""
446+
while x:
447+
o = orb(x[0])
448+
if o == 255:
449+
break
450+
if o == 0:
451+
x = x[1:]
452+
continue
453+
if len(x) < 2:
454+
break
455+
olen = orb(x[1])
456+
if len(x) < olen + 2:
457+
break
458+
if o == 52 and olen == 1:
459+
return orb(x[2])
460+
x = x[olen + 2:]
461+
return 0
462+
439463
def getfield(self, pkt, s):
440-
return b"", self.m2i(pkt, s)
464+
"""
465+
Retrieve the binary value of the s option field.
466+
RFC 3396: build the aggregated options buffer (options, then file, then sname)
467+
if the overload option is present.
468+
"""
469+
aggregate = s
470+
overload = self._find_overload(s)
471+
if (overload
472+
and pkt.underlayer is not None
473+
and isinstance(pkt.underlayer, BOOTP)):
474+
if overload in (1, 3):
475+
aggregate += pkt.underlayer.file
476+
if overload in (2, 3):
477+
aggregate += pkt.underlayer.sname
478+
return b"", self.m2i(pkt, aggregate)
479+
480+
def _concat_fragments(self, x, o):
481+
"""
482+
RFC 3396: concatenate consecutive option fragments with the same code o
483+
found at the beginning of buffer x.
484+
Returns (raw_value, remaining_buffer) where:
485+
- raw_value contains the concatenated data from all consumed fragments.
486+
- remaining_buffer is x advanced past those fragments.
487+
"""
488+
raw_value = b""
489+
while (x and len(x) >= 2 and orb(x[0]) == o):
490+
next_olen = orb(x[1])
491+
if len(x) < next_olen + 2:
492+
break
493+
raw_value += x[2:next_olen + 2]
494+
x = x[next_olen + 2:]
495+
return raw_value, x
441496

442497
def m2i(self, pkt, x):
443498
opt = []
@@ -456,40 +511,51 @@ def m2i(self, pkt, x):
456511
break
457512
elif o in DHCPOptions:
458513
f = DHCPOptions[o]
514+
olen = orb(x[1])
515+
x_before = x
516+
raw_value = x[2:olen + 2]
517+
x = x[olen + 2:]
518+
519+
# RFC 3396: concatenate subsequent fragments
520+
extra, x = self._concat_fragments(x, o)
521+
raw_value += extra
459522

460523
if isinstance(f, str):
461-
olen = orb(x[1])
462-
opt.append((f, x[2:olen + 2]))
463-
x = x[olen + 2:]
524+
opt.append((f, raw_value))
464525
else:
465-
olen = orb(x[1])
466526
lval = [f.name]
467527

468-
if olen == 0:
528+
if len(raw_value) == 0:
469529
try:
470530
_, val = f.getfield(pkt, b'')
471531
except Exception:
472-
opt.append(x)
532+
opt.append(x_before)
473533
break
474534
else:
475535
lval.append(val)
476536

477537
try:
478-
left = x[2:olen + 2]
538+
left = raw_value
479539
while left:
480540
left, val = f.getfield(pkt, left)
481541
lval.append(val)
482542
except Exception:
483-
opt.append(x)
543+
opt.append(x_before)
484544
break
485545
else:
486546
otuple = tuple(lval)
487547
opt.append(otuple)
488-
x = x[olen + 2:]
489548
else:
490549
olen = orb(x[1])
491-
opt.append((o, x[2:olen + 2]))
550+
x_before = x
551+
raw_value = x[2:olen + 2]
492552
x = x[olen + 2:]
553+
554+
# RFC 3396: concatenate subsequent fragments
555+
extra, x = self._concat_fragments(x, o)
556+
raw_value += extra
557+
558+
opt.append((o, raw_value))
493559
return opt
494560

495561
def i2m(self, pkt, x):
@@ -514,8 +580,11 @@ def i2m(self, pkt, x):
514580
warning("Unknown field option %s", name)
515581
continue
516582

517-
s += struct.pack("!BB", onum, len(oval))
518-
s += oval
583+
while oval:
584+
chunk = oval[:255]
585+
oval = oval[255:]
586+
s += struct.pack("!BB", onum, len(chunk))
587+
s += chunk
519588

520589
elif (isinstance(o, str) and o in DHCPRevOptions and
521590
DHCPRevOptions[o][1] is None):

test/scapy/layers/dhcp.uts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,3 +137,39 @@ assert result in [
137137
'<function scapy.ansmachine.dhcpd(self, pool: Union[scapy.base_classes.Net, List[str]] = Net("192.168.1.128/25"), network: str = \'192.168.1.0/24\', gw: str = \'192.168.1.1\', nameserver: Union[str, List[str]] = None, domain: Union[str, NoneType] = None, renewal_time: int = 60, lease_time: int = 1800, **kwargs)>',
138138
'<function scapy.ansmachine.dhcpd(self, pool=Net("192.168.1.128/25"), network=\'192.168.1.0/24\', gw=\'192.168.1.1\', nameserver=None, domain=None, renewal_time=60, lease_time=1800, **kwargs)>',
139139
]
140+
141+
= RFC 3396 - Encoding long DHCPv4 options (issue #4642)
142+
# i2m: option > 255 bytes is split into fragments (test case from issue)
143+
# m2i: consecutive fragments with same code are concatenated
144+
# m2i: non-consecutive same code options are NOT concatenated
145+
# m2i: concatenation works for unknown option codes
146+
# roundtrip: long option survives encode/decode
147+
# getfield: sname/file not aggregated without overload
148+
# getfield: overload=1 aggregates file field
149+
150+
import struct
151+
152+
r = raw(DHCP(options=[('captive-portal', 'a'*256), 'end']))
153+
assert r[:2] == b'\x72\xff' and r[2:257] == b'a'*255
154+
assert r[257:260] == b'\x72\x01a' and r[260:261] == b'\xff'
155+
156+
assert DHCP(b'\x06\x02\x01\x02\x06\x02\x03\x04').options == DHCP(b'\x06\x04\x01\x02\x03\x04').options
157+
158+
p = DHCP(b'\x0c\x02sc\x06\x04\x01\x02\x03\x04\x0c\x02py')
159+
assert p.options == [('hostname', b'sc'), ('name_server', '1.2.3.4'), ('hostname', b'py')]
160+
161+
assert DHCP(b'\xfe\x02AB\xfe\x02CD').options[0] == (254, b'ABCD')
162+
163+
pkt2 = DHCP(raw(DHCP(options=[('captive-portal', 'a'*400), 'end'])))
164+
assert pkt2.options[0] == ('captive-portal', b'a'*400) and pkt2.options[-1] == 'end'
165+
166+
bootp_pkt = BOOTP(chaddr="00:01:02:03:04:05", sname=b'myserver'+b'\x00'*56, file=b'bootfile'+b'\x00'*120, options=b'c\x82Sc') / DHCP(options=[('message-type', 'discover'), 'end'])
167+
p = BOOTP(raw(bootp_pkt))
168+
assert p[DHCP].options[0] == ('message-type', 1) and p[BOOTP].sname[:8] == b'myserver'
169+
170+
magic = b'\x63\x82\x53\x63'
171+
opts = b'\x34\x01\x01' + b'\x35\x01\x01' + b'\xff'
172+
file_field = (b'\x0c\x05scapy' + b'\xff' + b'\x00'*120)[:128]
173+
bootp_raw = struct.pack("!4B", 1, 1, 6, 0) + b'\x00'*4 + b'\x00'*4 + b'\x00'*16 + b'\x00'*16 + b'\x00'*64 + file_field + magic + opts
174+
p = BOOTP(bootp_raw)
175+
assert DHCP in p and ('hostname', b'scapy') in p[DHCP].options

0 commit comments

Comments
 (0)