Introduction
This post describes an arbitrary write to RCE chain I found in glibc’s per-thread descriptor (struct pthread) in TLS on x86_64.
On modern glibc versions (2.36+), the relevant fields are conveniently adjacent, allowing a single write to forge a cleanup node and achieve RCE. On older versions, the canceltype field is located far away, usually necessitating two separate writes.
Background
glibc stores per-thread cancellation and cleanup state in struct pthread. The cancellation-unwind path in unwind_stop eventually executes:
1 | curp->__routine(curp->__arg); |
If we corrupt the right fields in the thread descriptor, we can make this call attacker-controlled; turning normal cancellation cleanup into a RCE primitive.
Prerequisites
- An arbitrary write-what-where primitive
- A codepath that reaches
__libc_cleanup_pop_restore(e.g. normalexit/stdio cleanup,scanf-family functions) - Known addresses of the TLS region and target function/argument
Chain Walkthrough
The core idea: overwrite fields starting at fs_base + 0x2f8 to forge a cleanup node, then trigger the cancellation-unwind path.
flowchart TB
A[arbitrary write] --> B[overwrite struct pthread fields]
B --> B1[cleanup]
B --> B2[cleanup_jmp_buf]
B --> B3[cancelhandling]
B -->|"glibc < 2.36 only"| B4[canceltype]
C[trigger] --> D
subgraph CP["cancel and unwind path"]
direction LR
D[__libc_cleanup_pop_restore] --> E{cancel state matches}
E -->|yes| F[__do_cancel]
F --> G[__pthread_unwind]
G --> H[unwind_stop]
H --> I[read forged cleanup node]
I --> J["curp->__routine(curp->__arg)"]
endThe fields we overwrite at fs_base + 0x2f8:
cleanuppoints to our forged cleanup node.cleanup_jmp_bufmust point to valid memory used during unwind.cancelhandlingis set to0xEto satisfy the cancellation state check.canceltype(glibc < 2.36 only) must be0x1atfs_base + 0x972.
glibc 2.36+
All required fields are adjacent near fs_base + 0x2f8, so one single write suffices:
1 | target = fs_base + 0x2f8 |
This can generally be compacted to as few as 0x1f bytes, but it’s out of scope for this post. Additionally, the function and argument addresses can also live elsewhere in memory, referenced indirectly through the cleanup pointer to further shrink the inline payload.
glibc < 2.36
The canceltype field sits at fs_base + 0x972, a 0x67a-byte gap from the main overwrite region, making a single write impractical. This variant typically requires two separate writes: one for the main fields at +0x2f8 and one to set canceltype = 1 at +0x972.
Note
Since glibc 2.38, TLS is no longer at a fixed offset from the libc base, so the TLS address must be leaked or computed through other means rather than derived from a known libc address.