From 1c2a98232093a033e549bf4cb7b636912fa9023f Mon Sep 17 00:00:00 2001 From: kyd Date: Thu, 1 Oct 2026 11:48:04 +0100 Subject: [PATCH] fix(build): decode literal text in UDP payload probes (#938) Rewrite the nmap-payloads decoder in build.rs to follow Nmap's C-style escape format: `\xNN` and single-character escapes are decoded, all other characters are kept literally, and multi-line quoted segments are concatenated. Literal protocol text (e.g. the SNMP `public` community string, NetBIOS names, SSDP headers) was previously dropped, producing malformed probes. Also strip CR line endings when reading nmap-payloads. Adds regression tests pinning the SNMP (161) and SSDP (1900) probe bytes. Fixes #933. --- build.rs | 108 +++++++++++++++++++++++++++++++++++++-------- src/scanner/mod.rs | 43 ++++++++++++++++++ 2 files changed, 133 insertions(+), 18 deletions(-) diff --git a/build.rs b/build.rs index 63cc0ea..5a880da 100644 --- a/build.rs +++ b/build.rs @@ -25,6 +25,10 @@ pub fn main() { let mut curr = String::new(); for line in data.trim().split('\n') { + // Strip a trailing carriage return so CRLF checkouts parse + // identically to LF ones (otherwise `\r` pollutes port tokens and + // blank lines stop looking blank). + let line = line.strip_suffix('\r').unwrap_or(line); if line.contains('#') || line.is_empty() { continue; } @@ -167,38 +171,43 @@ fn payloads_v(fp_map: &BTreeMap) -> BTreeMap> { let mut payb_linenr: BTreeMap> = BTreeMap::new(); for (&line_nr, data) in fp_map { - if data.contains('\"') { - let start = data.find('\"').expect("payload opening \" not found"); - let payloads = &data[start + 1..]; - payb_linenr.insert(line_nr, parser(payloads.trim())); + if data.contains('"') { + let start = data.find('"').expect("payload opening \" not found"); + // Pass from the opening quote: the decoder pairs quotes itself + // to split multi-line segments. + payb_linenr.insert(line_nr, parser(data[start..].trim())); } } payb_linenr } -/// Converts a hexadecimal string to a Vec +/// Converts a quoted Nmap payload block to bytes. /// /// # Arguments /// -/// * `payload` - A string slice containing the hexadecimal payload +/// * `payload` - The raw block text starting at its first opening quote: +/// one or more `"..."` segments separated by whitespace /// /// # Returns /// -/// A vector of bytes representing the decoded payload +/// The decoded probe bytes: segments are decoded with [`decode_segment`] +/// and concatenated with no separators, so multi-line probes reassemble +/// exactly. fn parser(payload: &str) -> Vec { - let payload = payload.trim_matches('"'); - let mut tmp_str = String::new(); let mut bytes: Vec = Vec::new(); - - for (idx, char) in payload.chars().enumerate() { - if char == '\\' && payload.chars().nth(idx + 1) == Some('x') { - continue; - } else if char.is_ascii_hexdigit() { - tmp_str.push(char); - if tmp_str.len() == 2 { - bytes.push(u8::from_str_radix(&tmp_str, 16).unwrap()); - tmp_str.clear(); + let mut rest = payload.trim(); + while let Some(open) = rest.find('"') { + let after_open = &rest[open + 1..]; + match split_segment(after_open) { + Some((segment, remainder)) => { + decode_segment(segment, &mut bytes); + rest = remainder; + } + None => { + // Unterminated trailing quote: decode what's left literally. + decode_segment(after_open, &mut bytes); + break; } } } @@ -206,6 +215,69 @@ fn parser(payload: &str) -> Vec { bytes } +/// Splits off the first `"..."` segment: `text` must start just after an +/// opening quote. Escaped quotes (`\"`) do not terminate the segment. +/// +/// Returns the segment body and the remainder after the closing quote. +fn split_segment(text: &str) -> Option<(&str, &str)> { + let mut escaped = false; + for (i, c) in text.char_indices() { + if escaped { + escaped = false; + continue; + } + if c == '\\' { + escaped = true; + continue; + } + if c == '"' { + return Some((&text[..i], &text[i + 1..])); + } + } + None +} + +/// Decodes one quoted Nmap payload segment in C-style escape format: +/// `\xNN` becomes a byte, common single-char escapes (`\\`, `\"`, +/// `\0`, `\n`, `\r`, `\t`) decode to their value, and every other +/// character contributes its literal bytes. +/// +/// Preserving literal text matters: several probes embed plain-text +/// protocol words (the SNMP `public` community string, NetBIOS names, +/// LDAP `objectClass`, SSDP headers). The previous hexdigits-only +/// decoding mangled them into wrong bytes, so agents never answered. +fn decode_segment(segment: &str, bytes: &mut Vec) { + let mut chars = segment.chars(); + while let Some(c) = chars.next() { + if c != '\\' { + let mut buf = [0; 4]; + bytes.extend_from_slice(c.encode_utf8(&mut buf).as_bytes()); + continue; + } + match chars.next() { + Some('x') => { + let hi = chars.next().unwrap_or('0'); + let lo = chars.next().unwrap_or('0'); + let hex: String = [hi, lo].iter().collect(); + bytes.push(u8::from_str_radix(&hex, 16).unwrap_or(0)); + } + Some('0') => bytes.push(0), + Some('n') => bytes.push(b'\n'), + Some('r') => bytes.push(b'\r'), + Some('t') => bytes.push(b'\t'), + Some('\\') => bytes.push(b'\\'), + Some('"') => bytes.push(b'"'), + // Unknown escape: keep the character literally, matching Nmap, + // which passes unrecognized escapes through untouched. + Some(other) => { + let mut buf = [0; 4]; + bytes.extend_from_slice(other.encode_utf8(&mut buf).as_bytes()); + } + None => bytes.push(b'\\'), + } + } +} + /// Combines the ports BTreeMap and the Payloads BTreeMap /// /// # Arguments diff --git a/src/scanner/mod.rs b/src/scanner/mod.rs index ae4686c..a22f388 100644 --- a/src/scanner/mod.rs +++ b/src/scanner/mod.rs @@ -303,3 +303,46 @@ impl Scanner { } } } + +#[cfg(test)] +mod tests { + use super::*; + + // These tests only inspect the payload table generated by build.rs; + // they never open sockets. + + /// Regression test for https://github.com/bee-san/RustScan/issues/933: + /// the SNMP public-walk probe must be the exact 33-byte BER packet, with + /// the literal `public` community string intact. The old hexdigits-only + /// decoding mangled it into a 28-byte probe that agents never answered. + #[test] + fn udp_snmp_probe_bytes_match_nmap() { + let payload = get_parsed_data() + .iter() + .find(|(ports, _)| ports.contains(&161)) + .map(|(_, payload)| payload) + .expect("no UDP payload registered for port 161"); + let expected: Vec = vec![ + 0x30, 0x1f, 0x02, 0x01, 0x00, 0x04, 0x06, b'p', b'u', b'b', b'l', b'i', b'c', 0xa1, + 0x12, 0x02, 0x01, 0x00, 0x02, 0x01, 0x00, 0x02, 0x01, 0x00, 0x30, 0x07, 0x30, 0x05, + 0x06, 0x01, 0x00, 0x05, 0x00, + ]; + assert_eq!(*payload, expected); + } + + /// The SSDP probe mixes `\xNN` escapes, `\"` escapes and literal text + /// across two quoted segments: segments must decode and concatenate + /// with no separators. + #[test] + fn udp_ssdp_probe_decodes_escapes_and_literal_text() { + let payload = get_parsed_data() + .iter() + .find(|(ports, _)| ports.contains(&1900)) + .map(|(_, payload)| payload) + .expect("no UDP payload registered for port 1900"); + let expected = + b"M-SEARCH * HTTP/1.1\r\nHost: 239.255.255.250:1900\r\nMan: \"ssdp:discover\"\r\nMX: 5\r\nST: ssdp:all\r\n\r\n" + .to_vec(); + assert_eq!(*payload, expected); + } +}