Writeups

All writeups

Daily AlpacaHack - Linear Coffee Generator

2026/01/31

問題ソースコードは以下の通り。

問題ソースコード
import os
import hashlib
from Crypto.Util.number import *
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
 
flag = os.environ.get("FLAG", "Alpaca{dummy}").encode()
 
# You don't need to focus on encrypt_flag/decrypt_flag :)
def encrypt_flag(pt, params):
    key = hashlib.sha256(str(params).encode()).digest()
    cipher = AES.new(key, AES.MODE_CBC)
    return cipher.iv + cipher.encrypt(pad(pt, 16))
 
def decrypt_flag(ct, params):
    key = hashlib.sha256(str(params).encode()).digest()
    cipher = AES.new(key, AES.MODE_CBC, iv=ct[:16])
    return unpad(cipher.decrypt(ct[16:]), 16)
 
class LCG:
    def __init__(self):
        self.p = getPrime(64)
        self.a = getRandomRange(1, self.p)
        self.b = getRandomRange(1, self.p)
        self.s = getRandomRange(1, self.p)
 
    def serve_coffee(self):
        self.s = (self.a * self.s + self.b) % self.p
        return self.s
 
 
lcg = LCG()
enc_flag = encrypt_flag(flag, (lcg.p, lcg.a, lcg.b, lcg.s)).hex()
 
print("#1 order:", lcg.serve_coffee())
print("#2 order:", lcg.serve_coffee())
print("#3 order:", lcg.serve_coffee())
print("#4 order:", lcg.serve_coffee())
# If you can recover p, a, b, and s, you can get the flag using decrypt_flag(bytes.fromhex(enc_flag), (p, a, b, s))!
print("encrypted flag:", enc_flag)

64bitのランダムな素数pp、およびpp未満のランダムな整数a,b,sa, b, sを用いて、以下のようにしてserve_coffeeの出力が計算されています。

sn+1=(asn+b)modps_{n+1} = (a \cdot s_n + b) \mod p

与えられた4つの出力からp,a,b,sp, a, b, sをすべて復元することができたらdecrypt_flagでフラグが得られます。 これはLCG(Linear Congruential Generator)という疑似乱数生成器の一種です。

encrypt_flagdecrypt_flag(p, a, b, s)を使ってflagを暗号化/復号していますが、 特定の値を計算できたらフラグが得られる、という形式の問題ではよくあるパターンで、これらの関数には特に脆弱性はありません。

まずは、与えられた4つの出力から式を立てましょう。出力をそれぞれs1,s2,s3,s4s_1, s_2, s_3, s_4とすると、以下の4つの式が成り立ちます。

s1as+b(modp)s2as1+b(modp)s3as2+b(modp)s4as3+b(modp)\begin{align*} s_1 &\equiv a \cdot s + b \pmod p \\ s_2 &\equiv a \cdot s_1 + b \pmod p \\ s_3 &\equiv a \cdot s_2 + b \pmod p \\ s_4 &\equiv a \cdot s_3 + b \pmod p \end{align*}

これらの式を変形して (既知の値からなる式)0(modp)\equiv 0 \pmod p の形に持っていきます。 まず隣接する式同士を引き算して、bbを消去します。

s2s1a(s1s)(modp)s3s2a(s2s1)(modp)s4s3a(s3s2)(modp)\begin{align*} s_2 - s_1 &\equiv a (s_1 - s) \pmod p \\ s_3 - s_2 &\equiv a (s_2 - s_1) \pmod p \\ s_4 - s_3 &\equiv a (s_3 - s_2) \pmod p \end{align*}

