Fix use-after-free: keep escaped handles alive when their escapable scope closes - #223
Conversation
napi_escape_handle inserts the escaped handle at the scope start index so that it lives in the parent scope, but napi_close_escapable_handle_scope freed and resized the handle stack back to that same index. The escaped handle was therefore destroyed by the very close it was supposed to survive, so every consumer of napi_escape_handle got a dangling napi_value back. This is reachable from ordinary code: Napi::ObjectReference::Get uses an EscapableHandleScope, and Napi::Error::Message and Napi::Error::what are built on it. Reporting the message of a native error was therefore a heap-use-after-free, which is how this was found. Track which scopes have had a handle escaped and keep that one entry when the scope closes. Escaping now also reports napi_escape_called_twice on a second call, which Node-API requires and which the previous implementation silently allowed. The escape path no longer special cases scope_start == 0: inserting at begin() + 0 is already the correct behavior for that case. Adds NodeApi.EscapedHandleOutlivesItsScope, which reads the escaped value back after closing the scope and churning the parent scope. Under ASan it fails with heap-use-after-free before this change and passes after.
There was a problem hiding this comment.
Pull request overview
Fixes a QuickJS Node-API handle-scope lifetime bug where napi_close_escapable_handle_scope could free an escaped handle, producing dangling napi_values and enabling a heap-use-after-free in common node-addon-api paths (e.g., Napi::Error::what()).
Changes:
- Track escapable scopes that have performed an escape and preserve the escaped handle when closing the escapable scope (QuickJS backend).
- Enforce the Node-API rule of at most one
napi_escape_handlecall per escapable scope (returnnapi_escape_called_twice). - Add a regression unit test ensuring escaped handles outlive the escapable scope and that a second escape is rejected.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| Tests/UnitTests/Shared/Shared.cpp | Adds regression coverage for escaped-handle lifetime and double-escape rejection. |
| Core/Node-API/Source/js_native_api_quickjs.h | Adds env tracking for escapable scopes that have escaped (escaped_scope_starts). |
| Core/Node-API/Source/js_native_api_quickjs.cc | Preserves escaped handle on scope close; rejects double escape; simplifies insertion logic. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
The JavaScriptCore shim does not implement the napi_escape_called_twice check, so asserting it in the cross-engine test fails there. That gap is a separate issue from the use-after-free this change fixes, so the test now only covers the portable contract: an escaped handle must still be readable after its scope closes.
Match the convention the other Dispatch-based tests in this file use. Reporting through the promise and returning early also means a failure in the setup calls can no longer leave the promise unset and hang the waiter.
|
Good catch, thanks. Reworked the test to report through the promise and return early, matching the convention the other Dispatch-based tests here use. As you note it also removes a real hazard: an assertion failure in the setup calls would previously have left the promise unset and hung the waiter rather than failing the test. Also dropped the double-escape assertion in a separate commit. The JavaScriptCore shim does not implement the |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
Core/Node-API/Source/js_native_api_quickjs.cc:1966
- The newly added
napi_escape_called_twicebranch is not exercised by the regression test, which callsnapi_escape_handleonly once. Add a QuickJS-specific assertion that a second call for the same still-open scope returnsnapi_escape_called_twiceand does not disturb the first escaped handle.
// Node-API allows napi_escape_handle to be called at most once per scope.
if (!env->escaped_scope_starts.insert(scope_start).second) {
return napi_set_last_error(env, napi_escape_called_twice);
napi_escape_handle inserted the escaped handle into handle_scope_stack at scope_start so it would belong to the parent scope. That insert shifts every entry above it, and scope tokens are built from the stack size at open, so any nested scope still open at that point ends up with a stale recorded start. Closing it then keeps the wrong slot and frees the escaped handle, which reproduces the dangling napi_value this change set set out to fix. Hold the escaped handle in a map on the env instead, keyed by scope start, and push it onto the stack in napi_close_escapable_handle_scope once the scope's own handles are gone. It lands at scope_start, in the parent scope, so it still outlives the close, but the stack is never modified in the middle and no index is ever invalidated. Env teardown frees any handles still held aside for scopes that were never closed. current_scope_start was being adjusted to compensate for the shift; it is only ever written, never read, so that bookkeeping goes away with the insert. Add NestedEscapableScopesBothEscape, which escapes from both an inner and an outer scope and reads both back, and SecondEscapeIsRejected, which covers the napi_escape_called_twice contract. The nested test fails against the previous implementation on every run and passes with this one. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 88569c10-a7ff-4373-9a58-afa9c68b8c09
|
The nested-scope report was correct, and it was a real bug rather than a theoretical one. Thanks — I've reworked the fix. What was wrong. How it's fixed. Rather than patching up the indices, escaped handles are now held aside in a map on the env keyed by scope start, and pushed onto the stack in
Tests. Both suggestions are in:
QuickJS and V8 are both 9/9 locally. One pre-existing limitation I'll note but haven't changed here: two scopes opened at the same stack size get identical tokens and are indistinguishable. Fixing that needs stable scope IDs instead of index-derived tokens, which is a bigger change than this PR should carry. |
|
Some real-world evidence for this fix, which I ran into by accident. BabylonNative #1835 has been failing
inline MaybeOrValue<Napi::Value> ObjectReference::Get(const char* utf8name) const {
EscapableHandleScope scope(_env);
...
return scope.Escape(result);
}So any native code that throws a That makes this reachable from ordinary A/B on the same tree, CI flags (clang, QuickJS, RelWithDebInfo, no sanitizers), only this dependency swapped:
Worth noting the crash is intermittent in the way use-after-free usually is (139/1/139), so it reads as flaky CI rather than as a clear bug. |
The Chakra and JavaScriptCore shims implement napi_escape_handle as a pass through that returns the escapee and does not track scopes, so they always report napi_ok and cannot return napi_escape_called_twice. Asserting that contract there fails on three CI jobs for a limitation unrelated to this change, so gate the test on a capability define set from CMake, following the existing JSRUNTIMEHOST_NAPI_ENGINE_JSI pattern. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 88569c10-a7ff-4373-9a58-afa9c68b8c09
The Android tests compile Shared.cpp into their own UnitTestsJNI target, so a define set on the UnitTests target alone left Android_JSC still building and failing the double-escape test. Set it as an INTERFACE definition on napi instead, which reaches every consumer through JsRuntime and AppRuntime, so no test target has to repeat the engine check or be kept in sync. Verified on both sides: JavaScriptCore builds 8 NodeApi tests with the double-escape test excluded, QuickJS builds 9 with it included. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 88569c10-a7ff-4373-9a58-afa9c68b8c09
Hermes validates that napi_escape_handle is called on the innermost open scope and returns napi_handle_scope_mismatch otherwise, which is a legitimate refusal rather than a failure. Treat it as such and keep asserting the inner handle, which is the part the regression is about.
924f215 to
6238b5a
Compare
…e escape contract napi_open_escapable_handle_scope derived its opaque token from the current handle stack position, so two escapable scopes opened with no handle allocated between them received the same token. Their escaped handles then collided on one key and the second scope to escape was refused with napi_escape_called_twice having never escaped. Each scope now gets a record keyed by a monotonic counter, which is the stable per-scope state the position-derived token was standing in for. The Chakra and JavaScriptCore shims returned the escapee without tracking scopes at all, so they could never report napi_escape_called_twice. Both now record which scopes have escaped. Values on those engines are rooted independently of any scope, so nothing else about their passthrough changes. This removes the need for JSRUNTIMEHOST_NAPI_ESCAPE_HANDLE_IS_PASSTHROUGH, so SecondEscapeIsRejected now runs on every backend rather than being compiled out on two of them. The tests closed their escapable scopes only on the success path. A failing assertion returned with a scope still open, which made the enclosing Napi::HandleScope fail to close and Napi::Error::Fatal throw from its implicitly-noexcept destructor, terminating the process with no FAILED line. A guard now closes the scope on every path. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: c26bf58d-8462-4ea4-908d-67d366b657c5
The four escape tests left the lambda body at the same level as the runtime.Dispatch call it belongs to. Every other Dispatch-based test in this file indents it. Whitespace only. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: c26bf58d-8462-4ea4-908d-67d366b657c5
The Chakra and JavaScriptCore tracking added in 927d952 keyed on the token without checking it was open, so an escape against a closed or never-opened scope reported success and left an entry that only a matching close removed. Both now track open scopes and reject an unknown token. napi_close_escapable_handle_scope on QuickJS resized the handle stack to a recorded start that a non-LIFO close can leave past the end, growing it with null entries for the next close to dereference. Report the mismatch instead. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: c26bf58d-8462-4ea4-908d-67d366b657c5
The test asserted that escaping against a closed scope is rejected. Node-API does not define that: V8 deletes the scope wrapper on close, so the call reads freed memory and both V8 jobs failed. The backends we implement still reject an unopened token, which is worth doing, but it is not a contract a shared test can assert. napi_open_handle_scope keeps its position-derived token, which is what a regular scope needs. Its comment now says so, and says not to key per-scope state on it, which is the mistake the escapable version made. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: c26bf58d-8462-4ea4-908d-67d366b657c5
|
[Commented by Copilot on behalf of @bghgary] I pushed four commits here rather than leaving review comments. Two of them change decisions you had made, so calling those out explicitly:
The description is updated to match. Say if you disagree with either, particularly the second, since it widens the change past QuickJS. |
bghgary
left a comment
There was a problem hiding this comment.
[Reviewed by Copilot on behalf of @bghgary]
Pushed to this branch rather than leaving these as asks.
The scope token came from the handle-stack position, so two escapable scopes opened with no handle between them shared a key and the second to escape was refused with napi_escape_called_twice having never escaped; scopes now get a record keyed by a counter. The tests closed their scopes only on success, turning a failing assertion into process termination with no FAILED line; they now close on every path. Chakra and JavaScriptCore track open scopes, which deletes JSRUNTIMEHOST_NAPI_ESCAPE_HANDLE_IS_PASSTHROUGH, lets SecondEscapeIsRejected run on every backend, and bounds state that previously only a matching close could remove. A non-LIFO close no longer resizes the QuickJS stack past its end.
QuickJS 10/10, Chakra 10/10 on Windows Release; the new test fails against the pre-fix code. JavaScriptCore does not build here, so its change rests on CI.
napi_open_handle_scope keeps its position-derived token. A position is all a regular scope needs, because nothing stores per-scope state against it: the token only says where the close truncates, and two closes truncating to the same index is a no-op. Giving it a counter would mean recovering the position from a lookup, so a token this code has never seen becomes an error return, and HandleScope's destructor turns any non-napi_ok into Error::Fatal — a new abort path on a scope opened per Dispatch, for no behavioural change. Its comment now records that the token is a position and must not be keyed on, which is the mistake the escapable version made.
The Ubuntu_Clang_QuickJS job segfaults immediately after entering the NativeDraco tests. The cause is in JsRuntimeHost, not here: QuickJS is refcounted, and napi_escape_handle dropped the escaped value's last reference when its escapable scope closed, so the caller was left holding a freed JSValue. The Draco encoder returns a typed array out of an escapable scope and so hits it directly. Fixed upstream by BabylonJS/JsRuntimeHost#223, merged as dbd4620f. That is the only commit between the old pin and the new one, so this is a fast-forward with no other behavioural change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 88569c10-a7ff-4373-9a58-afa9c68b8c09
[Updated by Copilot on behalf of @bghgary]
napi_escape_handleinserted the escaped handle at the scope start index so it would live in the parent scope, butnapi_close_escapable_handle_scoperecomputedscope_startfrom the token and calledresize(scope_start), freeing the very handle the close was supposed to preserve. Every caller ofnapi_escape_handlegot a danglingnapi_valueback.This is reachable from ordinary code, not just direct N-API use.
Napi::ObjectReference::Getuses anEscapableHandleScopeandNapi::Error::Message()/what()are built on it, so reading the message of a native error on QuickJS was a heap-use-after-free.Napi::FunctionReference::CallandMakeCallbackescape as well, which puts every WebSocket,setTimeout,XMLHttpRequestandAbortSignalcallback on this path: instrumentingnapi_escape_handlecounted ~201 escapes in a singleJavaScript.Allrun with no escape-specific test in scope.How it was found. BabylonNative #1835 adds tests that make a native module throw.
ExternalCallback::Callbackcallse.what()when there is no pending QuickJS exception, walking straight into the freed handle; itsUbuntu_Clang_QuickJSjob segfaulted while every other engine and platform passed.The change
Each open escapable scope gets a record on the env, keyed by a monotonic counter that is handed out as the opaque token. The escaped handle lives in that record until
napi_close_escapable_handle_scopepushes it onto the handle stack, once the scope's own handles are gone; it lands atscope_start, in the parent scope, so it outlives the close.The token is a counter rather than a position because two escapable scopes opened with no handle allocated between them occupy the same position. Keyed on that, their escaped handles collide and the second scope to escape is refused with
napi_escape_called_twicehaving never escaped.The handle stack is never modified in the middle, which matters: inserting at
scope_startshifts every entry above it and invalidates the recorded start of any nested scope still open, reintroducing the same dangling value by a different route.A close whose recorded start is past the end of the stack now reports
napi_handle_scope_mismatchrather than resizing, which previously grew the stack with null entries for the next close to dereference. Env teardown frees handles still held for scopes that were never closed.napi_open_handle_scopekeeps its position-derived token: a position is all a regular scope needs, and its comment now says not to key per-scope state on it, which is the mistake the escapable version made.Chakra and JavaScriptCore
Both returned the escapee without tracking scopes, so neither could report
napi_escape_called_twice. Both now track open escapable scopes; values there are rooted independently of any scope, so this is the error contract only. That removes the need forJSRUNTIMEHOST_NAPI_ESCAPE_HANDLE_IS_PASSTHROUGH, soSecondEscapeIsRejectedruns on every backend rather than being compiled out on two of them.Testing
Four tests in
Tests/UnitTests/Shared/Shared.cpp:EscapedHandleOutlivesItsScope— reproduces the originalheap-use-after-freeunder ASan without the fix.NestedEscapableScopesBothEscape— fails on every run against the pre-fix implementation.SecondEscapeIsRejected— thenapi_escape_called_twicecontract.AdjacentEscapableScopesEscapeIndependently— two scopes with no handle allocated between them; confirmed to fail against the position-derived token and pass with the counter.Each test closes its escapable scopes on every exit path. Leaving one open made the enclosing
Napi::HandleScopefail to close, andNapi::Error::Fatalthrows from a destructor that is implicitlynoexcept, so a failing assertion terminated the process instead of reportingFAILED.Verified locally at this head on Windows Release: QuickJS 10/10 and Chakra 10/10. V8, JavaScriptCore and Hermes are covered by CI.
The BabylonNative #1835 end-to-end run (clang + QuickJS + RelWithDebInfo, changing only this dependency:
mastergives exit 139, 1, 139; this branch gives exit 0 × 5, clean 16/16) was made against6238b5ab, before the scope-identity change.