AIS3 EOF CTF Quals 2021 WriteUps

發表於
分類於 CTF

This article is automatically translated by LLM, so the translation may be inaccurate or incomplete. If you find any mistake, please let me know.
You can find the original article here .

This time, as the final exam for the course, I participated in the AIS3 EOF CTF 2021 Quals with the team meow? and took first place, successfully solving all challenges. Here is the writeup integrated by the whole team, but I will still post some of the challenges I solved here. The content may differ between the two, so refer to this one.

Scoreboard Screenshot

Challenge Completion Screenshot

Crypto

babyPRNG

from flag import flag
import random
import string

charset = string.ascii_letters + string.digits + '_{}'

class MyRandom:
	def __init__(self):
		self.n = 2**256
		self.a = random.randrange(2**256)
		self.b = random.randrange(2**256)

	def _random(self):
		tmp = self.a
		self.a, self.b = self.b, (self.a * 69 + self.b * 1337) % self.n
		tmp ^= (tmp >> 3) & 0xde
		tmp ^= (tmp << 1) & 0xad
		tmp ^= (tmp >> 2) & 0xbe
		tmp ^= (tmp << 4) & 0xef
		return tmp

	def random(self, nbit):
		return sum((self._random() & 1) << i for i in range(nbit))

assert all(c in charset for c in flag)
assert len(flag) == 60

random_sequence = []
for i in range(6):
	rng = MyRandom()
	random_sequence += [rng.random(8) for _ in range(10)]

ciphertext = bytes([x ^ y for x, y in zip(flag.encode(), random_sequence)])
print(ciphertext.hex())

This challenge's PRNG has two 256-bit states a,ba,b, which are updated each time an output is generated using a=ba'=b and b=69a+1337bmod2256b'=69a+1337b \bmod{2^{256}}. The original aa is then tampered with and the lsb is taken as the output.

The tampering part can be observed or tested to find that the lsb of tmp remains unchanged, so the lsb of tmp directly comes from aa:

tmp ^= (tmp >> 3) & 0xde
tmp ^= (tmp << 1) & 0xad
tmp ^= (tmp >> 2) & 0xbe
tmp ^= (tmp << 4) & 0xef

Since we only care about the lsb, which is mod2\bmod{2}, and since 222562|2^{256}, we can directly treat % self.n as % 2, and then notice that the lsb of bb' only depends on a,ba,b.

So, considering only the lsb, this PRNG actually has only four states: 0 0 0 1 1 0 1 1.

Therefore, we can brute force the key stream output for the four initial states and xor back to the original flag.

class MyRandom:
    def __init__(self, a, b):
        self.n = 2 ** 256
        self.a = a
        self.b = b

    def _random(self):
        tmp = self.a
        self.a, self.b = self.b, (self.a * 69 + self.b * 1337) % self.n
        tmp ^= (tmp >> 3) & 0xDE
        tmp ^= (tmp << 1) & 0xAD
        tmp ^= (tmp >> 2) & 0xBE
        tmp ^= (tmp << 4) & 0xEF
        return tmp

    def random(self, nbit):
        return sum((self._random() & 1) << i for i in range(nbit))


def xor(a, b):
    return bytes([x ^ y for x, y in zip(a, b)])


keys = [
    bytes([rng.random(8) for _ in range(10)])
    for a in range(2)
    for b in range(2)
    if (rng := MyRandom(a, b))
]
ct = bytes.fromhex(
    "9dfa2c9ccd5c84c61feb00ea835e848732ac8701da32b5865a84db59b08532b6cf32ebc10384c45903bf860084d018b5d55a5cebd832ef8059ead810"
)
for i in range(0, len(ct), 10):
    for k in keys:
        s = xor(ct[i : i + 10], k)
        if s.isascii():
            print(s.decode(), end="")
print()

# FLAG{1_pr0m153_1_w1ll_n07_m4k3_my_0wn_r4nd0m_func710n_4641n}

almostBabyPRNG

from flag import flag
import random


class MyRandom:
    def __init__(self):
        self.n = 256
        self.a = random.randrange(256)
        self.b = random.randrange(256)

    def random(self):
        tmp = self.a
        self.a, self.b = self.b, (self.a * 69 + self.b * 1337) % self.n
        tmp ^= (tmp >> 3) & 0xDE
        tmp ^= (tmp << 1) & 0xAD
        tmp ^= (tmp >> 2) & 0xBE
        tmp ^= (tmp << 4) & 0xEF
        return tmp


class TruelyRandom:
    def __init__(self):
        self.r1 = MyRandom()
        self.r2 = MyRandom()
        self.r3 = MyRandom()
        print(self.r1.a, self.r1.b)
        print(self.r2.a, self.r2.b)
        print(self.r3.a, self.r3.b)

    def random(self):
        def rol(x, shift):
            shift %= 8
            return ((x << shift) ^ (x >> (8 - shift))) & 255

        o1 = rol(self.r1.random(), 87)
        o2 = rol(self.r2.random(), 6)
        o3 = rol(self.r3.random(), 3)
        o = (~o1 & o2) ^ (~o2 | o3) ^ (o1)
        o &= 255
        return o


