This example uses the shared Flask lab.
We plug LFI in as the fifth web module.
Complete the Chapter III Introduction setup first. The base app.py, schema.sql, seed.sql, and database.db must already exist.
The flow is:
create modules/lfi.py
register the blueprint in app.py
restart Flask
visit /lfi/basic
The LFI module provides four pages:
/lfi/basic
/lfi/blacklist
/lfi/better
/lfi/fixed
Plug In The Module
From the lab directory (disregard if you have already done this):
cd ~/ootw-web-lab
source .venv/bin/activate
mkdir -p modules
touch modules/__init__.py
Create modules/lfi.py:
cat > modules/lfi.py <<'EOF'
from pathlib import Path
from flask import Blueprint, abort, redirect, render_template_string, request
from app import get_db
bp = Blueprint("lfi", __name__, url_prefix="/lfi")
BASE_DIR = Path(__file__).resolve().parents[1]
LAB_ROOT = BASE_DIR / "lab_pages"
PUBLIC_ROOT = LAB_ROOT / "public"
PRIVATE_ROOT = LAB_ROOT / "private"
MAX_READ_BYTES = 4000
SAFE_PAGES = {
"welcome": "welcome.txt",
"status": "status.txt",
"help": "help.txt",
}
PAGE = """
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{{ title }}</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 900px; margin: 40px auto; line-height: 1.5; }
nav a { margin-right: 12px; }
label { display: block; margin-top: 12px; }
input { width: 100%; padding: 7px; box-sizing: border-box; }
button { margin-top: 12px; padding: 7px 10px; }
pre { background: #111; color: #eee; padding: 12px; overflow-x: auto; }
code { background: #eee; padding: 2px 4px; }
</style>
</head>
<body>
<h1>{{ title }}</h1>
<nav>
<a href="/lfi/basic?page=welcome.txt">basic</a>
<a href="/lfi/blacklist?page=welcome.txt">blacklist</a>
<a href="/lfi/better?page=welcome">better</a>
<a href="/lfi/fixed?page=welcome">fixed</a>
<a href="/lfi/audit">audit</a>
</nav>
<p>{{ description }}</p>
<h2>Expected Pages</h2>
<ul>
{% for item in visible_pages %}
<li><a href="/lfi/{{ stage }}?page={{ item["value"] }}">{{ item["label"] }}</a></li>
{% endfor %}
</ul>
<form method="get" action="/lfi/{{ stage }}">
<label for="page">Page</label>
<input id="page" name="page" value="{{ requested_page }}">
<button type="submit">Load Page</button>
</form>
{% if resolved_path %}
<h2>Resolved Path</h2>
<pre>{{ resolved_path }}</pre>
{% endif %}
{% if content is not none %}
<h2>Content</h2>
<pre>{{ content }}</pre>
{% endif %}
</body>
</html>
"""
AUDIT_PAGE = """
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>LFI Audit</title>
</head>
<body>
<h1>LFI Audit</h1>
<p>This page shows recent local file access events from the local lab.</p>
<p><a href="/lfi/basic?page=welcome.txt">back to LFI lab</a></p>
{% for event in events %}
<article>
<p><strong>{{ event["event_type"] }}</strong> - {{ event["username"] }}</p>
<pre>{{ event["detail"] }}</pre>
<small>{{ event["created_at"] }}</small>
</article>
{% else %}
<p>No LFI events yet.</p>
{% endfor %}
</body>
</html>
"""
def ensure_lab_pages():
PUBLIC_ROOT.mkdir(parents=True, exist_ok=True)
PRIVATE_ROOT.mkdir(parents=True, exist_ok=True)
files = {
PUBLIC_ROOT / "welcome.txt": "Welcome to the OOTW help center.\nThis page is intended to be public.\n",
PUBLIC_ROOT / "status.txt": "Status: all public systems nominal.\n",
PUBLIC_ROOT / "help.txt": "Help: choose one of the approved public pages.\n",
PRIVATE_ROOT / "internal-config.txt": (
"internal_mode=training\n"
"api_key=ootw-lfi-demo-key\n"
"reset_token=ootw-lfi-demo-reset\n"
),
}
for path, body in files.items():
if not path.exists():
path.write_text(body, encoding="utf-8")
def visible_pages_for(stage):
if stage == "fixed":
return [
{"label": "welcome", "value": "welcome"},
{"label": "status", "value": "status"},
{"label": "help", "value": "help"},
]
return [
{"label": "welcome.txt", "value": "welcome.txt"},
{"label": "status.txt", "value": "status.txt"},
{"label": "help.txt", "value": "help.txt"},
]
def load_content(path):
try:
if not path.is_file():
return None
data = path.read_bytes()[:MAX_READ_BYTES]
return data.decode("utf-8", errors="replace")
except OSError:
return None
def log_access(stage, requested_page, target, decision):
detail = (
f"stage={stage} "
f"requested_page={requested_page} "
f"target={target} "
f"decision={decision}"
)
db = get_db()
db.execute(
"""
INSERT INTO audit_log (event_type, username, detail)
VALUES (?, ?, ?)
""",
(f"lfi-{stage}", "operator", detail),
)
db.commit()
def render_lab(stage, title, description, requested_page, target, content):
resolved_path = ""
if target is not None:
resolved_path = str(target)
return render_template_string(
PAGE,
stage=stage,
title=title,
description=description,
requested_page=requested_page,
resolved_path=resolved_path,
content=content,
visible_pages=visible_pages_for(stage),
)
def weak_blacklist(value):
value = value.replace("../", "")
value = value.replace("..\\", "")
return value
@bp.get("/")
def index():
return redirect("/lfi/basic?page=welcome.txt")
@bp.get("/basic")
def basic():
ensure_lab_pages()
requested_page = request.args.get("page", "welcome.txt")
target = PUBLIC_ROOT / requested_page
content = load_content(target)
if content is None:
log_access("basic", requested_page, target, "missing")
abort(404)
log_access("basic", requested_page, target, "read-without-path-check")
return render_lab(
"basic",
"LFI Basic",
"The route joins the page parameter to the public page directory and reads the result.",
requested_page,
target,
content,
)
@bp.get("/blacklist")
def blacklist():
ensure_lab_pages()
requested_page = request.args.get("page", "welcome.txt")
filtered_page = weak_blacklist(requested_page)
target = PUBLIC_ROOT / filtered_page
content = load_content(target)
if content is None:
log_access("blacklist", requested_page, target, "missing")
abort(404)
log_access("blacklist", requested_page, target, "read-after-string-cleanup")
return render_lab(
"blacklist",
"LFI Weak Blacklist",
"The route removes one exact traversal spelling, then still reads a user-controlled path.",
requested_page,
target,
content,
)
@bp.get("/better")
def better():
ensure_lab_pages()
requested_page = request.args.get("page", "welcome")
filename = requested_page
if not filename.endswith(".txt"):
filename = f"{filename}.txt"
target = PUBLIC_ROOT / filename
content = load_content(target)
if content is None:
log_access("better", requested_page, target, "missing")
abort(404)
log_access("better", requested_page, target, "read-after-extension-append")
return render_lab(
"better",
"LFI Better Extension Appending",
"The route forces a .txt extension, but it still allows traversal into another text file.",
requested_page,
target,
content,
)
@bp.get("/fixed")
def fixed():
ensure_lab_pages()
requested_page = request.args.get("page", "welcome")
filename = SAFE_PAGES.get(requested_page)
if filename is None:
log_access("fixed", requested_page, PUBLIC_ROOT, "blocked-page-id")
abort(404)
public_root = PUBLIC_ROOT.resolve()
target = (PUBLIC_ROOT / filename).resolve()
try:
target.relative_to(public_root)
except ValueError:
log_access("fixed", requested_page, target, "blocked-path-escape")
abort(404)
content = load_content(target)
if content is None:
log_access("fixed", requested_page, target, "missing")
abort(404)
log_access("fixed", requested_page, target, "allowed-page-id")
return render_lab(
"fixed",
"LFI Fixed",
"The route maps page IDs to known files and verifies the resolved path stays inside the public directory.",
requested_page,
target,
content,
)
@bp.get("/audit")
def audit():
db = get_db()
events = db.execute(
"""
SELECT event_type, username, detail, created_at
FROM audit_log
WHERE event_type LIKE 'lfi-%'
ORDER BY id DESC
LIMIT 30
"""
).fetchall()
return render_template_string(AUDIT_PAGE, events=events)
EOF
Now register the module in app.py.
Add this near the bottom of app.py, after the route definitions and before the if __name__ == "__main__": block:
from modules.lfi import bp as lfi_bp
app.register_blueprint(lfi_bp)
If the earlier web modules are already plugged in, keep those lines and add LFI below them:
from modules.xss import bp as xss_bp
app.register_blueprint(xss_bp)
from modules.idor import bp as idor_bp
app.register_blueprint(idor_bp)
from modules.command_injection import bp as command_injection_bp
app.register_blueprint(command_injection_bp)
from modules.file_uploads import bp as file_uploads_bp
app.register_blueprint(file_uploads_bp)
from modules.lfi import bp as lfi_bp
app.register_blueprint(lfi_bp)
Run the app:
flask --app app run --host 127.0.0.1 --port 5000
Confirm the module is live:
curl -i "http://127.0.0.1:5000/lfi/basic?page=welcome.txt"
Basic Vulnerability
Open:
http://127.0.0.1:5000/lfi/basic?page=welcome.txt
This page reads the requested file from the public page directory:
target = PUBLIC_ROOT / requested_page
content = load_content(target)
The vulnerable route joins paths, but it never checks where the final path lands.
Normal request:
curl -i "http://127.0.0.1:5000/lfi/basic?page=welcome.txt"
Exploit application source code:
curl -i "http://127.0.0.1:5000/lfi/basic?page=../../app.py"
Exploit seed data:
curl -i "http://127.0.0.1:5000/lfi/basic?page=../../seed.sql"
The request escapes from:
lab_pages/public
and reaches files in the lab application directory.
This proves local file read through a user-controlled path.

