Home · ASPLOS 2024 · Security, Privacy & Trusted Execution

Cornucopia Reloaded: Load Barriers for CHERI Heap Temporal Safety

Nathaniel Wesley Filardo, Brett F. Gutstein, Jonathan Woodruff, Jessica Clarke, Peter Rugg, Brooks Davis and 6 others

Conference
ASPLOS 2024
Published
2024
Pages
251-268
Citations
15

Use-after-free remains one of the most exploited classes of software bug, and the CHERI hardware that already blocks out-of-bounds access cannot stop it, because a pointer to freed memory is still a perfectly valid pointer; the fix is to hold freed memory aside until every pointer to it has been erased from the machine, but the erasing must somehow be atomic with respect to a program that keeps running and keeps copying pointers — the previous system watched every pointer WRITE and then froze the program to catch up, pausing it for tens of milliseconds and sometimes over a second; this paper (Cambridge, Microsoft and SRI) watches every pointer READ instead, so the program can never obtain a stale pointer and therefore never creates work the sweeper must redo, collapsing the freeze to tens of microseconds.

The argument in one glance

  1. Problem

    Unforgeable pointers still go stale

    The CHERI architecture replaces pointers with CAPABILITIES that carry bounds and cannot be fabricated, which reliably stops a program reading or writing past the end of an object. It does nothing for the other failure: using a pointer after the object it names has been released. That capability is still perfectly valid — nothing about it changed when the memory was freed — so once the allocator hands that memory to a new object, the stale pointer grants full access to somebody else data. Use-after-free remains one of the most exploited classes of bug in real software.

  2. What people did

    Hold the memory back until every pointer to it is gone

    The established answer is REVOCATION. Do not reuse freed memory immediately; hold it in QUARANTINE, and periodically sweep the whole of a program memory destroying every capability that points into the quarantined ranges. Once the sweep completes, no pointer to that memory survives anywhere, so it is safe to reuse. Prior systems built this on CHERI and showed the overheads were tolerable — the memory is marked in a bitmap, one bit per pointer-sized slot, and the sweeper checks every capability it finds against that map.

  3. Why it fell short

    The program keeps making work faster than the sweeper finishes it

    A sweep must be atomic with respect to the program, and the program is still running. The previous system, Cornucopia, tracked capability WRITES using a per-page trap: sweep concurrently, note which pages the program dirtied, then STOP THE WORLD and re-sweep those. The flaw is structural rather than an inefficiency — the program can dirty pages faster than they are cleaned, so progress is not monotone and the only way to guarantee completion is to freeze the program. Measured on Morello, that freeze has a median as high as 30 milliseconds on memory-heavy workloads, with 92 observed pauses exceeding a full second. The authors judgement of their own prior work is blunt: an attractive idea whose implementation strategy is likely a dead end.

  4. This paper's idea

    Intercept reads instead of writes, and the extra work disappears

    This paper (Cambridge, Microsoft and SRI International — Filardo, Gutstein, Woodruff and colleagues) inverts which operation is watched. If every capability LOAD is intercepted and checked before it is allowed, the program can never obtain a stale pointer — and therefore can never store one. So any page the program writes during the sweep is ALREADY CLEAN and needs no revisit. Progress becomes monotone, measured simply as pages not yet scanned, and the freeze shrinks to the irreducible part: synchronising cores and scanning registers. Arm Morello and CHERI-RISC-V added a per-page load barrier for this purpose, armed globally by flipping one bit per core. Pauses fall to tens of microseconds, at no extra time cost and with less memory traffic.

Why this problem is worth solving

The gap this paper closes is specific, and it helps to see why the existing protection does not reach it.

CHERI replaces integer pointers with CAPABILITIES: an architectural type that carries the address plus the BOUNDS of the region it authorises. Hardware keeps a TAG bit alongside every capability-sized word in memory, so software can perfectly distinguish a real capability from an arbitrary bit pattern, and a capability can only ever be derived from one with wider authority — you cannot manufacture access you were not given. Compiled this way, every C and C++ pointer becomes a capability, and the allocator narrows the bounds on what it returns, so reading past the end of an array simply traps. That is SPATIAL safety. TEMPORAL safety is untouched, because freeing memory changes nothing about capabilities that point at it. They remain valid, in bounds, and tagged. When the allocator later hands that address range to a different object, every surviving stale pointer becomes a window into it
.

