Skip to content

Fix UDP payload decoding: probes with literal text go out malformed - #2

Merged
WouterHadrian merged 2 commits into
masterfrom
fix/udp-payload-parser
Sep 17, 2026
Merged

WouterHadrian merged 2 commits into
masterfrom
fix/udp-payload-parser

Conversation

@WouterHadrian

@WouterHadrian WouterHadrian commented Sep 17, 2026

Copy link
Copy Markdown

The bug

build.rs turns nmap-payloads into the payload table compiled into the binary. Its parser() keeps every ASCII hex digit and discards everything else:

if char == '\\' && payload.chars().nth(idx + 1) == Some('x') {
    continue;
} else if char.is_ascii_hexdigit() {
    tmp_str.push(char);
    ...
}

It has no notion of what is an escape and what is literal text. \x06 survives by luck — the backslash is skipped, x is not a hex digit, 06 parses. But payloads are not all hex. The SNMPv1 GetRequest spells its community string out:

udp 161,260,3401
  "\x30\x1f\x02\x01\x00\x04\x06public\xa1\x12\x02\x01\x00\x02"

public keeps only its b and c and becomes the single byte 0xbc:

on the wire:  30 1f 02 01 00 04 06 bc a1 12 ...
expected:     30 1f 02 01 00 04 06 70 75 62 6c 69 63 a1 12 ...

The BER header still declares an octet string of length 6 and a message length of 31, but 26 bytes go out. tcpdump labels our own packet [len26<asnlen31]. Agents discard it without replying, so the port reads as closed.

Impact

Every payload written as literal text is affected. Ports where all registered variants are corrupt, i.e. never detectable over UDP:

Port Literal text that breaks it
137 netbios CKAAAAAAAA…
389 ldap objectClass
427 svrloc service:service-agent
1900 ssdp M-SEARCH * HTTP/1.1
11211 memcached version
3283 ARD, 626

161 has two variants — the SNMPv3 one is pure hex and fine, but both share the key [161,260,3401] in a BTreeMap, so the later (corrupt) entry overwrites the good one. 53 also has a corrupt variant but a working one survives.

Ports whose payloads are pure hex — 67, 111, 123, 500, 623, 3478 — were never affected.

This is not specific to our fork: upstream master has a byte-identical build.rs. Introduced in 45fa80ac "Statically generated payloads (bee-san#643)", 2024-09-13.

The fix

Track quoted regions, decode \xNN and the usual C escapes, and take every other character as the byte it denotes. Whitespace and the quotes separating concatenated strings are structure, not payload, so they are skipped.

Verification

Lab: net-snmp 5.9.4 (v1/v2c, community public) and a scanner image built from this branch, on one Docker network. Identical flags to production.

nmap -sU -p 161            →  161/udp open   udp-response ttl 64
rustscan before this PR    →  UDP scan timed-out for all tries / Open Sockets found: []
rustscan after this PR     →  172.20.0.2 -> [161]

After the fix the agent answers, carrying the sysDescr our fingerprinter reads:

GetNextRequest  04 06 70 75 62 6c 69 63          ← "public", intact
GetResponse     .1.3.6.1.2.1.1.1.0 = "Linux testagent 6.1.0 #1 SMP aarch64 GNU/Linux"

cargo test — 70 passed, 0 failed, 8 ignored. rustfmt --check clean on both changed files.

Tests

tests/udp_payloads.rs asserts against generated::get_parsed_data(), so it exercises the real file → parser → table path rather than a copy of the logic:

  • SNMP payload still contains public
  • SNMP payload is well-formed BER — declared length matches the bytes, the specific property that made agents drop it
  • NetBIOS keeps its encoded wildcard name
  • SSDP, LDAP, memcached and svrloc keep their literal text
  • NTP still decodes \xNN to single bytes, and no payload retained a literal \x — the opposite regression

Without these, this regresses silently: a mis-decoded payload looks exactly like a closed port, which is how it went unnoticed for two years.

Not fixed here

The duplicate-key collapse. port_payload_map keys on the port vector, so ports with several payload variants keep only the last — nmap tries each in turn. Worth a follow-up; upstream PR bee-san#886 is rewriting that lookup.

🤖 Generated with Claude Code

parser() kept every ASCII hex digit in a payload literal and dropped
everything else, with no notion of what was an escape and what was text.
`\x06` survived by luck; `public` in the SNMPv1 GetRequest kept only its
`b` and `c` and became the single byte 0xbc.

The resulting probe is malformed -- its BER header declares an octet
string of length 6 and a message length of 31, but 26 bytes go out -- so
agents discard it without replying and the port reads as closed. Every
payload written as literal text is affected: udp/137, 389, 427, 1900,
3283, 11211 and 626 can never elicit a response.

Track quoted regions, decode the escapes, and take every other character
as the byte it denotes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@WouterHadrian
WouterHadrian merged commit 13a78f1 into master Sep 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant