Skip to content

Commit e58cf84

Browse files
DHCP: implement RFC 3396 encoding of long options (fixes #4642, #4343)
Split DHCP options longer than 255 bytes into multiple consecutive TLV entries during serialization (i2m), as specified by RFC 3396. Add opt-in RFC 3396 decoding via conf.contribs["dhcp"]["rfc3396"]. When enabled, all options sharing the same code are concatenated globally before interpretation, and the aggregate option buffer is built from options/file/sname fields when option overload is present (RFC 3396 section 5). When disabled (the default), decoding behavior is unchanged.
1 parent 5b81ba1 commit e58cf84

File tree

2 files changed

+185
-30
lines changed

2 files changed

+185
-30
lines changed

scapy/layers/dhcp.py

Lines changed: 133 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,14 @@
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
14+
15+
You can disable concatenation on DHCP options holding the same code
16+
while decoding DHCP packets with::
17+
18+
>>> conf.contribs["dhcp"]["rfc3396"] = False
19+
20+
(Defaults to True)
1321
"""
1422

1523
try:
@@ -69,6 +77,9 @@
6977
)
7078

7179
dhcpmagic = b"c\x82Sc"
80+
if "dhcp" not in conf.contribs:
81+
conf.contribs["dhcp"] = {}
82+
conf.contribs["dhcp"]["rfc3396"] = True
7283

7384

7485
class _BOOTP_chaddr(StrFixedLenField):
@@ -436,60 +447,145 @@ def i2repr(self, pkt, x):
436447
s.append(sane(v))
437448
return "[%s]" % (" ".join(s))
438449

439-
def getfield(self, pkt, s):
440-
return b"", self.m2i(pkt, s)
441-
442-
def m2i(self, pkt, x):
443-
opt = []
450+
def _extract_raw_entries(self, x):
451+
"""
452+
Extract raw TLV entries from the options buffer without interpreting them.
453+
Returns a list where each entry is either:
454+
- A string ('pad'/'end').
455+
- A tuple (code, raw_bytes).
456+
- Raw bytes for malformed trailing data.
457+
"""
458+
entries = []
444459
while x:
445460
o = orb(x[0])
446461
if o == 255:
447-
opt.append("end")
462+
entries.append("end")
448463
x = x[1:]
449464
continue
450465
if o == 0:
451-
opt.append("pad")
466+
entries.append("pad")
452467
x = x[1:]
453468
continue
454469
if len(x) < 2 or len(x) < orb(x[1]) + 2:
455-
opt.append(x)
470+
entries.append(x)
456471
break
457-
elif o in DHCPOptions:
458-
f = DHCPOptions[o]
472+
olen = orb(x[1])
473+
entries.append((o, x[2:olen + 2]))
474+
x = x[olen + 2:]
475+
return entries
459476

460-
if isinstance(f, str):
461-
olen = orb(x[1])
462-
opt.append((f, x[2:olen + 2]))
463-
x = x[olen + 2:]
477+
def _merge_entries(self, entries):
478+
"""
479+
RFC 3396: merge all entries sharing the same option code.
480+
Preserves order of first appearance.
481+
Pads, ends and malformed entries are kept in place.
482+
"""
483+
merged = {}
484+
order = []
485+
for entry in entries:
486+
if isinstance(entry, tuple) and len(entry) == 2:
487+
code, value = entry
488+
if code in merged:
489+
merged[code] += value
464490
else:
465-
olen = orb(x[1])
466-
lval = [f.name]
491+
merged[code] = bytearray(value)
492+
order.append(('option', code))
493+
else:
494+
order.append(('special', entry))
495+
result = []
496+
for kind, data in order:
497+
if kind == 'special':
498+
result.append(data)
499+
else:
500+
result.append((data, bytes(merged[data])))
501+
return result
467502

468-
if olen == 0:
503+
def _entries_to_raw(self, entries):
504+
"""
505+
Reconstruct raw bytes from a list of extracted entries.
506+
"""
507+
s = b""
508+
for entry in entries:
509+
if isinstance(entry, tuple) and len(entry) == 2:
510+
code, value = entry
511+
s += struct.pack("!BB", code, len(value)) + value
512+
elif entry == "end":
513+
s += b'\xff'
514+
elif entry == "pad":
515+
s += b'\x00'
516+
elif isinstance(entry, bytes):
517+
s += entry
518+
return s
519+
520+
def getfield(self, pkt, s):
521+
return b"", self.m2i(pkt, s)
522+
523+
def m2i(self, pkt, x):
524+
rfc3396 = conf.contribs.get("dhcp", {}).get("rfc3396", True)
525+
526+
entries = self._extract_raw_entries(x)
527+
528+
if rfc3396:
529+
# RFC 3396 section 5: check for option overload
530+
overload = 0
531+
for entry in entries:
532+
if (isinstance(entry, tuple) and len(entry) == 2
533+
and entry[0] == 52 and len(entry[1]) == 1):
534+
overload = orb(entry[1][0])
535+
break
536+
537+
# Build aggregate entries from file/sname if needed
538+
if (overload
539+
and pkt.underlayer is not None
540+
and isinstance(pkt.underlayer, BOOTP)):
541+
if overload in (1, 3):
542+
entries += self._extract_raw_entries(
543+
pkt.underlayer.file
544+
)
545+
if overload in (2, 3):
546+
entries += self._extract_raw_entries(
547+
pkt.underlayer.sname
548+
)
549+
550+
# Merge all entries with same code
551+
entries = self._merge_entries(entries)
552+
553+
# Interpret entries
554+
opt = []
555+
for i, entry in enumerate(entries):
556+
if isinstance(entry, tuple) and len(entry) == 2:
557+
code, raw_value = entry
558+
if code in DHCPOptions:
559+
f = DHCPOptions[code]
560+
if isinstance(f, str):
561+
opt.append((f, raw_value))
562+
continue
563+
lval = [f.name]
564+
if len(raw_value) == 0:
469565
try:
470566
_, val = f.getfield(pkt, b'')
471567
except Exception:
472-
opt.append(x)
568+
opt.append(
569+
self._entries_to_raw(entries[i:])
570+
)
473571
break
474572
else:
475573
lval.append(val)
476-
477574
try:
478-
left = x[2:olen + 2]
575+
left = raw_value
479576
while left:
480577
left, val = f.getfield(pkt, left)
481578
lval.append(val)
482579
except Exception:
483-
opt.append(x)
580+
opt.append(
581+
self._entries_to_raw(entries[i:])
582+
)
484583
break
485-
else:
486-
otuple = tuple(lval)
487-
opt.append(otuple)
488-
x = x[olen + 2:]
584+
opt.append(tuple(lval))
585+
else:
586+
opt.append((code, raw_value))
489587
else:
490-
olen = orb(x[1])
491-
opt.append((o, x[2:olen + 2]))
492-
x = x[olen + 2:]
588+
opt.append(entry)
493589
return opt
494590

495591
def i2m(self, pkt, x):
@@ -514,8 +610,15 @@ def i2m(self, pkt, x):
514610
warning("Unknown field option %s", name)
515611
continue
516612

517-
s += struct.pack("!BB", onum, len(oval))
518-
s += oval
613+
# RFC 3396: split options longer than 255 bytes
614+
if not oval:
615+
s += struct.pack("!BB", onum, 0)
616+
else:
617+
while oval:
618+
chunk = oval[:255]
619+
oval = oval[255:]
620+
s += struct.pack("!BB", onum, len(chunk))
621+
s += chunk
519622

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

test/scapy/layers/dhcp.uts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,3 +137,55 @@ 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 (fixes #4642, #4343)
142+
# i2m: option > 255 bytes is split into fragments
143+
# i2m: zero-length option is preserved
144+
# m2i (rfc3396=True): all options with same code are concatenated globally
145+
# m2i (rfc3396=True): concatenation works for unknown option codes
146+
# m2i (rfc3396=False): options with same code are NOT concatenated (legacy)
147+
# roundtrip: long option survives encode/decode with rfc3396=True
148+
# getfield (rfc3396=True): sname/file not aggregated without overload
149+
# getfield (rfc3396=True): overload=1 aggregates file field
150+
151+
import struct
152+
153+
r = raw(DHCP(options=[('captive-portal', 'a'*256), 'end']))
154+
assert r[:2] == b'\x72\xff' and r[2:257] == b'a'*255
155+
assert r[257:260] == b'\x72\x01a' and r[260:261] == b'\xff'
156+
157+
assert raw(DHCP(options=[('rapid_commit', b''), 'end'])) == b'\x50\x00\xff'
158+
159+
old_rfc3396 = conf.contribs.get("dhcp", {}).get("rfc3396", False)
160+
conf.contribs["dhcp"]["rfc3396"] = True
161+
162+
assert DHCP(b'\x06\x02\x01\x02\x06\x02\x03\x04').options == DHCP(b'\x06\x04\x01\x02\x03\x04').options
163+
164+
p = DHCP(b'\x0c\x02sc\x06\x04\x01\x02\x03\x04\x0c\x02py')
165+
assert p.options[0] == ('hostname', b'scpy')
166+
assert p.options[1] == ('name_server', '1.2.3.4')
167+
168+
assert DHCP(b'\xfe\x02AB\xfe\x02CD').options[0] == (254, b'ABCD')
169+
170+
conf.contribs["dhcp"]["rfc3396"] = False
171+
172+
p = DHCP(b'\x0c\x02sc\x06\x04\x01\x02\x03\x04\x0c\x02py')
173+
assert p.options == [('hostname', b'sc'), ('name_server', '1.2.3.4'), ('hostname', b'py')]
174+
175+
conf.contribs["dhcp"]["rfc3396"] = True
176+
177+
pkt2 = DHCP(raw(DHCP(options=[('captive-portal', 'a'*400), 'end'])))
178+
assert pkt2.options[0] == ('captive-portal', b'a'*400) and pkt2.options[-1] == 'end'
179+
180+
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'])
181+
p = BOOTP(raw(bootp_pkt))
182+
assert p[DHCP].options[0] == ('message-type', 1) and p[BOOTP].sname[:8] == b'myserver'
183+
184+
magic = b'\x63\x82\x53\x63'
185+
opts = b'\x34\x01\x01' + b'\x35\x01\x01' + b'\xff'
186+
file_field = (b'\x0c\x05scapy' + b'\xff' + b'\x00'*120)[:128]
187+
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
188+
p = BOOTP(bootp_raw)
189+
assert DHCP in p and ('hostname', b'scapy') in p[DHCP].options
190+
191+
conf.contribs["dhcp"]["rfc3396"] = old_rfc3396

0 commit comments

Comments
 (0)