The approach has an obvious cousin, and the difference between them is what makes it tractable.

REVOCATION and garbage collection face the same core task — find and inspect every pointer a program holds — but ask opposite questions. A collector must DISCOVER which objects are dead, which means walking the reference graph from the roots and following pointers wherever they lead. Revocation is TOLD which memory is dead, because the program called free, so it never chases anything: it simply tests every capability it encounters against a bitmap marking the quarantined ranges, and destroys the ones that match. It knows in advance exactly how much memory it will reclaim. The bitmap has one bit per pointer-sized slot of address space, and the test uses each capability BASE rather than its current address, because capabilities may legitimately point outside their bounds — a property the architecture guarantees and which has been formally verified for Morello. That asymmetry is why revocation can borrow collector engineering without inheriting the hard part
.

What people did before, and where it broke

  • Sweep with the program stopped. CHERIvoke establishes the approach: pause the program, walk its memory, destroy every capability pointing into quarantine, resume. Correct and simple, since nothing changes underneath the sweeper.
  • But the pause is the whole runtime of a sweep. Measured here on pgbench, the fully sequential version puts its 99th-percentile transaction 27 milliseconds behind the median, with a median freeze of 20 milliseconds.
  • Overlap the sweep with the program, then catch up. Cornucopia adds concurrency by using a per-page trap on capability STORES: a second core sweeps while the program runs, noting which pages get written, and then the world stops only to re-sweep those. This genuinely helped, cutting pause times by 70 to 90 percent on large-heap workloads.
  • But catching up is not guaranteed to converge. The program can dirty pages as fast as they are cleaned, so the residue is not reliably small and the only way to be sure of finishing is to freeze. On Morello this still means median pauses reaching 30 milliseconds, with 92 measured pauses over a second — and the authors note that adding a second concurrent pass barely helped while increasing total work.
  • Colour the memory and the pointers instead. Arm’s Memory Tagging Extension and similar schemes attach a few metadata bits to memory granules and to pointer addresses, checking they match on dereference, and recolouring on reuse. Cheap and deployed at scale.
  • But it is probabilistic and mostly used reactively. The colours must stay secret from an attacker who can forge pointers, and precise trapping of stores is expensive enough that production deployments track imprecisely, flagging violations for later analysis. That is a surveillance-and-patch posture rather than a deterministic fail-stop one.
  • The gap. Everyone was watching the operation that CREATES the problem — writing a pointer somewhere new. Nobody had asked what happens if you watch the operation that lets the program obtain a stale pointer in the first place, which turns out to change the structure of the problem rather than its constant factors.

How watching reads removes the catch-up phase

Arm a barrier on capability loads, and the program stops creating work for the sweeper

  1. Quarantine freed memory and mark it in a bitmap

    When the program frees an object, the allocator does not reuse the address range. It sets the corresponding bits in a shadow bitmap and holds the range aside until a sweep has certified that no capability anywhere still points into it. A policy decides when to sweep, here when quarantine exceeds a third of the allocated heap.

  2. Flip one bit per core to arm the barrier everywhere new

    Each page table entry compares one of its bits against a generation bit in a per-core register. Toggling the core register instantly makes every capability load in the address space trap, with no page table walk and no shootdown. Page table entries are then updated once each, as pages are cleaned.

  3. Stop the world only for what cannot be done concurrently new

    Synchronise the cores so they all adopt the new generation, then scan thread register files and the pointers the kernel is holding on the program behalf. A stale capability sitting in a register would break the invariant, so this part is irreducible — but it is bounded by register count, not by heap size.

  4. Let the program clean the pages it touches new

    The program resumes and immediately faults on its first capability load from any unswept page. The handler sweeps that page using the application own thread, updates its page table entry, and re-runs the load. The barrier is self-healing: the program repairs memory as it goes rather than merely being blocked by it.

  5. Sweep the rest in the background, with monotone progress new

    A background thread visits every page not yet reached, guaranteeing termination. Because the program cannot hold a stale capability, it cannot store one, so pages it writes during this phase need no revisit. Progress is simply pages remaining, which only ever decreases.

  6. Release the quarantine and restore the steady state new

    When every page has been visited the generation bits agree again and the barrier disarms. An epoch counter tells allocators when their quarantined ranges have been certified, after which the address space is safe to hand out again.

