Skip to content

Fix null deref in Bun.inspect when Proxy getPrototypeOf trap throws#30318

Closed
robobun wants to merge 2 commits into
mainfrom
farm/ee2e2ddf/fix-inspect-proxy-getprototype
Closed

Fix null deref in Bun.inspect when Proxy getPrototypeOf trap throws#30318
robobun wants to merge 2 commits into
mainfrom
farm/ee2e2ddf/fix-inspect-proxy-getprototype

Conversation

@robobun

@robobun robobun commented May 6, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes a null pointer dereference in JSC__JSValue__forEachPropertyImpl (used by Bun.inspect, console.log, and expect error formatting) when walking the prototype chain of an object whose prototype is a Proxy.

When getPrototype() is called on a ProxyObject, the getPrototypeOf trap may throw, causing getPrototype() to return an empty JSValue. Calling .getObject() on an empty JSValue then dereferences a null JSCell*.

JSCJSValueCell.h:92:33: runtime error: member call on null pointer of type 'JSC::JSCell'
    #0 JSC::JSValue::getObject() const
    #1 JSC__JSValue__forEachPropertyImpl<false> bindings.cpp:5447
    #2 JSC__JSValue__forEachProperty bindings.cpp:5461

Repro

const obj = {};
Object.setPrototypeOf(obj, new Proxy({}, {
  getPrototypeOf() { throw new Error("trap"); }
}));
Bun.inspect(obj);

The fix mirrors the existing handling of the same call earlier in the function: check the returned JSValue before calling .getObject() and clear any pending exception from the trap.

How did you verify your code works?

Added regression tests in test/js/bun/util/inspect.test.js covering:

  • A Proxy prototype with a throwing getPrototypeOf trap
  • A Proxy prototype wrapping a prototype with a throwing getter
  • An expect() result with a Proxy prototype (the original fuzzer scenario)

Found by Fuzzilli. Fingerprint: 0948b210d37148a3

@robobun

robobun commented May 6, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 7:09 AM PT - May 6th, 2026

@robobun, your commit 952850c has 1 failures in Build #52161 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 30318

That installs a local version of the PR into your bun-30318 executable, so you can run:

bun-30318 --bun

@github-actions github-actions Bot added the claude label May 6, 2026
@coderabbitai

coderabbitai Bot commented May 6, 2026

Copy link
Copy Markdown
Contributor

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: f57f68df-a6bc-4602-aadb-f76df906e027

📥 Commits

Reviewing files that changed from the base of the PR and between 611740f and 3c02359.

📒 Files selected for processing (2)
  • src/jsc/bindings/bindings.cpp
  • test/js/bun/util/inspect.test.js

Walkthrough

This change hardens prototype traversal in fast-path JS property enumeration to safely handle Proxy getPrototypeOf traps that throw. The binding code retrieves the prototype into a local variable with explicit exception clearing and null-checking instead of a direct chained call, alongside new test coverage validating the fix.

Changes

Proxy-Safe Prototype Traversal

Layer / File(s) Summary
Prototype Traversal Safety
src/jsc/bindings/bindings.cpp
forEachPropertyImpl replaces direct chained call iterating->getPrototype(globalObject).getObject() with stepwise retrieval: fetch proto via getPrototype, clear any exception from the trap, and assign iterating to proto.getObject() if proto is non-null.
Proxy Prototype Chain Tests
test/js/bun/util/inspect.test.js
New test suite "Proxy in prototype chain" with three tests verifying Bun.inspect does not throw when Proxies appear in the prototype chain, including scenarios with throwing getPrototypeOf traps and throwing prototype getters.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the main fix: a null deref in Bun.inspect when a Proxy's getPrototypeOf trap throws, which matches the core change in bindings.cpp.
Description check ✅ Passed The PR description comprehensively covers both required template sections: What does this PR do (with detailed explanation and repro) and How did you verify your code works (with specific test coverage details).
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions

github-actions Bot commented May 6, 2026

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Fix null JSCell derefs in Bun object lazy init and Bun.inspect with Proxy prototypes #30245 - Fix null JSCell derefs in Bun object lazy init and Bun.inspect with Proxy prototypes (also includes BunObject lazy init fixes)
  2. Fix null deref in Bun.inspect when Proxy prototype throws #30200 - Fix null deref in Bun.inspect when Proxy prototype throws
  3. fix(inspect): handle Proxy trap exceptions when walking prototype chain #30099 - fix(inspect): handle Proxy trap exceptions when walking prototype chain
  4. inspect: clear exceptions when walking Proxy prototype chain #29845 - inspect: clear exceptions when walking Proxy prototype chain
  5. inspect: handle throwing Proxy getPrototypeOf in forEachProperty #29814 - inspect: handle throwing Proxy getPrototypeOf in forEachProperty
  6. Fix null JSCell deref in Bun lazy property callbacks and forEachProperty #29671 - Fix null JSCell deref in Bun lazy property callbacks and forEachProperty (also includes BunObject lazy init fixes)
  7. fix(inspect): don't crash when a Proxy in the prototype chain throws #29642 - fix(inspect): don't crash when a Proxy in the prototype chain throws
  8. getPrototype exception checks #24985 - getPrototype exception checks (broader scope: changes getPrototype to propagate exceptions throughout Zig + C++ callers)

