Repository navigation
fix(usb): stop finalizer from dereferencing tsfn-owned callbackRef during shutdown (#790) #791
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
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
67e91f9
fix(usb): stop finalizer from dereferencing tsfn-owned callbackRef (#…
cb1kenobi 5d87e57
test(usb): clean up repro DB dirs; tighten finalizer comment
cb1kenobi df9d9e2
refactor(usb): use std::find and const key ref in identity removeList…
cb1kenobi 3ce03ab
Merge branch 'main' into fix/790-user-shared-buffer-finalize-uaf
cb1kenobi File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| /** | ||
| * Repro for the user-shared-buffer finalize use-after-free observed during | ||
| * Harper shutdown (ctrl-C) on macOS. The reported crash: | ||
| * | ||
| * userSharedBufferFinalize -> napi_get_reference_value (EXC_BAD_ACCESS) | ||
| * ... BufferFinalizer::FinalizeBufferCallback | ||
| * ... Environment::RunCleanup | ||
| * ... FreeEnvironment (worker / process teardown) | ||
| * | ||
| * Scenario (a graceful shutdown while a shared buffer is still referenced): | ||
| * 1. Open a DB and get one or more user-shared ArrayBuffers WITH a callback, so | ||
| * a listener (napi_ref + threadsafe function) is registered for each. | ||
| * 2. Shut the DB down (`db.close()` -> removeListenersByOwner releases each | ||
| * listener's tsfn, whose finalizer deletes the backing napi_ref). | ||
| * 3. Keep the ArrayBuffers referenced and let the process exit NATURALLY (no | ||
| * `process.exit()`), so Environment::RunCleanup finalizes the still-live | ||
| * external ArrayBuffers. | ||
| * | ||
| * `userSharedBufferFinalize` then reads `finalizeData->callbackRef` via | ||
| * `napi_get_reference_value` -- but that ref's ownership was transferred to the | ||
| * (now torn-down) listener tsfn in `addListener`, so it has already been | ||
| * deleted: a use-after-free. On a plain build the freed slot usually reads back | ||
| * benign; under an instrumented allocator (Guard Malloc on macOS, ASan on | ||
| * Linux) it faults, which is how the test surfaces it deterministically. | ||
| * | ||
| * Exit 0 = survived (expected once the finalizer stops touching the borrowed | ||
| * callbackRef); a crash/non-zero exit = the UAF reproduced. | ||
| */ | ||
| import { RocksDatabase } from '../../src/index.ts'; | ||
| import { mkdirSync } from 'node:fs'; | ||
|
|
||
| const dbPath = process.argv[2]; | ||
|
|
||
| if (!dbPath) { | ||
| console.error('Usage: fork-user-shared-buffer-shutdown.mts <dbPath>'); | ||
| process.exit(1); | ||
| } | ||
|
|
||
| mkdirSync(dbPath, { recursive: true }); | ||
|
|
||
| const db = RocksDatabase.open(dbPath, { name: 'shutdown-cf' }); | ||
|
|
||
| // Retain several buffers-with-callbacks so they are still "in use" at shutdown. | ||
| const held: ArrayBuffer[] = []; | ||
| for (let i = 0; i < 16; i++) { | ||
| const buffer = db.getUserSharedBuffer(`shutdown-key-${i}`, new ArrayBuffer(8), { | ||
| callback() { | ||
| // presence of the listener is what matters, not its body | ||
| }, | ||
| }); | ||
| new DataView(buffer).setBigUint64(0, 1n); | ||
| held.push(buffer); | ||
| } | ||
| // Keep them reachable past `db.close()` so teardown must finalize them. | ||
| (globalThis as unknown as { __heldBuffers?: ArrayBuffer[] }).__heldBuffers = held; | ||
|
|
||
| // Graceful shutdown: tear the DB down while the buffers are still referenced. | ||
| db.close(); | ||
|
|
||
| // Intentionally no `process.exit()`: allow a natural exit so the finalizers run | ||
| // during Environment::RunCleanup -- the crashing path from the report. | ||
| console.log('SUCCESS'); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,87 @@ | ||
| import { generateDBPath } from './lib/util.ts'; | ||
| import { spawn } from 'node:child_process'; | ||
| import { existsSync, rmSync } from 'node:fs'; | ||
| import { join } from 'node:path'; | ||
| import { describe, expect, it } from 'vitest'; | ||
|
|
||
| /** | ||
| * Regression repro for the user-shared-buffer finalize use-after-free seen on | ||
| * Harper shutdown (ctrl-C) on macOS: | ||
| * | ||
| * userSharedBufferFinalize -> napi_get_reference_value (SIGSEGV) | ||
| * ... Environment::RunCleanup -> FreeEnvironment (teardown) | ||
| * | ||
| * `userSharedBufferFinalize` reads the buffer's borrowed `callbackRef`, but that | ||
| * napi_ref is owned by the listener's threadsafe function and has already been | ||
| * deleted once the DB (and its listeners) were torn down at shutdown. See | ||
| * `test/fixtures/fork-user-shared-buffer-shutdown.mts` for the full scenario. | ||
| * | ||
| * Runs in a child process because a regression SIGSEGVs rather than throwing a | ||
| * catchable error. On a plain build the freed napi_ref slot usually reads back | ||
| * benign, so the child is run under Apple's Guard Malloc to make the UAF fault | ||
| * deterministically. This is macOS-only: Guard Malloc is macOS-specific, ASan | ||
| * does not work locally on recent macOS (see AGENTS.md), and a plain run on any | ||
| * other platform is a false green (and can flake if the freed ref slot gets | ||
| * reused), so it is skipped there rather than run without instrumentation. | ||
| */ | ||
| describe('User shared buffer shutdown use-after-free repro (child process)', () => { | ||
| const fixturePath = join(__dirname, 'fixtures', 'fork-user-shared-buffer-shutdown.mts'); | ||
| const GUARD_MALLOC = '/usr/lib/libgmalloc.dylib'; | ||
|
|
||
| // macOS + Guard Malloc + Node only (Bun/Deno don't run this fixture under | ||
| // Node's native type stripping the same way). | ||
| const runnable = | ||
| process.platform === 'darwin' && | ||
| existsSync(GUARD_MALLOC) && | ||
| !process.versions.bun && | ||
| !process.versions.deno; | ||
|
|
||
| it.runIf(runnable)( | ||
| 'should survive shutdown while user shared buffers with callbacks are still in use', | ||
| async () => { | ||
| // Loop: teardown-order UAFs only fault on a fraction of runs even under | ||
| // Guard Malloc, so a single shot can mask a regression. | ||
| for (let i = 0; i < 5; i++) { | ||
| const { code, signal } = await spawnShutdownRepro(fixturePath); | ||
| expect(signal, `iteration=${i}`).toBeNull(); | ||
| expect(code, `iteration=${i}`).toBe(0); | ||
| } | ||
| }, | ||
| 30_000 | ||
| ); | ||
| }); | ||
|
|
||
| function spawnShutdownRepro( | ||
| fixturePath: string | ||
| ): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { | ||
| return new Promise((resolve, reject) => { | ||
| const dbPath = generateDBPath(); | ||
| // Fault immediately on the use-after-free instead of reading benign freed | ||
| // memory. MallocScribble poisons freed blocks so a stale read is more | ||
| // likely to be caught even outside the guard page. | ||
| const env = { | ||
| ...process.env, | ||
| DYLD_INSERT_LIBRARIES: '/usr/lib/libgmalloc.dylib', | ||
| MallocScribble: '1', | ||
| }; | ||
|
|
||
| const child = spawn(process.execPath, [fixturePath, dbPath], { env }); | ||
|
|
||
| let stderr = ''; | ||
| child.stderr?.on('data', (chunk) => { | ||
| stderr += chunk.toString(); | ||
| }); | ||
| child.on('close', (code, signal) => { | ||
| if (code !== 0 || signal) { | ||
| console.error(`Shutdown repro stderr:\n${stderr}`); | ||
| } | ||
| // The child creates the DB dir; clean it up so repeated runs don't | ||
| // accumulate databases under the temp dir (honoring KEEP_FILES). | ||
| if (!process.env.KEEP_FILES) { | ||
| rmSync(dbPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); | ||
| } | ||
| resolve({ code, signal }); | ||
| }); | ||
| child.on('error', reject); | ||
| }); | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.