CTF
CTF をやり始めたので、解いた問題の記録を書いていく
ding-dong-ting-ping (CakeCTF 2023)
AlpacaHack で解いた。
問題
サーバーに対して次の操作ができる
- register:
{PREFIX}|user={username}|{timestamp}
を暗号化したものを教えてくれる。username
は base64 で渡すので、任意のバイト列を渡すことができる。PREFIX
は不明。username
にroot
が入っていたらだめ - login: register で作ったものを渡すと、username を返してくれる。これが
root
ならフラグがもらえる。解いてるときは気にしていなかったが、decode()
しているので UTF-8 じゃないバイト列になってしまうとエラーらしい
暗号化は 16 byte block の AES-CBC みたいなものだが、通常の CBC では前の block の暗号文と現在の block の平文の XOR をとるのに対し、この問題では前の block の暗号文 の MD5 と現在の block の平文の XOR をとる。
解法
{PREFIX}|user=root|(なにか)
を暗号化したものが得られれば勝ち。
block 境界がわからないと何もできないので、まず PREFIX
の長さを求めることを考える。
username
に aaaabaaaaaaa...
みたいなものを b
の位置を変えて渡して、どのブロックまで暗号文が一致しているかを観察することで、{PREFIX}|user=
部分の長さがわかる。今回の場合これが 23 であるとわかる。特に、PREFIX
の長さは 17 である。
block 境界がわかったので、username
に適当に長いバイト列を渡して、適当な block を見ると、その block の暗号文は AES(md5(その前の block の暗号文) xor (その block の平文))
となる。なので、その block の平文を細工することで、任意の 16 byte block に対して AES(block)
を直接求めることができる。
{PREFIX}|user=root|(なにか)
を暗号化することを考える。これは次のようにブロックに分割できる:
-
PREFIX
の先頭 16 文字 -
?
をPREFIX
の末尾の文字として、?|user=root|...
- (なにか) の後ろのほう
1 を暗号化した結果はわかっているので、2 の ?
が分かれば先ほどの手続きにより全体を暗号化した結果も求められることになる。?
は 1 バイトで、あり得るものは高々 256 通りしかないので、適当に長い username
に対して ?
を全通り試して先ほどの手続きで正しい暗号化結果になるものを選べばよい。
janken vs yoshiking 2 (CakeCTF 2023)
AlpacaHack で解いた。
問題
素数
解法
mod p での
今回、
ただ、この解法だと
AlpacaHack Round 3 (Crypto)
14 位。
qrime
問題
解法
素数定理より、平均的に
Rainbow Sweet Alchemist
問題
以下の手順で 1024 ビットの素数を作る。
- 64 ビットの素数を 16 個作る。
- その積の 2 倍 + 1 が素数かを判定する。素数ならばそれを返し終了する。
- 素数でないならば、今持っている 16 個の素数のうち 1 個ランダムに選んで、それを新たに作った 64 ビット素数で置き換え、2 に戻る。
ただし、64 ビット素数を作る過程は deterministic である(
この過程を 2 回繰り返して素数
解法
実験すると、↑の過程で
A Dance of Add and Mul
問題
暗号文をビット列で
楕円曲線
このとき、
思考過程
- 楕円曲線わからん。なんかアーベル群になることだけ知ってる
-
なら、o_1 \neq o_2 でo_i \cdot c_i 成分消してG_1 がわかって、隣り合うもので比較すると 01 や 10 の並びが特定できるんじゃね?どっちにもならなかったら前のビットと同じということで(o_i x_i) G_2 - とりあえず SageMath をインストールしないと話にならん→インストール
- 動かしてみたところ
が判明。困るo_1 = o_2 - factor により、
が判明するo_1 = 3 \cdot 11 \cdot 10177 \cdot 859267 \cdot 52437899 \cdot 52435875175126190479447740508185965837690552500527637822603658699938581184513 -
、o_1, o_2 の倍数っぽいし、3 の形で書いたときにa G_1 + b G_2 がa, b の倍数になっているかは3 倍することで判定できない?\frac{o_1}{3} - できなかった。↑は
がE の直和で書けることを前提としているが、そうではないため\langle G_1 \rangle , \langle G_2 \rangle - でも隣り合うものの差が
ないしG_1 の整数倍かどうかって使いたいよな~G_2 -
かどうかの判定ができればよいが、一般にそんなことができるとは思えない\langle c_i - c_{i+1} \rangle \subseteq \langle G_1 \rangle - (ライブラリのドキュメントを見て、そのような関数が存在しないかを探す)
-
っていうちょうどいい素因数があるし、10177 倍してから生成される群を見ればいいんじゃね?→正解\frac{o_1}{10177}
解法
まず
同様に、
これ以外のときは、
↑の手順により、隣り合うビットが同じか異なるかどうか、また異なるときは 0→1 か 1→0 のいずれか、というのもわかるため、全体のビットを決定可能である。
Hat Trick
問題
mod がランダムな 64 ビット素数、base が乱数なローリングハッシュが 3 つある。与えられた乱数 3 つ組に対して、ローリングハッシュ 3 個の値がそれになるような文字列を 1 個求めよ。文字列は英小文字 54 文字以下でなければならない。
ローリングハッシュが与えられてから 3 分以内に 3 組の乱数に対して解答する必要がある。
思考過程
- ローリングハッシュじゃん
- 競プロ的な構築か?
- 与えられる乱数が 192 bit くらいのエントロピーを持っていて、文字列のエントロピーが 253.8 bit くらいなので、そんなに余裕がない
- (ちょっと考える)→1 つのローリングハッシュの都合を合わせると他のローリングハッシュの値が壊れるので、直接構築を与えるのは難しそう
- CTF 典型?と思って調べる
- こんな記事が出てきた https://qiita.com/kusano_k/items/5509bff6e426e5043591
- LLL というアルゴリズムがあって、ナップサックを解くのに使えるらしい
- SageMath なら簡単に LLL を使える
- いろいろ試したら解けた
解法
文字列の長さを
この上で、↓のような行列を考える。
これに LLL を適用すると、52 行目 (1-based) の左のほうに求める
LLL について
最初、得られる行列の各行が「元の行列の行ないし列の線形結合の係数」みたいなものを表しているのかと思って混乱した。実際には、行列に対して「行の整数倍の和として書けるベクトルからなるベクトル空間」を考えたとき、LLL はこのベクトル空間が一致するような行列であって、係数が小さいものを求めているのである。
特に、LLL の出力で得られる行列の各行は、元の行列の行の整数倍の和として書くことができる。
LLL で得られた行列の各行の左側
IERAE CTF 2024
チーム poteti fan club
の semiexp
です。enjoyable lattice
, Free Your Mind
, analog
の 3 問を解きました
enjoyable lattice
問題
-
: 32 ビットの素数,q N = 32 R = \mathrm{GF}(q)[x] / (x^N + 1) -
をA の元からなるランダムR 行列とする。2 \times 2 をs, e の中の係数が小さいランダムR 要素ベクトルとして、2 とする。このときt = As + e は秘密鍵、s は公開鍵とするA, t - 暗号化のときは、平文のビット列を係数が 0/1 の
の元R に対応させる。m を (r, e_1 の中の) 係数が小さいランダムR 要素ベクトル、2 を係数が小さいランダム元として、e_2 とするu = A^\top r + e_1, v = t^\top r + e_2 + m \cdot \frac{q+1}{2}
公開鍵と暗号文から平文を復元せよ(与えられたコードには AES とか書いてあるが、それは本質ではない)
解法
秘密鍵がわかる場合の復号を考える。
Free Your Mind
問題
円分体からごにょごにょして作った体の元を
解法
-12215346
-43882763
10662968
55247289
20585228
これをサーバーに送りつけると
↑この謎のパラメータは何かというと
思考過程
-
がめちゃくちゃ小さければ直接e 乗根をとることができるので、ノルムがめちゃくちゃ小さくなるような元を求めたいe - 無理では?
- 素数生成部分を攻撃することを考える
set_random_seed(int(str(alpha).encode().hex(), 16) * current_randstate().ZZ_seed())
- random seed にめちゃくちゃ大きい値を入れる?
-
とか入れてもだめっぽい2^{20000} - そもそも↑の seed の下位ビットを固定できるとしたら
int(str(alpha).encode().hex(), 16)
が 2 でたくさん割り切れる必要があるが、そのためにはstr(alpha)
に大量の null 文字が必要なので、無理
- そういえばノルムって乗法的だな
- 非自明な(
でない)値であってノルムが1 になるものがあれば、それをたくさん掛けることでパラメータを大きくできそう1 -
のノルムが\omega + 1 であることが判明したので、それを1 乗したらパラメータがデカくなった20
analog
問題
stdin から入力を受け取って stdout にそれを変換した結果を吐くバイナリ、およびその出力が与えられるので、入力を復元する
解法
ghidra で逆コンパイルして気合で読むと、バイナリが次の 5 つの処理からなることがわかる
- 入力の各 byte の各 bit を 1 byte ずつにする
- 3 byte ずつ 1 byte (0~7) にする
- 各 byte を dict で別の byte (0~7) に変換する。全単射
- 各 byte について cos(x/8 * 2pi + pi/8), sin(x/8 * 2pi + pi/8) を float として出力先に 4 回ずつ書く
- それぞれの float 2 個組に対して独立に、乱数 a, b として cos(a * 2pi) * 0.4 * b, sin(a * 2pi) * 0.4 * b のノイズを加える
4, 5 をやる前の byte (というか octet) 列を復元できれば勝ち。各 byte ごとに独立にノイズを加えたものが 4 組あるので、平均をとることでノイズを小さくできる。あとはノイズがなかった場合に各バイトがどう変換されるかを考えると、ノイズがある場合でも最も近い点をとればよいことがわかる。
pwn 入門編
AlpacaHack Round1 echo
指定の関数 win
を呼び出すという問題。
(size = abs(size)) > BUF_SIZE
があるので size
は必ず 0 以上 BUF_SIZE
以下になるように見えるが、size == INT_MIN
のときに限り abs(size) == INT_MIN
で 0 未満となる。このとき get_data
は buf
に確保されたスタック領域を大きく逸脱して書き込みを行ってしまう。
あとは buf
のあるスタックフレームの後ろの方の return address をぶっ壊して win
のアドレスにすればよい。ghidra などで解析すると、このスタックフレームは buf
の先頭から saved rbp までが 0x110 byte であるとわかるから、saved rbp 含め 0x118 byte のダミー値 + win
のアドレス + get_data
を止めるための \n
をデータとして与えると echo
からの return 先が win
になる
メモ
この 0x110 byte という値は、echo
関数の最初のほうに
PUSH RBP
MOV RBP,RSP
SUB RSP, 0x110
とあることから判断した。この一連の命令により、スタックは(アドレスの小さいほうから順に)
RSP RBP
| 0x110 | (saved RBP) | (old RSP) |
という状態になっている。(old RSP)
はこの関数を呼び出すときの call
命令で保存されている。
AlpacaHack Round4 (Rev)
Simple Flag Checker
- ghidra で逆コンパイルしたら、関数
update
が大変なことになっていたので気合で読むのを諦める - とりあえず angr を試してみる→終わらないしメモリを食いつぶして大変なことになった
- チェック部分は 1 文字ずつ見ていて、不正解と判断された時点で特定のレジスタの値が変わるので、最初のほうから 1 文字ずつ決めていけば OK
- 各文字のチェックはそれより前の文字に依存するので、aaaa....aaa とか bbbb....bbb とかを順次試すだけでは不十分で、1 文字ずつ決めていく必要がある
- これを手動でやっていたらコンテストが終わるので、gdb script を書いて実行
Everything Silver
- ghidra で逆コンパイルして読んでみたが、なんか fork してるし、ptrace とか出てきてよくわからない
- 調べながら読んでみたところ、子プロセスは INT3(割り込み命令)が大量に並んだバイナリを実行し、割り込みがかかると親プロセスが適宜バイナリの内容を書き換えているということはわかった
- が、子プロセスに gdb attach もできないし、どうやって解析したらいいのかまるでわからなかった
XorshiftStream (AlpacaHack Round5)
xorshift により生成される bit stream と XOR をとることで暗号化しているものを復号する。
まず、xorshift の各状態は、最初の state を
c = xss.encrypt(key.hex().encode() + strxor(key, FLAG))
key.hex()
は 7c3f4d...
のように ASCII 文字列である。特に、各バイトの LSB は 0 である。このことから、各バイトの LSB に相当する位置では xorshift の bitstream の値そのものが判明することがわかる。簡単な計算により flag の長さは 63 バイトであることがわかり、key.hex()
部分の長さは 126 バイトであるから、key
, flag
の復元は容易。
以下のコードでは最初の _next
呼び出しの直後の state
の内容を求めている。
def xor_bvec(seq1, seq2):
assert len(seq1) == len(seq2)
return [i ^^ j for i, j in zip(seq1, seq2)]
nbits = 64
state = []
for i in range(nbits):
s = [1 if i == j else 0 for j in range(nbits)]
state.append(s)
mat_all = []
for i in range(126 // 8):
mat_all += state
for shift in [13, -7, 17]:
state2 = []
for j in range(nbits):
k = j - shift
if 0 <= k < nbits:
state2.append(xor_bvec(state[j], state[k]))
else:
state2.append(state[j])
state = state2
encrypted = bytes.fromhex("(output.txt の内容)")
mat = []
expected = []
for i in range(7, len(mat_all), 8):
mat.append(mat_all[i])
expected.append(encrypted[i // 8] >> 7)
mat = matrix(GF(2), mat)
expected = vector(GF(2), expected)
res = mat.solve_right(expected)
print(res)
initial_state = 0
for i in range(64):
initial_state |= int(res[i]) << i
print(initial_state)
NNNN (AlpacaHack Round5)
素数
であるが、
from Crypto.Util.number import getPrime, isPrime, long_to_bytes
# ni, ci を output.txt からコピペ
x = n1 - n0
y = n2 - n0
def find_approx(a, b, c, d, p, q):
while True:
u = a + c
v = b + d
if v > 2**50 and (y - v * v) % v == 0:
return u, v
if u > 2**200:
return
if u * q < p * v:
a, b = u, v
else:
c, d = u, v
d1, d2 = find_approx(0, 1, 1, 1, x, y)
assert (x - d1 * d1) % d1 == 0
assert (y - d2 * d2) % d2 == 0
import math
a = math.gcd(x // d1, y // d2)
for r in range(1, a + 1):
if a % r != 0:
continue
if x // (d1 * r) - d1 * r == y // (d2 * r) - d2 * r:
d1 *= r
d2 *= r
break
a = x // d1 - d1
b = n0
def sqrt(n):
l = 0
r = 2 ** 2000
while l < r:
mid = (l + r) // 2
if mid * mid < n:
l = mid + 1
else:
r = mid
return l
det = sqrt(a * a - 4 * b)
p = (a - det) // 2
q = (a + det) // 2
def solve(n, c):
d = (-(p + q) + sqrt((p + q) * (p + q) - 4 * (p * q - n))) // 2
pi = p + d
qi = q + d
ei = pow(0x10001, -1, (pi - 1) * (qi - 1))
m = pow(c, ei, pi * qi)
return long_to_bytes(m)
ans = solve(n0, c0) + solve(n1, c1) + solve(n2, c2) + solve(n3, c3)
print(ans)
SECCON CTF 13 Quals
チーム ZK Lovers
で参加しました。warmup しか解けなかった
reiwa_rot13
key (英小文字 10 文字) とその rot13 を RSA 暗号化したものが与えられるので key を復元する。
rot13 は ASCII 表現だと +13 か -13 のいずれかにしかならないので、各文字がどっちになるかを全探索することができる (1024 通り)。このパターンを決め打つと、C を定数として「key と (key + C) を RSA 暗号化したものが与えられるので key を復元する」という問題になる。これは Franklin-Reiter Related Message Attack で解くことができる。
Trillion Bank
送り先のユーザー名を指定して、自分の持っている金額を d 減らして送り先の金額を d 増やすということができる。ユーザー登録したとき金額は 10 である。この条件下で金額を 1 兆にする。
1000 億回ユーザー登録するなんてのは不可能なので金額の総和の保存則をユーザー登録以外で破る必要がある。送金部分のコードに注目すると、送り元から減らすときはユーザーを id で区別しているのに、送り先については name で区別していて怪しい。これを見ると、同じ name を持つ違うユーザーを作りたくなる(ただし直接これを行うことはできない)。
ここで、name は TEXT 型であるが、MySQL だと TEXT 型には 65535 文字しか入らない(これを超えると黙って切り落とされる)。これに注目すると、JavaScript 側では違う name なのに SQL 内では同じ name として扱われるユーザーを作ることができる。
具体的には、(longname)
を 65535 文字の適当な文字列として、(longname)
(longname)a
(longname)b
というユーザーを作る。(longname)a
や (longname)b
は SQL 内では (longname)
という name になるので、これらユーザーも (longname)
に対する送金を受け取ることができる。そのため、(longname)a
から (longname)
に送金すると、自分の所持金を減らさず (longname)b
の所持金を増やすことができる(a
b
を逆にしても同様)。a, b 交互に、自分の所持金すべてを送金する操作を繰り返すと、フィボナッチ数列のように所持金が増えていく。数十回の操作が必要になるが、十分に手動で対応可能である(多分本当は自動化したほうがいいんだけど、やり方がわからなかった)
ちなみにこの方法だと (longname)
でユーザー登録したセッションは消してしまって構わなくて、2 つのセッションを持っていれば解ける。ブラウザを 1 種類しかインストールしていなくても、普通のウィンドウとプライベートウィンドウで 2 つの異なるセッションを持つことができるので、解ける
1linepyjail (解けなかった)
builtins, ()
以外の (
や )
禁止で flag.txt
の中身を取得する。
この辺を参考にいろいろ試していたが、__builtins__
を取得するところまでは行けるものの、そこから先がうまくいかない
-
breakpoint()
→これを呼び出した時点で新しく何かをimport
しているらしく、__builtins__
がないことが効いてエラーになる -
help()
→pager がless
ではなくcat
になっていて、シェルを叩くなどができなくなっている