assert len(flag) == 36

rng = TruelyRandom()
random_sequence = [rng.random() for _ in range(420)]

for i in range(len(flag)):
    random_sequence[i] ^= flag[i]

open("output.txt", "w").write(bytes(random_sequence).hex())

This challenge uses a PRNG TruelyRandom composed of three MyRandom, each with 2562256^2 initial states.

The output is a byte from each random, which is then bitwise rotated and combined using o = (~o1 & o2) ^ (~o2 | o3) ^ (o1). Since it's not the lsb, we can't directly mod2\bmod{2} like the previous challenge.

Brute-forcing would require (2562)3=248(256^2)^3=2^{48} attempts to find the initial state, which might be possible with C/C++ and parallelization, but it's clearly not the intended solution.

Observing the output formula's truth table, we can see that o1 and ~o have a 75% chance of being the same, and o3 and ~o also have a 75% chance of being the same. In this case, we can use a correlation attack to individually brute force the seed of one MyRandom, requiring 2562+2562256^{2}+256^{2} attempts.

Since the output is a byte, during the correlation attack, we compare the probability of 8 bits being the same and take the average to see if it exceeds 70%.

After obtaining the seeds of r1 and r3, we can brute force the seed of r2 with 2562256^{2} attempts, and then xor back to the original flag.

class MyRandom:
    def __init__(self, a, b):
        self.a = a
        self.b = b

    def random(self):
        tmp = self.a
        self.a, self.b = self.b, (self.a * 69 + self.b * 1337) & 0xFF
        tmp ^= (tmp >> 3) & 0xDE
        tmp ^= (tmp << 1) & 0xAD
        tmp ^= (tmp >> 2) & 0xBE
        tmp ^= (tmp << 4) & 0xEF
        return tmp


def rol(x, shift):
    shift %= 8
    return ((x << shift) ^ (x >> (8 - shift))) & 255


ct = list(
    bytes.fromhex(
        "d5de8acdc0fa83d9c5bbe683cb33ef07949d6faeee8b00f6a2cc10cad800ca818e1cfd34f96f8fe71c9dbb3930ec8fb89183c9eef059cddcdc62a3fcf96eaea6dcab1bde96db8dbb13e3eb5d144fec9c6c91637cffdb0d8c988c2a189a8aaeaa136afe8cd469dddedf88ed7effbf2fd89e8f8afa88beb9ba1150eaaec0c8fdb5d4fbe3efff8ca866ecbf2bda996a7f9e136d6d6e1afbccb664e24d5ef98e9fa63e8d8b3a385aef999389d9dcfbe9f8f6d4908bdaf9bdbd8dfeaebafea28aca8c9181cb8ca8cbc9a6f48893dcf94b8b4efca91a8ab1a84f9893ac4fafb86ee9dbff7a9949ff6e8fe40a9daa2c30ea99b89383c9ecf459d8d8dc66a1fcff6daeb4caab0ad896c88cbb11e3eb4c134ff9886c84617cf9cf0d8b9d8c3b189a88adaa117dfe8ac369c8c9df88f87ef9ad2fce9f8f9be988a9adba1343eabbd6c8e8b4d4eaf2eff989a865febf3acb996c6d9e11696d6c1afbd9b664e64b5eff899fb42c8d9a383849ea99918dd9cdf8e9ede6d4858ddaffadbd8affaeabfaa288cd8c9392cb8abbcbdcb5f48882dcff5d8b58f9a90b9db1bf5f9891bb4fbaaa6efcdeff6b8c49"
    )
)


def gen_table(shift, ln=128):
    table = {}

    for a in range(256):
        for b in range(256):
            r = MyRandom(a, b)
            stream = tuple([rol(r.random(), shift) for _ in range(ln)])
            table[stream] = (a, b)
    return table


def solve_gen1():
    # a == ~(((~a)&b)^((~b)|c)^a) for 75%
    table = gen_table(87)
    for stream, (a, b) in table.items():
        same = [0] * 8
        for i in range(36, len(stream)):
            for j in range(8):
                mask = 1 << j
                if ((~ct[i]) & mask) == stream[i] & mask:
                    same[j] += 1
        rates = [s / (len(stream) - 36) for s in same]
        if sum(rates) / len(rates) > 0.70:
            print(a, b, rates)
            return a, b, stream