Implemented in CheriBSD and merged into its 23.11 release, evaluated on Arm Morello — a four-core 2.5GHz CHERI-extended ARMv8.2 machine with 64 GiB of memory — against SPEC CPU2006 integer workloads, PostgreSQL pgbench and a gRPC query benchmark.

The idea

Watch the operation that lets the problem spread, not the one that spreads it. The previous design watched capability stores, which is the natural choice: a store is what puts a stale pointer somewhere new, so tracking stores tells you exactly which pages need re-examining. The trouble is that this makes the program an adversary of the sweeper — every store creates work, the program can create work faster than the sweeper retires it, and the residue can only be bounded by freezing. Watching loads inverts the relationship completely. If the program cannot LOAD a stale capability, it cannot store one either, so anything it writes during the sweep is guaranteed clean by construction. The authors put the resulting invariant crisply: any pointer in a thread’s registers did not point into quarantine as of the last revocation, and progress is measured as pages not yet scanned. Compare the old invariant, phrased over pages marked dirty, which advances only when the world stops. Choosing the checkpoint that makes progress monotone, rather than the one that most directly names the problem, is the same shift-where-you-intervene move covered elsewhere on this site where acting at the point that generates the difficulty turns an unbounded chase into a bounded one.

Borrow forty years of collector engineering. The paper is explicit that this is not a new idea so much as a transplant. Garbage collectors faced exactly this problem — inspect every pointer while the program runs — and worked through stop-the-world, then incremental, then concurrent designs, arriving at BARRIERS on the program’s loads and stores that maintain collector invariants. Cornucopia’s page-dirty tracking is a store barrier in the style of Boehm, Demers and Shenker, and card-marking collectors. This paper adds a load barrier in the style of Appel, Ellis and Li, as shipped in Azul’s pauseless collector, whose defining invariant is that the object graph the program sees outside barrier code looks as if collection had already finished. What is new is doing it for revocation, where the task is easier because nothing must be traced, and doing it with architectural support. Notably the authors had CONJECTURED this direction in the Cornucopia paper itself and describe this work as quantitatively affirming that intuition — a rare case of a group publishing the refutation of its own approach.

Make the hardware feature fit the algorithm, not the other way round. A straightforward load barrier would put a flag in every page table entry, cleared at the start of an epoch and restored as pages are cleaned. That means updating every entry TWICE per epoch, each with the address-translation invalidation traffic that implies, which for a large address space would swamp the benefit. Morello and CHERI-RISC-V instead added, specifically for this use, a comparison: each page table entry checks one of its bits against a GENERATION bit held in a per-core register. In the steady state they agree and loads proceed. Flipping the core register — one write, per core — instantly arms the barrier across the whole address space, and each entry is then updated exactly once as its page is swept. The expensive global operation becomes a local one. Replacing a sweep over metadata with a single version counter that invalidates everything at once is the same indirect-through-a-generation trick covered elsewhere on this site where bumping one number retires an entire set of stale entries without touching any of them.

Two ways to sweep a running program

The same revocation epoch, once with a store barrier and once with a load barrier

All figures measured on Morello. Cornucopia is the authors' own re-implementation of their prior system.

Step 1 of 5

The program frees objects and they pile up

Freed ranges are not reused. Their bits go into the shadow bitmap and the address space waits. When quarantine passes a third of the allocated heap, a sweep is triggered. How often this happens varies enormously: a SPEC workload may sweep less than once per second, while the database server sweeps almost fifteen times per second.

that database has a heap of only 23 MiB but cycles 55 GiB through it, a ratio of freed to allocated bytes of about 2500
the largest SPEC workload has a 625 MiB heap cycling 66.9 GiB, a ratio of 110, so its quarantine fills far more slowly

