Skip to content

Commit a6f63cf

Browse files
committed
fix: validate ASCII and preserve embedded nulls in CommandString parsing
Previously, CommandString decoding would silently normalize invalid commands containing embedded null bytes. For example, "mem\0pool" would be incorrectly decoded as "mempool", bypassing consensus validation. Found through differential fuzzing between btcd and rust-bitcoin, where rust-bitcoin was accepting messages with embedded null bytes in Command fields while btcd (and Bitcoin Core) correctly reject them.
1 parent ad40e69 commit a6f63cf

File tree

1 file changed

+22
-12
lines changed

1 file changed

+22
-12
lines changed

p2p/src/message.rs

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
//! This module defines the `NetworkMessage` and `RawNetworkMessage` types that
66
//! are used for (de)serializing Bitcoin objects for transmission on the network.
77
8-
use core::{fmt, iter};
8+
use core::fmt;
99
use std::borrow::{Cow, ToOwned};
1010
use std::boxed::Box;
1111

@@ -114,14 +114,15 @@ impl Decodable for CommandString {
114114
#[inline]
115115
fn consensus_decode<R: BufRead + ?Sized>(r: &mut R) -> Result<Self, encode::Error> {
116116
let rawbytes: [u8; 12] = Decodable::consensus_decode(r)?;
117-
let rv = iter::FromIterator::from_iter(rawbytes.iter().filter_map(|&u| {
118-
if u > 0 {
119-
Some(u as char)
120-
} else {
121-
None
122-
}
123-
}));
124-
Ok(CommandString(rv))
117+
118+
// Find the last non-null byte and trim null padding from the end
119+
let trimmed = &rawbytes[..rawbytes.iter().rposition(|&b| b != 0).map_or(0, |i| i + 1)];
120+
121+
if !trimmed.is_ascii() {
122+
return Err(crate::consensus::parse_failed_error("Command string must be ASCII"));
123+
}
124+
125+
Ok(CommandString(Cow::Owned(unsafe { String::from_utf8_unchecked(trimmed.to_vec()) })))
125126
}
126127
}
127128

@@ -892,9 +893,18 @@ mod test {
892893
assert_eq!(cs.as_ref().unwrap().to_string(), "Andrew".to_owned());
893894
assert_eq!(cs.unwrap(), CommandString::try_from_static("Andrew").unwrap());
894895

895-
let short_cs: Result<CommandString, _> =
896-
deserialize(&[0x41u8, 0x6e, 0x64, 0x72, 0x65, 0x77, 0, 0, 0, 0, 0]);
897-
assert!(short_cs.is_err());
896+
// Test that embedded null bytes are preserved while trailing nulls are trimmed
897+
let cs: Result<CommandString, _> =
898+
deserialize(&[0, 0x41u8, 0x6e, 0x64, 0, 0x72, 0x65, 0x77, 0, 0, 0, 0]);
899+
assert!(cs.is_ok());
900+
assert_eq!(cs.as_ref().unwrap().to_string(), "\0And\0rew".to_owned());
901+
assert_eq!(cs.unwrap(), CommandString::try_from_static("\0And\0rew").unwrap());
902+
903+
// Invalid CommandString, must be ASCII
904+
assert!(deserialize::<CommandString>(&[0, 0x41u8, 0x6e, 0xa4, 0, 0x72, 0x65, 0x77, 0, 0, 0, 0]).is_err());
905+
906+
// Invalid CommandString, must be 12 bytes
907+
assert!(deserialize::<CommandString>(&[0x41u8, 0x6e, 0x64, 0x72, 0x65, 0x77, 0, 0, 0, 0, 0]).is_err());
898908
}
899909

900910
#[test]

0 commit comments

Comments
 (0)