Geekcon CTF 2024 Writeup

 

tl;dr: Writeups for Geekcon CTF 2024 including challenges ZkMaze and all “real” crypto challenges. I ended up in 10th place and really appreciated the challenges. The topics related to the crypto challenges are HNP, RSA and bilinear map.

ZkMaze

Challenge Info: I made a maze, I think no one can reach the target…Right? flag format flag{.*}. Attachment

After auditing the codes of circom circuit, we can find extremely unsafe operations in CheckInMaze template:

template checkInMaze(n) {
    signal input pos_x;
    signal input pos_y;
    signal output out;
    signal tmp_x;
    signal tmp_y;
    tmp_x <-- pos_x >= 0 && pos_x < n;
    tmp_x * (tmp_x - 1) === 0;    
    tmp_y <-- pos_y >= 0 && pos_y < n;
    tmp_y * (tmp_y - 1) === 0;
    out <== tmp_x * tmp_y;
}

<-- is used and the values of tmp_x and tmp_y are hence not constrained in the final r1cs of proof. It means we can assign arbitrary values to tmp_x and tmp_y and generate a new proof for the modified circuit which can still be verified by the verifier of the original circuit.

Therefore, we fix tmp_x = 1 and tmp_y=1 and in this case CheckInMaze always settles!

template checkInMaze(n) {
    signal input pos_x;
    signal input pos_y;
    signal output out;
    signal tmp_x;
    signal tmp_y;
    tmp_x <-- 1;
    tmp_x * (tmp_x - 1) === 0;    
    tmp_y <-- 1;
    tmp_y * (tmp_y - 1) === 0;
    out <== tmp_x * tmp_y;
}

