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. normal exit/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)"]
    end

The fields we overwrite at fs_base + 0x2f8:

  • cleanup points to our forged cleanup node.
  • cleanup_jmp_buf must point to valid memory used during unwind.
  • cancelhandling is set to 0xE to satisfy the cancellation state check.
  • canceltype (glibc < 2.36 only) must be 0x1 at fs_base + 0x972.

glibc 2.36+

All required fields are adjacent near fs_base + 0x2f8, so one single write suffices:

1
2
3
4
5
6
7
8
target = fs_base + 0x2f8
payload = p64(target + 0x14) # cleanup
payload += p64(target + 0x8) # cleanup_jmp_buf
payload += p32(0xE) # cancelhandling
payload += p64(libc.sym['system']) # __routine
payload += p64(binsh) # __arg

write_at(target, payload)

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.