def solve_gen3():
    # c == ~(((~a)&b)^((~b)|c)^a) for 75%
    table = gen_table(3)
    for stream, (a, b) in table.items():
        same = [0] * 8
        for i in range(36, len(stream)):
            for j in range(8):
                mask = 1 << j
                if ((~ct[i]) & mask) == stream[i] & mask:
                    same[j] += 1
        rates = [s / (len(stream) - 36) for s in same]
        if sum(rates) / len(rates) > 0.70:
            print(a, b, rates)
            return a, b, stream


a1, b1, s1 = solve_gen1()
a3, b3, s3 = solve_gen3()
table2 = gen_table(6)
for s2, (a2, b2) in table2.items():
    oo = [((~o1 & o2) ^ (~o2 | o3) ^ (o1)) & 0xFF for o1, o2, o3 in zip(s1, s2, s3)]
    if oo[36:] == ct[36 : len(oo)]:
        print(a2, b2)
        print(bytes([x ^ y for x, y in zip(oo, ct)][:36]))

# pypy3 takes less than 3s
# FLAG{1_l13d_4nd_m4d3_4_n3w_prn6_qwq}

notRSA

from Crypto.Util.number import *
from secret import flag

n = ZZ(bytes_to_long(flag))
p = getPrime(int(640))
assert n < p
print(p)

K = Zmod(p)

