Breaking Control Flow Integrity by Abusing Modern C++

Black Hat USA 2025 · Day 1 · Briefings

Overview

C++20 coroutines — a language feature for suspendable, resumable functions used in async programming — create a class of heap-allocated objects with function pointers stored in writable memory, allowing attackers to bypass even hardware-enforced control flow integrity schemes. Researcher Marcos from CISPA demonstrates that coroutine frames, their resume and destroy pointers, and continuation pointers can be manipulated to chain arbitrary function calls with arbitrary arguments while evading Intel CET, Control Flow Guard, and 11 other CFI implementations — a technique that requires only a standard memory corruption primitive and works across GCC, Clang, and MSVC. ---

Watch on YouTube

Visual summary for Breaking Control Flow Integrity by Abusing Modern C++
Visual summary for Breaking Control Flow Integrity by Abusing Modern C++

Key moments

  1. 1:59 Thesis: C++ coroutines introduce new primitives to bypass CFI defenses
  2. 4:00 CFI overview: Intel CET shadow stack vs. indirect branch tracking explained
  3. 8:00 Insight: coroutine resume/destroy stubs create attacker-controllable function pointers
  4. 14:00 Attack: coroutine frame vtable pointer enables CFI bypass without ROP
  5. 19:59 Demo: exploiting coroutine handle to redirect control flow past LLVM CFI
  6. 25:59 Finding: coarse-grained CFI (Intel CET IBT) fully bypassed via coroutine misuse
  7. 30:00 Root cause: C++ coroutines compile to heap-allocated frames with function pointers
  8. 33:00 Mitigation: proposed compiler-level instrumentation to protect coroutine stubs

Breaking Control Flow Integrity by Abusing Modern C++

Speaker: Marcos (PhD student), CISPA Helmholtz Center for Information Security, Germany (supervised by Prof. Christian Rossow, System Security Group)

Conference: Black Hat USA 2025 — August 6-7, 2025, Mandalay Bay, Las Vegas

YouTube: https://www.youtube.com/watch?v=hxIPoi4ONNA

Reading time: ~9 minutes

Type: Briefing

TL;DR

C++20 coroutines — a language feature for suspendable, resumable functions used in async programming — create a class of heap-allocated objects with function pointers stored in writable memory, allowing attackers to bypass even hardware-enforced control flow integrity schemes. Researcher Marcos from CISPA demonstrates that coroutine frames, their resume and destroy pointers, and continuation pointers can be manipulated to chain arbitrary function calls with arbitrary arguments while evading Intel CET, Control Flow Guard, and 11 other CFI implementations — a technique that requires only a standard memory corruption primitive and works across GCC, Clang, and MSVC.

Introduction

Fifty years ago, the first documented mention of what would become the buffer overflow established a principle that has governed offensive and defensive security ever since: if you can corrupt a program's memory, you can control what instructions it executes. The history since then is an arms race — ASLR, non-executable stacks, code reuse attacks that circumvented them, and eventually, in 2005, control flow integrity (CFI). CFI takes a different approach entirely: rather than hardening memory, it constrains which code the instruction pointer is permitted to reach, using static and dynamic analysis to construct a control flow graph and enforcing it at runtime.

By 2025, hardware-enforced CFI is shipping in production — Intel CET (Control-flow Enforcement Technology) with shadow stacks and Indirect Branch Tracking (IBT), and Windows' Control Flow Guard (CFG). These defenses are real, meaningful, and present in major applications including browsers. But C++20, also shipping in production, introduced coroutines — a powerful asynchronous programming primitive — and the interaction between coroutines and CFI turns out to be deeply problematic.

How CFI Works: Shadow Stacks and Indirect Branch Tracking

Before attacking CFI, it helps to understand what each major scheme protects.

▶ Watch: Overview of User-Space CFI Defenses (02:00)

Intel CET Shadow Stack protects backward-edge control flow — return addresses. When a function is called, the return address is stored both on the regular stack and in a separate shadow stack page inaccessible to user-space code. On function return, the hardware compares both values. If they differ, a fault occurs. This defeats stack buffer overflows that overwrite return addresses.

Intel CET Indirect Branch Tracking (IBT) protects forward-edge control flow — indirect function calls and jumps. Every valid indirect call target must begin with an ENDBR instruction. Calling any other address generates a fault, effectively restricting indirect calls to the beginnings of functions.

