Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Panicking when used as global allocator in WASM #24

Closed
khoover opened this issue Jan 20, 2024 · 10 comments
Closed

Panicking when used as global allocator in WASM #24

khoover opened this issue Jan 20, 2024 · 10 comments

Comments

@khoover
Copy link
Contributor

khoover commented Jan 20, 2024

Hi, I'm able to produce a memory access OOB error when using Talck as the global allocator, using nearly unsafe-free code (the bits I have are copy + paste from std from things like ReadBuf). Here's the branch I can consistently UB from, using cargo on 1.74, exact console output is:

khoover@DESKTOP-CUT25MA:~/base32768-rs/examples/js-bench$ RUSTFLAGS="-C target-cpu=mvp" wasm-pack build --release --target nodejs
[INFO]: 🎯  Checking for the Wasm target...
[INFO]: 🌀  Compiling to Wasm...
warning: unused import: `std::mem::MaybeUninit`
 --> /home/khoover/base32768-rs/src/optimized/mod.rs:1:5
  |
1 | use std::mem::MaybeUninit;
  |     ^^^^^^^^^^^^^^^^^^^^^
  |
  = note: `#[warn(unused_imports)]` on by default

warning: unused import: `std::ptr::addr_of_mut`
 --> /home/khoover/base32768-rs/src/optimized/mod.rs:3:5
  |
3 | use std::ptr::addr_of_mut;
  |     ^^^^^^^^^^^^^^^^^^^^^

warning: unused import: `std::hint::unreachable_unchecked`
 --> /home/khoover/base32768-rs/src/optimized/encoder_impl.rs:2:5
  |
2 | use std::hint::unreachable_unchecked;
  |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

warning: unused import: `Array`
  --> /home/khoover/base32768-rs/src/optimized/encoder_impl.rs:12:20
   |
12 | use self::js_sys::{Array, JsString};
   |                    ^^^^^

warning: `base32768` (lib) generated 4 warnings (run `cargo fix --lib -p base32768` to apply 4 suggestions)
   Compiling js-bench v0.1.0 (/home/khoover/base32768-rs/examples/js-bench)
    Finished release [optimized] target(s) in 3.42s
[INFO]: ⬇️  Installing wasm-bindgen...
[INFO]: found wasm-opt at "/usr/bin/wasm-opt"
[INFO]: Optimizing wasm binaries with `wasm-opt`...
[WARN]: ⚠️   origin crate has no README
[INFO]: License key is set in Cargo.toml but no LICENSE file(s) were found; Please add the LICENSE file(s) to your project directory
[INFO]: ✨   Done in 3.97s
[INFO]: 📦   Your wasm pkg is ready to publish at /home/khoover/base32768-rs/examples/js-bench/pkg.
khoover@DESKTOP-CUT25MA:~/base32768-rs/examples/js-bench$ node pkg/bench-runner.js 
Took 5 ms to load and init bench wasm
wasm://wasm/00043216:1


RuntimeError: memory access out of bounds
    at wasm://wasm/00043216:wasm-function[30]:0x65d5
    at wasm://wasm/00043216:wasm-function[17]:0xc94
    at module.exports.test_codecs (/home/khoover/base32768-rs/examples/js-bench/pkg/js_bench.js:138:10)
    at Object.<anonymous> (/home/khoover/base32768-rs/examples/js-bench/pkg/bench-runner.js:26:1)
    at Module._compile (node:internal/modules/cjs/loader:1241:14)
    at Module._extensions..js (node:internal/modules/cjs/loader:1295:10)
    at Module.load (node:internal/modules/cjs/loader:1091:32)
    at Module._load (node:internal/modules/cjs/loader:938:12)
    at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:83:12)
    at node:internal/main/run_main_module:23:47

Node.js **v20.7.0**

When using the global allocator, the program runs to completion.

@khoover
Copy link
Contributor Author

khoover commented Jan 20, 2024

From debug mode, I get

