A few years ago, I wrote boogie-woogie for DiceCTF 2024. The premise was to convert relative byte swaps in the .data section to code execution.

The challenge itself isn’t important for this post, but it inspired enzocut’s woogie-boogie from LA CTF 2024. It used a byte swap primitive too, but relative to the stack. Recently, Enzo showed me the writeup, and I was surprised by and interested in unvariant’s solution:

There are other solutions as well, such as getting libc’s read to return to main due to its call to __libc_enable_asynccancel, which ends up calling a mangled pointer that you can swap (credit to unvariant for finding this). This leaves a return address on the stack that allows you to control arguments to read.

The read function isn’t just a wrapper for the syscall? This was news to me.

read is a POSIX “cancellation point”. When a thread calls pthread_cancel(thread) on another thread, it requests the other thread terminate. But, by default, POSIX thread cancellation is deferred, so the thread will cancel at the next cancellation point, like read. __libc_enable_asynccancel checks to see if cancellation should happen, and if it should, it kills the thread1. The mangled function pointer that unvariant uses is called here.

Killing a thread, however, isn’t straightforward. Consider a hypothetical thread: Deep in its call stack, it notices it must cancel at a cancellation point. If it has C++ objects allocated on its stack, how will their destructors run? Should glibc run them?

Probably2. Discarding objects without running their destructors could leave their file descriptors open, refcounts incremented, or mutexes locked. However, iterating over the thread’s stack frames to destruct these C++ objects, or performing a “forced unwind”, isn’t trivial.

Calculating the address of the previous stack frame, off the fast path, can invoke DWARF expressions. This is because highly optimized functions may not construct independently interpretable stack frames, and extra information on how to find the frame must be stored off the stack, in the ELF’s .eh_frame_hdr and .eh_frame sections. These expressions can read anywhere in memory to compute whatever values you need. This logic is implemented in libgcc_s, which glibc will dlopen during unwinding.

It’s often helpful, when writing an exploit, to hijack a weird machine to perform the operations required for custom code execution. Ideally, we could just call one_gadget or system("your command here"), but this is not practical in a media decoding sandbox. Famously, printf and runtime relocations are accessible from glibc. However, by virtue of being “weird”, they’re often not portable or difficult to program. To get code execution, could we just program the DWARF VM?

We’ll need to convince the program that it’s supposed to cancel, and substitute the .eh_frame_hdr data it uses when unwinding with our own.

Amazingly, we can do this without needing to break ASLR or pointer mangling! We’ll need, at minimum, two primitives: the ability to control the contents of a large malloc, and out-of-bounds null byte writes. 4-byte aligned out-of-bounds writes work too. It’s worth noting this is already a pretty powerful primitive, and it’s possible there are other avenues to code execution.

We’ll implement this on the current latest glibc, 2.44. Let’s first make an example C program that we’ll get leakless code execution on.

#include <stdlib.h> #include <unistd.h> int main(void) { size_t size, off; unsigned char val; read(0, &size, sizeof(size)); unsigned char *p = malloc(size); while (1) { read(0, &val, sizeof(val)); read(0, &off, sizeof(off)); if (off >= size) val = 0; p[off] = val; } }

The program allocates some chunk of our size. Then, in an infinite loop, we can write either arbitrary data into our chunk, or a null byte out of bounds.

In order to execute DWARF bytecode, the program needs to think it’s cancelling. Here’s read and the syscall’s thread cancellation wrapper:

ssize_t __libc_read(int fd, void *buf, size_t nbytes) { return SYSCALL_CANCEL(read, fd, buf, nbytes); } long int __internal_syscall_cancel (/* snip */) { long int result; struct pthread *pd = THREAD_SELF; /* If cancellation is not enabled, call the syscall directly and also for thread terminatation to avoid call __syscall_do_cancel while executing cleanup handlers. */ int ch = atomic_load_relaxed (&pd->cancelhandling); if (SINGLE_THREAD_P || !cancel_enabled (ch) || cancel_exiting (ch)) { result = INTERNAL_SYSCALL_NCS_CALL (nr, a1, a2, a3, a4, a5, a6 __SYSCALL_CANCEL7_ARCH_ARG7); if (INTERNAL_SYSCALL_ERROR_P (result)) return -INTERNAL_SYSCALL_ERRNO (result); return result; } /* Call the arch-specific entry points that contains the globals markers to be checked by SIGCANCEL handler. */ result = __syscall_cancel_arch (&pd->cancelhandling, nr, a1, a2, a3, a4, a5, a6 __SYSCALL_CANCEL7_ARCH_ARG7); /* If the cancellable syscall was interrupted by SIGCANCEL and it has no side-effect, cancel the thread if cancellation is enabled. */ ch = atomic_load_relaxed (&pd->cancelhandling); /* The behaviour here assumes that EINTR is returned only if there are no visible side effects. POSIX Issue 7 has not yet provided any stronger language for close, and in theory the close syscall could return EINTR and leave the file descriptor open (conforming and leaks). It expects that no such kernel is used with glibc. */ if (result == -EINTR && cancel_enabled_and_canceled (ch)) __syscall_do_cancel (); return result; }

In order to trigger cancellation, we need the condition SINGLE_THREAD_P || !cancel_enabled (ch) || cancel_exiting (ch) to be false. We can do this with a spray and some null byte writes:

struct pthread every page in our malloc chunk, whose size can be chosen to force it to be mmap’d. We only need to initialize offset +0x308, which contains the cancelhandling flag, so we don’t need an ASLR leak.THREAD_SELF pointer to 0.__libc_single_threaded_internal to 0.