さらにこれらの式を使って、aaも消去します。 2つ目と3つ目の式を使うと、 (s4s3)(s2s1)(s3s2)2(modp)(s_4 - s_3)(s_2 - s_1) \equiv (s_3 - s_2)^2 \pmod p が成り立ちます。ここから、(s4s3)(s2s1)(s3s2)2(s_4 - s_3)(s_2 - s_1) - (s_3 - s_2)^2pp の倍数であることが分かります。 具体的にこの値を計算すると1801518614506842617727473429546349213218015186145068426177274734295463492132となります。これは素数ではなく、124bit程度と大きいため、pと別の数の積となっていると考えられます。 この値を素因数分解してみると、22×9197623×37447334611×130762208467167514612^2 \times 9197623 \times 37447334611 \times 13076220846716751461となります。 ちなみに、ある程度高速な素因数分解アルゴリズムを使わないと時間がかかります。例えば、O(N)O(\sqrt N)時間の試し割りアルゴリズムでは計算しているうちに地球が滅亡してしまいます。Daily AlpacaHackなのでせめて24時間以内には終わってほしいです。 Pythonであればsympy.ntheory.factorintや、SageMathのfactor関数を使うと一瞬で答えが求まります。それすら面倒であれば https://factordb.com などのオンラインサービスもあります。

さて、素因数のうち、64bitの素数は1つしかないので、1307622084671675146113076220846716751461ppであると分かります。 次はaaを求めましょう。s3s2a(s2s1)(modp)s_3 - s_2 \equiv a (s_2 - s_1) \pmod pという式から、 a(s3s2)(s2s1)1(modp)a \equiv (s_3 - s_2) \cdot (s_2 - s_1)^{-1} \pmod pと求まります。(s2s1)1(modp)(s_2 - s_1)^{-1} \pmod pはPythonであればpow(s2 - s1, -1, p)で計算できます。 aaも求まりました。次はbbです。s2as1+b(modp)s_2 \equiv a \cdot s_1 + b \pmod pという式を変形して、b(s2s1a)(modp)b \equiv (s_2 - s_1 \cdot a) \pmod pと求まります。 最後にssを求めましょう。s1as+b(modp)s_1 \equiv a \cdot s + b \pmod pという式を変形して、s(s1b)a1(modp)s \equiv (s_1 - b) \cdot a^{-1} \pmod pと求まります。 これでp,a,b,sp, a, b, sがすべて求まりました。あとはdecrypt_flagに与えてフラグを得ましょう。

これを実装したソルバは以下の通りです。

import hashlib
from sympy.ntheory import factorint
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
 
s1 = 4052328936969804578
s2 = 8676271689691567645
s3 = 2647032430467963079
s4 = 6612596210231769351
flag_enc = "0c5355c5bb76b2a86aa7cf53279fb2350883865f2ca7423ff47512278a59a8db1ed85e82e0d84c2fec52e29d0b3aefd97d791f11edf18efdf1febc07ae860b8b"
 
def decrypt_flag(ct, params):
    key = hashlib.sha256(str(params).encode()).digest()
    cipher = AES.new(key, AES.MODE_CBC, iv=ct[:16])
    return unpad(cipher.decrypt(ct[16:]), 16)
 
d1 = s2-s1
d2 = s3-s2
d3 = s4-s3
 
p = max(factorint(d2*d2 - d1*d3).keys())
a = d2*pow(d1,-1,p)%p
b = (s2-s1*a)%p
s = (s1-b)*pow(a,-1,p)%p
print(decrypt_flag(bytes.fromhex(flag_enc), (p,a,b,s)))

ちなみに、SageMathを使うと面倒な式変形を一切せずに、グレブナー基底を計算する関数に与えられた式をそのまま投げるだけで解けます。

PR = PolynomialRing(ZZ, "a, b, s")
a, b, s = PR.gens()
I = PR.ideal([
    s*a+b - s1,
    s1*a+b - s2,
    s2*a+b - s3,
    s3*a+b - s4,
]).groebner_basis()
print(I) # [a + 1466936314606773311942879293366691031, b + 3280619628752045602450999089964835177, s + 2922826573297502520907432861726796897, 4503796536267106544318683573865873033]
mul_p = int(I[3])
p = max(factorint(mul_p).keys())
a = int(I[0].univariate_polynomial().roots()[0][0] % p)
b = int(I[1].univariate_polynomial().roots()[0][0] % p)
s = int(I[2].univariate_polynomial().roots()[0][0] % p)
print(p, a, b, s) # 13076220846716751461 6534081916410610007 12854780772490122855 12938961673315310206
print(decrypt_flag(bytes.fromhex(flag_enc), (p, a, b, s)))