Control Flow Guard (CFG) is Microsoft's software-based equivalent for IBT — not hardware-enforced, but applied at compile time. Every indirect call in the program is translated to a direct call to a CFG validation function that checks whether the target address is in the set of valid indirect call targets. The result is similar to IBT in effect.

LLVM CFI (used in Clang/LLVM) is type-based: it enforces that every indirect call targets a function with the correct prototype — matching return type and argument types.

C++ Coroutines: The New Attack Surface

Coroutines are functions that can suspend and resume. Unlike a normal function that executes from start to finish, a coroutine reaches a suspension point — a co_yield, co_await, or co_return statement — pauses there, and can be resumed later from exactly that point. This enables cooperative multitasking, asynchronous I/O, and generator patterns.

▶ Watch: How C++ Coroutines Work Internally (06:00)

Under the hood, three stub functions are generated for every coroutine: a creation stub (which allocates the coroutine frame), a resume stub (which advances execution from the current suspension point), and a destroy stub (which frees the coroutine frame). Every active coroutine instance has a corresponding coroutine frame — a heap-allocated object containing:

  • A resume pointer — a function pointer to the resume stub (invoked via indirect call)
  • A destroy pointer — a function pointer to the destroy stub (also an indirect call)
  • A promise object — shared storage for values passed between the coroutine and its callers
  • Parameters passed to the coroutine (stored on the heap, not the stack)
  • Local variables that must persist across suspension points (also on the heap)

This is the critical difference from normal functions: in a coroutine, every local variable that must survive a suspension point lives in the heap, not the stack. There are no stack canaries on the heap. Variable reordering protections the compiler applies to stack frames do not function properly in coroutine frames.

co_await — the most powerful and commonly used operator — adds a third object, the awaiter, with its own await_ready, await_suspend, and await_resume functions. When one coroutine awaits another, a continuation pointer is stored in the awaited coroutine's promise — a handle pointing back to the awaiting coroutine so execution can return after the inner coroutine completes. These continuation pointers are also stored in writable heap memory.

Two Attack Primitives: Frame Manipulation and Frame Injection

The fundamental observation driving all the attacks: coroutine handles and frames live in writable memory. From this, two attack primitives emerge.

▶ Watch: Coroutine Frame Exploitation Primitives (16:01)

Frame manipulation — an attacker finds an existing coroutine frame in memory and overwrites it. This is the basis for data-only attacks: modifying parameters stored in the frame, modifying local variables that are initialized before a suspension point but used after one, or causing the destroy stub to call free() on an attacker-controlled pointer, creating an arbitrary free primitive.

Frame injection — an attacker injects entirely fake coroutine frames into memory (stack, heap, or otherwise) and links them so the program treats them as real. This enables the full chain attacks described below.

Data-only attacks (no pointer modification, no CFI triggers): Since parameters are always stored in the coroutine frame, they can be overwritten directly. Local variables are more nuanced — the compiler may initialize a variable only at the suspension point where it is first used, making overwriting it before that point ineffective. Heap-allocated members (like vectors) must be freed somewhere; by overwriting the pointer to such an object, an attacker achieves arbitrary free() calls.

Heap overflow attacks convert stack-based overflows into heap-based overflows because coroutine variables live on the heap. There are no stack canaries, variable reordering is incomplete, and in ptmalloc (Linux), higher-address coroutine frames may be overwriteable. At minimum, a heap overflow allows overwriting the coroutine index — the state variable that tells the resume stub which suspension point to resume from, enabling execution of the wrong code path inside the coroutine.

Bypassing CFI: The Coroutine Chain Attack

With writable resume and destroy pointers in every coroutine frame, the question is how to leverage them against coarse-grained CFI schemes like Intel CET and CFG.

▶ Watch: The Coroutine Chain Attack — Arbitrary Function Calls (26:03)

Thirteen CFI schemes were analyzed. Eleven fine-grained schemes that generate instrumentation for every function in the program fail to instrument coroutine stubs at all — the resume and destroy pointers are uninstrumented. This leaves Intel CET (IBT) and CFG as the two remaining obstacles.

Against IBT and CFG, resume and destroy pointers are constrained to call only the beginnings of functions. That sounds limiting — but the researchers devise a Control Frame Pointer (CFP) technique: any pointer that directly or indirectly points to a coroutine's resume or destroy pointer qualifies as a CFP. Programs using coroutines abundantly (Chrome's codebase includes coroutines, for example) have many CFPs to choose from, and continuation pointers inside awaiter objects add more.

