Skip to content

Unix: sigaltstack is munmapped on thread exit without SS_DISABLE, leaving a dangling registration (memory corruption under ASan embedders) #13857

Description

@tiandiwonder

Summary

On Unix, lazy_per_thread_init (crates/wasmtime/src/runtime/vm/sys/unix/signals.rs) registers a custom alternate signal stack per thread. Its TLS destructor deallocates it like this:

impl Drop for Stack {
    fn drop(&mut self) {
        unsafe {
            // Deallocate the stack memory.
            let r = rustix::mm::munmap(self.mmap_ptr, self.mmap_size);
            debug_assert!(r.is_ok(), "munmap failed during thread shutdown");
        }
    }
}

The stack is munmapped without first calling sigaltstack with SS_DISABLE (or restoring the previous stack), so the kernel keeps the freed range registered as the thread's alternate signal stack for the remainder of thread teardown.

Consequences

  1. If any SA_ONSTACK signal is delivered to the thread after this destructor runs (other TLS destructors, sanitizer teardown, etc.), the kernel pushes the signal frame onto unmapped memory.

  2. Worse, for AddressSanitizer embedders: ASan's thread-destruction hook calls UnsetAlternateSignalStack() (compiler-rt/lib/sanitizer_common/sanitizer_posix_libcdep.cpp), which queries the currently registered altstack and unconditionally UnmapOrDie(oldstack.ss_sp, oldstack.ss_size). With the dangling registration left by Stack::drop, ASan re-munmaps a 256 KiB range that was already freed — and possibly already reused by the allocator — silently destroying pages of live, unrelated heap allocations. The eventual crash appears at a random later point in whatever code owned the reused memory, which makes it extremely hard to attribute.

This looks like the actual mechanism behind the long-standing mystery documented in the comment at the top of lazy_per_thread_init (the ASan fuzzing crashes reproduced in https://gist.github.com/alexcrichton/6815a5d57a3c5ca94a8d816a9fcc91af): it is not TLS-destructor ordering corrupting ASan state — it is the dangling sigaltstack registration making ASan's UnsetAlternateSignalStack unmap a stale (reused) range. The existing cfg!(asan) guard only protects builds where the Rust code is compiled with -Zsanitizer=address; embedders that instrument only their C/C++ side with ASan and link wasmtime built without it (a common setup) still hit the bug.

Real-world impact

We (ClickHouse) chased sporadic ASan CI crashes for two months, where random large read buffers were found unmapped mid-use. Forensics of a core dump showed the faulted buffer was still registered as live in ASan's secondary allocator (never freed) and the destroyed page range measured exactly one wasmtime sigaltstack allocation (guard page + MIN_STACK_SIZE = 64×4096); a WASM module had executed on a pool thread minutes earlier, and the crash fired when that thread was reaped. Details: ClickHouse/ClickHouse#104692, fixed on our side by re-enabling cfg(asan) via ClickHouse/ClickHouse#109950.

Suggested fix

In Stack::drop, disable (or restore the previously registered) alternate signal stack before unmapping:

impl Drop for Stack {
    fn drop(&mut self) {
        unsafe {
            let disable = libc::stack_t {
                ss_sp: ptr::null_mut(),
                ss_flags: libc::SS_DISABLE,
                ss_size: MIN_STACK_SIZE,
            };
            let r = libc::sigaltstack(&disable, ptr::null_mut());
            debug_assert_eq!(r, 0, "sigaltstack(SS_DISABLE) failed during thread shutdown");
            let r = rustix::mm::munmap(self.mmap_ptr, self.mmap_size);
            debug_assert!(r.is_ok(), "munmap failed during thread shutdown");
        }
    }
}

With that in place the cfg!(asan) opt-out may even become unnecessary, since ASan's UnsetAlternateSignalStack would then see a disabled stack instead of a dangling one (worth verifying — UnmapOrDie(NULL, ...) on a disabled stack would fail loudly rather than corrupt, so ASan-instrumented-Rust builds may still want the guard).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions