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.
This method is suitable for glibc 2.36+.
General Idea
glibc keeps per-thread cancellation and cleanup state in struct pthread. By corrupting selected fields in that structure, we can force libc’s normal cancellation-unwind path to process attacker-forged cleanup objects.
In unwind_stop, libc executes:
curp->__routine(curp->__arg)
If both pointer and argument are attacker-controlled, this becomes a RCE sink.
Prerequisites
- Arbitrary write-what-where primitive
- A way to reach
__libc_cleanup_pop_restore(commonly by normal exit/stdio cleanup paths orscanf-family functions) - Addresses of the TLS and of the chosen function and parameter
Chain overview
flowchart TB
A[arbitrary write] --> B[overwrite struct pthread fields]
B --> B1[cleanup]
B --> B2[cleanup_jmp_buf]
B --> B3[cancelhandling]
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)"]
endPoC
1 | target = fs_base + 0x2f8 |
This payload can be compacted, often reducing the total size to 0x1f bytes, but that’s out of scope for this post.
Additionally, the function address and the parameter string address can be stored elsewhere in memory and only referenced here through the
cleanupfield, further reducing the payload size.
Caveats
- For versions below 2.36, this method is not worth it: besides
cleanup,cleanup_jmp_bufandcancelhandling, you also need to controlcanceltype, which sits a lot farther away in the structure. - Since glibc 2.38, the TLS is no longer at a fixed offset from libc base, therefore we can no longer find the TLS address via a fixed offset from libc.