The target maze (# represents for barrier and S represents for road):

S#S##
S##SS
S###S
#S##S
#S##S

Now we can run out of the $5 \times 5$ maze and reach to our target point from outside!

  R#S##
  R##SS
 RR###S
 R#S##S
 R#S##R
 RRRRRR

Our solution will be input.json:

{
    "goal": "24",
    "maze": [
        ["0", "1", "0", "1", "1"],
        ["0", "1", "1", "0", "0"],
        ["0", "1", "1", "1", "0"],
        ["1", "0", "1", "1", "0"],
        ["1", "0", "1", "1", "0"]
    ],
    "answer": ["3", "3", "0", "3", "3", "3", "1", "1", "1", "1", "1", "2", "6", "6", "6"]
}

Use the modified circuit to generate a valid witness witness.wtns, and then generate a proof:

$ snarkjs groth16 prove circuit1.zkey witness.wtns proof.json public.json

BabyPairing

Challenge Info: I believe a larger space is good for security. I am testing my PKE instance with a short paragraph. The flag is of the format flag{a_sentence_replacing_all_spaces_with_underlines}. Attachment

The public curve parameters are:

p = 74952228632990620596507331669961128827748980750844890766694540917154772000787
a = 7527668573755289684436690520541820188297794210531835381305219764577170135028
b = 23620438740221081546022174030845858657033205037887915215836562897142269481377
F_p = GF(p)
F_p2 = GF(p^2)
a,b = F_p2(a),F_p2(b)
E = EllipticCurve(F_p2,[a,b])
g = E.random_element()
sk = F_p.random_element()
pk = g*sk

We are working on curve $E: y = x^3 + ax + b$ defined in $\mathbb{F}_{p^2}$ and the embedding degree of curve $E$ is 1, making it pairing-friendly! Denote the generator and public key as $G$ and $PK = [sk]G$ respectively, the encryption of a given point $P \in E$​ is as follows:

\[\textsf{ENC}_r(P) = ([r]G, [r]PK + P) = (C_1, C_2)\]

where $r$ is chosen randomly.

One can verifies $P = C_2 - [sk]C_1 = P$ quickly by substituting $PK$ with $[sk]G$. Back to our challenge, we are given encryption of file test_passage.txt. Note that for the same character c, it’s encoded to a fixed point $P(c)$ and then encrypted to $\textsf{ENC}_r(P(c))$.

Can we distinguish ciphertext from the same plaintext? That is to say, given two ciphertexts of a fixed point $P$​ :

\[\textsf{ENC}_{r_1}(P) = ([r_1]G, [r_1]PK + P) = (C_{11}, C_{12}) \\ \textsf{ENC}_{r_2}(P) = ([r_2]G, [r_2]PK + P) = (C_{21}, C_{22})\]

Can we distinguish it from ($P_1 \ne P_2$​):

\[\textsf{ENC}_{r_1}(P_1) = ([r_1]G, [r_1]PK + P_1) = (C_{11}^\prime, C_{12}^\prime) \\ \textsf{ENC}_{r_2}(P_2) = ([r_2]G, [r_2]PK + P_2) = (C_{21}^\prime, C_{22}^\prime)\]

The answer is yes by the bilinear map or pairing (more frequently used in pairing-friendly cryptography and zero-knowledge proof). Denote $e$ as the pairing $(E, E) \mapsto \mathbb{F}_{p^2}$:

\[e([a]G, [b]G) = e(G, G)^{ab}\]

Given $C_{11}, C_{12}, C_{21}, C_{22}$​ :

\[\begin{aligned} C_{22} - C_{12} &= [r_2 - r_1]PK\\ &=[sk(r_2-r_1)]G\\ C_{21} - C_{11} &= [r_2 - r_1]G \end{aligned}\]

Construct pairing:

\[\begin{aligned} e(C_{22} - C_{12}, G) & = e([sk(r_2-r_1)]G, G)\\ & = e(G, G)^{sk(r_2-r_1)}\\ e(C_{21} - C_{11}, PK) & = e([(r_2-r_1)]G, [sk]G) \\ & = e(G, G)^{sk(r_2-r_1)} \end{aligned}\]

This derives $e(C_{22} - C_{12}, G) = e(C_{21} - C_{11}, PK)$. It is an effective ciphertext distinguisher which we can use to reduce the original decryption problem in $E$​ to the decryption problem of ascii substitution cipher! We can decrypt most of the characters through frequency attack.

Finally we can get ciphertext like:

abcdefghijkibldmfdndodfjbfpamqdkaorshndfsbatatodmjkhgcdcuobdkdmvtjmrbkmkabavtbfawdnmkavmfnmkhgibgdjxrkydvhcdsdzlksdzlkydvhcdcufatkchfawkAdvhcdrdmokmfjkhufjdBmjdkqmkCekyrdmvtchcdfabkqodBbhekrdagidzabfpibldmkfhsgimldbfxsbfadokahDcsdmodyhofsdpohEsdidmofEdihndsdkegFdosdGhrmfjsdmpdmkEdGheofdratohepwibgdheoqdokHdvabndkBtmfpdheoqobhDbaIdkCtbgamfjheoefjdDkamfjbfpjdzqdfksdvhcdbfauatdshoijmkyimflvmfnmkdCmfjidxndmkydmeabgeiJrvhcqidKcmkadoqbdvdkabcdbCatdmoabkaLtmakwmqdkeCmiJgimpMrheNtmndNghefjOatdNybibfdmoOqmbobfpNahPyodmlNjQtNmkCecqabhfR

Put it in quipqiup and solves for the flag.

Exploit
import os
from Crypto.Util.number import long_to_bytes,bytes_to_long
p = 74952228632990620596507331669961128827748980750844890766694540917154772000787
a = 7527668573755289684436690520541820188297794210531835381305219764577170135028
b = 23620438740221081546022174030845858657033205037887915215836562897142269481377
F_p = GF(p)
F_p2 = GF(p^2)
a,b = F_p2(a),F_p2(b)
E=EllipticCurve(F_p2,[a,b])
# g=E.random_element()
# sk=F_p.random_element()
# pk=g*sk


def load_element(x1,x2):
    is_positive=(x1<p)
    x = F_p2([x1,x2])
    y2 = x^3+a*x+b
    if y2^((p^2-1)//2)==F_p2(-1):
        return None
    else :
        tmp = y2.square_root()
        #print((int(tmp)>(p-1)//2),is_positive)
        if (int(tmp[0])>(p-1)//2) ^^ is_positive : return E(x,tmp)
        else: return E(x,-tmp)

def compress_element(P):
    x,y=P.xy()
    return (int(x[0]),int(x[1])) if int(y[0])<=(p-1)//2 else (int(x[0])+p,int(x[1]))

def int_to32(x,l=17):
    b32dict="0123456789abcdefghijklmnopqrstuv"
    ret = ""
    while x>0:
        ret = b32dict[x%32]+ret
        x=x//32
    return "0"*max(0,l-len(ret))+ret

def enc_element(P):
    r = F_p.random_element()
    c2 = g*r
    c1 = pk*r+P
    ce1,ce2 = compress_element(c1),compress_element(c2)
    return "%065x%065x%065x%065x\n"%(*compress_element(c1),*compress_element(c2))
    #return int_to32(ce1[0])+int_to32(ce1[1])+int_to32(ce2[0])+int_to32(ce2[1])

def enc_str(s):
    enc_map,ret = {},""
    for c in s :
        if c in enc_map:
            cF_p2=enc_map[c]
        else :
            prefix = os.urandom(29)+bytes(c,encoding='ascii')
            x1 = int(F_p.random_element())
            for i in range(256):
                cF_p2 = load_element(x1,bytes_to_long(prefix+bytes([i])))
                if cF_p2!=None:
                    enc_map[c]=cF_p2
                    break
        ret = ret+enc_element(cF_p2)
    return ret

def dec_element(ct):
    c11,c12,c21,c22=[ct[i*65:i*65+65] for i in range(4)]
    C1,C2=load_element(int("0x"+c11,16),int("0x"+c12,16)),load_element(int("0x"+c21,16),int("0x"+c22,16))
    P=C1-C2*sk
    return (compress_element(P)[1]&0xffff)>>8

def pairing(g1, g2, n=None):
    if n is None:
        n = g1.order()
    return g1.weil_pairing(g2, n)

def compare_ct_equal(c1, c2, G, pk, n):
    c11,c12,c21,c22 = [c1[i*65:i*65+65] for i in range(4)]
    ct1 = load_element(int("0x"+c11,16),int("0x"+c12,16)),load_element(int("0x"+c21,16),int("0x"+c22,16))
    
    c11,c12,c21,c22 = [c2[i*65:i*65+65] for i in range(4)]
    ct2 = load_element(int("0x"+c11,16),int("0x"+c12,16)),load_element(int("0x"+c21,16),int("0x"+c22,16))
    
    r1G = ct1[1]
    r1xG_P = ct1[0]
    r2G = ct2[1]
    r2xG_P = ct2[0]
    T1 = pairing(r1xG_P - r2xG_P, G, n)
    T2 = pairing(r1G - r2G, pk, n)
    return T1 == T2

# print("g = load_element(*"+str(compress_element(g))+")")
# print("pk = load_element(*"+str(compress_element(pk))+")")
g = load_element(*(29278822809335293856257839032454656006652898948724335358857767737708161772420, 4396426956433009559948787995869502944693089612343852188342374458334039665950))
pk = load_element(*(148673571405127568767045322546948264768210305253661849398897382818523458361167, 23902769016595610010920651447268476259469689719718232568266731055385481225779))
# embedding degree is 1
assert (p**2 - 1) % g.order() == 0, "embedding degree is not 1"

print(f"[+] {pairing(g, pk, p+1) = }")


enc = open("./ciphertext").readlines()
enc_map = {}
Ls = []
for line in enc:
    Ls.append(line)

table = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' + ' '
cnt = 0
while len(Ls) > 0:
    print(f"[+] left {len(Ls)}")
    ct0 = Ls[0]
    Ls.remove(ct0)
    enc_map[ct0] = table[cnt]
    for ct in Ls:
        check = compare_ct_equal(ct0, ct, g, pk, p+1)
        if check:
            enc_map[ct] = table[cnt]
            Ls.remove(ct)
    cnt += 1
    
msg = [enc_map[ch] for ch in enc]
s = ''.join(msg)
print(f"[+] {s = }")

HNP

Challenge Info: I am suffering from Herniated Nucleus Pulposus, please help me! Attachment.

It’s a mixture of HNP-SUM and EHNP. A HNP solver is enough for this challenge with low probability. Therefore, I use an EHNP solver.

Let’s focus on the second HNP-SUM example, given fixed $X^\prime$, $Y_1^\prime, \cdots, Y_8^\prime \le 2^{256}$ and $N \approx 2^{2048}$:

\[Z_i = Y_i^\prime X^{\prime} \mod N\]

We have multiple chunk leaks of $Z_i$ including the 341 least significant bits. This is exactly the HNP-SUM problem described in The Hidden Number Problem with Small Unknown Multipliers: Cryptanalyzing MEGA in Six Queries and Other Applications and we can solve for $Y_1^\prime, Y_2^\prime, \cdots, Y_8^\prime$ using lattice. Since we have 8 HNP equations and each equation leaks 341 least significant bits of $Z_i$, intuitively we can solve for the full $X^{\prime}$ using a classic HNP-solver.

However, if we only use the 341 least significant bits, this does not work since $Y_i^\prime$ is small. Thinking of which part of $X^{\prime}$ affects the 341 least significant bits of $Z_i$? Let’s split $X^{\prime}$ to 3 parts:

\[X^\prime = \underbrace{X[0:256] * 2^{2048 - 256}}_{A_1} + \underbrace{X[256: -341] * 2^{341}}_{A_2} + \underbrace{X[-341:]}_{A_3}\]

Then:

\[Y_i^\prime X^{\prime} = A_1Y_i^\prime + A_2Y_i^\prime + A_3Y_i^\prime \mod N\]

Notice that $A_2Y_i^\prime \le N$ and $A_2 Y_i^\prime \equiv 0 \mod 2^{341}$, $A_2$ is almost independent of the 341 least significant bits of $Y_i X^{\prime}$. I guess this is the reason that we cannot recover the full $X^\prime$. Luckily, we can still recover most bits of $A_1, A_3$ mentioned above i.e. 256 most significant bits and 256 least significant bits of $X^\prime$. This is enough to recover the original $X$​​ by constructing an EHNP lattice of :

\[X^{\prime}_i = Y_i X\mod N, \quad i \in[1..8]\]

Actually, my solver only needs the 341 lsb leaks for solving the whole challenge which is obliviously unintended. After recovering the 256-bit $Y_i^\prime$ from HNP-SUM, we can recover both the 256 least significant bits and 256 most significant bits of $X^\prime$ since $Y_i^\prime$ is too small compared to modulo. Using these leaks of 8 samples to construct an EHNP lattice, we can solve for the hidden $X$ with high probability. (HNP solver also works but with low probability).

Exploit
from Crypto.Util.number import *
from random import *
from pwn import remote, process, context, log
from pwnlib.util.iters import mbruteforce
from string import ascii_letters, ascii_lowercase, digits
from hashlib import sha256
from sage.all import matrix, ZZ, QQ, lcm, load, Matrix, vector, inverse_mod, gcd

def eliminate_x_lsb(A0_inv, A1, B0, B1, mod, leak_bits):
    # mask = 2**leak_bits
    # A0 x = B0 + b0 * mask
    # A1 x = B1 + b1 * mask
    # A1(B0 + b0 * mask) = A0(B1 + b1 * mask)
    # A1 * A0^{-1} * (B0 + b0 * mask) = B1 + b1 * mask
    # (A1 * A0^{-1} * B0 - B1) + (A1 * A0^{-1} * mask) b0 =  mask * b1
    # (A1 * A0^{-1} * B0 - B1) * mask^{-1} + (A1 * A0^{-1}) b0 =  b1
    # C + D * b0 = b1
    # A0_inv = ZZ(inverse_mod(A0, mod))
    assert mod % 2 == 1, "even prime is not supported"
    mask = 2 ** leak_bits
    mask_inv = ZZ(inverse_mod(mask, mod))
    C = (A1 * A0_inv * B0 - B1) * mask_inv % mod
    D = A1 * A0_inv
    return C , D

# in this improved solver, the mod must be odd (prime)
def solve_hnp_lsb_improved_odd(As, Bs, mod, leak_bits, unknown_bits=None, bkz = None):
    # build a (n + 1)(n + 1) lattice where x is NOT in the shortest vector
    # using recentering method
    assert len(As) == len(Bs), "bad input"
    # assert all(b < 2**leak_bits for b in Bs), "leak info not valid"
    n = len(As)
    mod_bits = ZZ(mod).nbits()
    if unknown_bits is None:
        unknown_bits = mod_bits - leak_bits
    # keep array state not modified out of the function
    As = As[:]
    Bs = Bs[:]
        
    for Ai,Bi in zip(As, Bs):
        if gcd(Ai, mod) == 1:
            As.remove(Ai)
            Bs.remove(Bi)
            break
    assert gcd(Ai, mod) == 1, "no invertable element"
    A0, B0 = Ai, Bi
    A0_inv = ZZ(inverse_mod(A0, mod))
    
    Cs, Ds = [], []
    recentering_bound = 2**(unknown_bits - 1)
    for Ai,Bi in zip(As, Bs):
        C, D = eliminate_x_lsb(A0_inv, Ai, B0, Bi, mod, leak_bits)
        # C + D * b0 = bi where b0, bi in [0, 2 * recentering_bound]
        # C + D * (bi - recentering_bound) + D * recentering_bound - recentering_bound = b0 - recentering_bound
        # E = C + D * recentering_bound - recentering_bound
        # F = D
        # E + D * bi' = b0' where b0', bi' in [- recentering_bound , recentering_bound]
        E = (C + D * recentering_bound - recentering_bound) % mod
        F = D
        Cs.append(E)
        Ds.append(F)
        
    M = matrix(ZZ, n + 1, n + 1)
    M[0] = Ds + [1, 0]
    M[1] = Cs + [0, recentering_bound]    
    for i in range(n -1):
        M[i + 2, i] = mod
    
    if bkz != None:
        print(f"[*] Starting BKZ reduction with dimensions = {M.dimensions()} and  block_size = {bkz}")
        L = M.BKZ(block_size = bkz)
    else:
        print(f"[*] Starting LLL reduction with dimensions = {M.dimensions()}")
        L = M.LLL()
        
    xs = []
    for row in L:
        row_bits = [abs(ZZ(num)).nbits() for num in row[:-1]]
        if all( 1 <= rbit < unknown_bits for rbit in row_bits):
            m = row[-1]/recentering_bound
            if m not in [1,-1]:
                # print(m , row_bits)
                continue
            b0 = ZZ(row[-2])*m + recentering_bound
            x = ((B0 + (b0 << leak_bits)) * A0_inv) % mod
            # print(f"Secret candidate {x = }")
            # check
            # leaks = [(a*x % mod) % 2**leak_bits for a in As]
            xs.append(x)
            # if leaks == Bs:
            #     xs.append(x)
            #     print(f"[+] Yep! Secret Recovered (leaks checked): {x = }")
    return xs

def Babai_CVP(M, target):
    M = Matrix(QQ, M).LLL()
    target = vector(QQ, target)
    G = M.gram_schmidt()[0]
    diff = target
    for i in reversed(range(M.nrows())):
        diff -=  M[i] * ((diff * G[i]) / (G[i] * G[i])).round()
    return target - diff

delta = QQ(1/(10^8))
def EHNP(xbar, N, pis, nus, alphas, rhos, mus, betas):
    assert len(pis) == len(nus)
    assert len(alphas) == len(rhos) == len(mus) == len(betas)
    assert all(len(rho) == len(mu) for rho, mu in zip(rhos, mus))
    m = len(pis)
    d = len(alphas)
    L = sum(len(rho) for rho in rhos)
    D = d + m + L
    # print(f"{D = }, {d = }, {m = }, {L = }")
    B = [[0 for _ in range(D)] for _ in range(D)] # +1 for CVP
    # N * I_d
    for i in range(d):
        B[i][i] = N
    # A
    for j in range(m):
        for i in range(d):
            B[d + j][i] = alphas[i]*2^pis[j]
    # X
    for i in range(m):
        B[i + d][i + d] = delta / (2^nus[i])
    # rhos
    c = 0
    for i in range(d):
        for j, rho in enumerate(rhos[i]):
            B[d + m + c][i] = rho
            c += 1
    # K
    c = 0 # quick and dirty way to not have to do math
    for mu_arr in mus:
        for mu in mu_arr:
            B[d + m + c][d + m + c] = delta / (2^mu)
            c += 1

    kappa = (2^(D / 4) * (m + L)^(1/2) + 1) / 2
    # print((delta * kappa).n())
    assert 0 < delta * kappa < 1
    v = [(beta - alpha * xbar) % N for beta, alpha in zip(betas, alphas)] + [delta / 2 for _ in range(L + m)]
    W = Babai_CVP(B, v)
    xs = [(W[d + j] * 2^(nus[j])) / delta for j in range(m)]
    return ZZ(xbar + sum(xs[j]*2^(pis[j]) for j in range(m)))



MASK1 = 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
MASK2 = 0x1fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00000000000000000000000000000000000000000000000000000000000000000000000000000000000007ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc0000000000000000000000000000000000000000000000000000000000000000000000000000000000001fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff

def gen_challenge(A, n):
    N = getPrime(A)
    X = getRandomRange(1, N)
    Y = [getRandomRange(1, N) for _ in range(n)]
    Z = [X * Y[_] % N for _ in range(n)]
    Z_s = []
    for _ in range(n):
        X_ = Z[_]
        Y_ = [(Y[_] >> (__ * B)) & MASK1 for __ in range(n)]
        Z_ = [(X_ * Y_[__] % N) & MASK2 for __ in range(n)]
        Z_s.append(Z_)
    return N, X, Y, Z, Z_s

def solve_pow(io:remote):
    # print('sha256(XXXX + {}) == {}'.format(proof[4: ], digest))
    # print('Give me XXXX:')
    io.recvuntil(b"sha256(XXXX + ")
    suffix = io.recvuntil(b") == ", drop=True)
    target_digest = io.recvuntil(b"\n", drop=True).decode()
    log.info(f"suffix: {suffix}, target_digest: {target_digest}")
    sol = mbruteforce(lambda x: sha256(x.encode() + suffix).hexdigest() == target_digest, ascii_letters + digits, 4)
    log.info(f"solved pow: {sol}")
    io.sendlineafter(b"Give me XXXX:\n", sol.encode())
    return True

def get_oracle(local=True):
    if local:
        io = process(["python3", "task.py"])
    else:
        # nc chall.geekctf.geekcon.top 40555
        io = remote("chall.geekctf.geekcon.top", 40555)
    solve_pow(io)
    return io

def get_challenge_sample(io:remote, n=8):
    N = int(io.recvline().decode().strip())
    Zs = []
    for i in range(n):
        Zs.append(eval(io.recvline().decode().strip()))
    return N, Zs

def hnp_sum_solver(a, q, E, T):
    n = len(a)
    M = matrix(ZZ, n + 1, n + 1)
    for i in range(n):
        M[i,n] = a[i]
        M[i, i] = 2*E
    M[n, n] = q
    B = M.LLL()
    
    new_E = max(E, T)
    sub_lattice = B[:(n-1),:n] / (2*E) * (2*new_E)
    sub_lattice[:, 0] /= (2 * new_E)
    t0 = None
    t0s = []
    ts = []
    for i in range(1, n):
        sub_lattice[:,i] /= (2 * new_E)
        sub_lattice = sub_lattice.LLL()
        ti, t0_alt = sub_lattice[0,0], -sub_lattice[0,i]
        if t0_alt < 0:
            t0_alt, ti = -t0_alt, -ti
        t0s.append(t0_alt)
        ts.append(ti)
        sub_lattice[:,i] *= (2 * new_E)
    t0 = lcm(t0s)
    assert t0 < T
    rts = [ZZ(t0)]
    for ti, _t0 in zip(ts, t0s):
        rts.append(ZZ(ti * (t0 // _t0)))
    return rts

def solve_challenge(N, Z_s):
    n = 8
    A = 2048
    B = A // n

    T = ZZ(2 ** 256)
    leak_bit = 341
    E = ZZ(2 ** (A - leak_bit))
    q = ZZ(N)

    recovered_Y = []
    X_leaks = []
    X_leak_bit = 256
    for ts in Z_s:
        a = [ZZ(t % 2**leak_bit) for t in ts]
        a_ = [ZZ(t % 2**leak_bit) * inverse(2**leak_bit, N) % N for t in ts]
        ys = hnp_sum_solver(a_, q, E, T)
        rY = ZZ(ys, base=2**256)
        ys_ = [ZZ(y) for y in ys]
        sol = solve_hnp_lsb_improved_odd(ys_, a, N, leak_bit)[0]
        x_msb = (sol>>(2048 - X_leak_bit)) << (2048 - X_leak_bit)
        x_lsb = sol % 2**X_leak_bit
        X_leaks.append(x_msb + x_lsb)
        recovered_Y.append(rY)  
    
    d = n
    xbar = ZZ(0)
    p = ZZ(N)
    Pi = [ZZ(0)]
    Nu = [ZZ(2048)]
    Alpha = [ZZ(y) for y in recovered_Y]
    rho = [ZZ(2**X_leak_bit)]
    mu = [ZZ(2048 - X_leak_bit * 2)]
    RHO =[rho[:] for i in range(d)]
    MU =[mu[:] for i in range(d)]
    Beta = X_leaks[:]
    unknown_bits = 2048 - X_leak_bit * 2
    rX = solve_hnp_lsb_improved_odd(recovered_Y, X_leaks, N, X_leak_bit, unknown_bits)
    log.info(f"Recovered X from hnp: {rX}")
    rX =  EHNP(xbar, p, Pi, Nu, Alpha, RHO, MU, Beta)
    return rX

while True:
    io = get_oracle(local=False)
    for i in range(3):
        N, Zs = get_challenge_sample(io)
        log.info(f"Challenge {i}")
        try:
            sol = solve_challenge(N, Zs)
            log.info(f"solution: {sol}")
            io.sendlineafter(b'X:', str(sol).encode())
            response = io.recvline().decode().strip()
            log.info(f"response: {response}")
            if response != 'Good!':
                io.close()
                break
            else:
                print(f"[*] Challenge {i} solved")
                if i == 2:
                    print(io.recvline())
                    io.close()
                    exit()
        except Exception as e:
            # log.error(f"error: {e}")
            io.close()
            break
        
# flag{HNP-SUM_i$_4_pi3ce_0f_c@ke_f0r_u}

SpARse

Challenge Info: You stole Alice’s RSA private key, but it is sparse, can you recover the whole key? flag is md5sum privkey.pem | awk '{print "flag{"$1"}"}'. Attachment

We are given a corrupted RSA private key file in pem file format. We can extract as much as possible information from the perm file. One may be confused why a 2048 bit n has to be encoded as 257 byte long which is one byte more than expected and sometimes not. This is due to the symbol bit to recognize positive and negative numbers encoded in ASN.1 DER. Due to this reason, we may need to try all possible combinations.

After decoding all the known bits, we have approximately 29.81% leaks of $p,q,d,d_p,d_q$​ :

$ python exp.py
[+] 0.275390625% leak of d
[+] 0.291015625% leak of p
[+] 0.298828125% leak of q
[+] 0.37890625% leak of dp
[+] 0.26953125% leak of dq
[+] total leak : 0.2981770833333333%
[+] Using default e value 65537

This sample should be solved by the conclusion of paper : Reconstructing RSA Private Keys from Random Key Bits. However, the solver from the paper does not work since the leak bits are not evenly distributed. When searching for up to 860th bit of private key, the pruning space powers since there are not enough leak bits! Notice that when 860 bits of $p$ are precisely recovered (meaning only one candidate in queue), we can run the coppersmith algorithm to factor $n$​ and recover the whole private key pem file.

You can refer to my repository RSA-PEM-Reconstructor for detailed exploit and other related challenges. If you like it, please give me a star!

Real or Not

Challenge Info: Are these pictures real or not? Attachment

I use the api from sight engine which is accurate. It answers about 3.5s for one image and is not enough for the timer set in the server. Therefore, I used a cache-and-hit strategy to solve the challenge (guessing there are not so many images in the server). Every time we receive an image, we calculate its hash, get an answer from the api and then store a (hash, answer) pair in a dictionary. After several tries, most of the images hit the cache dictionary and we can obtain the flag.

Exploit
from pwn import remote, process, context, log
from pwnlib.util.iters import mbruteforce
from string import ascii_letters, ascii_lowercase, digits
from hashlib import sha256
# this example uses requests
import requests
import json
import base64
import os
import time
import zlib

def crc32(data:bytes) -> str:
    return hex(zlib.crc32(data) & 0xffffffff)[2:].zfill(8)

api_user = "**********"
api_secret = "*****************"

params = {
  'models': 'genai',
  'api_user': f'{api_user}',
  'api_secret': f'{api_secret}'
}

if not os.path.exists("./table.json"):
    table = {}
else:
    with open("table.json", "r") as fio:
        table = json.load(fio)

def check_image(img_path):
    files = {'media': open(img_path, 'rb')}
    r = requests.post('https://api.sightengine.com/1.0/check.json', files=files, data=params)
    output = json.loads(r.text)
    assert "status" in output, f"{output = }"
    assert output["status"] == "success", f"{output = }"
    prob = output["type"]["ai_generated"]
    log.info(f"ai_generated {prob = }")
    return "N" if prob > 0.5 else "Y"

# context.log_level = "DEBUG"

def solve_pow(io:remote):
    io.recvuntil(b" SHA256(solution + '")
    challenge = io.recvuntil(b"') must start with '", drop=True)
    assert len(challenge) == 16, f"{challenge = }"
    prefix = io.recvuntil(b"\n", drop=True).decode()[:-2]
    log.info(f"{challenge  = }, {prefix = }")
    diff = len(prefix)
    log.info(f"{diff = }")
    sol = mbruteforce(lambda x: sha256(x.encode() + challenge).hexdigest().startswith(prefix), ascii_letters + digits, diff + 2)
    log.info(f"solved pow: {sol}")
    io.sendlineafter(b"Enter PoW solution: ", sol.encode())
    return True

def read_image(io:remote):
    log.info(io.recvuntil(b"Is this picture real or not (Y/N)? \n").decode().strip())
    return io.recvline().decode().strip()

# nc -X connect -x instance.chall.geekctf.geekcon.top:18081 4bj3p4t84p4xwf98 1
# nc -X connect -x instance.chall.geekctf.geekcon.top:18081 7v6cweq6tp8wwmt2 1
# nc -X connect -x instance.chall.geekctf.geekcon.top:18081 fx2cec9xkk3qvk33 1
io = process(["nc", "-X", "connect", "-x", "instance.chall.geekctf.geekcon.top:18081", "fx2cec9xkk3qvk33", "1"])
assert solve_pow(io), "pow failed"
log.info(io.recvline().decode().strip())

st = time.time()
sol = ""
cached_hit = 0
for i in range(20):
    b64_dat = read_image(io)
    raw_data_bytes = base64.b64decode(b64_dat)
    fingerprint = crc32(raw_data_bytes)
    if  fingerprint in table:
        ans = table[crc32(base64.b64decode(b64_dat))]
        log.info(f"found {ans = } with cached data")
        sol += ans
        cached_hit += 1
        continue
    
    with open(f"imgs{i}.png", "wb") as fio:
        fio.write(base64.b64decode(b64_dat))
    ans = check_image(f"imgs{i}.png")
    sol += ans
    table[fingerprint] = ans
    et = time.time()
    # remove the file
    os.remove(f"imgs{i}.png")

log.info(f"cached hit: {cached_hit} / {20}")
log.info(f"round {i + 1} total time: {et - st}")

# save the table
with open("./table.json", "w") as fio:
    json.dump(table, fio)

io.sendlineafter(b"Enter your answers for all 20 rounds (Y/N): ", sol.encode())
log.info(io.recvline().decode().strip())
io.close()

Real or Not Revenge

The same as Real or Not