Skip to content

Commit 5f4d71e

Browse files
committed
Merge rust-bitcoin#4649: fix: preserve embedded nulls in CommandString for consensus validation
a6f63cf fix: validate ASCII and preserve embedded nulls in CommandString parsing (Erick Cestari) Pull request description: 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. ACKs for top commit: apoelstra: ACK a6f63cf; successfully ran local tests tcharding: ACK a6f63cf Tree-SHA512: fccf7fc29fb3eac8ca0e3dd2e18d7fadbfdce913448d5e05f3b0c1e545993952a70680fc652acf6e5b7a4bc8b1b053622081f9af733e45d7c51ab47bc8675a1d
2 parents 3233cc9 + a6f63cf commit 5f4d71e

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)