The point of the second write is to redirect __internal_syscall_cancel read for cancelhandling flag into our spray. It’s not immediately obvious that this write will place THREAD_SELF in our spray and, in fact, it’s not guaranteed. Let’s work out the odds.

First, THREAD_SELF is contained in—and points to—an mmap allocation performed by ld.so’s __minimal_malloc, a bump allocator used at program startup. Because mmap allocates towards lower addresses, another mmap, like our spray, allocates under this tls section. This means they’ll be contigious, and that THREAD_SELF will point to one page after your spray.

For real programs, it’s unlikely your allocation will perform the first mmap, so the odds could be lower. In this case, it probably makes sense to perform a 4-byte null byte write. The 3-byte write in the PoC is fine for CTF purposes—our spray will be much smaller but less reliable.

The 3-byte write effectively subtracts two quantities from the THREAD_SELF pointer:

The maximum page displacement occurs when the high nibbles are 0xfff, and no page displacement occurs when the high nibbles are 0x000. This means an allocation size of 16 MiB is needed to catch the maximum displacement. THREAD_SELF points to a value at an offset a 0x11c0 from ld.so’s bump allocator VMA. This means 2 out of the 4096 displacements leave cancelhandling outside the spray: 0x000 and 0x001. For a 4-byte write, there are 256 times as many candidates, but still only two bad displacements. This means the 3-byte write with a 16 MiB spray cancels , and the 4-byte write with a 4 GiB spray cancels of the time.

.eh_frame_hdr section pointer to our spray

Now that our thread cancels, it’ll parse the .eh_frame_hdr sections of the loaded binaries to decide what DWARF expressions to run. The pointers to these sections are also stored in the ld.so bump allocator’s backing mmap, so we can also write null bytes to its bottom 3 or 4 bytes. This will redirect reads to the EH frame header to our spray too, though, the odds will be slightly worse. This time, the number of pages between our spray and the actual .eh_frame_hdr section is 82 instead of 2, so the odds are for 3-byte writes and for 4-byte writes.

Once we have our own .eh_frame_hdr parsed, the process to turn this into code execution is pretty mechanical. I won’t go into depth about it here, but, briefly, we’ll need to know a bit about the Exception Handling in the Itanium C++ ABI. Our sprayed .eh_frame_hdr will provide a relative offset to our .eh_frame containing frame description entries, FDEs, and common information entries, CIEs. FDEs define a “language specific data area”, or LSDA, and CIEs define an LSDA and a “personality”. The personality value will be __gxx_personality_v0, which we can get from libgcc_s. __gxx_personality_v0 inspects the LSDA to see where “landing pads” are located. The location of these landing pads can be dynamically calcuated, alongside the register state they are entered with.

It’s basically a setcontext where your registers can be dynamically calculated using DWARF expressions.

I wrote a CTF challenge for DiceCTF 2026 Finals, called “powckle”. It takes a proof-of-work challenge from the user and spawns 32 sandboxed C processes to search for a solution. To contrive the bug (and send agents in search of a one-shot down the rabbit hole of trying to pwn Python’s unpickler with find_class = None), the Python and C programs communicate using pickle.

In summary, the first bug is that the C unpickler skips over unrecognized pickle opcodes. For example, by specifying a giant number for the difficulty of the proof-of-work, the Python pickler chooses to use LONG4 instead of BININT2. Since LONG4 isn’t registered by the C unpickler, it skips the opcode, and interprets the operands of LONG4 as opcodes. This allows execution of arbitrary pickle opcodes. With arbitrary opcodes, you could trigger the second bug via the FRAME opcode—out-of-bounds null byte write from the buffer storing the pickle data. Using the above technique, which I dubbed “house of windy”, your DWARF bytecode can find the flag in the environment variable on the stack and write it back to the Python program.

Congrats to the two teams who solved the challenge, ley and SPL, and unvariant for ley’s first blood. Apologies to the other teams for the burnt tokens.

It’s amusing to me that glibc’s read can eventually call into a virtual machine, but I’m writing about it because I think running DWARF bytecode in an exploit has utility inside and outside of CTFs.

If you squint, what we’re essentially doing is spraying executable instructions in memory so that we can jump to them by partially corrupting a function pointer. However, the advantage to corrupting the .eh_frame pointer instead of a geniune function pointer is that BTI, CFI, and, most importantly, non-executable memory, won’t apply to the call. I haven’t triggered this myself, but in theory, unwinding can also be accessed via C++ exceptions, and both bionic and musl store internal pointers to .eh_frame like glibc.

I think many of the challenges and solutions we develop in CTF often have limited use; the byte swap primitives in boogie-woogie or woogie-boogie are unlikely to come up in practice. However, in the process of sharing, solving, and building on them, we discover new and more powerful primitives that are useful. This, to me, is one of the most fun parts of CTF I’ll come to miss.

The name is because __libc_enable_asynccancel temporarily allows the thread to be cancelled asynchronously. That way, if the thread was hanging in the read syscall, it could be cancelled without needing to wait for the call to return.

There are tradeoffs for depending on the unwinder for thread cancellation. The case would be much less compelling if glibc didn’t already depend on libgcc_s for backtrace. It makes sense that musl doesn’t unwind during thread cancellation, since it’s a large dependency for a lightweight libc. Bionic doesn’t even implement thread cancellation, forgoing POSIX compliance (though, its pthread_exit implementation doesn’t unwind, despite being statically linked with libunwind. I don’t know the codebase well enough to know why).