def hash(B, W, H):
    def step(a, b, c):
        return vector([9 * a - 36 * c, 6 * a - 27 * c, b])
    def steps(n):
        Kx.<a, b, c> = K[]
        if n == 0: return vector([a, b, c])
        half = steps(n // 2)
        full = half(*half)
        if n % 2 == 0: return full
        else: return step(*full)
    return steps(n)(B, W, H)

print(hash(79, 58, 78))

This challenge provides a hash function with inputs nn and three constants B,W,HB,W,H, where nn is the flag. The step function outputs a vector [9a36c,6a27c,b][9a-36c,6a-27c,b] for three inputs a,b,ca,b,c. All calculations are done in a Fp\mathbb{F}_p field.

The steps function uses symbolic a,b,ca,b,c for input nn, which can be seen as simple exponentiation. Assuming step represents an unknown transformation TT, half = steps(n // 2) calculates Tn/2T^{\lfloor{n/2}\rfloor}, and half(*half) is (Tn/2)2(T^{\lfloor{n/2}\rfloor})^2. Depending on the parity of nn, it may multiply by TT. So steps(n) represents Tn(x)T^n(x).

The step function itself can be seen as a matrix multiplication:

T([abc])=[90366027010][abc]=A[abc]T( \begin{bmatrix} a \\ b \\ c \end{bmatrix} )= \begin{bmatrix} 9 & 0 & -36 \\ 6 & 0 & -27 \\ 0 & 1 & 0 \end{bmatrix} \begin{bmatrix} a \\ b \\ c \end{bmatrix} =A \begin{bmatrix} a \\ b \\ c \end{bmatrix}

Thus, this challenge is about finding nn given v=Anuv=A^n u, similar to a discrete log problem.

To find the matrix power, the first thing to try is diagonalization, but AA is not diagonalizable and can only be put in Jordan form, i.e., A=PJP1A=PJP^{-1}, where JJ is an upper triangular matrix.

Additionally, An=(PJP1)n=PJnP1A^n=(PJP^{-1})^n=PJ^nP^{-1}, so v=Anuv=A^n u can be transformed to v=Jnuv'=J^n u', where v=P1vv'=P^{-1}v and u=P1uu'=P^{-1}u.

The JJ for this challenge is:

J=[310031003]J= \begin{bmatrix} 3 & 1 & 0 \\ 0 & 3 & 1 \\ 0 & 0 & 3 \end{bmatrix}

The general term formula for the power of an upper triangular matrix can be derived, but I used WolframAlpha for the calculation: General Term Formula

Jn=3n22[186nn2n0186n0018]J^n= \frac{3^{n-2}}{2} \begin{bmatrix} 18 & 6n & n^2-n \\ 0 & 18 & 6n \\ 0 & 0 & 18 \end{bmatrix}

So

[abc]=Jn[abc]=3n22[186nn2n0186n0018][abc]\begin{bmatrix} a' \\ b' \\ c' \end{bmatrix}=J^n \begin{bmatrix} a \\ b \\ c \end{bmatrix}= \frac{3^{n-2}}{2} \begin{bmatrix} 18 & 6n & n^2-n \\ 0 & 18 & 6n \\ 0 & 0 & 18 \end{bmatrix} \begin{bmatrix} a \\ b \\ c \end{bmatrix}

where a,b,ca,b,c and a,b,ca',b',c' are the three dimensions of uu' and vv', respectively, and are known values. Since 3n22\frac{3^{n-2}}{2} is unknown, we set it as kk for convenience and eliminate it later:

b=k(18b+6cn)c=k(18c)\begin{aligned} b'&=k(18b+6cn) \\ c'&=k(18c) \end{aligned}

Dividing gives

b(18c)=c(18b+6cn)b'(18c)=c'(18b+6cn)

So

n=18bc18bc6ccn= \frac{18b'c-18bc'}{6cc'}
from Crypto.Util.number import *

p = 2888665952131436258952936715089276376855255923173168621757807730410786288318040226730097955921636005861313428457049105344943798228727806651839700038362786918890301443069519989559284713392330197
K = Zmod(p)
A = matrix(K, [[9, 0, -36], [6, 0, -27], [0, 1, 0]])
u = vector(K, (79, 58, 78))
v = vector(
    K,
    (
        2294639317300266890015110188951789071529463581989085276295636583968373662428057151776924522538765000599065000358258053836419742433816218972691575336479343530626038320565720060649467158524086548,
        1566616647640438520853352451277215019156861851308556372753484329383556781312953384064601676123516314472065577660736308388981301221646276107709573742408246662041733131269050310226743141102435560,
        2794094290374250471638905813912842135127051843020655371741518235633082381443339672625718699933072070923782732400527475235532783709419530024573320695480648538261456026723487042553523968423228485,
    ),
)
J, P = A.jordan_form(transformation=True)
# A^n*v=u
# A=PJP^-1
# J^n*vv=uu
print(J)
vv = ~P * v
uu = ~P * u
a, b, c = uu
aa, bb, cc = vv
n = (18 * bb * c - 18 * b * cc) / (6 * c * cc)
print(long_to_bytes(n))

# FLAG{haha_diagonalization_go_brRRRrRrrrRrRrrrRrRrRRrrRrRrRRRRrrRRrRRrrrrRrRrRrR}

babyRSA

from Crypto.Util.number import *
from secret import flag

p = getPrime(512)
q = getPrime(512)
n = p * q
m = n**2 + 69420
h = (pow(3, 2022, m) * p**2 + pow(5, 2022, m) * q**2) % m
c = pow(bytes_to_long(flag), 65537, n)

print('n =', n)
print('h =', h)
print('c =', c)

This challenge provides an additional h(ap)2+(bq)2(modm)h \equiv (ap)^2+(bq)^2 \pmod{m} on top of regular RSA, where m=n2+69420m=n^2+69420, a31011(modm)a \equiv 3^{1011} \pmod{m}, b51011(modm)b \equiv 5^{1011} \pmod{m}.

p,qp,q are both 512 bits, so nn is 1024 bits, and mm is 2048 bits. We can write the following Lattice basis:

Note: In this article, Lattice is always represented as row vectors for the basis

B=[m00a210b201]B= \begin{bmatrix} m & 0 & 0 \\ a^2 & 1 & 0 \\ b^2 & 0 & 1 \end{bmatrix}

We know that v=(h,p2,q2)v=(h,p^2,q^2) exists in this Lattice, where only hh is known, and the others are only known to be 0p2,q2210240 \leq p^2,q^2 \leq 2^{1024}. So we can write the bounds of vv as lb=(h,0,0)\mathit{lb}=(h,0,0) and ub=(h,21024,21024)\mathit{ub}=(h,2^{1024},2^{1024}).

A simple idea is to use LLL to reduce BB and then use Babai's nearest plane to approximate (lb+ub)/2(\mathit{lb}+\mathit{ub})/2, but we find that the resulting hh' differs significantly from the actual hh. The solution is to adjust the weights, multiplying the first column by a large value to make the first dimension of the reduced basis very small. In CVP, since we are confident that the first dimension is hh, making the first dimension of the reduced basis small helps approximate the target vector better.

This technique is implemented in the rkm0959/Inequality_Solving_with_CVP repo. The method is to multiply the dimensions with small differences in ublb\mathit{ub}-\mathit{lb} by large values and those with large differences by smaller values. This way, the reduced basis will have large and small variations based on the target's upper and lower bounds, making it easier to find the target (lb+ub)/2(lb+ub)/2.

Using the CVP solver from that repo, the resulting vector vv has about a 1413\frac{1}{4} \sim \frac{1}{3} chance of having a perfect square p2p^2 in the second dimension. If not, we can take the first two short vectors λ1,λ2\lambda_1,\lambda_2 from the reduced basis and brute force nearby vectors, i.e., {v+aλ1+bλ2a,b[10,10]}\{v+a\lambda_1+b\lambda_2 \mid a,b \in [-10,10]\}. According to tests, about 99% of the time, we can find the target p2p^2, allowing us to factor nn and decrypt the flag.

Note: Since p2<m=n2+69420p^2<m=n^2+69420, p2(modm)p^2\pmod{m} will be a perfect square.

from Crypto.Util.number import *

n = 60116508546664196727889673537793426957208317552814539722187167053623876043252780386821990827068176541032744152377606007460695865230455445490316090376844822501259106343706524194509410859747044301510282354678419831232665073515931614133865254324502750265996406483174791757276522461194898225654233114447953162193
h = 2116009228641574188029563238988754314114810088905380650788213482300513708694199075187203381676605068292187173539467885447061231622295867582666482214703260097506783790268190834638040582281613892929433273567874863354164328733477933865295220796973440457829691340185850634254836394529210411687785425194854790919451644450150262782885556693980725855574463590558188227365115377564767308192896153000524264489227968334038322920900226265971146564689699854863767404695165914924865933228537449955231734113032546481992453187988144741216240595756614621211870621559491396668569557442509308772459599704840575445577974462021437438528
c = 50609945708848823221808804877630237645587351810959339905773651051680570682896518230348173309526813601333731054682678018462412801934056050505173324754946000933742765626167885199640585623420470828969511673056056011846681065748145129805078161435256544226137963588018603162731644544670134305349338886118521580925
e = 65537
m = n ** 2 + 69420
a = pow(3, 1011, m)
b = pow(5, 1011, m)

B = matrix(ZZ, [[m, 0, 0], [a ^ 2, 1, 0], [b ^ 2, 0, 1]])

load("solver.sage")  # https://github.com/rkm0959/Inequality_Solving_with_CVP
lb = [h, 0, 0]
ub = [h, 2 ^ 1024, 2 ^ 1024]
result, applied_weights, fin = solve(B, lb, ub)  # PS: B will be modified inplace
v = vector(
    [x // y for x, y in zip(result, applied_weights)]
)  # closest vector to (lb+ub)/2
print(v)
if not v[1].is_square():
    R = B.LLL()
    l0 = vector([x // y for x, y in zip(R[0], applied_weights)])
    l1 = vector([x // y for x, y in zip(R[1], applied_weights)])
    # enumerate nearby vectors
    for i in range(-10, 10):
        for j in range(-10, 10):
            vv = v + l0 * i + l1 * j
            if vv[1].is_square():
                print("found", i, j)
                p = vv[1].sqrt()
                q = vv[2].sqrt()
                assert p * q == n
                d = inverse_mod(e, (p - 1) * (q - 1))
                m = power_mod(c, d, n)
                print(long_to_bytes(m))
# FLAG{7hI5_i5_4_C0MPL373_r4nD0M_fL49_8LinK}

easyRSA

from Crypto.Util.number import *
import os

from flag import flag

blen = 256

def rsa(p: int, q: int, message: bytes):
	n = p * q
	e = 65537
	
	pad_length = n.bit_length() // 8 - len(message) - 2 # I padded too much
	message += os.urandom(pad_length)
	m = bytes_to_long(message)
	return (n, pow(m, e, n))

def level1(message1: bytes, message2: bytes):
	while True:
		p1 = getPrime(blen) # 512-bit number
		p2 = (p1 - 1) // 2
		if isPrime(p2):
			break

	q1 = getPrime(blen)
	q2 = getPrime(blen)

	return rsa(p1, q1, message1), rsa(p2, q2, message2)

def level2(message1: bytes, message2: bytes):
	while True:
		p1 = getPrime(blen) # 512-bit number
		p2 = (p1 + 1) // 2
		if isPrime(p2):
			break

	q1 = getPrime(blen)
	q2 = getPrime(blen)

	return rsa(p1, q1, message1), rsa(p2, q2, message2)

assert len(flag) == 44
l = len(flag) // 4
m1, m2, m3, m4 = [flag[i * l: i * l + l] for i in range(4)]
c1, c2 = level1(m1, m2)
c3, c4 = level2(m3, m4)

print(f'{c1 = }')
print(f'{c2 = }')
print(f'{c3 = }')
print(f'{c4 = }')

In this challenge, the flag is divided into four parts, each encrypted with a different RSA modulus. The first two are level1, and the last two are level2.

level1

n1=p1q1n2=p2q2p2=p112\begin{aligned} n_1&=p_1 q_1 \\ n_2&=p_2 q_2 \\ p_2&=\frac{p_1-1}{2} \end{aligned}

The solution starts with Fermat's Little Theorem ap11(modp)a^{p-1} \equiv 1 \pmod{p}.

Even after raising to the kk-th power, it holds ak(p1)1(modp)a^{k(p-1)} \equiv 1 \pmod{p}.

Since 2n2=2p2q2=(p11)q22n_2=2p_2q_2=(p_1-1)q_2 is a multiple of p11p_1-1, a2n21(modp1)a^{2n_2} \equiv 1 \pmod{p_1}, so p1=gcd(a2n21,n1)p_1 = \gcd(a^{2n_2}-1,n_1) holds for most aa.

However, n2n_2 is a large number, making it impossible to raise any integer to the 2n22n_2-th power without modn1\bmod{n_1}. So, p1=gcd((a2n2modn1)1,n1)p_1=\gcd((a^{2n_2}\bmod{n_1})-1,n_1).

This works because p1n1p_1|n_1. If it doesn't divide, this method won't work due to the following property:

xy(modab)    xy(moda)x \equiv y \pmod{ab} \implies x \equiv y \pmod{a}

This concept is similar to Pollard's p-1 method. When p1p-1 is smooth enough, multiplying by many small primes gives RR. If (p1)R(p-1)|R, raising to the RR-th power allows factoring. This challenge uses 2n22n_2 to provide a multiple of p1p-1 for easy factoring.

level2

n1=p1q1n2=p2q2p2=p1+12\begin{aligned} n_1&=p_1 q_1 \\ n_2&=p_2 q_2 \\ p_2&=\frac{p_1+1}{2} \end{aligned}

The only difference is that p11p_1-1 becomes p1+1p_1+1. Seeing p1+1p_1+1 reminds us of the Williams p+1 factorization method.

According to this article, for the following Lucas Sequence:

V0=2V1=AVn=AVn1Vn2\begin{aligned} V_0&=2 \\ V_1&=A \\ V_n&=AV_{n-1}-V_{n-2} \end{aligned}

Let D=A24D=A^2-4. If pDp \nmid D, then pgcd(Vm2,n)p \mid \gcd(V_m-2,n), where mm is a multiple of p(Dp)p-\left(\dfrac{D}{p}\right), and (Dp)\left(\dfrac{D}{p}\right) is the Legendre symbol.

If DD is not a quadratic residue modulo pp, then (Dp)=1\left(\dfrac{D}{p}\right)=-1, so mm must be a multiple of p+1p+1 to factor pp. Since 2n2=(p+1)q22n_2=(p+1)q_2 is a multiple of p+1p+1.

D=A24D=A^2-4 can be guessed randomly, with about a 12\frac{1}{2} chance of not being a quadratic residue. The remaining task is to calculate V2n2V_{2n_2}, which can be done modulo n1n_1 to avoid large numbers. Directly calculating VnV_n using the recursive formula takes O(n)\mathcal{O}(n), which is too slow for 2n22n_2.

Noticing that VnV_n is similar to the Fibonacci sequence, we can use matrix exponentiation to calculate the nn-th term in O(logn)\mathcal{O}(\log{n}), then use gcd\gcd to find p1p_1 and decrypt.

from Crypto.Util.number import *

c1 = (
    7125334583032019596749662689476624870348730617129072883601037230619055820557600004017951889099111409301501025414202687828034854486218006466402104817563977,
    4129148081603511890044110486860504513096451540806652331750117742737425842374298304266296558588397968442464774130566675039127757853450139411251072917969330,
)
c2 = (
    2306027148703673165701737115582071466907520373299852535893311105201050695517991356607853174423460976372892149320885781870159564414187611810749699797202279,
    600009760336975773114176145593092065538518609408417314532164466316030691084678880434158290740067228766533979856242387874408357893494155668477420708469922,
)
c3 = (
    9268888642513284390417550869139808492477151321047004950176038322397963262162109301251670712046586685343835018656773326672211744371702420113122754069185607,
    5895040809839234176362470150232751529235260997980339956561417006573937337637985480242398768934387532356482504870280678697915579761101171930654855674459361,
)
c4 = (
    6295574851418783753824035390649259706446806860002184598352000067359229880214075248062579224761621167589221171824503601152550433516077931630632199823153369,
    3120774808318285627147339586638439658076208483982368667695632517147182809570199446305967277379271126932480036132431236155965670234021632431278139355426418,
)
e = 65537


def solve_level1(n1, c1, n2, c2):
    p1 = gcd(power_mod(2, 2 * n2, n1) - 1, n1)
    p2 = (p1 - 1) // 2
    q1 = n1 // p1
    q2 = n2 // p2
    d1 = inverse_mod(e, (p1 - 1) * (q1 - 1))
    d2 = inverse_mod(e, (p2 - 1) * (q2 - 1))
    m1 = power_mod(c1, d1, n1)
    m2 = power_mod(c2, d2, n2)
    return long_to_bytes(m1), long_to_bytes(m2)


def lucas_v(a, n):
    # computes n-th lucas number for v_n=a*v_{n-1}-v_{n-2} with fast matrix power
    v0 = 2
    v1 = a
    R = a.base_ring()
    M = matrix(R, [[a, -1], [1, 0]])
    v = M ^ (n - 1) * vector(R, [v1, v0])
    return v[0]


def solve_level2(n1, c1, n2, c2):
    # based on Williams p+1: http://users.telenet.be/janneli/jan/factorization/williams_p_plus_one.html
    for a in range(2, 10):
        p1 = ZZ(gcd(lucas_v(Mod(a, n1), 2 * n2) - 2, n1))
        if 1 < p1 < n1:
            break
    p2 = (p1 + 1) // 2
    q1 = n1 // p1
    q2 = n2 // p2
    d1 = inverse_mod(e, (p1 - 1) * (q1 - 1))
    d2 = inverse_mod(e, (p2 - 1) * (q2 - 1))
    m1 = power_mod(c1, d1, n1)
    m2 = power_mod(c2, d2, n2)
    return long_to_bytes(m1), long_to_bytes(m2)


m1, m2 = solve_level1(*c1, *c2)
m3, m4 = solve_level2(*c3, *c4)

flag = b"".join([x[:11] for x in [m1, m2, m3, m4]])
print(flag)

# flag{S0rry_1_f0r9ot_T0_cH4nGe_7h3_t35t_fl46}

Extended Explanation

The principle of Williams p+1 is to transfer operations to Zn[i]/(i2T)\mathbb{Z}_n[i]/(i^2-T). This is similar to extending R\mathbb{R} to C\mathbb{C} with an ii satisfying i2+1=0i^2+1=0. If (Tp)=1\left(\dfrac{T}{p}\right)=-1, then i2Ti^2-T is irreducible, reducing to the field Fp2\mathbb{F}_{p^2}.

Since the multiplicative order of Fp2\mathbb{F}_{p^2} is (p21)=(p+1)(p1)(p^2-1)=(p+1)(p-1), there are different subgroups. Deriving, we find:

ip=T(p1)/2i=i(a+bi)p=ap(1+bai)p=a(1+(bai)p)=a(1bai)=(abi)(a+bi)p+1=(abi)(a+bi)=a2(bi)2=a2Tb2\begin{aligned} i^p&=T^{(p-1)/2}i=-i \\ (a+bi)^p&=a^p(1+\frac{b}{a}i)^p=a(1+(\frac{b}{a}i)^p)=a(1-\frac{b}{a}i)=(a-bi) \\ (a+bi)^{p+1}&=(a-bi)(a+bi)=a^2-(bi)^2=a^2-Tb^2 \end{aligned}

When raising to the p+1p+1-th power, the imaginary part disappears, indicating it's a multiple of pp, allowing factoring with gcd.

If (Tp)=1\left(\dfrac{T}{p}\right)=1, then ip=ii^p=i, so:

(a+bi)p=ap(1+bai)p=a(1+(bai)p)=a(1+bai)=(a+bi)(a+bi)p1=a+bia+bi=1\begin{aligned} (a+bi)^p&=a^p(1+\frac{b}{a}i)^p=a(1+(\frac{b}{a}i)^p)=a(1+\frac{b}{a}i)=(a+bi) \\ (a+bi)^{p-1}&=\frac{a+bi}{a+bi}=1 \end{aligned}

The imaginary part still disappears, allowing factoring with gcd. This means Williams p+1 can also handle the p-1 case. However, Pollard p-1 uses the known real part 11 for gcd.

Using sage for a simple verification:

p = random_prime(2 ^ 512)
q = random_prime(2 ^ 512)
n = p * q
P.<x> = Zmod(n)[]


def test(T):
    print("QR", kronecker(T, p))
    R.<i> = P.quotient(x^2 - T)
    z = 1 + i
    print("p-1", gcd((z ^ (p - 1))[1], n))
    print("p+1", gcd((z ^ (p + 1))[1], n))


T = next(x for x in range(2, 100) if kronecker(x, p) == 1)
test(T)
T = next(x for x in range(2, 100) if kronecker(x, p) == -1)
test(T)

The original Williams p+1 uses the Lucas sequence to achieve the same goal, calculating high powers in Zn[i]/(i2T)\mathbb{Z}_n[i]/(i^2-T).

V0=2V1=AVn=AVn1Vn2\begin{aligned} V_0&=2 \\ V_1&=A \\ V_n&=AV_{n-1}-V_{n-2} \end{aligned}

For the characteristic polynomial x2Ax+1x^2-Ax+1 of this sequence, we find two roots and use the initial terms to solve for constants c1=c2=1c_1=c_2=1, giving the general term formula:

Vn=(A+A242)n+(AA242)nV_n=(\frac{A+\sqrt{A^2-4}}{2})^n+(\frac{A-\sqrt{A^2-4}}{2})^n

Considering modp\bmod{p}, we find D=A24D=A^2-4 is the TT, determining whether it's p+1p+1 or p1p-1. For (Tp)=1\left(\dfrac{T}{p}\right)=-1, we derive:

Vp+1=(A+A242)p+1+(AA242)p+1=(A2+12i)p+1+(A212i)p+1=(A2414T)+(A2414T)=2(4+D4D4)=2\begin{aligned} V_{p+1}&=(\frac{A+\sqrt{A^2-4}}{2})^{p+1}+(\frac{A-\sqrt{A^2-4}}{2})^{p+1} \\ &=(\frac{A}{2}+\frac{1}{2}i)^{p+1}+(\frac{A}{2}-\frac{1}{2}i)^{p+1} \\ &=(\frac{A^2}{4}-\frac{1}{4}T)+(\frac{A^2}{4}-\frac{1}{4}T) \\ &=2(\frac{4+D}{4}-\frac{D}{4}) \\ &=2 \end{aligned}

Multiplying by a multiple of p+1p+1 gives the same result, so gcd(Vc(p+1)2,n)\gcd(V_{c(p+1)}-2,n) yields pp.

This article provides a more advanced mathematical explanation, but I couldn't fully understand the latter part...

notPRNG

from secret import flag
import sys

def genModular(x):
    if x <= 2 * 210:
        return random_prime(2^x, False, 2^(x - 1))
    return random_prime(2^210, False, 2^209) * genModular(x - 210)

N, L = 100, 200
M = genModular(int(N * N * 0.07 + N * log(N, 2)))

# generate a random vector in Z_M
a = vector(ZZ, [ZZ.random_element(M) for _ in range(N)])

# generate a random 0/1 matrix
while True:
    X = Matrix(ZZ, L, N)
    for i in range(L):
        for j in range(N):
            X[i, j] = ZZ.random_element(2)
    if X.rank() == N:
        break

# let's see what will this be
b = X * a
for i in range(L):
    b[i] = mod(b[i], M)

print('N =', N)
print('L =', L)
print('M =', M)
print('b =', b)

x = vector(ZZ, int(N))
for j in range(N):
    for i in range(L):
        x[j] = x[j] * 2 + X[i, j]



def bad():
    print("They're not my values!!!")
    sys.exit(0)

def calc(va, vx):
    ret = [0] * L
    for i, vai in enumerate(va):
        for j in range(L):
            bij = (vx[i] >> (L - 1 - j)) & 1
            ret[j] = (ret[j] + vai * bij) % M
    return ret


if __name__ == '__main__':
    print('What are my original values?')
    print('a?')
    aa = list(map(int, input().split()))
    print('x?')
    xx = list(map(int, input().split()))

    if len(aa) != len(a):
        bad()
    if len(xx) != len(x):
        bad()
    if calc(a, x) != calc(aa, xx):
        bad()

    print(flag)

The code is complex, but simply put, it gives a vector bb and a large integer MM, and asks for a matrix XX and a vector aa such that Xab(modM)Xa \equiv b \pmod{M}. Each element of XX can only be 0,10,1, and rank(X)=N\operatorname{rank}(X)=N.

The following part of the source code compresses the 0,10,1 matrix XX:

x = vector(ZZ, int(N))
for j in range(N):
    for i in range(L):
        x[j] = x[j] * 2 + X[i, j]

The calc function uses the compressed matrix vx and vector va to compute Xa(modM)Xa \pmod{M}.

This problem's XX is an L×NL \times N matrix, aa is an N×1N \times 1 vector, and bb is an L×1L \times 1 vector, with L=200,N=100L=200,N=100. If N=L=200N=L=200, a trivial solution is XX as the identity matrix InI_n and a=ba=b. However, the challenge is that N<LN<L.

This problem asks how to decompose an LL-dimensional bb vector into NN basis vectors composed of 0,10,1. Initially, I couldn't think of a solution. However, if aa is known, the problem becomes solving LL subset sum problems. Although this problem is NP-Complete in general, it can be solved in polynomial time using algorithms like LLL in low-density cases. (Solving Low-Density Subset Sum Problems)

Given the generation method of MM, the density is:

d=Nlog2(maxbi)Nlog2M=10.07N+log2N0.0733d=\frac{N}{\log_2(\max{b_i})} \approx \frac{N}{\log_2{M}} = \frac{1}{0.07 N+\log_2{N}} \approx 0.0733

For subset sum problems with density less than 0.94080.9408, LLL can handle them (source), so this problem is related to LLL.

There are two approaches: finding XX first or aa first. I don't know how to find aa first, but fixing aa doesn't guarantee a subset sum solution. Even if a solution exists, solving LL subset sums takes about 20 seconds each on my computer, totaling 20×20020 \times 200 seconds, which would timeout, so this approach is infeasible.

The other approach is finding XX first, then solving for aa. However, not all 0,10,1 matrices have solutions. The goal is to find a method to obtain XX, even if XX' differs from the original XX, as long as it can solve for aa' such that Xab(modM)X'a' \equiv b \pmod{M}.

First, reduce the following Lattice:

B=[Tb01Tb11TbL11TM]B= \begin{bmatrix} Tb_0 & 1 \\ Tb_1 && 1 \\ \vdots &&& \ddots \\ Tb_{L-1} &&&& 1 \\ TM \end{bmatrix}

where the blank parts are 00, and TT is a large constant to keep the first dimension of the reduced basis small. The reduced basis will have multiple short vectors $v=(0,k_0,k_1,...,k_{L-