N1CTF 2023 Writeups
這次和 Balsn 參加這場,解了些 Crypto 方面的題目,把其中學到的東西紀錄下來。
e2Is0
from string import ascii_letters
from sympy import sqrt
import random
import signal
import os
FLAG = os.environ.get('FLAG', 'n1ctf{XXXXFAKE_FLAGXXXX}')
def banner():
print("""
░░░░░░░ ░░░░░░ ░░ ░░░░░░░ ░░░░░░
▒▒ ▒▒ ▒▒ ▒▒ ▒▒ ▒▒
▒▒▒▒▒ ▒▒▒▒▒ ▒▒ ▒▒▒▒▒▒▒ ▒▒ ▒▒
▓▓ ▓▓ ▓▓ ▓▓ ▓▓ ▓▓
███████ ███████ ██ ███████ ██████
""")
def curve_init():
p = random_prime(2^256)
F.<i> = GF(p^2, modulus = x**2 + 1)
R.<t> = PolynomialRing(GF(p))
guess = ''.join(random.choices(ascii_letters, k=20))
RR = RealField(256)
num = RR(int(guess.encode().hex(),16))
j = F(str(sqrt(num)).split('.')[1])
E = EllipticCurve(j=j)
P = E(0).division_points(3)
P.remove(E(0))
phi = E.isogeny(random.choice(P))
E2 = phi.codomain()
j2 = E2.j_invariant()
assert list(R(j2))[1] != 0
return E2, p, guess
def leak(E, p):
F = Zmod(p^2)
R.<t> = PolynomialRing(GF(p))
r = random.getrandbits(20)
x = F(input("your magic number?\n$ "))^r - 1
j_ = E.j_invariant()^x
print(list(R(j_))[0])
def main():
signal.alarm(120)
banner()
para = None
print("Curve Initialization...")
while not para:
try:
para = curve_init()
except:
continue
E, p, guess = para
print(f"einfo: {E.base_ring()}")
leak(E, p)
if input("guess > ").strip('\n') == guess:
print(f"Congratz, your flag: {FLAG}")
else:
print("Game over!")
if __name__ == "__main__":
try:
main()
except:
print("error!")
首先生成一個質數 ,還有一個長度 20 的 ascii_letters
字串作為 guess
,然後取它 sqrt 之後的小數部分當作一個整數 。接下來在 上生成一條 j-invariant 為 的 curve ,之後在它 3-isogeny 的鄰居中找出 ,並且輸出 的 j-invariant 必須是 的形式,其中 。
之後告訴你 ,然後給你一個 oracle 可以輸入一個數 ,然後 server 計算 之後給你 的常數項,目標是要得到 guess
。
要解這題很重要的關鍵是要知道有個叫做 modular polynomial 的東西,對於任兩個存在 n-degree isogeny 的兩條橢圓曲縣 , 來說都有 。而 的公式是可以在這邊 找到的。
modular polynomial 在這邊有個很大的意義,因為在 下的 j-invariant 都是 形式的,也就是說 ,其中 。把它們代入 中後按照 和 可列出兩個等式。這告訴我們一個很重要的事實,就是只要 中有任兩個是已知的話那就能透過解聯立方程式的方式得到另外兩個。這個方法正是解決 Isogeny Hidden Number Problem 的方法之一 (參考這篇的 Chapter 8)。
回到題目本身,可知 的 ,而 中我們什麼都不知道,所以很明顯是要透過 leak
oracle 想辦法求出一些關係來。我一開始的想法是直接 leak ,也就是要 ,也就是 ,但我想不到一個可以在 是隨機的狀況下做到這件事。後來我用的作法是設 ,那麼在 為奇數的情況下 ,在 下對某個數開 次方正好就是算它的 inverse。
因此我如果設 ,其中 leak
回傳的值會是 ,那麼就有 ,這邊一樣能透過把實部和虛部分開得到 上的兩條等式。所以在一共有六個未知數 ,其中透過 modular polynomial 和 inverse 的部分列出兩條等式,透過分離係數得到四條,而我們知道 所以代表這個系統有唯一解的。跑 groebner basis 好像有點慢,所以我是自己手動做 resultant 最後得到一個 ,所以求根就能得到 ,也就是一開始的 。
得到 ,也就是 的小數部分之後要想辦法求 。這邊我是先對 做些轉換得到 ,然後設 後移項平方得:
注意到 會是個小於 的正整數,所以有 ,然後給 估個大小後用 LLL 就能找出 ,然後就有 了。
證明 : 代表的是 ,所以 。
import ast
# https://math.mit.edu/~drew/ClassicalModPolys.html
mp3_def_str = """[1,0] 1855425871872000000000
[1,1] -770845966336000000
[2,0] 452984832000000
[2,1] 8900222976000
[2,2] 2587918086
[3,0] 36864000
[3,1] -1069956
[3,2] 2232
[3,3] -1
[4,0] 1"""
mp3_def = [
[ast.literal_eval(x) for x in line.split(" ")] for line in mp3_def_str.split("\n")
]
mp_term = (
lambda e, coef: lambda x, y: coef * x ** e[0] * y ** e[1]
+ coef * x ** e[1] * y ** e[0]
if e[0] != e[1]
else coef * x ** e[0] * y ** e[1]
)
mp = lambda mp_def: lambda x, y: sum([mp_term(*term)(x, y) for term in mp_def])
mp3 = mp(mp3_def)
from pwn import process, remote
import subprocess
def connect():
# io = process(["sage", "task.sage"])
# return io
io = remote("121.41.9.20", int(6668))
io.recvuntil(b") solve ")
pow_token = io.recvlineS().strip()
input(f"continue run pow with {pow_token}?")
ret = subprocess.check_output(
"python3 <(curl -sSL https://goo.gle/kctf-pow) solve " + pow_token,
shell=True,
timeout=20,
)
print(ret)
io.sendline(ret)
return io
io = connect()
io.recvuntil(b"of size ")
p = ZZ(io.recvuntil(b"^2", drop=True).decode())
io.sendlineafter(b"$ ", b"-1") # (-1)^r-1=p^2-2 (mod p^2) if r is odd
leak = ZZ(io.recvline().decode())
print(leak)
"""
j1=A+0i # two unk
j2=C+Di # two unk
phi(j1,j2)=0 # two eq
B=0 # one eq
(C+Di)*(leak+Fi)=1 # one unk, two eq
"""
x = var("x")
Fp = GF(p)
Fp2 = GF(p ^ 2, "i", modulus=x**2 + 1)
i = Fp2.gen()
aa, cc, dd, ff = Fp2["aa,cc,dd,ff"].gens()
PR_Fp = Fp["aa,cc,dd,ff"]
f = mp3(aa + 0 * i, cc + dd * i)
f_real = PR_Fp(f.map_coefficients(lambda c: c.polynomial()[0]))
f_imag = PR_Fp(f.map_coefficients(lambda c: c.polynomial()[1]))
g = (cc + dd * i) * (leak + ff * i) - 1
g_real = PR_Fp(g.map_coefficients(lambda c: c.polynomial()[0]))
g_imag = PR_Fp(g.map_coefficients(lambda c: c.polynomial()[1]))
aa, cc, dd, ff = PR_Fp.gens()
f1 = f_real.sylvester_matrix(f_imag, dd).det() # function of a, c
f2 = f_real.sylvester_matrix(g_real, dd).det() # function of a, c, f
f3 = g_real.sylvester_matrix(g_imag, dd).det() # function of c, f
f4 = f2.sylvester_matrix(f3, ff).det() # function of a, c
g = f1.sylvester_matrix(f4, cc).det().univariate_polynomial() # function of a
for r, _ in g.roots():
print(r, ZZ(r).bit_length())
if r > 0 and ZZ(r).bit_length() < 200:
target_j = ZZ(r)
print("j", target_j)
def recover(r):
# r = sqrt(n) - floor(sqrt(n))
# set k = floor(sqrt(n)) is integer
# (r+k)^2 = r^2 + 2rk + k^2 = n
# n - 2rk - k^2 - r^2 = 0
# set m = n - k^2, which is approximately equal to k
error = -4.22494828768973e-29 # the error by experimenting
k_ap = 2 ** (20 * 8 // 2) # approximate k
B = matrix(
QQ, [[1, 1, 0, 0], [-2 * QQ(r), 0, 1, 0], [-QQ(r) ** 2, 0, 0, 1]] # m # k # 1
)
bounds = [QQ(abs(error)), k_ap, k_ap, 1]
Q = diagonal_matrix([2**512 // b for b in bounds])
B *= Q
T, mul = B._clear_denom()
T = T.LLL()
B = T.change_ring(QQ) / mul
B /= Q
for row in B:
if row[-1] < 0:
row = -row
if row[-1] == 1:
n_sub_k2 = row[1]
k = row[-2]
return k**2 + n_sub_k2
R = RealField(256)
r = R(target_j) / 10 ** len(str(target_j)) # take the fractional part of sqrt(n)
print(r)
n = recover(r)
print(n)
guess = int(n).to_bytes(20, "big").decode()
print(guess)
io.sendline(guess.encode())
io.interactive()
# n1ctf{0h_you_c4n_get_j_fr0m_th3_h4lf@#$%}
e2D1p
from Crypto.Util.number import *
import os
FLAG = os.environ.get('FLAG', b'n1ctf{XXXXFAKE_FLAGXXXX}')
assert FLAG[:6] == b'n1ctf{' and FLAG[-1:] == b'}'
FLAG = FLAG[6:-1]
def keygen(nbits):
while True:
q = getPrime(nbits)
if isPrime(2*q+1):
if pow(0x10001, q, 2*q+1) == 1:
return 2*q+1
def encrypt(key, message, mask):
return pow(0x10001, message^mask, key)
p = keygen(512)
flag = bytes_to_long(FLAG)
messages = [getRandomNBitInteger(flag.bit_length()) for i in range(200)]
enc = [encrypt(p, message, flag) for message in messages]
print(f'message = {messages}')
print(f'enc = {enc}')
簡單來說有個未知的 ,然後設 為 bits 的 flag,然後生成 個 bits 的 計算 ,其中 是 flag 還有 。
看到這個題目我第一個想起的是 TetCTF 2022 - Fault (by @hellman),因為前半恢復 modulus 的部分幾乎是一樣的。把那篇的方法改寫成這篇所用的符號會是這樣的:
要求 ,需要找到一些夠小的 使得:
那麼把 分成正負的部分相乘得 ,那麼 ,所以 gcd 即可求 p,所以問題就是要怎麼找到 。為了要做 gcd,還需要有多組 。
把前面展開得到:
取 log 看指數部分:
也就是說只要使 和 即可,為了解決這個問題,@hellman 構造了一個矩陣:
每個 row 塞了 的 bits,然後後面多的一個 是為了讓 成立。
可知我們要找的 是 (left kernel) 中的短向量,這部分用 LLL 就能求了。
這部分的概念其實和我之前出的 No modulus 以及去年 N1CTF 的 ezdlp 還有 HITCON CTF 2022 - Secret 很像
求得 之後還要想辦法拿 flag,參考 @hellman 的 writeup 知道有辦法求 ,可是 ,明顯沒辦法做 DLP,所以這樣做是沒用的。我這邊的方法是透過觀察:
中的指數部分有出現一個 ,所以想說能不能 bit by bit 求 。在這題中我發現嘗試解 會找不到解,也就是不存在一個 符合 ,不過 則是有解的,即:
這邊 的意思是 的 lsb,因為 是 k bits 的緣故所以 是已知的。之後如果我想知道 flag 的第 bits 是多少,就求 ,這樣就能得到:
因此 代表 , 代表 ,然後把 換成其他的 bit 就能一個一個求出來了。
from sage.all import *
from Crypto.Util.number import *
from subprocess import check_output
from re import findall
from output import message, enc # ln -s output.txt output.py
from binteger import Bin
def flatter(M):
# compile https://github.com/keeganryan/flatter and put it in $PATH
z = "[[" + "]\n[".join(" ".join(map(str, row)) for row in M) + "]]"
ret = check_output(["flatter"], input=z.encode())
return matrix(M.nrows(), M.ncols(), map(int, findall(b"-?\\d+", ret)))
def clean(n):
for p in sieve_base:
while n % p == 0:
n //= p
return n
"""
c[i]=e^(flag xor msg[i]) % p
=e^flag*prod((-1^flag[j])*(2^j)*msg[i][j])
=a*prod(k[i][j]^e[i][j])
"""
# ref: https://affine.group/writeup/2022-01-TetCTF#fault
bl = 159
mat = matrix(
ZZ,
[
Bin(e, bl).tuple[::-1] + (1,) for e, c in zip(message, enc)
], # 1 for the base b = 0x10001^flag
)
L = mat.augment(identity_matrix(mat.nrows()))
L[:, : bl + 1] *= 2**32
L = flatter(L)
assert L[0][bl] == L[1][bl] == 0
xx = product([QQ(y) ** x for x, y in zip(L[0][bl + 1 :], enc)])
yy = product([QQ(y) ** x for x, y in zip(L[1][bl + 1 :], enc)])
p = clean(gcd(xx.numer() - xx.denom(), yy.numer() - yy.denom()))
print(p)
# p = 25070940894294854944213724808057610995878580501834029791436842569011975159898869595617649716256078890953233822913946718277574163417715224718477846001735447
def field_exp(el, e):
a, b = e.numerator(), e.denominator()
el **= a
q = el.parent().characteristic() // 2
d = inverse_mod(b, q)
return el**d
F = GF(p)
sol = mat.solve_left(vector([0] * (bl - 1) + [1, 1])) # pick a base that works
y = product([field_exp(F(y), z) for y, z in zip(enc, sol)])
# y = F(0x10001) ** flag * F(0x10001) ** ((-1) ** flagbits[bl - 1] * 2 ** (bl - 1))
# also, flagbits[bl - 1] = 1 is known
# so we can recover bits using `c[i]=e^flag*prod((-1^flag[j])*(2^j)*msg[i][j])`
recovered_bits = [0] * bl
recovered_bits[bl - 1] = 1
for i in reversed(range(bl - 1)):
base = vector([0] * (bl - 1) + [1, 1])
base[i] = 1
sol = mat.solve_left(base)
x = product([field_exp(F(y), z) for y, z in zip(enc, sol)])
z = F(0x10001) ** (2**i)
if x == y * z:
recovered_bits[i] = 0
else:
recovered_bits[i] = 1
flag = Bin(recovered_bits[::-1]).bytes
print(flag)
# n1ctf{s0o0_ezzz_dLLLp%$#@!}
e20k
#!/usr/bin/sage
from Crypto.Util.Padding import pad
from Crypto.Util.number import *
from Crypto.Cipher import AES
from hashlib import md5
import signal
import random
import os
FLAG = os.environ.get('FLAG', 'n1ctf{XXXXFAKE_FLAGXXXX}')
class ECPrng:
def __init__(self):
self.N = self.keygen()
self.E = EllipticCurve(Zmod(self.N), [3, 7])
self.bias = random.randint(1, 2^128)
self.state = None
print(f"N = {self.N}")
def keygen(self):
P = getPrime(256)
while True:
q = getPrime(256)
if is_prime(2*q-1):
Q = 2*q^2-q
return P*Q
def setState(self, x0, y0):
self.state = self.E(x0, y0)
def next(self):
self.state = 4*self.state
return int(self.state.xy()[0])+self.bias
def v2l(v):
tp = []
for item in v:
tp.append(item.list())
return tp
def Sample(eta, num, signal=0):
if signal:
random.seed(prng.next())
s = []
for _ in range(num):
if random.random() < eta:
s.append(1)
else:
s.append(0)
return Rq(s)
class P:
def __init__(self, A, s, t):
self.A = A
self.s = s
self.t = t
self.y = None
def generateCommit(self, Verifier):
self.y = vector(Rq, [Sample(0.3, N, 1) for _ in range(m)])
w = self.A * self.y
Verifier.w = w
print(f"w = {w.list()}")
def generateProof(self, Verifier, c):
z = self.s*c + self.y
print(f"z = {v2l(z)}")
Verifier.verifyProof(z)
class V:
def __init__(self, A, t):
self.A = A
self.t = t
self.w = None
self.c = None
def challenge(self, Prover):
self.c = Sample(0.3, N)
print(f"c = {self.c.list()}")
Prover.generateProof(self, self.c)
def verifyProof(self, z):
if self.A*z == self.t*self.c + self.w:
return True
return False
def Protocol(A, secret, t):
prover = P(A, secret, t)
verifier = V(A, t)
prover.generateCommit(verifier)
verifier.challenge(prover)
if __name__ == "__main__":
try:
signal.alarm(120)
print("ECPRNG init ...")
prng = ECPrng()
x0, y0 = input("PLZ SET PRNG SEED > ").split(',')
prng.setState(int(x0), int(y0))
N, q, m = (256, 4197821, 15)
PRq.<a> = PolynomialRing(Zmod(q))
Rq = PRq.quotient(a^N + 1, 'x')
A = vector(Rq, [Rq.random_element() for _ in range(m)])
secret = vector(Rq, [Sample(0.3, N) for _ in range(m)])
t = A*secret
print(f"A = {v2l(A)}")
print(f"t = {t.list()}")
Protocol(A, secret, t)
cipher = AES.new(md5(str(secret).encode()).digest(), mode=AES.MODE_ECB)
print(f"ct = {cipher.encrypt(pad(FLAG.encode(), 16)).hex()}")
except:
print("error!")
這題有個神奇的 PRNG 和一個我不知道是那個 protocol 的 ZKP。
首先是 PRNG 的部分會先生成 ,然後你需要決定某個參數固定的橢圓曲縣 上面的點 。用 sage 檢查可知 上沒有 rational point,所以要求 需要有辦法分解 才行。
這部分我是先注意到 ,而 是 的一個倍數的這個事實,所以想到用 做 algebraic-group factorisation。不清楚這個的話可以參考我之前出的 Imaginary CTF 2023 - Sus。
分解之後要選 就是透過選 之後 CRT 就能搞定。
再來是後面 ZKP 的部分,它有參數 (這邊的 不要和前面搞混),總之它會在 做一些運算。一開始有一個 vector ,還有一個 secret vector ,其中 中的任何一個 component 都是 ,且只有 30% 是 1
(from Sample
)。之後求向量內積 當 public key。
之後 protocol 的部分比較複雜,不過真正和解題有關的只有 和 , 。首先 ,一樣用 Sample
出來的,所以 component 都是 ,其中只有 30% 是 1
。 也是個 vector,其中每個 component 也都是來自 Sample
,不過這邊它有設定 signal=1
,也就是在 sample 前會 call random.seed(prng.next())
。最後的 proof 符合 而已。
此處的 , ,所以 應該要理解為 scalar-vector multiplication
在 未知的情況下要透過 和 解 我能想到的方法只有 lattice reduction,不過這題的 讓它很難透過 lattice reduction 解這題。另一個方向就是想辦法去干涉 ,因為只有 在 sample 的時候會透過 PRNG 去 seed,而 prng 我們在某種程度上是可控的。
PRNG 的更新式很簡單,就是 ,然後輸出 ,其中 是某個隨機生成的 bias 值,這我們不知道。在不知道 的情況下不管怎麼控 都沒辦法預測 。我這邊想說讓 的話至少能讓 一樣,而這個的條件就是 ,也就是 是 3-torsion subgroup 的 generator。因此只要在 的時候就能找到這樣的 。
而當 PRNG 的輸出 都相同的後果就是 vector 中的每個 都是相同的值。然後拿出原本的 來看,分開討論各 component 會得到 ,但因為 為定值,所以可得:
因此可得 ,不過這樣要解 還沒那麼容易。這邊的關鍵是注意到 這些多項式的係數都是 ,而它們的減法表為:
- | 0 | 1 |
---|---|---|
0 | 0 | 1 |
1 | -1 | 0 |
這代表的是 只有可能是由 產生的, 也只有可能是由 產生的。因此這代表 中係數為 的項代表的是 在同一個位置也是 , 也是同理。所以就想辦法求多組 就能求出 了。去年 HITCON CTF 2022 - Easy NTRU 也有用到類似的技巧。
from pwn import process, remote, context
import ast
from Crypto.Cipher import AES
from hashlib import md5
from Crypto.Util.Padding import unpad
import subprocess
N, q, m = (256, 4197821, 15)
PRq = PolynomialRing(Zmod(q), "a")
a = PRq.gen()
Rq = PRq.quotient(a ^ N + 1, "x")
mod = a ^ N + 1
def connect():
# io = process(["sage", "task.sage"])
# return io
io = remote("121.41.9.20", int(6666))
io.recvuntil(b") solve ")
pow_token = io.recvlineS().strip()
input(f"continue run pow with {pow_token}?")
ret = subprocess.check_output(
"python3 <(curl -sSL https://goo.gle/kctf-pow) solve " + pow_token,
shell=True,
timeout=20,
)
print(ret)
io.sendline(ret)
return io
io = connect()
io.recvuntil(b"N = ")
n = int(io.recvline().strip())
def do_factor(n):
# n=p*q*r=p*q*(2*q-1)
# |GF(r^2)|=r^2-1=(r+1)*(r-1)
# r+1=2*q -> 2*n is a multiple of r+1
# so we can apply algebraic group factorization
R = Zmod(n)["x"]
while True:
Q = R.quo(R.random_element(2))
rr = gcd(ZZ(list(Q.random_element() ^ (2 * n))[1]), n)
if rr != 1:
qq = (rr + 1) // 2
pp = n // qq // rr
assert pp * qq * rr == n
return pp, qq, rr
def pick_xy(primes):
xs = []
ys = []
for i, p in enumerate(primes):
# we want 4P=P, that is, 3P=0
# so we want to find a generator to the 3-torsion subgroup
# we can't use P=0 as .xy() will throw at point at infinity
# success rate is 1/27 :)
E = EllipticCurve(GF(p), [3, 7])
assert E.order() % 3 == 0, f"unlucky at {i}-th prime"
G = E.gen(0)
P = G * (E.order() // 3)
x, y = map(ZZ, P.xy())
xs.append(x)
ys.append(y)
x = crt(xs, primes)
y = crt(ys, primes)
return x, y
p, q, r = do_factor(n)
print(p, q, r)
x, y = pick_xy([p, q, r])
print(x, y)
io.sendline(f"{x},{y}".encode())
io.recvuntil(b"A = ")
a_list = ast.literal_eval(io.recvlineS().strip())
io.recvuntil(b"t = ")
t_list = ast.literal_eval(io.recvlineS().strip())
io.recvuntil(b"c = ")
c_list = ast.literal_eval(io.recvlineS().strip())
io.recvuntil(b"z = ")
z_list = ast.literal_eval(io.recvlineS().strip())
io.recvuntil(b"ct = ")
ct = bytes.fromhex(io.recvlineS().strip())
A = vector([PRq(a) for a in a_list])
t = PRq(t_list)
c = PRq(c_list)
z = vector([PRq(z) for z in z_list])
# pz[i]-pc*secret[i]=y[i]
# so pz[i]-pz[j]-pc*(secret[i]-secret[j])=y[i]-y[j]=0 (we controlled the prng to make y[i]-y[j]=0)
# and we can solve for secret[i]-secret[j]
# note that secret[i] are binary polynomials (i.e. 0, 1)
# so a `1` in secret[i]-secret[j] is only possible if secret[i]=1 and secret[j]=0
# so we can recover secret[i] by checking which bits are `1` in secret[i]-secret[j]
# another way to solve for the inverse using linear algebra
# x = PRq.gen()
# mat = matrix([vector((c * x**i % mod).padded_list(N)) for i in range(N)])
# this can be optimized by accounting for `-1` in secret[i]-secret[j]
# from n*(n-1) to C(n, 2)=n*(n-1)/2
def get_secret(i):
result = [0] * N
for j in range(m):
if j == i:
continue
# another way to solve for the inverse using linear algebra
# sol = mat.solve_left(vector(((z[i] - z[j]) % mod).padded_list(N)))
sol = ((Rq(z[i]) - Rq(z[j])) / c).lift().padded_list(N)
oneidxs = [i for i, v in enumerate(sol) if v == 1]
for idx in oneidxs:
result[idx] = 1
return PRq(result)
secret_prq = vector([get_secret(i) for i in range(m)])
assert (A * secret_prq - t) % mod == 0
secret = vector([Rq(x) for x in secret_prq])
cipher = AES.new(md5(str(secret).encode()).digest(), mode=AES.MODE_ECB)
flag = unpad(cipher.decrypt(ct), 16)
print(flag)
# n1ctf{A_Weak_RSIS_pr0bl3m_H373!!}
從 flag 可以知道這原來是基於 ring-SIS 的,
賽後從 @Neobeo 那邊知道還有兩個改進的策略,一個是利用 和 擁有相同 x 座標的性質,也可以允許 5-torsion subgroup。再來是題目其實還有給一個 ,所以在 都相同的情況下可求 。