#870·py-spy

copy_string: UCS-4 path transmutes unvalidated target bytes to char (UB)

Author: korniltsev-grafanistaCreated Sep 17, 2026Updated Sep 17, 2026

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

rust
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() and ascii() read the PyASCIIObject.state bitfield from the target
  • size() reads length from the target
  • address() returns the raw target pointer self.data.any when state.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.rs probing candidate interpreter addresses before one is confirmed
  • a torn/racing read of a PyCodeObject in --nonblocking mode, or during interpreter shutdown
  • Python 3.13+, where f_executable is not guaranteed to be a PyCodeObject (there is already a comment in stack_trace.rs acknowledging this)
  • py-spy dump --core on 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:

rust
(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.