Compare commits

..

1 Commits

Author SHA1 Message Date
Claude Bot
600a190ace fix(shell): use-after-free in interpreter when setupIOBeforeRun fails (#26919)
When `setupIOBeforeRun()` fails in `runFromJS()` (e.g., stdout/stderr
closed on Windows), the error path called `#deinitFromExec()` which
freed the interpreter struct via `allocator.destroy(this)`. The GC
would later finalize the JSShellInterpreter wrapper, accessing the
already-freed memory and causing a segfault.

Fix: use `#derefRootShellAndIOIfNeeded(true)` instead, which cleans
up runtime resources (IO, shell env) and sets `cleanup_state` to
`.runtime_cleaned`, deferring struct deallocation to the GC finalizer.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-11 22:24:32 +00:00
4 changed files with 42 additions and 76 deletions

View File

@@ -756,16 +756,6 @@ fn scryptSync(global: *JSGlobalObject, callFrame: *jsc.CallFrame) JSError!JSValu
defer ctx.deinitSync();
const buf, const bytes = try jsc.ArrayBuffer.alloc(global, .ArrayBuffer, ctx.keylen);
ctx.runTask(bytes);
if (ctx.err) |err| {
if (err != 0) {
var err_buf: [256]u8 = undefined;
const msg = BoringSSL.ERR_error_string_n(err, &err_buf, err_buf.len);
return global.ERR(.CRYPTO_OPERATION_FAILED, "Scrypt failed: {s}", .{msg}).throw();
}
return global.ERR(.CRYPTO_OPERATION_FAILED, "Scrypt failed", .{}).throw();
}
return buf;
}

View File

@@ -1154,7 +1154,7 @@ pub const Interpreter = struct {
_ = callframe; // autofix
if (this.setupIOBeforeRun().asErr()) |e| {
defer this.#deinitFromExec();
defer this.#derefRootShellAndIOIfNeeded(true);
const shellerr = bun.shell.ShellErr.newSys(e);
return try throwShellErr(&shellerr, .{ .js = globalThis.bunVM().event_loop });
}

View File

@@ -1,65 +0,0 @@
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe } from "harness";
describe("crypto.scryptSync error handling", () => {
test("scryptSync throws on invalid parameters that pass validation but fail at derivation", async () => {
// This tests that scryptSync properly checks for errors after key derivation
// and doesn't return uninitialized memory. We use a subprocess to test the
// password length > INT32_MAX path which sets err=0.
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
const crypto = require("crypto");
// Test that valid params work
const good = crypto.scryptSync("password", "salt", 64);
if (good.length !== 64) {
process.exit(1);
}
console.log("ok");
`,
],
env: bunEnv,
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stdout.trim()).toBe("ok");
expect(exitCode).toBe(0);
});
test("scryptSync throws on N=3 (not a power of 2)", () => {
const crypto = require("crypto");
expect(() => {
crypto.scryptSync("password", "salt", 64, { N: 3, r: 8, p: 1 });
}).toThrow();
});
test("scryptSync throws on maxmem too low", () => {
const crypto = require("crypto");
expect(() => {
// N=16384, r=8 requires ~16MB, maxmem=1 should fail
crypto.scryptSync("password", "salt", 64, { N: 16384, r: 8, p: 1, maxmem: 1 });
}).toThrow();
});
test("scryptSync returns correct result for valid parameters", () => {
const crypto = require("crypto");
const result = crypto.scryptSync("password", "salt", 64);
expect(result).toBeInstanceOf(Buffer);
expect(result.length).toBe(64);
// Verify it's deterministic (same params = same result)
const result2 = crypto.scryptSync("password", "salt", 64);
expect(result.toString("hex")).toBe(result2.toString("hex"));
});
test("async scrypt throws synchronously for invalid params", () => {
const crypto = require("crypto");
// N=3 is not a power of 2, so param validation fails synchronously even for async scrypt
expect(() => {
crypto.scrypt("password", "salt", 64, { N: 3, r: 8, p: 1 }, () => {});
}).toThrow(/Invalid scrypt params/);
});
});

View File

@@ -0,0 +1,41 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe } from "harness";
// Regression test for https://github.com/oven-sh/bun/issues/26919
// When setupIOBeforeRun() fails in runFromJS (e.g., because stdout is closed),
// the error path used to call #deinitFromExec() which freed the interpreter struct.
// The GC would later finalize the already-freed JSShellInterpreter wrapper,
// causing a use-after-free / segfault.
test("issue #26919 - shell interpreter should not segfault when stdout is closed", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
const fs = require("fs");
// Close stdout so that the shell interpreter's setupIOBeforeRun() will fail
// when it tries to dup() stdout.
fs.closeSync(1);
try {
// This should throw an error (not segfault) because stdout is closed
await Bun.$\`echo hello\`;
} catch (e) {
// Write to stderr since stdout is closed
fs.writeSync(2, "caught: " + e.constructor.name + "\\n");
}
// Force GC to run - this would trigger the use-after-free crash before the fix
Bun.gc(true);
fs.writeSync(2, "done\\n");
`,
],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
// The process should not crash (segfault would give non-zero exit and no "done" message)
expect(stderr).toContain("done");
expect(exitCode).toBe(0);
});