Hollow Courier
Hollow Courier
Platform: CUSTOM | Category: Web | Type: Challenge | Difficulty: Medium | OS: N/A | **Author: XESXOR Techniques: xff_spoofing, proxyfix_hop_miscount, trust_boundary_fix, patch_submission
Summary
Walkthrough of the CUSTOM Web Hollow Courier. Task: HTB SecureCoding patch challenge; a Flask app behind a single Caddy reverse proxy uses ProxyFix(x_for=2) but only one real proxy fronts it, so IP-based internal-only access control can be spoofe
Description
Salt Gate checkpoint. Restore trustworthy forwarded-client provenance across the Caddy and Flask boundary while preserving the legitimate internal route.
An HTB "SecureCoding" patch-submission challenge. You clone a git repo with the
developer account, find and FIX a security vulnerability, then push to the
developer branch. The push auto-creates a pull request; once the PR is accepted
(hard score 60/60 plus minimum soft score) the flag is served at /flag.
Target: 154.57.164.67:30746 — Flask/Werkzeug 3.1.8 behind Caddy 2.10.2.
Clone URL: http://htb_developer:HTBDeveloperPassword@[IP]:[PORT]/git/core_application.git
Analysis
Deployment topology
Client -> Caddy (:8000, reverse proxy, the single perimeter "wall") -> gunicorn (:5000, Flask app)
Only ONE real proxy (Caddy) sits in front of the application.
Relevant repo files (checkpoint/)
conf/Caddyfile:reverse_proxyto127.0.0.1:5000withtrusted_proxies private_ranges,header_up X-Real-IP {remote_host},header_up X-Forwarded-Proto {scheme}.app/__init__.py:
The comment is misleading — it implies two proxies, but only Caddy exists.python read-only • copy allowed# Deployment expects an edge cache and the application perimeter. app.wsgi_app = ProxyFix(app.wsgi_app, x_for=2, x_proto=1, x_host=1)app/gate.py:is_internal_request()derives the origin fromrequest.remote_addr.INTERNAL_NETWORKS = 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, plus the watch-relay loopback alias127.0.0.2/32.app/routes.py: internal-only endpointPOST /app/gate/decreemints CROWN authority, gated solely bygate.require_internal()(403 otherwise).app/static/js/chronicle.js: an in-app "provenance desk" that literally spells out the intended fix — "origin is read only from the hop the wall itself witnessed, never from the courier's own chain."
Root cause — X-Forwarded-For trust boundary mismatch
Caddy appends the address it witnessed (its TCP peer) to X-Forwarded-For.
With only one real proxy, exactly ONE XFF entry is trustworthy — the one Caddy
itself appended. But ProxyFix(x_for=2) tells Flask to trust TWO XFF entries and
therefore reads the SECOND-from-right entry as remote_addr.
The second-from-right entry is fully client-controlled. An external "courier on the
road" injects X-Forwarded-For: 127.0.0.2; Caddy appends its own peer address, so
Flask sees 127.0.0.2, <caddy-peer> and ProxyFix resolves remote_addr = 127.0.0.2.
That address is inside INTERNAL_NETWORKS, so is_internal_request() returns true
and the request reaches the crown-decree desk. This is the "Hollow Courier": a
request that looks internal but isn't.
Solution
Exploit proof (live target)
Baseline (no header) — blocked:
bash read-only • copy allowedcurl -X POST http://TARGET/app/gate/decree -d "order=x" # HTTP 403
Forged XFF — bypass:
bash read-only • copy allowedcurl -X POST http://TARGET/app/gate/decree \ -H "X-Forwarded-For: 127.0.0.2" \ -d "order=Ashguard stand down" # 200 {"authority":"CROWN","decree":1,"order":"...","sealed":true}
Reproduced locally in Docker: from a trusted (loopback) source,
X-Forwarded-For: 127.0.0.2 makes Caddy forward 127.0.0.2, 127.0.0.1; ProxyFix
x_for=2 picks the 2nd-from-right entry 127.0.0.2 → 200 CROWN.
The fix (one line, minimal, correct)
Align the trusted-proxy hop count with reality. Only Caddy fronts the app, so Flask must trust exactly ONE forwarding hop — the one the perimeter witnessed.
diff read-only • copy allowed- # Deployment expects an edge cache and the application perimeter. - app.wsgi_app = ProxyFix(app.wsgi_app, x_for=2, x_proto=1, x_host=1) + # Only the perimeter proxy (Caddy) sits in front of the application, so + # exactly one forwarding hop is trustworthy. Trusting more X-Forwarded-For + # entries than there are real proxies lets a client on the road forge the + # left-hand hops and impersonate an internal origin. Read provenance solely + # from the single hop the perimeter itself witnessed and appended. + app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
This matches chronicle.js guidance ("read origin only from the hop the wall
witnessed").
Validation (all in local Docker before submitting)
- After patch: forged
X-Forwarded-For: 127.0.0.2→ 403 (exploit closed). - Legitimate internal route preserved: a REAL TCP connection whose source is
127.0.0.2(the watch-relay loopback alias) → 200 CROWN, decree sealed (simulated by binding the client socket source address to127.0.0.2). - Full test suite (
docker build --target testthen run) → 25 passed.
Submission
bash read-only • copy allowedgit checkout -b developer git commit -am "Fix XFF hop miscount: ProxyFix x_for=2 -> x_for=1" git push -u origin developer
The push triggers a server-side hook ("Push on developer branch detected,
registering PR event..."), which registers a PR. GET /pulls showed the status
badge "accepted"; GET /flag then returned the flag with HARD_SCORE: 60 and
soft scores code_quality: 14, security_reasoning: 14, patch_correctness: 8.
Key Takeaways / Lessons
- Mastering xff_spoofing, proxyfix_hop_miscount, trust_boundary_fix, patch_submission was key to this challenge.
- Always check for common misconfigurations and verify assumptions.
Crafted by XESXOR Research • 2026-07-28 • CUSTOM • Hollow Courier • Difficulty: Medium