How CFI Defenses Work
Three CFI schemes dominate user space. Intel CET combines a shadow stack, which stores return addresses in a separate memory page inaccessible to normal code, with indirect branch tracking (IBT), which forces every indirect jump to land on an ENDBR instruction. Control Flow Guard on Windows takes a software path: it replaces every indirect call with a check against a bitmap, allowing calls only to function entry points. LLVM CFI goes finer-grained, computing a type signature for each indirect pointer and rejecting calls to functions with a different prototype.
C++ Coroutine Internals
Every running C++ coroutine gets a heap-allocated coroutine frame. At the top of that frame sit two function pointers: one for resume, one for destroy. Below them are the promise (which holds return values), a copy of every parameter passed at call time, all local variables promoted from the stack to the heap, and a coroutine index, an integer that tells the resume stub which suspension point to jump to next. Resuming a coroutine is an indirect call through the resume pointer. Destroying one is an indirect call through the destroy pointer. Both are writable.
Data-Only Attacks Through the Coroutine Frame
The simplest bypass never touches a function pointer. Coroutine parameters are always copied into the frame at creation, so an attacker who can overwrite heap memory can replace a parameter and set the coroutine index to skip directly to any suspension point, feeding chosen data into code the program would reach legitimately. The same frame holds heap-object pointers the destroy stub calls free on. Replacing one with an attacker-controlled address produces an arbitrary free. Stack overflows inside a coroutine become heap overflows because every local variable lives in the frame, with no stack canary present.
Infinite Coroutine Chaining for Arbitrary Code Execution
11 of the 13 CFI schemes Bajo analyzed either break with coroutines or leave the resume and destroy pointers completely uninstrumented. Only Intel CET and Control Flow Guard enforce entry-point-only calls. That still limits each call to zero useful arguments: RDI is fixed to the coroutine frame address. The fix is infinite coroutine chaining. Inject a sequence of fake frames where each destroy pointer points to the next frame’s resume stub, building a chain of arbitrary length. Argument control comes from overlaying the frame with a C++ member-function gadget that loads chosen register values from attacker-placed offsets.
PoC and Proposed Defenses
SerenityOS’s browser runs its web content process under Intel CET with shadow stack enabled. Bajo used a real 2021 CVE integer overflow to leak a pointer, then triggered the SerenityOS browser exploit with Intel CET enabled (31:02): three indirect calls with arbitrary arguments inside a CET-protected process. Two mitigations: replace raw resume and destroy pointers in the frame with an identifier resolved against a read-only table, or use heap allocation optimization to move frames to the stack. MSVC added the latter in version 17.13 after Microsoft received this report. GCC still does not support it, and Clang’s implementation is broken.
Notable Quotes
Here’s the fun part. Cor routines and Marcos Bajo · ▶ 14:58
pointers are uninstrumented and this Marcos Bajo · ▶ 22:17
So when when we found this we said okay Marcos Bajo · ▶ 33:27
Key Takeaways
- C++ coroutine frames store resume and destroy function pointers in writable heap memory, creating a new class of CFI bypass.
- 13 CFI schemes analyzed: most either break with coroutines or leave resume/destroy pointers completely uninstrumented.
- Infinite coroutine chaining converts a single uninstrumented indirect call into arbitrarily many calls with full argument control.