Why it works

  • The program can only load checked capabilities → it can only ever store checked capabilities → so pages it writes during a sweep are clean by construction and never need revisiting, which is the entire source of the old catch-up phase.
  • Progress is measured as pages not yet scanned, which only decreases → the sweeper cannot be given new work by the program → so termination is guaranteed without freezing, and the freeze covers only registers and core synchronisation.
  • Page table entries compare against a per-core generation bit → arming the barrier across an address space is one register write per core → so entries are touched once per epoch as pages are swept, instead of twice with invalidation traffic.
  • The faulting thread sweeps its own page and re-runs the load → the program repairs memory instead of waiting for someone else to → converting a synchronous global stall into short work distributed over the pages actually touched.
  • Not rescanning pages means fewer memory accesses overall → memory traffic falls to a median 87 percent of the previous design across SPEC and under half on the database → so the pause improvement costs nothing in bus bandwidth.

What it costs (trade-offs and scope)

  • It needs new hardware. The generation-bit comparison in page table entries was added to Morello and CHERI-RISC-V for this purpose. The authors are direct that the result is a strict improvement only if that modest hardware support can be provided.
  • Use-after-free is still possible; only use-after-reallocation is stopped. A stale pointer keeps working until the next sweep, as if the object’s lifetime were extended. What is guaranteed is that it never aliases a DIFFERENT later object. The authors argue this gap is a tolerable trade and sketch how composing with memory colouring could close it.
  • Throughput is unchanged, not improved. The gRPC benchmark loses 12.82 percent of its queries per second, statistically indistinguishable from the previous design, and SPEC overheads reach 29.4 percent on the worst case. The contribution is entirely in the tail.
  • The extreme tail can still misbehave. On gRPC both designs push the 99.9th percentile to roughly ten times baseline, with some transactions stalled across two whole epochs — behaviour not seen on the database workload and not fully explained.
  • The comparison baseline is a re-implementation. The authors rewrote Cornucopia to share code with the new design and say plainly that what they call Cornucopia is their imperfect re-implementation, not the original software. They also list known defects around copy-on-write aliases that the benchmarks happen not to trigger.

The evaluation

Everything runs on Morello, an experimental CHERI-extended ARMv8.2 machine with four cache-coherent 2.5GHz cores and 64 GiB of memory — a real desktop-class system rather than the FPGA prototypes earlier CHERI work relied on. Four configurations are compared: the new design, a re-implementation of the previous one, that re-implementation with its concurrent phase removed to stand in for the original stop-the-world approach, and a configuration that does all the quarantine bookkeeping but never sweeps, which provides no safety at all but isolates the cost of everything except revocation itself. Cores are pinned so the application, the sweeper and the rest of the system do not contend.

Sweeping a running program to eliminate stale pointers

PropertyStop the world and sweepWatch stores, then catch upWatch loads
What bounds the freeze
Median freeze on the database
99th-percentile latency penalty
Memory traffic
Progress guarantee
On the multi-threaded gRPC benchmark, mean freeze time falls from 8.7 milliseconds to 0.3, and the 99th-percentile latency penalty from more than tripling to doubling — the new mean is below the old design's measured minimum. Throughput is statistically unchanged at a 12.82 percent reduction against 12.88. Across SPEC the worst wall-clock overhead is 29.4 percent on xalancbmk, essentially unchanged, while its revocation memory traffic falls from 68 to 60 percent and omnetpp from 50 to 45.Underlined values explain themselves when clicked.

Check yourself

Check your understanding

  1. 1. The previous design traps on capability STORES and still needs a stop-the-world phase; this one traps on capability LOADS and nearly does not. Predict what changes.

  2. 2. A load barrier could be built by putting a flag in every page table entry. Morello instead compares a bit in each entry against a per-core register. Predict why.

  3. 3. Throughput is essentially identical between the two designs — 12.82 percent overhead against 12.88. Predict why the paper is nonetheless a substantial result.

  4. 4. The database workload sweeps almost fifteen times a second while the largest SPEC workload sweeps about half a time a second, even though the latter cycles more memory in total. Predict why.

Sources

Related papers on Security, Privacy & Trusted Execution

See all 153 →