0CTF 2021 Quals WriteUps
這次獨自挑戰了 0CTF/TCTF 2021 Quals,感想是題目真的好難...。總共只解了 5 題(含 Welcome & Survey),排名 63,不過也學到了很多新的東西所以寫下來作紀錄。
題目名稱上有標 *
的代表的是我有試著解但是沒成功的題目,比賽結束後才把題目解掉。
Misc
GutHib
這題提供了一個 GitHub repo: awesome-ctf/TCTF2021-Guthib,裡面可以看到它有 flag 但是已經用 BFG 清掉再 force push 了。
如果有幹過和 repo 裡面一樣的事的經驗的話應該會知道就算清掉後 force push,GitHub 上直接去看該 commit 還是能看到資料,這個的原因是 GitHub 那邊的 repo 還有暫存,要清除的話只能找客服請他們幫你做 gc。
詳細可參考此回答
所以我們知道有 flag 的 commit 還留在 GitHub 上面,只要知道 commit hash 就能看到 flag 了,這邊有兩個做法。
使用 API 的 events
GitHub 有個 API 能看到 repo 的 events,例如:
1 | https://api.github.com/repos/awesome-ctf/TCTF2021-Guthib/events?page=6 |
其中的 PushEvent
可以看到 commit
的所有紀錄,從裡面就能找到目標的 commit hsah 了,所以最終的目標就是 da883505ed6754f328296cac1ddb203593473967。
暴力
這個是我用的做法,因為我不知道有那個 events API 所以就這麼做了,這大概算是(有點) unintended 的解法。
可以注意到 GitHub 的 commit hash 最短可以只用 4 個字元去存取:
- https://github.com/awesome-ctf/TCTF2021-Guthib/commit/da88 可以存取
- https://github.com/awesome-ctf/TCTF2021-Guthib/commit/da8 不能存取
所以方法就很簡單,暴力掃過所有的 4 個 hexdigits 的可能性,一共
我先用這個腳本生成一個 wordlist:
1 | from random import shuffle |
然後使用 feroxbuster 去暴力掃描就能找到了。
記得要設 ratelimit,不然很快就會得到 HTTP 429
*pypypypy
此題是個很特殊的 pyjail 題目,使用者提供的是 bytecode
(codestring
) 和額外的兩個輸入 gift1
和
gift2
。要求 len(codestring) <= 2000
和
len(gift1) <= 10
len(gift2) <= 10
。
版本: Python 3.8.11
關於 bytecode 一些的參考資料
整個題目最核心的部分在於:
1 | code = CodeType(0, 0, 0, 0, 0, 0, codestring, (), (f'__{gift1}__', f'__{gift2}__'), (), '', '', 0, b'') |
首先因為 co_conts
是空的,各種常數都只能自己從零開始生。而 co_names
給你了兩個 __xx__
格式的機會可以使用。
首先是處裡怎麼生成數字,我的作法是用 BUILD_TUPLE 0
和
UNARY_NOT
得到
True
,然後用一些加法去湊。例如下面這個的結果是數字
2
:
1 | BUILD_TUPLE 0 |
用這個方法可以遞迴寫出生成所需 bytecode 的函數:
1 | def gen_num(n): |
接下來的第二目標是生成字串,這邊用的是
FORMAT_VALUE 4
,它的效果等價於
format(TOS, TOS1)
,如果 TOS1 = 'c'
且
TOP
是一個數字,這樣就能用 ascii code
去產生字串。所以現在的問題是怎麼產生出字串 'c'
。
這邊我先用 BUILD_SLICE
產生出
slice(x, y, z)
,然後用 FORMAT_VALUE 0
把它變成字串,之後用 BINARY_SUBSCR
取第三個字元得到
'c'
。
1 | def gen_c(): |
有 'c'
之後要生成任意字元就很簡單了,而生成任意字串的部分一樣是遞迴把它寫出來。
1 | def gen_str(s): |
到這一步就能簡單的生成出字串了,下一步是怎麼繞過
__builtins__ = None
。它的繞法傳統上是利用
().__class__.__base__
得到 object
,然後
object.__subclasses__()[n]...
去取得需要的東西。只是困難點在於 co_names
只有兩個,沒辦法這麼直接的使用。
我在比賽結束前有弄出一個 codestring
和
gift1
都超過長度的 payload,可以執行
sh
。裡面用的是 __getattribute__
和
__base__
,經過很長的反覆運用後可以得到結果。細節可以自己看,裡面註解都有標
stack 的狀態。
1 | import sys |
這裡面的 string 生成方式稍微不同是因為這個是我比較早的時候寫的
比賽結束後題目作者有整理了一下解法的種類,一共有 5 個:
- 最多人用的作法:
__class__
和__dict__
組合起來得到type
,然後用mro
(之類的) 結合__getattribute__
拿到object
,所以可以有object.__dict__['__getattribute__']
,這樣就能做到一切事情了。(用法和getattr
差不多) - Unintended solution 1:
[].__reduce_ex__(3)[0].__globals__
- Unintended solution 2:
co_consts
是 empty tuple,可以用LOAD_CONST
去造成 oob access 取得你要的物件,包括module 'posix'
。offset 可以用id(something) - id(())
計算,不過復現不出來這個的 payload。 - Unintended solution 3:
co_names
雖然不是空的,但它的 offset 還是固定的,一樣有辦法找出來適當的 offset 去 leak 東西。 - 作者的解: 觸發 exception 然後
TOS
會有 exception 的 class,從它的__mro__
可以拿到object
,然後剩下都一樣。
還有一個我另外看到的解,在 bytecode 一開始直接
LOAD_CONST 6
(oob) 可以直接拿到
object
,我也不知道為什麼...。
用 __class__
和
__dict__
這部分不需要用到生成 string 的那些函數,所以 payload
很短。為了偷懶,所以我還有直接用 LOAD_CONST 6
直接變出
object
。正常情況要從 type.__dict__
拿到
__getattribute__
和 __base__
才能拿到
object
。
1 | from types import CodeType |
用
[].__reduce_ex__(3)[0].__globals__
__reduce_ex__
類似於 pickle 的
__reduce__
,只是它多支援一個 protocol
參數當做
pickle 版本號。回傳值 tuple 的第一個值是個函數,它上面的
__globals__
可以直接拿到 eval
input
等東西。
1 | import pathlib # No use, but it MUST exist |
可以在最上面看到
import pathlib
,但整個腳本都沒用到它,不過如果移除掉它之後很神奇的就會失敗。問了一下,別人說那是因為
[].__reduce_ex__
仰賴了 pickle 的存在,如果
pickle
不存在於 sys.modules
裡面的話那它就會試著 __import__
,但是因為 eval
有限制 __builtins__
所以會失敗。而 pathlib
內部似乎有 import 到 pickle
,所以就能繞過這個問題。
另一個在 remote 沒用的解
object.__subclasses__()
裡面有個
_sitebuiltins._Helper
,因為
_sitebuiltins._Helper() = help
,所以
_sitebuiltins._Helper()()
可以有 help()
的效果。
在 help 的介面下輸入隨便的條目,例如 str
就會顯示資訊來,而它還會偵測是否有 tty 的存在,若是有的話就會試著呼叫
less
或是 more
來當作 pager。在
less
或是 more
之中都能透過 !ls
這樣的方式來執行指令。
不過這個解法的問題在於要有 tty,因為這題是直接 nc 過去的,它沒有 tty
所以也不會呼叫 less
或是
more
。不過自己在本地端測試到是有效。
1 | from types import CodeType |
Crypto
checkin
題目很單純,要在 10 秒鐘內計算出一個
其中的
最快的做法是直接 gmpy2 弄下去解,它的效能比 python 內建的
pow
還快多了,只要 5 秒鐘左右即可計算出來。
1 | from pwn import remote |
我居然在這題拿到了 first blood ==
zerolfsr-
有三個不同的 LFSR,在時間內選兩個 generator 去找出它的 initial state 就能拿到 flag。
這題我的作法很單純,就直接 z3 下去解而已,選的 Generator 是 1 和 3,因為 2 看起來就複雜很多,z3 也解不太掉。
1 | from z3 import * |
flag 的內容有說作者已經想辦法避免變成 z3 能解的狀況了,不過看來這個防範措施不是很有效...
Web
*1linephp
程式碼就只有一行:
1 |
|
如果熟悉的話會知道這個題目是致敬 One
Line PHP Challenge 的,唯一的差別在於原題是沒有在後綴上多加上
.php
而已。不知道的話最好要先讀一讀它的做法。
原題的做法是透過 PHPSESSID
和
PHP_SESSION_UPLOAD_PROGRESS
在 sessions
的資料夾留下檔案,然後透過 php://filter
去做一些 decode
操作讓檔案的 prefix 變成 @<?php
之後就能 get shell。
例如:
1 | curl http://127.0.0.1/ -H 'Cookie: PHPSESSID=maple' -F 'PHP_SESSION_UPLOAD_PROGRESS=CONTENT' -F 'file=@some_file' |
會在 /tmp/sess_maple
裡面暫時寫入
upload_progress_CONTENT|...
的資料。
然而因為 PHPSESSID
裡面不能有
.
,所以沒辦法寫入 sess_maple.php
之類的檔案。
要繞過後綴有幾個做法,但是因為此題是 php 7.4.11,所以透過 path 過長(4096) 或是 null byte (%00) 去截斷都是無效的。
剩下的作法有兩個,phar:///tmp/asd.phar/shell.php
或是
zip:///tmp/asd.zip#shell.php
都能讀到檔案。
前者的問題在於 php 在處裡 php://
的路徑的時候會找副檔名做分割,例如
phar:///tmp/asd.phar/shell.php
之中的
/tmp/asd.phar
是 phar 檔案的路徑,而 shell.php
是 phar 裡面的檔案路徑。後綴無論是 .phar
還是
.png
等等的都可以,php
都會照樣接受,但是無論如何都還是要有個副檔名在才行,所以在這題的情況沒辦法用。
後者 zip://
改用了 #
來分隔,所以沒有副檔名的問題,只要能給予一個合法的 zip file
就能讓它解壓。然而問題在於 sess_xxx
的檔案開頭都會有礙事的
upload_progress_
存在。
我的第一個想法是和原題一樣,用 base64 和 php://filter
去處理,然而 zip://php://filter/...
這樣其實是不行的,從 source
code 可以看到它是直接把 path 放到 libzip 的 zip_open
函數中,不會經過 php 處裡,所以 php://filter
無法使用。
比賽時我到這邊就卡住了,沒辦法往下解,因為不知道怎麼處裡 zip header...
這題的關鍵在於 zip 其實是個格式很鬆散的檔案格式,檔案的前面的 byte
並不一定要是 PK
,因為 zip
解壓時似乎是從後面反過來找的,只要長度剛好即可。
這邊有個很簡單的作法,是先建立 shell.php
裡面放需要的
code,然後壓縮成 out.zip
,之後把它的前面 16 bytes 換成
upload_progress_
會發現它一樣能很正常的解壓縮。
根據作者的 WriteUp,直接刪除 16 bytes 並不是 intended 的,正確做法是要自己修 offset,或是學 Perfect Blue 使用
zip -F
修復:(printf upload_progress_ && cat file.zip) > out.zip; zip -F out.zip --out fixed.zip
所以方法就只要在 PHP_SESSION_UPLOAD_PROGRESS
的裡面塞
zip_data[16:]
即可,至於檔案存在時間很短的部分就用 race
condition 去試即可。
最後去造訪
?yyyx=zip:///tmp/sess_maple%23shell&cmd=ls
即可。
1 | import string |
zip://
並不是一直都能用的,需要另外安裝 zip 的 extension 才行
一個理論上能成功但機率很低的不同做法
比賽時我還有參考了 Return
of One line PHP,裡面因為
session.upload_progress.enabled = Off
的關係所以沒辦法有
sess_xxx
的檔案。
裡面用的方法是把 payload 改放到上傳的檔案之中,然後透過 php 的 bug
讓它 segfault,這樣上傳的暫存檔案 phpXXXXXX
就會被留在資料夾之中。
然而這題的 php 版本較新,沒辦法用那個 bug 去強制留下檔案。我這邊就手動用 socket 去控制 request 的傳送時間,讓它很慢才結束,這樣檔案就會留久一點,比較有機會暴力成功。
手動用 socket 控制 request 時間的腳本:
1 | from pwn import remote, context, sleep |
暴力搜尋的腳本:
1 | const xf = require('xfetch-js') |
我跑這兩個腳本好幾個小時都沒成功 QQ