Local File Inclusion happens when attacker-controlled input decides which local file the server reads or includes.
The primitive is local file access through the web application. The application intends to load one approved file, but the user-controlled path crosses into another file on the same server.
In some stacks, inclusion can execute code. In this Flask lab, we demonstrate local file read. The core security failure is the same: the backend trusts a path supplied by the request.
Common LFI surfaces include:
- Documentation viewers
- Theme selectors
- Template loaders
- Language selectors
- Report viewers
- Download routes
- Log viewers
- Help pages
- Image preview routes
- PDF or text file renderers
- Import status pages
Common path traversal sequences include:
../
..\
....//
%2e%2e%2f
%252e%252e%252f
absolute paths
Common abuse scenarios include:
- Reading application source code
- Reading configuration files
- Reading database seed files
- Reading logs
- Reading private files outside the intended directory
- Leaking credentials stored in local files
- Chaining file upload with inclusion on stacks that execute included code
When enumerating LFI opportunities, identify:
- Parameters named
file,path,page,template,lang,theme,view, ordocument - Whether the value looks like a filename
- Whether the response changes when adding
../ - Whether extensions are appended automatically
- Whether encoded traversal is decoded by the framework
- Whether absolute paths work
- Whether the app returns file content, errors, or timing differences
- Whether the app logs the resolved path
Types
Basic LFI reads a requested local file directly.
page=../../app.py
Traversal bypass happens when a filter removes one exact traversal string but leaves another equivalent path.
page=....//....//app.py
Extension-appending bypass happens when the application appends a safe extension but still allows traversal to another file with that extension.
page=../private/internal-config
Absolute path LFI happens when the application lets an absolute path override the intended base directory.
page=/path/to/file
Enumeration
Start with a normal page:
welcome.txt
status.txt
help.txt
Then test traversal:
../private/internal-config.txt
../../app.py
../../seed.sql
Test encoded traversal:
..%2f..%2fapp.py
%2e%2e%2f%2e%2e%2fapp.py
Test filter bypass patterns:
....//....//app.py
....//private/internal-config.txt
Compare:
- Status code
- Response length
- File content
- Error messages
- Resolved path in logs
- Whether the extension changed
Interesting findings include:
- Python source code in the response
- SQL seed data in the response
- Private lab files outside the public page directory
- Different behavior after URL encoding
- One stage blocking
../while another traversal pattern still works - Extension appending that still allows access to sibling directories
Once file read is confirmed, keep the proof small. The goal is to prove that the request controls local file access.
Exploit
- Confirm the normal file load.
GET /lfi/basic?page=welcome.txt
- Move up from the public page directory.
GET /lfi/basic?page=../../app.py
- Read another local lab file.
GET /lfi/basic?page=../../seed.sql
- If a blacklist blocks
../, use an equivalent traversal form.
GET /lfi/blacklist?page=....//....//app.py
- If the app appends
.txt, target another text file outside the public directory.
GET /lfi/better?page=../private/internal-config
- Stop once impact is proven.
The exploit is not the specific filename. The exploit is proving that a user-controlled path escaped the intended directory.
Remediation
Patch LFI by removing direct path control:
- Use page IDs instead of raw filenames.
- Map allowed page IDs to server-side filenames.
- Resolve the final path before reading it.
- Verify the resolved path stays inside the intended directory.
- Reject absolute paths.
- Reject traversal instead of trying to clean it.
- Avoid exposing detailed filesystem errors.
- Keep sensitive config outside paths reachable by file viewer features.
- Log rejected traversal attempts.
Safe pattern:
SAFE_PAGES = {
"welcome": "welcome.txt",
"status": "status.txt",
}
page_id = request.args.get("page", "welcome")
filename = SAFE_PAGES.get(page_id)
if filename is None:
abort(404)
base = PUBLIC_ROOT.resolve()
target = (PUBLIC_ROOT / filename).resolve()
target.relative_to(base)
String replacement is not a security boundary. The final resolved path decides whether the request stayed inside the allowed directory.