Basic Protection - Weak Blacklist
Open:
http://127.0.0.1:5000/lfi/blacklist?page=welcome.txt
This page removes one exact traversal spelling:
value = value.replace("../", "")
value = value.replace("..\\", "")
That damages this payload:
../../app.py
It does not make the route safe.
Bypass with a one-pass cleanup trick:
curl -i "http://127.0.0.1:5000/lfi/blacklist?page=....//....//app.py"
The blacklist turns:
....//....//app.py
into:
../../app.py
The route still reads the application source code.

Better Protection - Extension Appending
Open:
http://127.0.0.1:5000/lfi/better?page=welcome
This page forces the filename to end in .txt:
if not filename.endswith(".txt"):
filename = f"{filename}.txt"
That blocks a direct attempt to read app.py.
Test it:
curl -i "http://127.0.0.1:5000/lfi/better?page=../../app.py"
Expected:
404 Not Found
However, the route still allows traversal into another .txt file.
Exploit the private lab config:
curl -i "http://127.0.0.1:5000/lfi/better?page=../private/internal-config"
The application appends .txt and reads:
lab_pages/private/internal-config.txt
The extension changed. The path boundary did not.
![[Chapter III - Web/5. LFI/Exploit.png]]
Fixed Version
Open:
http://127.0.0.1:5000/lfi/fixed?page=welcome
The fixed page does three things:
- It accepts page IDs, not filenames.
- It maps page IDs to known server-side filenames.
- It verifies the resolved path stays inside the public page directory.
The safe lookup:
filename = SAFE_PAGES.get(requested_page)
target = (PUBLIC_ROOT / filename).resolve()
target.relative_to(PUBLIC_ROOT.resolve())
Run a normal request:
curl -i "http://127.0.0.1:5000/lfi/fixed?page=welcome"
Run the original traversal:
curl -i "http://127.0.0.1:5000/lfi/fixed?page=../../app.py"
Expected:
404 Not Found
Run the private config traversal:
curl -i "http://127.0.0.1:5000/lfi/fixed?page=../private/internal-config"
Expected:
404 Not Found
The route no longer lets the request choose a filesystem path.
Local Impact Proof
Use the vulnerable route to prove local file read:
curl -i "http://127.0.0.1:5000/lfi/basic?page=../../app.py"
curl -i "http://127.0.0.1:5000/lfi/basic?page=../../seed.sql"
Then view:
http://127.0.0.1:5000/lfi/audit
The audit page shows the requested page, target path, and decision.
This proves the useful impact:
stage=basic
requested_page=../../app.py
decision=read-without-path-check
The application did not need SQL injection, XSS, or command execution. It read local files because the request controlled the path.

Remediation
Patch LFI by removing raw path control:
- Use page IDs instead of user-controlled filenames.
- Map allowed IDs to server-side filenames.
- Resolve the final path before opening it.
- Verify the resolved path stays inside the intended directory.
- Reject absolute paths.
- Reject traversal instead of trying to clean it.
- Avoid detailed filesystem errors in responses.
- Keep sensitive config outside directories used 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)
The final resolved path is the boundary that matters.
Detection
Useful signals include:
- Requests containing
../,..\\, or repeated dot-slash patterns. - URL-encoded traversal such as
%2e%2e%2f. - Requests for source files such as
app.py,config.py, orseed.sql. - Requests for private application files from public viewer routes.
- Repeated
404responses across many path depths. - File read events where the resolved path leaves the intended directory.
- Blacklist bypass patterns such as
....//.
Useful payload indicators:
../
..\
....//
%2e%2e%2f
%252e%252e%252f
app.py
seed.sql
internal-config
Detection is not the fix. It shows which path boundaries attackers are trying to cross.