[JSC] ConservativeRoots: apply the butterfly end-pointer rules to a JSCellButterfly only when it has no elements - #636
Conversation
…SCellButterfly only when it has no elements A Butterfly* can point up to sizeof(IndexingHeader) past the end of the allocation it refers to. ConservativeRoots::genericAddPointer() honours that in three places: the last cell of the previous MarkedBlock, the previous cell in the same MarkedBlock, and the end+8 bound of a PreciseAllocation. All three are gated on mayHaveIndexingHeader(cellKind), which is true for Auxiliary and for JSCellWithIndexingHeader. JSCellWithIndexingHeader is JSCellButterfly only. Its Butterfly* is toButterfly() = cell + offsetOfData() (16 bytes). That is past the last byte of the cell only for a cell with no elements, which is the 16-byte size class. For every other JSCellButterfly it is an interior pointer, and the interior pointer path already marks the cell. So for those cells the end-pointer rules can only produce false roots. The false root matters because a JSCellButterfly is scanned. Map and Set tables are JSCellButterfly cells, and a replaced table keeps a pointer to the table that replaced it (for iterators) plus its stale entries. One replaced table that stays marked keeps every later table of that Map or Set alive, with the entries that were live at each rehash or clear. In bun 1.4.3 canary (6a92015fc, linux x64 release) the JSC::VM object sits at the 16 KB boundary directly after the first MarkedBlock of 224-byte JSCellButterfly cells. 224 bytes is a Map or Set table at its initial capacity. VM* is in about 30 live stack slots at every collection, so the previous-block rule marks the last cell of that block in every collection. The first Map or Set table allocated there is never freed, and that Map or Set leaks a table every three or four insertions from then on. A node:http server leaks its closed sockets this way (the Set of tracked connections). Add mayBeReferencedByButterflyEndPointer(cellKind, cellSize): Auxiliary at any size, JSCellWithIndexingHeader only when cellSize <= offsetOfData(), JSCell never. Use it for the three gates. A precise JSCellButterfly keeps the exact one-past-the-end bound that other cells with trailing storage have.
Preview Builds
|
|
Warning Review limit reached
On-demand reviews are free for the next 8 days. After that, they cost $0.25 per reviewed file. Or wait 8 minutes for your next included review. View limit detailsLimit details: You’ve used all 5 included reviews currently available. Review configuration: ⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Essentials Run ID: 📒 Files selected for processing (1)
Comment |
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. Because it tightens the conservative root scanner in a way that could turn a false positive into a missed root if the layout assumption ever slips, a human familiar with JSC GC should confirm the reasoning before it lands.
What was reviewed:
- Verified
HeapCell::JSCellWithIndexingHeaderis used only byimmutableButterflyAuxiliarySpace/JSCellButterfly, so the "JSCellWithIndexingHeader ⇒ JSCellButterfly" assumption holds today. - Checked
JSCellButterfly::toButterfly()=this + offsetOfData(); for any cell with ≥1 element that lands strictly inside the allocation, so the interior-pointer path ingenericAddPointerstill marks it. - Confirmed
MarkedBlock::cellSize()andPreciseAllocation::cellSize()exist and are the right accessors at each of the three call sites.
Extended reasoning...
Overview
This PR touches a single file, Source/JavaScriptCore/heap/ConservativeRoots.cpp, adding a helper mayBeReferencedByButterflyEndPointer(kind, cellSize) and swapping it in for mayHaveIndexingHeader(kind) at the three "butterfly end-pointer" gates in genericAddPointer: the PreciseAllocation bounds check, the previous-MarkedBlock probe, and the previous-cell-in-block probe. For HeapCell::JSCellWithIndexingHeader the new predicate only passes when cellSize <= JSCellButterfly::offsetOfData() (i.e., a zero-element JSCellButterfly), otherwise it falls back to the existing behavior. The intent is to stop unrelated stack words that happen to land in the first 8 bytes after a JSCellButterfly block from pinning its last cell — the leak the PR describes for Map/Set tables.
Security risks
The conservative root scanner is what keeps stack-referenced heap cells alive across GC. Narrowing any gate here risks under-marking: if a Butterfly* to a JSCellButterfly could ever legitimately point past the end of the allocation for a cell whose size exceeds offsetOfData(), this change would let that cell be collected while still referenced, yielding a use-after-free. The argument that this cannot happen rests on toButterfly() being cell + offsetOfData() and allocationSize(n) = offsetOfData() + n*8, which I verified in JSCellButterfly.h; for n ≥ 1 the butterfly pointer is strictly interior. I also confirmed that JSCellWithIndexingHeader is only ever assigned via immutableButterflyHeapCellType → immutableButterflyAuxiliarySpace, and only JSCellButterfly::subspaceFor returns that space. There is no injection, auth, or data-exposure surface here — the risk is purely memory safety.
Level of scrutiny
High. This is core GC root-scanning logic that CLAUDE.md explicitly calls out as underpinning C++ integration safety, and Source/JavaScriptCore is CODEOWNERS-covered by @ WebKit/jsc-reviewers. The change is small and the reasoning reads correctly, but the failure mode (a rare UAF that only manifests when a JSCellButterfly's Butterfly* is the sole live stack reference at collection time) would be very hard to diagnose. It also encodes a layout invariant — that no JSCellWithIndexingHeader type other than JSCellButterfly exists, and that its Butterfly* is always this + offsetOfData() — that a future change elsewhere could silently break. A human JSC/GC reviewer should sign off on that invariant.
Other factors
The PR is still a draft and the author is explicitly waiting on a Bun release build to validate the fix against the repro. No regression test is added under JSTests/stress/. The change follows the pattern of the earlier #398 (which excluded plain JSCell from these gates) and the three call-site edits are mechanically consistent, so I have reasonable confidence it is correct — but not enough to bypass human review on a GC-safety path.
|
#637 was the same fix from another report. I closed it in favor of this PR. Two things from it may be useful here.
A test that does not depend on the memory layout is linked from oven-sh/bun#42460. It fails on release, ASAN debug and Windows builds that lack the fix, and it passes against this PR's preview build. |
|
I found the same bug from a third report (a A
The same test as a My diff used the exact pointer for the 16-byte cell ( Numbers for the |
|
I found the same bug from a fourth report and arrived at the same predicate at the same three gates. This PR covers it, so I did not open another one. In that report only the first Map or Set of a process whose table reaches that cell is hit, and it keeps the values too: 30000 Three things that are not in the thread yet. 1. A test for the side of the predicate that keeps the rules. Branch
2. Upstream has the left-neighbor case too. A JSCOnly build of WebKit/WebKit 3. A control build for the fourth report. bun
|
…d neighbor alive The storage of a Set or a Map is a JSCellButterfly. JSC's conservative scan let a stack word that points at the start of one such cell also mark the cell on its left, as if it were a butterfly pointer past the end of that cell. oven-sh/WebKit#636 stops that. The test allocates 200 Sets, drops every second one, keeps the storage of the others on the stack with nested forEach calls, and collects. It does not depend on where JSC::VM is, so it also fails on a debug or ASAN build that lacks the engine fix (98 of 100 dropped keys stay alive).
The preview tag is gone now that oven-sh/WebKit#636 is merged. 80e6489f5bb9 is cf1b36ec8703 plus that one commit. The autobuild release has the same 42 artifacts as the release of the previous pin. The two layout-dependent tests now point at the "conservative roots" case in test/js/bun/jsc/bun-jsc.test.ts, which fails without the engine fix on every build.
The WebKit branch now sits on cf1b36ec, the commit this repo pinned before. The preview differs from that pin by the one commit of oven-sh/WebKit#650 and no longer carries oven-sh/WebKit#634 and oven-sh/WebKit#636.
The preview tag goes away now that the WebKit PR has merged. The new pin is fork main. It also picks up the five other commits that landed there since cf1b36ec8703: oven-sh/WebKit#636, #634, #632, #652 and #646. Bun builds against the new headers with no source change.
Problem
clear(). On bun 1.4.3 canary (6a92015fc, linux x64 release),s.add(v); s.delete(prev)3000 times leaves 929Cell Butterflycells afterBun.gc(true). Anode:httpserver keeps about 240 of 1000 closed sockets alive, with theirIncomingMessageandServerResponse.ConservativeRoots::genericAddPointer(heap/ConservativeRoots.cpp:141). A stack wordp <= blockFor(p) + sizeof(IndexingHeader)marks the last cell of the MarkedBlock beforepwhen that blockmayHaveIndexingHeader. In that build theJSC::VMobject sits at the 16 KB boundary after the first block of 224-byteJSCellButterflycells.VM*is in about 30 live stack slots at every collection, so that cell is marked in every collection.Fix
mayBeReferencedByButterflyEndPointer(cellKind, cellSize)and use it for the three end-pointer gates: the previous block, the previous cell in the block, and the end+8 bound of aPreciseAllocation.Auxiliarypasses at any size.JSCellWithIndexingHeaderpasses only whencellSize <= JSCellButterfly::offsetOfData().JSCellnever passes, as in [JSC] ConservativeRoots: no past-the-end butterfly slack for cells that cannot hold a butterfly (MarkedBlock rule + PreciseAllocation::contains) #398.JSCellWithIndexingHeaderisJSCellButterflyonly, and itsButterfly*istoButterfly()= cell + 16. That is past the last byte of the cell only when the cell has no elements (the 16-byte size class). For every other size it is an interior pointer, and the interior-pointer path already marks the cell.JSCellButterflykeeps the exact one-past-the-end bound that [JSC] ConservativeRoots: no past-the-end butterfly slack for cells that cannot hold a butterfly (MarkedBlock rule + PreciseAllocation::contains) #398 kept for cells with trailing storage.9e1603604pinned toautobuild-preview-pr-636-d4a52a7a. The Set repro above leaves 4Cell Butterflycells (3 before the loop), also withBUN_JSC_useJIT=0,BUN_JSC_useGenerationalGC=0andBUN_JSC_useConcurrentGC=0. 0 to 1 of the 3000 values stay alive, for a Set and for a Map, and 1 of 1000 closednode:httpsockets.Background
Butterfly*points after theIndexingHeader, so for a butterfly with no indexed storage it points up to 8 bytes past the end of its allocation.genericAddPointertherefore also marks the cell before the one such a pointer lands on. [JSC] ConservativeRoots: no past-the-end butterfly slack for cells that cannot hold a butterfly (MarkedBlock rule + PreciseAllocation::contains) #398 turned that off for plainJSCellblocks.JSCellButterflyis a cell with a butterfly layout: cell header,IndexingHeader, then the elements. It backs copy-on-write array literals and the tables of Map and Set. The collector visits its elements.clear(). The old table keeps a pointer to the new table so that a live iterator can move to it, and it keeps its stale entries. Nothing else points to the old table.Notes
How the cause was found (release build with symbols of bun
6a92015fc, linux x64):generateHeapSnapshotForDebugging()after the Set loop: the kept tables have no incoming edge and no root entry.JSCellButterfly::visitChildrenreports its elements withappendValuesHidden, and conservative roots report nothing. The kept range starts at the last 224-byte cell of one MarkedBlock and covers every later table.vm = 0x4dbdcf80000inBun::evaluateCommonJSModuleOnceandthis = 0x4dbdcf80000inJSC::VM::drainMicrotasks. The first kept table is at0x4dbdcf7ff20, the last cell of the block0x4dbdcf7c000, which ends at0x4dbdcf80000. 33 live stack words hold0x4dbdcf80000. No stack word points into the table.ConservativeRoots::addthat shows the same thing from the other side: after the scan of the register state the root set holds exactly one cell,VM - 0xe0, and none on the fixed build.VM*words includeJSC::VM::drainMicrotasks,JSC::JSModuleLoader::makeModuleand bun'sEventLoop::tick, so the word is on the stack in every collection that runs while JS runs.The rule has a second trigger that needs no special layout. A stack word that points at the start of a
JSCellButterflyalso marked the cell on its left (the third gate). Consecutive tables of one Map or Set are neighbors, and so are the tables of Maps and Sets that are created in a row.JSTests/stressfiles for this case and for the 16-byte cell that keeps the rules are linked in the comments below. The bun-side test for it is the "conservative roots" case intest/js/bun/jsc/bun-jsc.test.ts(oven-sh/bun#42460).Measurements from the four reports that ended here are in the comments below and in oven-sh/bun#42460: 2832 bytes per
add+deletecycle with two live 2 KB entries, 55 MB for 30000set()calls with aclear()after every third, and 1549 to 1657 of 2400 abortednode:httprequests.