copy_string: UCS-4 path transmutes unvalidated target bytes to char (UB)
Summary
copy_string reinterprets unvalidated bytes read from the target process as Rust chars. For the kind == 4 (UCS-4) case this is undefined behaviour, not just a missing validity check.
https://github.com/benfred/py-spy/blob/master/src/python_data_access.rs#L32-L50
match (kind, obj.ascii()) {
(4, _) => {
#[allow(clippy::cast_ptr_alignment)]
let chars = unsafe {
std::slice::from_raw_parts(bytes.as_ptr() as *const char, bytes.len() / 4)
};
Ok(chars.iter().collect())
}
(2, _) => { /* ... String::from_utf16(chars)? */ }
(1, true) => Ok(String::from_utf8(bytes)?),
(1, false) => Ok(bytes.iter().map(|&b| b as char).collect()),
_ => Err(format_err!("Unknown string kind {}", kind)),
}Why it is UB
char is not u32: its validity invariant is "a Unicode scalar value", i.e. 0..=0x10FFFF excluding the surrogate range 0xD800..=0xDFFF. bytes here is a Vec<u8> freshly copied out of the target process, so any 4-byte word in it can hold an arbitrary value.
chars.iter().collect() then produces &char references to, and copies, those bit patterns. Constructing a char outside the valid range is instant UB regardless of whether the value is ever printed, so the compiler is free to miscompile the surrounding code, and the resulting String can contain bytes that are not valid UTF-8 (breaking the String invariant too).
Secondary issue on the same line: bytes.as_ptr() has alignment 1, and it is cast to *const char (align 4). The #[allow(clippy::cast_ptr_alignment)] suppresses the lint rather than fixing it. The (2, _) arm has the same alignment problem for *const u16.
Note the inconsistency: the kind == 2 arm validates via String::from_utf16, and the kind == 1, ascii arm validates via String::from_utf8. Only the UCS-4 arm skips validation entirely -- and that is the arm selected for any Python string containing a non-BMP character.
Why the input is untrusted
Every input to that match comes out of target memory via the StringObject trait impl for PyUnicodeObject:
kind()andascii()read thePyASCIIObject.statebitfield from the targetsize()readslengthfrom the targetaddress()returns the raw target pointerself.data.anywhenstate.compact() == 0
So the bytes do not have to come from a real PyUnicodeObject at all. In practice this arm can be reached with arbitrary data by:
- the BSS scan in
python_process_info.rsprobing candidate interpreter addresses before one is confirmed - a torn/racing read of a
PyCodeObjectin--nonblockingmode, or during interpreter shutdown - Python 3.13+, where
f_executableis not guaranteed to be aPyCodeObject(there is already a comment instack_trace.rsacknowledging this) py-spy dump --coreon an untrusted or truncated core file
Blast radius
copy_string is the single primitive for every string py-spy reads out of a target, so a non-UTF-8 String can reach Frame::name, Frame::filename, LocalVariable::name and thread names, and from there the JSON, flamegraph, speedscope and chrometrace serializers.
Suggested fix
Validate the code points instead of transmuting, which also removes the unaligned read:
(4, _) => {
let mut out = String::with_capacity(bytes.len());
for chunk in bytes.chunks_exact(4) {
let cp = u32::from_ne_bytes(chunk.try_into().unwrap());
out.push(char::from_u32(cp).unwrap_or(char::REPLACEMENT_CHARACTER));
}
Ok(out)
}char::from_u32 is a range check plus a surrogate check, so the cost is negligible next to the process.copy that produced the bytes. The (2, _) arm can drop its from_raw_parts the same way by building the u16 slice with chunks_exact(2) + u16::from_ne_bytes.
Substituting U+FFFD keeps the current behaviour of "return the frame anyway" for real non-BMP strings while making the garbage case well-defined. Returning an Err is the other reasonable choice, though it would newly drop frames that today are returned (with garbage names), since stack_trace.rs skips any frame whose filename or name fails to copy.
Happy to send a PR if the approach looks right.
Source: benfred/py-spy