🤖 Generated with Claude Code

@robobun

robobun commented May 6, 2026

Copy link
Copy Markdown
Collaborator Author

Duplicate of #29642 (and several others). Closing.

@robobun robobun closed this May 6, 2026
@robobun robobun deleted the farm/ee2e2ddf/fix-inspect-proxy-getprototype branch May 6, 2026 14:08
Comment on lines +5447 to +5450
JSValue proto = iterating->getPrototype(globalObject);
// Ignore exceptions from Proxy "getPrototypeOf" trap.
CLEAR_IF_EXCEPTION(scope);
iterating = proto ? proto.getObject() : nullptr;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟣 Heads up (pre-existing, not introduced by this PR): the same unguarded pattern lives at src/jsc/bindings/napi.cpp:1837current_object->getPrototype(globalObject).getObject() — reachable via napi_get_all_property_names with napi_key_include_prototypes + a writable/enumerable/configurable filter. A Proxy whose getPrototypeOf trap throws will cause the same null JSCell* deref there; the if (!proto) on the next line doesn't help because the UB happens inside .getObject(). Since you're fixing the same root cause here, you may want to apply the same JSValue-then-check guard there too.

Extended reasoning...

What the bug is

The exact pattern this PR fixes — calling .getObject() directly on the result of getPrototype(globalObject) without first checking whether the returned JSValue is empty — also exists at src/jsc/bindings/napi.cpp:1837:

while (!current_object->getOwnPropertyDescriptor(globalObject, key.toPropertyKey(globalObject), desc)) {
    JSObject* proto = current_object->getPrototype(globalObject).getObject();
    if (!proto) {
        break;
    }
    current_object = proto;
}

When current_object (or something in its prototype chain) is a ProxyObject whose getPrototypeOf trap throws, getPrototype() returns an empty JSValue. As demonstrated by the UBSAN trace in this PR's description, an empty JSValue (encoded as 0) passes the isCell() check (it doesn't have NotCellMask bits set), so .getObject() proceeds to call asCell()->isObject() on a null JSCell* — undefined behavior / null deref.

Why the existing if (!proto) doesn't help

It's tempting to think the if (!proto) break; on line 1838 covers this, but it doesn't: the null dereference happens inside .getObject() on line 1837, before proto is ever assigned. This is exactly the failure mode the PR fixes in bindings.cpp (the original code there was iterating->getPrototype(globalObject).getObject(), and the fix splits it into JSValue proto = ...; iterating = proto ? proto.getObject() : nullptr;).

How it's reachable

This loop sits inside napi_get_all_property_names and runs when called with key_mode = napi_key_include_prototypes and a key_filter that includes any of napi_key_writable | napi_key_enumerable | napi_key_configurable (so the per-key descriptor-lookup loop at line 1829 executes). The NAPI_RETURN_IF_EXCEPTION at line 1823 catches an exception thrown during the initial allPropertyKeys walk, but the per-key loop calls getPrototype again for every key — so a stateful trap (succeeds the first N calls, throws on call N+1), or a trap that only throws after getOwnPropertyDescriptor has run, will reach line 1837 with a pending exception and an empty return value.

Step-by-step proof

  1. Native addon calls napi_get_all_property_names(env, obj, napi_key_include_prototypes, napi_key_writable, napi_key_numbers_to_strings, &result) where obj has a Proxy in its prototype chain whose getPrototypeOf trap is stateful (e.g., returns {} the first few times, then throw new Error("trap")).
  2. Line 1818 allPropertyKeys walks the chain — trap returns normally, no exception. Line 1823 passes.
  3. key_filter & filter_by_any_descriptor is true → enter the per-key loop.
  4. For some key, getOwnPropertyDescriptor returns false on object, so we enter the while body and call current_object->getPrototype(globalObject).
  5. The Proxy trap now throws. getPrototype() returns an empty JSValue (encoded 0).
  6. .getObject()isCell() on encoded 0 returns true → asCell() returns nullptrnullptr->isObject()null deref, identical to the UBSAN trace in this PR.

Impact and fix

Impact: a native module using this N-API combination on a user-controlled object with a hostile Proxy prototype hits UB / a crash. Same severity class as the bug being fixed in this PR, just on a less-traveled path.

Fix: apply the same guard this PR adds in bindings.cpp:

JSValue protoValue = current_object->getPrototype(globalObject);
RETURN_IF_EXCEPTION(scope, ...); // or CLEAR_IF_EXCEPTION + break, matching local conventions
JSObject* proto = protoValue ? protoValue.getObject() : nullptr;
if (!proto) break;

This is pre-existing — the PR doesn't touch napi.cpp and doesn't make it any worse — but since it's the same root cause and the same one-line fix, it seemed worth flagging here for completeness.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant