← Back to Writeups
CUSTOMMediumWeb

Hollow Courier

XESXOR9/2/20264 min read
#web#custom#medium#xff_spoofing#proxyfix_hop_miscount#trust_boundary_fix#patch_submission

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_proxy to 127.0.0.1:5000 with trusted_proxies private_ranges, header_up X-Real-IP {remote_host}, header_up X-Forwarded-Proto {scheme}.
  • app/__init__.py:
    python
    # 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)
    The comment is misleading — it implies two proxies, but only Caddy exists.
  • app/gate.py: is_internal_request() derives the origin from request.remote_addr. INTERNAL_NETWORKS = 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, plus the watch-relay loopback alias 127.0.0.2/32.
  • app/routes.py: internal-only endpoint POST /app/gate/decree mints CROWN authority, gated solely by gate.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
curl -X POST http://TARGET/app/gate/decree -d "order=x"
# HTTP 403

Forged XFF — bypass:

bash
curl -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
-    # 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 to 127.0.0.2).
  • Full test suite (docker build --target test then run) → 25 passed. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Submission

bash
git 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