The coroutine chain attack works as follows:

  1. Inject a chain of fake coroutine frames into memory.
  2. Resume the first frame at the second suspension point — right after a co_await call and right before a call to destroy.
  3. The destroy call targets the second frame in the chain, which has been modified so its destroy pointer is actually its resume pointer — calling destroy thus resumes the next element.
  4. Each element in the chain has a hijacked continuation pointer pointing to the next fake frame.
  5. The final element in the chain calls destroy on a target object the attacker controls, producing an arbitrary function call.

For arbitrary arguments (not just the coroutine handle in RDI), the researchers exploit C++ member function calling conventions: member functions use RDI as the this pointer, accessing member variables at fixed offsets from it. By colliding a fake coroutine frame with a victim object, specific offsets from RDI can be set to arbitrary values, allowing full control of function arguments.

A live demonstration against the SerenityOS browser (using a real CVE from 2021 as the memory corruption primitive and running under Intel CET) executes whoami multiple times and makes additional calls with arbitrary arguments — all while CET is active.

Defenses

▶ Watch: Defenses and Heap Allocation Elision (34:04)

The researcher proposes several mitigations:

  • Coroutine index protection. The coroutine index (state variable) can be given a unique identifier, making it harder to forge a valid frame without knowing the expected index value — though this is not a complete mitigation since the identifier can be modified.
  • Heap allocation elision optimization (HALO). A compiler optimization that moves coroutine frames from the heap to the stack as a byproduct eliminates the resume and destroy pointers from the frame entirely. However, HALO requires extremely favorable conditions: caller and callee in the same translation unit, full knowledge of the coroutine's scope, and compiler awareness that resume and destroy happen in the same place. Most real-world programs do not qualify, and GCC does not support HALO at all.
  • CFI scheme updates. CFI implementations must be updated to instrument coroutine stubs explicitly. Currently, the resume and destroy pointers are invisible to most fine-grained CFI schemes.

LLVM already appears to be responding — moving the promise to the last position in the frame and allocating oversized frames — indicating compiler-level awareness of the issue.

Notable Quotes

"Coroutines, and specifically the task objects, are an awaitable. You can do things like awaiting coroutines from coroutines, and this is very common." — Marcos ▶ 14:01

"The coroutine handle and the frames are in writable memory. And this is very bad." — Marcos ▶ 16:01

"There's no stack canaries in the heap. The variable reordering that the compiler does is not really very well working with coroutines." — Marcos ▶ 20:02

"CFI schemes need to be updated with how the programming paradigms evolve, because otherwise they will be left behind and will not account for new things like coroutines." — Marcos ▶ 22:03

Key Takeaways

  • C++20 coroutines create function pointers in writable heap memory — resume and destroy pointers in every coroutine frame — which existing CFI instrumentation largely ignores.
  • Eleven of thirteen analyzed CFI schemes do not instrument coroutine stubs, leaving their pointers as uninstrumented indirect calls even under fine-grained CFI.
  • The coroutine chain attack achieves arbitrary function calls with arbitrary arguments against Intel CET and CFG by chaining fake coroutine frames and using C++ member function calling conventions to control register values.
  • Any program using C++20 coroutines with CFI is potentially vulnerable — the technique works with GCC, Clang, and MSVC, and coroutines are already present in production software including Chrome and Windows Terminal.
  • CFI implementations must be updated to explicitly instrument coroutine stubs; until then, heap allocation elision optimization (HALO) provides incidental protection but is rarely achievable in real-world programs.

No slides PDF was listed for this talk.

Reviews

Dr. Zero (Offensive Security Researcher) — MUST SEE

A PhD student finds that C++20 coroutines put function pointers in writable heap memory, eleven of thirteen CFI schemes don't instrument them, and Intel CET plus CFG both fall to a chained fake-frame attack with arbitrary arguments. This is a genuine new primitive, demonstrated live on SerenityOS with a real CVE. Full marks.

Heather Calloway (CISO) — PASS

C++20 coroutines create function pointers in writable heap memory that thirteen out of thirteen fine-grained CFI schemes fail to protect. The coroutine chain attack is technically sophisticated and the research is solid. This is deep exploit development work for a narrow audience of browser security engineers and systems researchers. Route to Zero.

→ Top-rated talks at Black Hat USA 2025

All talks from Black Hat USA 2025