Writeup: TSG CTF 2021
今週土日に開催されていたTSG CTF 2021に出たので自分が解いた問題のWriteupを書きます。
ちなみに3位でした、やったね。チームメイトの皆さんありがとうございました。
他のチームメイトのWriteupはこちら
- Prizeの為にGithubに上げたもの
- Advanced Fisher (by ネコチャン)
- lkgit (by Dronex)
- optimized (by Dronex)
- udon (English, by Ark)
- TSG CTF 2021 writeup - Udon | XS-Spin Blog
- udon (by Ark)
- TSG CTF writeup stream - YouTube
- Beginner's Crypto 2021, This is DSA (by kurenaif)
- TSG CTF 2021 - This is DSA write up
- This is DSA (English, by kurenaif)
Beginner's Rev 2021 §
引数に32文字の文字列を与えるよくあるCrackme。check()
という関数に引数に与えた文字列のポインタが渡されており、Ghidraで見ると次のようになっている(一部変数をrenameしています)。
ulong check(long param_1)
{
__pid_t pid;
int __fd;
int depth;
int iVar1;
uint uVar2;
long in_FS_OFFSET;
undefined local_34;
byte local_33;
long local_30;
uVar2 = 0;
iVar1 = 0;
depth = 0;
local_30 = *(long *)(in_FS_OFFSET + 0x28);
do {
pid = fork();
iVar1 = iVar1 + 1;
if (pid == 0) {
iVar1 = 0;
uVar2 = uVar2 | 1 << ((byte)depth & 0x1f);
__fd = open("/dev/null",1);
dup2(__fd,1);
}
depth = depth + 1;
} while (depth != 5);
depth = iVar1 + -1;
__fd = is_correct((ulong)(uint)(int)*(char *)(param_1 + (int)uVar2),(ulong)uVar2);
_uVar2 = (ulong)(__fd == 0);
uVar2 = (uint)(__fd == 0);
if (iVar1 != 0) {
do {
depth = depth + -1;
wait(&local_34);
uVar2 = (uint)_uVar2 | (uint)local_33;
_uVar2 = (ulong)uVar2;
} while (depth != -1);
}
if (uVar2 == 0) {
puts("correct");
}
else {
puts("wrong");
}
if (local_30 == *(long *)(in_FS_OFFSET + 0x28)) {
return (ulong)uVar2;
}
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}
雑に観察した程度だが、何やら子プロセスの生成を一定の深さになるまで繰り返し(多分32個)、対応したインデックスの文字が合っているかをis_correct()
関数でチェックしているように思える。ちなみにis_correct()
関数は静的解析したら一生を終えるぐらいには面倒そうだったのでそっ閉じした。
ご丁寧にもあっていれば"Correct"
、誤っていれば"wrong"
と主力されるので、1文字ずつ与える文字列を確定させていくことが出来そうなのだが、各子プロセスでは標準出力が/dev/null
になっているので目視で確認する事は出来ない。
そこでstrace
の力を借りる。標準出力が捨てられていてもwrite
システムコールは呼ばれているはずなので"Correct"
か"wrong"
をwrite
しているシステムコールを探せば対応する文字が合っているかが分かる。また、strace
は-f
オプションでforkしたプロセスのシステムコールまで捕捉してくれる。
いつものように頭から確定させようと思ったが、考えられる全文字を試しても"correct"
が現れない。上記コードをよく見ると中盤でwait()
で子プロセスの様子を待っていることから子プロセスで判定した文字が正解でないと自信も正解でないとGuessして下から確定させた。
もちろんこれを全部手動でやるのは骨が折れるので面倒なことをやらされる係ことPythonに投げた。
import subprocess
from string import ascii_letters, digits, punctuation
letters = ascii_letters + digits + punctuation
print(letters)
# TSGCTF{y0u_kN0w_m@ny_g0od_t0015}
def exploit():
known = b""
length = 32 - len(known)
for i in range(length - 1, -1, -1):
print(i)
flag = bytearray(b"X" * (i+1) + known)
for c in letters:
flag[i] = ord(c)
cmd = ["strace", "-f", "-o", "output.txt", "./beginners_rev", flag.decode()]
res = subprocess.run(cmd, stdout=subprocess.DEVNULL)
output_res = open("output.txt").read()
cnt = output_res.count("correct")
if cnt > 31 - i:
print("[Found]:", i, c)
known = c.encode() + known
break
print(flag.decode())
if __name__ == "__main__":
exploit()
Flag: TSGCTF{y0u_kN0w_m@ny_g0od_t0015}
静的解析と動的解析のどちらも要求されてレベル感もちょうどよく、今回のCTFで見た中でかなり好きな問題です。
Minimalist's Private §
次のようなスクリプトとその実行結果が渡される。
from Crypto.Util.number import isPrime
from random import randrange
from secret import p, q, L, e, d
class RSA:
def __init__(self, p, q, L, e, d):
assert(isPrime(p) and isPrime(q))
self.N = p * q
self.L = L
self.e = e
self.d = d
# these are the normal RSA conditions
for _ in range(100):
assert(pow(randrange(1, self.N), self.L, self.N) == 1)
assert(self.e * self.d % self.L == 1)
# minimal is the best
assert(self.L * self.L <= 10000 * self.N)
def gen_private_key(self):
return (self.N, self.d)
def gen_public_key(self):
return (self.N, self.e)
def encrypt(self, msg):
return pow(msg, self.e, self.N)
def decrypt(self, c):
return pow(c, self.d, self.N)
flag = open('flag.txt', 'rb').read()
msg = int.from_bytes(flag, byteorder='big')
assert(msg < p * q)
rsa = RSA(p, q, L, e, d)
encrypted = rsa.encrypt(msg)
assert(rsa.decrypt(encrypted) == msg)
print(f'N, e = {rsa.gen_public_key()}')
print(f'c = {encrypted}')
注目すべきは$L$という秘密のパラメータでこいつは$N$未満の任意整数$a$に対して$a^L \equiv 1 \mod N$となっている(正確では任意ではなくランダムに選んだ100個だが十分な数である)。という事は多分こいつはカーマイケル関数$\lambda(N) = \mathrm{lcm}(p-1, q-1) = \frac{(p-1)(q-1)}{\mathrm{gcd}(p-1, q-1)}$の倍数だと思われる。
ここで$L^2 \leq 10000N$が成り立っているので、$p-1,q-1$の最大公約数を$g \coloneqq \mathrm{gcd}(p-1)(q-1)$とおき、$L \coloneqq C \lambda(N) \ C \in \mathrm{Z}$とおくと次のような不等式が成り立つ。
$$ \begin{aligned} C^2 \frac{(p-1)^2(q-1)^2}{g^2} &\leq 10000pq \cr \frac{(p-1)(q-1)}{g^2} &\leq 10000 \cr \frac{p-1}{g}\frac{q-1}{g} &\leq 10000 \end{aligned} $$
正確には1行目から2行目の変形は厳密ではない、$p,q$が大きい数なので多分成り立つという仮定で変形した。
ここで$g$が$p-1,q-1$の最大公約数なので$k_p \coloneqq \frac{p-1}{g}, \ k_q \coloneqq \frac{q-1}{g}$とおくと$k_p,k_q$は互いに素で$k_pk_q \leq 10000$が成り立つ。
このような条件を満たす$k_p, k_q$はそこまで多くない(だいたい6万個とちょっと)。これである程度$g$の候補は絞れた事になる。
これらの数値を用いると$N = pq = (k_pg+1)(k_qg + 1) = k_pk_qg^2 + (k_p+k_q)g + 1$が成立する。これは$g$についての2次方程式であり、$g$は整数解の1つである。
ということはこの2次方程式が整数解を持つかどうかを各$k_p,k_q$で判定して、整数解を持つなら$p,q$を求めて$N$が素因数分解されるかどうかを調べればいい。整数解を持つかどうかの判定は判別式が整数になるかどうかを確認した。
このような$k_p,k_q$は対称性を除けば1つに定まり、無事に素因数分解出来た。
使用コードは次の通り
from Crypto.Util.number import long_to_bytes, GCD
from math import isqrt
from xcrypto.rsa import dec_pq
def get_params():
N, e = (1108103848370322618250236235096737547381026108763302516499816051432801216813681568375319595638932562835292256776016949573972732881586209527824393027428125964599378845347154409633878436868422905300799413838645686430352484534761305185938956589612889463246508935994301443576781452904666072122465831585156151, 65537)
c = 254705401581808316199469430068831357413481187288921393400711004895837418302514065107811330660948313420965140464021505716810909691650540609799307500282957438243553742714371028405100267860418626513481187170770328765524251710154676478766892336610743824131087888798846367363259860051983889314134196889300426
return (N, e), c
def search_k():
ret = []
for kp in range(1, 10001):
for kq in range(1, 10000 // kp + 1):
if GCD(kp, kq) == 1:
ret.append((kp,kq))
return ret
def exploit():
(N, e), ct = get_params()
k_cand = search_k()
print(len(k_cand))
for kp, kq in k_cand:
a = kp*kq
b = kp + kq
c = -(N - 1)
D = b**2 - 4*a*c
sqrtD = isqrt(D)
if sqrtD**2 != D:
continue
for sig in [-1, 1]:
g = None
nume = -b + sig * sqrtD
denom = 2*a
if nume % denom == 0:
g = nume // denom
if g is not None:
p = g*kp + 1
q = g*kq + 1
flag = dec_pq(ct, p, q, e)
print(long_to_bytes(flag))
if __name__ == "__main__":
exploit()
Flag: TSGCTF{Roll_Safe:_You_c4n't_be_exploited_1f_you_are_a_minimali5t_enough_and_y0u_don't_have_any_s3crets_in_your_mind}
Baba is Flag §
フラグを提出したのは別のチームメイトですが、解法の半分ぐらいを考えた+自分で解けるスクリプトを書いたので書きます。
次のようなスクリプトが与えられる。
require 'openssl'
require 'digest'
STDOUT.sync = true
class OpenSSL::PKey::EC::Point
def xy
n = to_bn(:uncompressed).to_i
mask = (1 << group.degree) - 1
return (n >> group.degree) & mask, n & mask
end
alias_method :+, :add
alias_method :*, :mul
end
class ECDSA
def initialize
@curve = OpenSSL::PKey::EC::Group.new('secp256k1')
@G = @curve.generator
@n = @curve.order.to_i
@d = OpenSSL::BN.rand(@curve.degree).to_i
@Q = @G * @d
end
def inv(x)
x.pow(@n - 2, @n)
end
def sign(msg)
z = Digest::SHA256.hexdigest(msg).hex
k = OpenSSL::BN.rand(@curve.degree / 3).to_s.unpack1('H*').hex
x, y = (@G * k).xy
# We all like hacks, ain't we?
# s = (z + x * @d) * inv(k) % @n
s = (z + @d) * inv(k) % @n
return x, s
end
def verify(msg, x, s)
return false if x % @n == 0 || s % @n == 0
z = Digest::SHA256.hexdigest(msg).hex
# ditto
# x2, y2 = (@G * (z * inv(s)) + @Q * (x * inv(s))).xy
x2, y2 = (@G * (z * inv(s)) + @Q * inv(s)).xy
return x == x2
end
end
ecdsa = ECDSA.new
5.times do
puts <<~EOS
1. Sign
2. Find rule
3. Exit
EOS
print 'choice? '
case gets.chomp
when '1'
x, s = ecdsa.sign('Baba')
puts 'Baba is:'
puts "x = #{x}"
puts "s = #{s}"
when '2'
print 'Which rule do you want to know? '; msg = gets.chomp
print 'x? '; x = gets.to_i
print 's? '; s = gets.to_i
if ecdsa.verify(msg, x, s)
if msg == 'Baba'
puts 'Baba is you'
elsif msg == 'Flag'
puts "Flag is #{ENV['FLAG']}"
else
puts 'Not Found :('
end
else
puts 'Invalid :('
end
else
exit
end
end
puts 'You is defeat.'
ECDSAで使われている$x$が$s$の計算で用いられていない。この事実によって署名の検証は$\frac{z}{s}G + \frac{Q}{s}$のx座標と等しい数字を与えたかどうかになる。
という事はもし$Q$がわかっていれば(通常のECDSAであれば分かっている)、任意$z$に対して適当な$s$を持ってきて右辺を計算することで署名を生成出来てしまう。
この事実をチームメイトから言われてから、与えられた'Baba'
の署名$r,s$から、$sP - zG = Q$である事に気付いて共有したら解いてくれた。ここで$P$はx座標が$r$である点であり、署名に使われたnonce$k$を用いると$P = kG$を満たす。なお、該当する点は2つ存在するので外したらもう片方を試せば良い。
使用コードは次の通り
from pwn import remote
from hashlib import sha256
from Crypto.Util.number import long_to_bytes, bytes_to_long
def create_conn():
host = "34.146.212.53"
port = 65434
return remote(host, port)
def get_params():
p = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F
a= 0
b = 7
order = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
G = (0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798,
0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8)
return (p, a, b, order), G
def exploit():
(p, a, b, order), G = get_params()
curve = EllipticCurve(GF(p), [a, b])
assert order == curve.order()
sc = create_conn()
sigs = []
for _ in range(1):
sc.recvuntil(b"choice? ")
sc.sendline(str(1).encode())
sc.recvuntil(b"x = ")
x = int(sc.recvline())
sc.recvuntil(b"s = ")
s = int(sc.recvline())
sigs.append((x,s))
print(sigs)
x,s = sigs[0]
inv_s = inverse_mod(s, order)
G = curve(G)
P = curve.lift_x(GF(p)(x))
z = bytes_to_long(sha256(b"Baba").digest())
Q = s*P - z * G
_z = bytes_to_long(sha256(b"Flag").digest())
rhs = _z * inv_s * G + inv_s * Q
_x, _y = rhs.xy()
print(_x, s)
sc.recvuntil(b"choice? ")
sc.sendline(str(2).encode())
sc.recvuntil(b"know? ")
sc.sendline(b"Flag")
sc.recvuntil(b"x? ")
sc.sendline(str(_x).encode())
sc.recvuntil(b"s? ")
sc.sendline(str(s).encode())
sc.interactive()
if __name__ == "__main__":
exploit()
Flag: TSGCTF{HACKER_IS_YOU._POINT_IS_MOVE._POINT_ON_CURVE_IS_HACKED._YOU_IS_WIN.}
k
の作り方が異常な上にやや短いのでBiased Nonce Attackを疑い、ずっとそれを試したせいで簡単な解法を見逃してしまった。ちなみに続編のFlag is WinではBiased Nonce Attackが想定解らしいのですが解けませんでした、格子錬成力が足りない。
[Misc] Baba is Flag §
CTF終了後に運営のmoraさんがこんなツイートをしていたので、皆さんもBaba is Youを起動してカスタムレベルを有効にしてプレイしましょう。もちろんCTFプレイヤーの皆さんなら既に購入していますよね?していないなら今直ぐ購入しましょう。
Pro Tips: "Baba is Flag"に置かれているBaba is you の問題はダウンロードして解ける
— mora (@moratorium08) October 3, 2021
(解けたので500点くれませんか?順位も変動しないし...)
Others §
フラグは提出していないし貢献度も微妙でしたが、ある程度考えた問題について大雑把に書いておきます。
This is DSA §
通常のDSAでは$p-1 | q$が成り立っているが、その制約を外したらどうなる?という問題。pycryptodome
の該当チェックを削除してこれが用いられている。
実はpycryptodome
のDSAの$p,q$のチェックではコメントアウトで「$p,q$は素数」と書いているにも関わらず、$q$が素数なら$p$が合成数であってもチェックを通過する。
該当箇所はこの辺りから。
ちなみに本家pycryptodome
を見に行ったら直ってなかった。((key.p - 1) % key.q) != 0
エラーのお陰で多分影響は無いんだと思う、多分。
って思ったら作問者のhakatashiさんがプルリクを出してました。以前送ったのと合わせてmergeされてほしいです。
というわけで$p = q^2C$のような$p$を提出すれば$\phi(p) = q(q-1)C'$のようになって$p$の法の下では乗法の位数が$q$であるものが存在し、それを$g$として提出すればその下のチェックも通る。
このようにして作られた$p,q,g$でも$g$の位数が$q$の位数である事に苦しんで離散対数問題を上手く解くことが出来ず悩んでいたが、上記の事実をまとめて投げたら、kurenaif大先生が任意署名の片割れ$r$が1になる事に気付いて解いていた。
kurenaifさんのWriteup Streamはページ上部にリンクを載せたのでそちらからどうぞ。