khoover@DESKTOP-CUT25MA:~/base32768-rs/examples/js-bench$ node pkg/bench-runner.js 
Took 7 ms to load and init bench wasm
wasm://wasm/00189ade:1


RuntimeError: unreachable
    at __rust_start_panic (wasm://wasm/00189ade:wasm-function[1201]:0x449d6)
    at rust_panic (wasm://wasm/00189ade:wasm-function[1173]:0x44891)
    at std::panicking::rust_panic_with_hook::hc20eadded6bfe687 (wasm://wasm/00189ade:wasm-function[363]:0x32839)
    at std::panicking::begin_panic_handler::{{closure}}::h82415fe35b0e2001 (wasm://wasm/00189ade:wasm-function[443]:0x36681)
    at std::sys_common::backtrace::__rust_end_short_backtrace::h71f504d46a203d88 (wasm://wasm/00189ade:wasm-function[1196]:0x449b3)
    at rust_begin_unwind (wasm://wasm/00189ade:wasm-function[708]:0x3e2fa)
    at core::panicking::panic_fmt::h7a368385936888dc (wasm://wasm/00189ade:wasm-function[947]:0x425b9)
    at core::panicking::panic::h7bbea3773b752235 (wasm://wasm/00189ade:wasm-function[859]:0x41116)
    at talc::Talc<O>::free::h14aa50d45611ce00 (wasm://wasm/00189ade:wasm-function[81]:0x1a142)
    at <talc::talck::Talck<R,O> as core::alloc::global::GlobalAlloc>::dealloc::hcf08400e756d2221 (wasm://wasm/00189ade:wasm-function[496]:0x386e4)

@khoover khoover changed the title UB when used as global allocator in WASM Panicking when used as global allocator in WASM Jan 20, 2024
@khoover
Copy link
Contributor Author

khoover commented Jan 20, 2024

This is also observable if you replace the static-based ClaimOnOom handler with WasmHandler, i.e.

#[cfg(target_family = "wasm")]
#[global_allocator]
static ALLOCATOR: talc::Talck<talc::locking::AssumeUnlockable, talc::WasmHandler> = {
    // static mut MEMORY: [std::mem::MaybeUninit<u8>; 128 * 1024 * 1024] =
    //     [std::mem::MaybeUninit::uninit(); 128 * 1024 * 1024];
    // let span = talc::Span::from_base_size(unsafe { MEMORY.as_ptr() as *mut _ }, 128 * 1024 * 1024);
    talc::Talc::new(unsafe { talc::WasmHandler::new() }).lock()
};

@SFBdragon
Copy link
Owner

SFBdragon commented Jan 20, 2024

Ouch. I'll see what I can do. Hopefully I can get to bottom of this by tomorrow. Thanks for the details.

@SFBdragon
Copy link
Owner

I'm trying to run the WASM test, but bench-runner.js isn't present and wasn't generated by building the test with wasm-pack. (in examples/js-bench/pkg/)

How do I get this up and running?

@khoover
Copy link
Contributor Author

khoover commented Jan 20, 2024

Ah, forgot that the pkg directory was gitignore'd, I've moved the runner into the js-benches directory now.

@SFBdragon
Copy link
Owner

SFBdragon commented Jan 21, 2024

Thanks.

I haven't quite figured out the why of what's going wrong, but I've figured out the what, I think.

The panic is caused in bench_jasper_decode. The reason is that an invalid sequence of invocations of the allocator occurs and this corrupts the arena metadata, soon causing an error.

Specifically, hooking up a logging callback for the allocator yielded an odd subsequence:

  • Memory is allocated with size A, yielding pointer X
  • Memory X is grown from size A to B
  • Memory X is freed with a size C, where C < B

Of course, that last operation violated the safety contract on Talck::dealloc and Talc::free. The actual failure occurs in a later allocator operation (dropping local_str, curiously) as a result of allocator metadata corruption.

The relevant code:

fn bench_jasper_decode(bytes: &[u8]) -> f64 {
    // changing this fixes the issue, see below
    let encoded = base32768::alternative::encode(bytes)
        .chunks(64)
        .map(JsString::from_char_code)
        .reduce(|a, b| a.concat(&b))
        .unwrap();
    // the problem only shows up if this line is also present, otherwise nothing wrong occurs
    let local_str: String = encoded.clone().into();
    // you can comment out everything else, it doesn't affect anything
    ...

This change fixes the problem entirely, no more bad deallocation:

-    let encoded = base32768::alternative::encode(bytes)
-        .chunks(64)
-        .map(JsString::from_char_code)
-        .reduce(|a, b| a.concat(&b))
-        .unwrap();
+    let encoded = arr_to_str(base32768::alternative::encode(bytes));

I don't see what's wrong with the original code. For starters, it's completely safe as far as I can tell, so it causing UB is pretty sinister. Could this be a bug in Rust? No idea. I'll poke around some more tomorrow.

@SFBdragon
Copy link
Owner

Addendum: it's worth noting that while I can't easily get a list of allocator operations when using the default WASM global allocator, dlmalloc, it's fairly safe to say it's the same. Why doesn't dlmalloc exhibit any issues then? Because its a direct port of a C allocator, and C allocators don't take a size, just void free(void *ptr). The Rust port thus ignores the layout given to dealloc wholesale, and it doesn't check this invariant.

Not checking the coherence of the allocation size isn't strictly wrong but I think they should be checking this in debug builds because it invites safety violations. A similar issue cropped up here #17 where bad data passed to dealloc caused problems with Talc but went unnoticed when using dlmalloc. I'll open an issue/submit a PR for that.

@khoover
Copy link
Contributor Author

khoover commented Jan 21, 2024

So, one thing here is that the UTF-16 sequence emitted by that encode function can have unpaired surrogates, if that impacts anything? It's strange that the version using arr_to_str doesn't have this problem, though, since it's almost a 1:1 translation of the Rust impl into JS (replacing a.concat(b) with s += b). It's probably a wasm-bindgen bug, though, so I'll submit there too.

@SFBdragon
Copy link
Owner

I'm not sure. Perhaps of note, A is 1600000, B is 4800000, C is 4694072. (My earlier statement that C > B was a mistake.)

Anyhow, a wasm-bindgen bug seems plausible, submitted there sounds like a solid idea. I'll close this issue as Talc is in the clear, but feel free to comment further.

@khoover
Copy link
Contributor Author

khoover commented Jan 21, 2024

A is 1600000, B is 4800000, C is 4694072.

That pattern would mean the bad allocation is indeed local_str, since this matches up with what wasm-bindgen does to copy strings in:

function passStringToWasm0(arg, malloc, realloc) {

    if (typeof(arg) !== 'string') throw new Error('expected a string argument');

    if (realloc === undefined) {
        const buf = cachedTextEncoder.encode(arg);
        const ptr = malloc(buf.length, 1) >>> 0;
        getUint8Memory0().subarray(ptr, ptr + buf.length).set(buf);
        WASM_VECTOR_LEN = buf.length;
        return ptr;
    }

    let len = arg.length;
    let ptr = malloc(len, 1) >>> 0; // Where the A alloc happens

    const mem = getUint8Memory0();

    let offset = 0;

    for (; offset < len; offset++) {
        const code = arg.charCodeAt(offset);
        if (code > 0x7F) break;
        mem[ptr + offset] = code;
    }

    if (offset !== len) {
        if (offset !== 0) {
            arg = arg.slice(offset);
        }
        ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0; // Where the realloc to B happens
        const view = getUint8Memory0().subarray(ptr + offset, ptr + len);
        const ret = encodeString(arg, view);
        if (ret.read !== arg.length) throw new Error('failed to pass whole string');
        offset += ret.written;
    }

    WASM_VECTOR_LEN = offset;
    return ptr;
}

Looking at it, what I think the bug is is that bindgen doesn't give the Rust side the right capacity? Unless it tries to infer it from the length of the JS string plus the number of bytes decoded?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants