LA CTF 2024

Below are writeups for all the cryptography challenges. Big shoutouts to PBR at UCLA for hosting this incredible event.

valentines-day

Starting off with the classical ciphers, we have a vigenere cipher encrypted with a very long key of known length.

Br olzy Jnyetbdrc'g xun, V avrkkr gb sssp km frja sbv kvflsffoi Jnuc Sathrg. Wkmk gytjzyakz mj jsqvcmtoh rc bkd. Canjc kns puadlctus!

L xyw fmoxztu va tai szt, dbiazb yiff mt Zzhbo 1178 gyfyjhuzw vhtkqfy sniu eih vbsel edih tpcvftz, xcie ysnecsmge hbucqtu qt wcorr crzhg-olhm srr gkh gdsjxqh gnxxl rtr guez jewr klkkgak dx uuka nnv hmvwbj gmv glz fvyh, jueg eww oq i wuqglh Z lrigjsss ynch xun esivmpwf: "oof hvrb frtbrq it Kcmlo?"

C ltzihfvxsq ghp abqs qrfzf glvx de HN bnty gocr gr:

Eiaj zek rvocf vnriiu ob Puiza. Xegjy webrvbvrj. Frat s vgxhidm kepldrv gbq phxgv.

Ehlb'w wuhu C ixyzchlr, ilc srez foq e wxzb sdz nrbrb. Eej W und siieesx nd pvvgb zvr pooi. B fox wc nrax v pedgei aex phvqe. Hqdru pc tvvtrv, C zyoxvxsq ghq wyvbg yzgmex KEKN=/ife/lgcyr/qg/ejl:$TNXC, eej hurn mlp qowtswvqn:

wrm ~cuamyh/umlofikjayrvplzcwm.gdg | pzwj
ropgf{qvjal_dfuxaxzbk_gbq_jeci_hdt_nr_hdr_eexij}

Yiqqeefl, cywfylnt zlrv finqvyq sqii sm oncw.

Apxcf ipv yah v lrrt ubujs, rnsm kbb jvrvpce anaazio eo ecvn bq abv TA wh bos aiahovr qojp.

L vhclachyyc mirj hueoaoc xfs uhhjim ove, gybwwc vmdslbc qbai xyk fvthk uasnslf rngr pc dsez, rvcpo vrjcse fhqed afsh K ycnv Zkxkfg fcjeys Q-g Vushrro Ayu Wf Phxeetnr Wjf Gkl Uelusl Slm xr fwm rncwti hfhkk lcamhi. D ary wa gozig wfcwe. Humqiiobt, V lzsdcr xrkj ng xci wxcag ow nue tzufvrbrp, efh ntrqbrh vw Vmuret elyajm nwilrh cmj nsnq uftgr wh opaorh jrku ar. "V wbpw akttp ybx," oy vvfcyl. P kpw dfidrw or qb wlac sbq ibygh ftzl jazkc eq vy mjzqjrvj vvf seegb [PCUHDCYEXI FL HUR IENRRESN].

V eorowiv jihk as fivx, aaacvns ofip gyxvpnp prcaqxl slubkbhv' ecwxw vru ydevnmmyr ua ble fwhcil ybx wbh dj Dfurm rbrs wal Kgmfg us Wxmvtqrf. Bab D gmifx hrrni knog Rgrikr kuv qhbfi jr de hnvl. Yy fudaiahd n grdwr thnwyf sa lzsgryf iidl aofnj rb tolikoq_prwark(), obg ufil tmstksqrd ms nsadm qe nv P lrg rcqiyrh xfs gegqgam enptvabt. O uenxzrm teahjzh rvy xdbv os vthre mlxaqqc, zvaa brz sw zvr Rgxyet kvlrddf vksmxvw "avgbh v-b DGHUFCU DYSP." W ntzcq skieobt ghw dmuf, pxu gx fljykkr ng mx: "Tpcmtawibq kyebcr." C ahws "vk -t xsba" olh utu qflc gnr qvxyyqv ouotymlu.

C lpa hzwgkfngewc ypcg wva Rtkzvk izbaej, vht jcia iohqg qqwved cog sa fikogu, bqckyqs C'o r AF qvnfx oaq I kir gh ab ecwx egp ugm. Fhdwiywy, T aew bql iw Xeuyza'g rpmbyw edb bszg apw zoyrjet. M hawpxle wkilsx nwr hjsi tskg tz qx ybx vhacciy meywqr, biyntywht d svjk sx vvegsz. Oyo xykb V gudnoh kmmgcd nvjyej oaq surcgasg.

"Xcppshi ku al bfymnp." Eroirg hjbfxb. "Mic'cs bebs cx fx tyv. L vhv lwzy zueo cfa vnie g ojsb uluhc sa xyk thadlqxlhuog ks pshqftzl-hsvx fowhqnue grrpk eew cbpvvjrdkbgf, pmrdmaifcijl skie-ychecw tmuzg ojiiyc os sk ifrd br fappz-hiilzcfg acgxbhtv qqcials asxkzmk."

A kuoztzvvj oaq bgkfib whnd glz gfxbre oq lbq cziwyr.

"Lsp qre pr joexrrzba urw lrx kgx yxps. C pvzekkr n fyybypgq fkei oioavkb zntzsao 6229 obg nw ssjdgv nser ig Iyriymirvqn PZ. B uohb fcj xm yhsj cvyx L pvv'l ziez lsp. M ngg zrrktt xcgncct cjy."

Z miyceo egb ziryaq vros yog rlej. "Lraczs?"

"Lhs..." Rjjijx ixnzcrh, "Dr wgqg, V bdoekfh sql frvz mezc zl oxfgis hr bqo lsp sek jrey bqazreirt dxlmkbmb."

"Qphh?" X wrinxrasb.

Ijzlzl dsntrh wetq wa uiy kcegf, mgxymik. "Pui fre, qsk rvy soog fiqiigz lraim V hrjy ohea exmdhzge ij n xzed ut uvgtli gydudcc vrymmorhnlk, olkg hkbr gnr vrkoqvcami qzrpqkn pbiyilcqozphn xffyegb tpsp isiuwg fapl vw LYQ nad ybjt rvyg. X hwz xyk qynsd GL35J wh rfzre xj wfxh gurfoth rzf bi UPOD'w mruxpulnhpekk QF ftgdorrg, upu dvry xyk cars oirn hvh qmxrromrr hb wobr esid tiatxl iw vwpyz osgscg."

"Nlnc'g... Rldm'z qehcfyvfgi..." Z yovq.

"Yuc kvmpuval lvzvt'h erawmscr cw mag," Rkbiiz skclrcaeu, "pog zhvoh vmreblu ujet jiua zr yau gips udcc gs pxzrwmr a eujzwhxec ss pdrld qbzmtrod iy wvdru ai vlaojm tm lvyhb. Hb hcs yqwlzkloaj jlvx knog zegvn?"

"Yf...." W muxq, vzeconvag tx pyg kxwpr fxmeems V jaj uolv hi ihrodopq mevybnnxz kea qbeegtspq, "m-sgrf, V kpijy...?"

Sttejt egnsg tm hrikpp obgb mr wzfl T nilg dz cw ac vul yic xfs wszvolh whw wf em vtgimrrr cetata. "Esgb gy, pah osxkhurr hi pgzf finir hjae zvr joifq."

sfilph: hgwsw://oan.kcrxvx.xsd/x/ipya/oowqcbnu/s2b4md/hc_vddreiwnak_kwwi_rlr_gn35p_wobny/

Plugging the ciphertext into an online bruteforcing tool we find an approximation of the key

naverfonmagiceyouuphevergonnaletyoudownneverlonnaeunaroundanddezertyoynevergonrumakeyougryndvergomnasaygoocqyenevergonnatellalieandhurtyouihopeyouenjoyedthissong

That looks... familiar. Now using CyberChef with the full key and proper spelling, we get

Flag

lactf{known_plaintext_and_were_off_to_the_races}

selamat pagi

Another classical cipher. This one I solved mostly by inspection by accumulating enough substitutions to guess the word, and then correcting any spelling mistakes using Google Translate.

Efe kqkbkx czwkf akfs kdkf qzfskf wzdcjtfk
Ieqku kqk akfs ikxj kck akfs wkak ukikukf :Q
Lzfqztk ukdj kqk qe wefe: bkvim{wzbkdki_ckse_kckukx_ukdj_wjuk_kfkbewew_mtzujzfwe}

For example:

  • Since the flag format was lactf{}, that gives us 5 letters for free.

  • I guessed that the word for flag (bendera) would be in the last line, and there was only one word with the matching number of characters.

  • The end of the flag was beginning to look like the words "frequency analysis", which in Indonesian would be spelt analisis frekuensi.

  • A search through the 100 most common indonesian words for short palindromes gave us a few more letters.

Flag

lactf{selamat_pagi_apakah_kamu_suka_analisis_frekuensi}

very-hot

We have a multiprime RSA problem,

from Crypto.Util.number import getPrime, isPrime, bytes_to_long

# from flag import FLAG

# FLAG = bytes_to_long(FLAG.encode())

p = getPrime(384)
while not isPrime(p + 6) or not isPrime(p + 12):
    p = getPrime(384)
q = p + 6
r = p + 12

n = p * q * r
e = 2**16 + 1
ct = pow(FLAG, e, n)

print(f"n: {n}")
print(f"e: {e}")
print(f"ct: {ct}")

but the prime factors of n are related by a simple equation. In particular p is a root of the polynomial

\[ p(p+6)(p+12) - n \]

which we can solve easily in Sage to reveal the factorisation of n.

#!/usr/bin/env sage
from Crypto.Util.number import *

# Solve polynomial p*(p+6)(p+12) to get p
n = 10565111742779621369865244442986012561396692673454910362609046015925986143478477636135123823568238799221073736640238782018226118947815621060733362956285282617024125831451239252829020159808921127494956720795643829784184023834660903398677823590748068165468077222708643934113813031996923649853965683973247210221430589980477793099978524923475037870799
solve(x * (x + 6) * (x + 12) - n, x)
p = 21942765653871439764422303472543530148312720769660663866142363370143863717044484440248869144329425486818687730842077
q = p + 6
r = p + 12


e = 65537
ct = 9953835612864168958493881125012168733523409382351354854632430461608351532481509658102591265243759698363517384998445400450605072899351246319609602750009384658165461577933077010367041079697256427873608015844538854795998933587082438951814536702595878846142644494615211280580559681850168231137824062612646010487818329823551577905707110039178482377985

phi = (p - 1) * (q - 1) * (r - 1)
d = inverse_mod(e, phi)

print(long_to_bytes(pow(ct, d, n)))

Flag

lactf{th4t_w45_n0t_so_53xY}

hOlyT

We are provided with an RSA problem, and an oracle which, when provided with a quadratic residue \(x\) such that there exists \(x_1, x_2 \in \mathbb{Z}\) with

\[\begin{aligned} x &= x_1^2 \pmod p\\ x &= x_2^2 \pmod q, \end{aligned}\]

will return one of

\[\begin{aligned} ux_1q + vx_2p &\pmod n\\ -ux_1q + vx_2p &\pmod n\\ ux_1q - vx_2p &\pmod n\\ -ux_1q - vx_2p &\pmod n\\ \end{aligned}\]

where \(u, v\) are the Bezout coefficients of \(p\) and \(q\).

from Crypto.Util.number import getPrime, bytes_to_long
import random


def legendre(a, p):
    return pow(a, (p - 1) // 2, p)


def tonelli(n, p):
    q = p - 1
    s = 0
    while q % 2 == 0:
        q //= 2
        s += 1
    if s == 1:
        return pow(n, (p + 1) // 4, p)
    for z in range(2, p):
        if p - 1 == legendre(z, p):
            break
    c = pow(z, q, p)
    r = pow(n, (q + 1) // 2, p)
    t = pow(n, q, p)
    m = s
    t2 = 0
    while (t - 1) % p != 0:
        t2 = (t * t) % p
        for i in range(1, m):
            if (t2 - 1) % p == 0:
                break
            t2 = (t2 * t2) % p
        b = pow(c, 1 << (m - i - 1), p)
        r = (r * b) % p
        c = (b * b) % p
        t = (t * c) % p
        m = i
    return r


def xgcd(a, b):
    if a == 0:
        return 0, 1

    x1, y1 = xgcd(b % a, a)
    x = y1 - (b // a) * x1
    y = x1

    return x, y


def crt(a, b, m, n):
    m1, n1 = xgcd(m, n)
    return (b * m * m1 + a * n * n1) % (m * n)


def advice(x, p, q):
    if legendre(x, p) != 1:
        exit()
    if legendre(x, q) != 1:
        exit()
    x1 = tonelli(x, p) * random.choice([1, -1])
    x2 = tonelli(x, q) * random.choice([1, -1])
    y = crt(x1, x2, p, q)
    return y


def main():
    p = getPrime(1024)
    q = getPrime(1024)
    N = p * q
    e = 65537
    m = bytes_to_long(b"lactf{redacted?}")
    ct = pow(m, e, N)
    print(f"ct = {ct}")
    print(f"N = {N}")
    print(f"e = {e}")
    while 1:
        x = int(input("What do you want to ask? > "))
        ad = advice(x, p, q)
        print(ad)


if __name__ == "__main__":
    main()

The observation here is that \(x = 1\) is a valid input, which reveals to us

\[\begin{aligned} a &= uq - vp \pmod n\\ b &= uq + vp \pmod n \end{aligned}\]

Then since \(a + b = 2uq \pmod n\) shares a common factor with \(n\), we can recover \(q\) as \(\mathrm{gcd}(a + b, n)\).

Flag

lactf{1s_r4bin_g0d?}

prove it!

Here we are working in the field \(\mathbb{F}_p\) for a 1024-bit prime \(p\). We are given the values

\[ g^s, g^{s^2}, g^{s^3},\ldots, g^{s^7}, \\ g^{\alpha s}, g^{\alpha s^2}, g^{\alpha s^3}, \ldots, g^{\alpha s^7} \]

for \(g, s, \alpha \in \mathbb{F}_p\) unknown, as well as the coefficients of a polynomial \(t \in \mathbb{F}_p\left[x\right]\) of degree 7. To obtain the flag, we must find non-trivial elements \(f, h, f_\alpha \in \mathbb{F}_p\) such that \(f^\alpha = f_\alpha\) and \(f = h^{t(s)}\). We are allowed two attempts, and if we fail in the first attempt, then \(t(s)\) is given to us and a new polynomial \(t\) is chosen by the server.

#!/usr/local/bin/python
import random
from Crypto.Util.number import *

flag = "lactf{??????????}"
p = 171687271187362402858253153317226779412519708415758861260173615154794651529095285554559087769129718750696204276854381696836947720354758929262422945910586370154930700427498878225153794722572909742395687687136063410003254320613429926120729809300639276228416026933793038009939497928563523775713932771366072739767

if __name__ == "__main__":
    s = random.getrandbits(128)
    while GCD(s, p - 1) != 1:
        s = random.getrandbits(128)
    alpha = random.getrandbits(40)
    g = redacted
    ss = [pow(g, s**i, p) for i in range(1, 8)]
    alphas = [pow(g, alpha * s**i, p) for i in range(1, 8)]
    print(f"Use these values to evaluate your polynomials on s")
    print(f"Powers of s: {ss}")
    print(f"Powers of alpha*s: {alphas}")
    tries = 0
    while True:
        if tries >= 2:
            print("Fool me once shame on you, fool me twice shame on me")
            break
        print("Can you prove to me you know the polynomial f that im thinking of?")
        target = []
        for i in range(8):
            target.append(random.randrange(p))
        print(f"Coefficients of target polynomial: {target}")
        ts = sum([(pow(s, 7 - i, p) * target[i]) % p for i in range(len(target))]) % p
        f = int(input("give me your evaluation of f(s) > ")) % p
        h = int(input("give me your evaluation of h(s) > ")) % p
        fa = int(input("give me your evaluation of f(alpha * s) > ")) % p
        if f <= 1 or h <= 1 or fa <= 1 or f == p - 1 or h == p - 1 or fa == p - 1:
            print("nope")
            exit()

        if pow(f, alpha, p) != fa:
            print("Failed f_alpha != fa")

        if f != pow(h, ts, p):
            print("Failed h_ts != f")

        if pow(f, alpha, p) != fa or f != pow(h, ts, p):
            print(f"failed! The target was {ts}")
            tries += 1
            continue

        print(f"you made it! here you got {flag}")
        break

To avoid ambiguity, let \(t_1\) and \(t_2\) be the polynomials chosen by the server in the first and secound rounds respectively. Observe that after the first round, we know both the coefficients of \(t_1 \in \mathbb{F}_p\left[x\right]\) and the value of \(t_1(s)\). Hence \(s\) is a root of the polynomial \(t_1 - t_1(s)\). This allows us to recover the secret element \(s\) (if there are multiple roots, then choose the root closest in size to 128-bits as the most likely candidate). Then, since we know both \(s\) , \(g^s\) and \(\phi(p) = p - 1\), we can recover \(g\) by taking

\[ g = (g^s)^{d} \pmod p \]

where \(d = s^{-1} \pmod {p - 1}\). This reveals to us the redacted value \(g = 2\). Similarly by taking

\[ g^\alpha = (g^{\alpha s})^{d} = ((g^\alpha) ^ s) ^ d \pmod p \]

we can determine the value of \(g^\alpha\).

We now have enough information to answer the server's challenge in the second round. Since \(s\) and \(t_2\) are known, then \(t_2(s)\) is known. Put

\[\begin{aligned} f &= g^{t_2(s)} \\ f_\alpha &= (g^{\alpha})^{t_2(s)} = g^{\alpha t_2(s)}\\ h &= g \end{aligned}\]

Then

\[ f^\alpha = (g^{t_2(s)})^\alpha = g^{\alpha t_2(s)} = f_\alpha\\[1em] h^{t_2(s)} = g^{t_2(s)} = f \]

as desired. Below is an implementation of the solution in SageMath.

#!/usr/bin/env python3
import itertools
from pwn import *

p = 171687271187362402858253153317226779412519708415758861260173615154794651529095285554559087769129718750696204276854381696836947720354758929262422945910586370154930700427498878225153794722572909742395687687136063410003254320613429926120729809300639276228416026933793038009939497928563523775713932771366072739767


def find_g(ss, alphas, coeff_1, ts_1):
    K = GF(p)
    x = PolynomialRing(K, "x").gen()
    poly = sum([(x ^ (7 - i) * coeff_1[i]) for i in range(len(coeff_1))]) - K(ts_1)
    root = poly.roots()
    idx = min((abs(len(bin(s_)) - 130), i) for i, (s_, _) in enumerate(root))[1]
    print(root)
    s = root[idx][0]
    print(f"Chose s = {s}")
    d = inverse_mod(Integer(s), phi)
    gs = ss[0]
    g = pow(gs, d, p)  # g = 2 fixed?

    return g, s, d


def evalpoly(base, coeff):
    # Assuming base is ordered lowest powers to highest powers
    # coeff is ordered highest powers to lowest powers
    return prod(pow(base[7 - i], coeff[i], p) for i in range(len(coeff))) % p


def evalpoly_additive(point, coeff):
    return sum([(pow(point, 7 - i, p) * coeff[i]) % p for i in range(len(coeff))]) % p


# First round: Fail deliberately, but keep track of parameters and hints
conn = connect("chall.lac.tf", int(31179))
# conn = process(["python", "server.py"])
print(conn.readline())
ss = conn.readline()
ss = eval(ss[len(b"Powers of s: ") :].decode("ascii"))
alphas = conn.readline()
alphas = eval(alphas[len(b"Powers of alpha*s: ") :].decode("ascii"))
print(conn.readline())
coeff_1 = conn.readline()
coeff_1 = eval(coeff_1[len(b"Coefficients of target polynomial: ") :].decode("ascii"))
conn.sendafter("give me your evaluation of f(s) > ", "2\n")
conn.sendafter("give me your evaluation of h(s) > ", "2\n")
conn.sendafter("give me your evaluation of f(alpha * s) > ", "2\n")
ts_1 = conn.readline()
ts_1 = eval(ts_1[len(b"failed! The target was ") :].decode("ascii"))

# Round 2
# Gather data like in round 1
print(conn.readline())
coeff_2 = conn.readline()
coeff_2 = eval(coeff_2[len(b"Coefficients of target polynomial: ") :].decode("ascii"))

# First solve rsa problem for g
g, s, d = find_g(ss, alphas, coeff_1, ts_1)
g_alpha = pow(alphas[0], d, p)

ss_full = [g] + ss
alphas_full = [g_alpha] + alphas

# Want f = g^ts_2
ts_2 = evalpoly_additive(s, coeff_2)
f = pow(g, ts_2, p)
fa = pow(g_alpha, ts_2, p)
h = g

conn.sendafter("give me your evaluation of f(s) > ", str(f) + "\n")
conn.sendafter("give me your evaluation of h(s) > ", str(h) + "\n")
conn.sendafter("give me your evaluation of f(alpha * s) > ", str(fa) + "\n")
print(conn.recvall())

Flag

lactf{2kp_1s_ov3rr4t3d}

budget bag

Let \(n\) be the number of characters in the flag. In this challenge, we are given \(n\) points on an unknown elliptic curve \(E / \mathbb{F}_p\) of the form

\[ \left[f_1\right]P_1, \left[f_2\right]P_2, \ldots, \left[f_n\right]P_n \]

where the \(P_i\) are random points of \(E(\mathbb{F}_p)\) and the \(f_i\) are the character codes of the flag after transformation by a simple invertible padding scheme. In addition to the \(n\) points, we are also given their sum in \(E(\mathbb{F}_p)\)

\[ s = \sum_{i = 1}^n \left[f_i\right]P_i. \]
import random
from hidden import DollarStore
p = 95773813056613526295315889615423014145567365190638271416026411091272805051097
def legendre(a, p):
    return pow(a, (p - 1) // 2, p)

def tonelli(n, p):
    q = p - 1
    s = 0
    while q % 2 == 0:
        q //= 2
        s += 1
    if s == 1:
        return pow(n, (p + 1) // 4, p)
    for z in range(2, p):
        if p - 1 == legendre(z, p):
            break
    c = pow(z, q, p)
    r = pow(n, (q + 1) // 2, p)
    t = pow(n, q, p)
    m = s
    t2 = 0
    while (t - 1) % p != 0:
        t2 = (t * t) % p
        for i in range(1, m):
            if (t2 - 1) % p == 0:
                break
            t2 = (t2 * t2) % p
        b = pow(c, 1 << (m - i - 1), p)
        r = (r * b) % p
        c = (b * b) % p
        t = (t * c) % p
        m = i
    return r
class EllipticCurve:
    def __init__ (self, a, b, p):
        self.p = p
        self.a = a
        self.b = b
    def getPoint(self, x):
        ys = (x**3 + self.a*x + self.b) % p
        if legendre(ys, self.p) != 1:
            return None
        y = tonelli( ys ,p)
        return CurvePoint(x, y, self)
    
class CurvePoint:
    def __init__(self,x,y, curve):
        self.curve = curve
        self.x = x % curve.p
        self.y = y % curve.p
    def __str__(self):
        return f"({self.x},{self.y})"
    def __eq__(self, other):
        return str(self) == str(other)
    __repr__ = __str__
    def __add__(self, other):
        x1,y1 = self.x, self.y
        x2,y2 = other.x, other.y
        if x2 == 0 and y2 == 1:
            return self
        if x1 == 0 and y1 == 1:
            return other
        if x1 == x2 and y2 == self.curve.p - y1:
            return CurvePoint(0, 1, self.curve)
        if x1 == x2 and y1 == y2:
            lam = ((3*pow(x1, 2, self.curve.p) + self.curve.a) * pow(2* y1, -1, self.curve.p))% self.curve.p
        else:
            lam = ((y2 - y1) * pow(x2 - x1, -1, self.curve.p)) % self.curve.p
        x = (lam**2 - x1 - x2) % self.curve.p
        y = (lam*(x1 -x) - y1) % self.curve.p
        return CurvePoint(x, y, self.curve)


def scalar_mult(x, n, ec) -> CurvePoint:
    Q = x
    R = CurvePoint(0,1 ,ec)
    if n == 0: return R
    if n == 1: return x
    while n > 0:
        if n % 2 == 1:
            R = R + Q
        Q = Q + Q
        n = n // 2
    return R

flag = "lactf{REDACTED}" 

flag = flag.lstrip("lactf{").rstrip("}")

flagarray = [((random.randrange(2**10) >> 8) << 8) + ord(flag[i]) for i in range(len(flag))]

ec = DollarStore()

points = []
while len(points) < len(flagarray):
    x = random.randrange(p)
    point = ec.getPoint(x)
    if point != None:
        points.append(point)

s = scalar_mult(points[0], flagarray[0], ec)
for i in range(1,len(flagarray)):
    s += (scalar_mult(points[i], flagarray[i], ec))

print(f"s= {s}")

print(f"points = {points}")

Since the hidden elliptic curve \(E\) is defined by a Weierstrass equation with unknown coefficients \(a, b\), we can substitute in two of our given points into the equation

\[\begin{aligned} y^2 = x^3 + ax + b \end{aligned}\]

to recover the hidden values \(a\) and \(b\). This reveals to us that \(a = b = 0\), and so our elliptic curve is in fact the singular curve

\[\begin{aligned} y^2 = x^3. \end{aligned}\]

The elliptic curve group for such curves has a particularly simple structure provided the gradient of the tangent line(s) at the singularity are in the field \(\mathbb{F}_p\): they are either isomorphic to the additive group \(\mathbb{F}_p^{+}\) or the multiplicative group \(\mathbb{F}_p^{\times}\) depending on whether the curve has a node or a cusp at the singularity. Silverman (Proposition III.2.5) provides details of a proof and an explicit formula for the isomorphism. In our case, our curve has a cusp at the origin and the gradient of the tangent line is zero. Hence the map

\[\begin{aligned} \phi : E(K) &\longrightarrow K^{+}\\ (x,y) &\longmapsto xy^{-1} \end{aligned}\]

is an isomorphism. Applying \(\phi\) to each \(\left[f_i\right]P_i\) as well as \(s\), we obtain the following linear equation in \(\mathbb{F}_p\)

\[ \phi(s) = \sum_{i = 1}^n f_i \phi(P_i) \]

with \(n\) unknowns. The solution space forms a \(n - 1\) dimensional affine subspace of \(\mathbb{F}_p^n\). We can lift the solution to a lattice in \(\mathbb{Z}^{n}\) by appending arbitrary linear combinations of \(p\mathbf{e}_1,\ldots,p\mathbf{e}_n \in \mathbb{Z}^n\). Our solution will then be a lattice vector with all coordinates lying the range of the padded character codes. Such a vector can be found by a CVP solver provided we know a suitable estimate for the center of the range. For this, we assume the flag format consists of reasonable ASCII characters, and so each of the \(f_i\)'s should lie within the interval \(\left[65, 890\right] \subseteq \mathbb{Z}\).

#!/usr/bin/env python3

# Initial implementation in Sage to reduce apply the isomorphism and reduce the problem to F_p
s = (
    4690331917760414380672348505790486524786128272326163170078478915876334878778,
    77902523131087061897126273610460347147805642819184490444996378236375931739511,
)

p = 95773813056613526295315889615423014145567365190638271416026411091272805051097

points = [
    # large challenge output elided #
]


def find_ec_params(p_1, p_2):
    x_1, y_1 = p_1
    x_2, y_2 = p_2

    a = ((y_1**2 - y_2**2) - (x_1**3 - x_2**3)) * inverse_mod(x_1 - x_2, p)
    a = a % p
    b = (y_1**2 - x_1**3 - a * x_1) % p

    return a, b # a = b = 0

K = GF(p)

def isomorphism(pt):
    x, y = pt
    return (x * inverse_mod(y, p)) % p


weights = list(map(isomorphism, points))
target = isomorphism(s)
# Implemention of the CVP portion in Julia
using AbstractAlgebra
using LinearAlgebra

function solve()
  p = 95773813056613526295315889615423014145567365190638271416026411091272805051097
  K = GF(p)
  weights = BigInt[
    40934699560187760118076789839292640635422518557448839865404745220866778025546,
    78310223071590492194236922321229075543476357564542955520452209526227950970041,
    91705555999958172847918714712488726144644562704604773757857932534858806325518,
    42501728239045823811266018530645187132678419583755394096925398410774167785495,
    46119371924278215331040469610168125127482006988589418572015362580083398169762,
    2330179126243704396521139915466741247281559783872381157453234051670693598729,
    34253065139905257255915277994868632884775435324309315594946460625587903576019,
    68623145750851611402492363714838439447614096997584503429073048348890609090063,
    55215455687459981679753425402241794055048803159690691698625684881196534104984,
    13160214960944791745737571666294687886179342741878106108153828171214300480356,
    93632248451840935013313489637674048557270261830159812386104018347434531096708,
    15750226451420001555465233524738870514754843878573661397197302153086698120116,
    35034732986188570282752403373961714079465830549670098557342305666201533823308,
    22731773695351497338883943431178589221370002456797684486999646027708779697151,
    60817920765222559034343733934804760803601437813912852650747129379361411913898,
    53493968718303524964250571104151904930212396250296291581259283032383539967004,
    70883580398171358365333237193019023493492808520671476249469154781910452853710,
  ]

  target =
    big"23544990289611147830739672041010834898609229946569110903803362822865326025687"

  A = matrix(K, transpose(weights))
  b = matrix(K, reshape([target], 1, 1))
  particular_sol = AbstractAlgebra.solve(A, b)
  nullity, homogenous_sol = nullspace(A)

  d = length(particular_sol)

  # Solution is a subspace of Fp^n. Lift solution to ZZ by appending (e1, e2, ..., e_d) to the basis
  particular_sol = lift.(Matrix(particular_sol))
  homogenous_sol = [
    lift.(Matrix(homogenous_sol)) p*diagm(ones(Int64, d))
  ]

  reduced_basis = open(`fplll`; read = true, write = true) do fplll
    write(fplll, to_fplll(transpose(homogenous_sol))) # tranpose becuase fplll expects row vectors
    reduced_basis = read(fplll, String)
    reduced_basis = "[" * join(split(reduced_basis, "\n")[end-17-1:end])
  end

  # What is our target vector?
  lo = 65
  hi = 122 + 768
  for i = lo:hi
    midpoint = fill(i, d)
    target = to_fplll(reshape(midpoint - particular_sol, :))

    closest_vector = open(`fplll -a cvp`; read = true, write = true) do cvp
      write(cvp, reduced_basis * target)
      closest = from_fplll(read(cvp, String))
    end
    solution = mod.(particular_sol + closest_vector, p)
    @show String(UInt8.(decode(solution)))
  end
end

function decode(v)
  offsets = [0, 256, 512, 768]
  out = []
  for x in v
    push!(out, x - offsets[searchsortedlast(offsets, x)])
  end
  out
end

function to_fplll(matrix::AbstractMatrix)
  ret = "["
  for row in eachrow(matrix)
    ret *= "[" * join(string.(row), " ") * "]"
  end
  ret *= "]"

  ret
end

function to_fplll(v::AbstractVector)
  ret = "[" * join(string.(v), " ") * "]"
  ret
end

function from_fplll(v::AbstractString)
  reshape(permutedims(Meta.eval(Meta.parse(v))), :)
end

Flag

lactf{im_w4y_t00_br0k3!}

pprngc

In this challenge, we have an unknown invertible function \(f: \mathbb{F}_2^{16} \longrightarrow \mathbb{F}_2^{16}\) and a hidden 16-bit seed value \(s\). Our task is to recover the seed \(s\) given the value of \(f(s)\) and the following oracles.

  1. prng-oracle

The prng-oracle chooses a public random 16-bit predicate \(p \in \mathbb{F}_2^{16}\) and produces the value \(f^{17}(s)\) together with 16 values \(S(p) = (s_1, s_2, \ldots, s_{16})\) given by

\[\begin{aligned} s_1 &= \langle f(s), p\rangle\\ s_2 &= \langle f^2(s), p \rangle\\ \vdots\\ s_{16} &= \langle f^{16}(s), p \rangle \end{aligned}\]

where \(\langle \cdot , \cdot \rangle\) denotes the dot product

\[ \langle x, y \rangle = \sum_{i = 1}^n x_iy_i. \]

We are allowed unlimited queries to the prng-oracle.

  1. pprngc-oracle

The pprngc-oracle takes a 16-bit state \(s'\), a \(k\)-bit predicate \(p\) and a \(k\)-bit vector \((t_1, \ldots, t_k)\) such that

\[ \langle f^{-i}(s'), p \rangle = t_i, \quad 1 \leq i \leq k. \]

If valid inputs are provided, the oracle returns the value \(\langle f^{-(k +1)}(s'), p \rangle\). We are allowed to query this oracle at most 16 times.

  1. f-oracle

The f-oracle takes a 16-bit value \(x\) and computes \(f(x)\). We are allowed up to 15 queries to this oracle.

#!/usr/local/bin/python3

import secrets
from super_secret_stuff import flag, f, f_inverse

seed = secrets.randbits(16)
function_uses = 0
oracle_uses = 0
done = False


def compute_output_bit(state, pred):
    return bin((state & pred)).count("1") % 2


def prng(pred):
    cur_state = seed
    outputs = []
    for i in range(16):
        cur_state = f(cur_state)
        outputs.append(compute_output_bit(cur_state, pred))
    cur_state = f(cur_state)
    cur_state_bits = format(cur_state, "b").zfill(16)
    all_bits = ("".join([str(i) for i in outputs]) + cur_state_bits)[::-1]
    return all_bits


def pprngc(pred, stream):
    state = int("".join(stream[:16][::-1]), 2)
    for i in range(16, len(stream)):
        state = f_inverse(state)
        if not compute_output_bit(state, pred) == int(stream[i]):
            return None
    state = f_inverse(state)
    return compute_output_bit(state, pred)


print("All you need to know about f is that it's a function from 16 bits to 16 bits")
print("The output on today's secret seed is:", f(seed))
if __name__ == "__main__":
    while not done:
        choice = input("What can I do for you? 1. get random bits 2. use f 3. predict next bit 4. guess seed ").strip()
        if choice == "1":
            rand_pred = secrets.randbelow(2**16)
            output = prng(rand_pred)
            print(output)
            print("Using predicate", rand_pred)
            print("Ignore the fact that the first 16 bits are always the same (or don't, your choice)")
        elif choice == "2":
            if function_uses == 15:
                print("Nah, you've had enough.")
                continue
            num = input("Gimme a number! ").strip()
            try:
                num = int(num)
                if num < 0 or num >= 2**16:
                    print("Out of range!")
                else:
                    print(f(num))
                    function_uses += 1
            except:
                print("Uh something went wrong.")
        elif choice == "3":
            if oracle_uses == 16:
                print("Nah, you've had enough.")
                continue
            stream = input("Gimme the bitstream you want to predict! ").strip()
            if len(stream) < 16:
                print("Out of range!")
            else:
                pred = input("Oh, can you also give a predicate with that? ").strip()
                try:
                    pred = int(pred)
                    if pred < 0 or pred >= 2**64:
                        print("Out of range!")
                    else:
                        output = pprngc(pred, stream)
                        if output is None:
                            print("Uh lemme get back to you on that...")
                            print("*liquidates assets, purchases fake identity, buys one way ticket to Brazil*")
                            done = True
                        else:
                            print(output)
                            oracle_uses += 1
                except:
                    print("Uh something went wrong.")
        elif choice == "4":
            guess = input("Well, let's see it! ").strip()
            if str(seed) == guess:
                print(flag)
            else:
                print("Nope! Sorry!")
            done = True
        else:
            print("Buh bye!")
            done = True

To solve this challenge, we observe that the outputs of the prng-oracle are valid inputs to the pprngc-oracle. Hence, given a pair \((f^{17}(s), S(p))\) from the prng-oracle, we can pass \((f^{17}(s), p, S(p))\) into the pprngc-oracle to obtain the value \(\langle s, p \rangle\) which tells us some information about the parity of set bits in \(s\). Repeating the above process, we hope to obtain enough information about \(s\) within our allotted budget of 16 pprngc-oracle queries to restrict \(s\) to one of at most 16 possibilities. From there, we can use the f-oracle together with the known value of \(f(s)\) to identity \(s\) from amongst the 16 possibilities.

Depending on which predicates \(p\) are chosen by the prng-oracle, this approach may not always produce enough constraints to achieve the desired condition. In theory, one can query the prng-oracle a large number of times to gather a pool of \(n\) candidate predicates, and then select from those candidates the best set of 16 predicates to pass to the pprngc-oracle. Here, we define "best" to mean a set of predicates \(\{p_1, \ldots, p_{16} \}\) such that each \(p_i\) has low Hamming weight (so that locally the constraint returned by the pprngc-oracle admits a minimal number of solutions), whilst ensuring that the element wise product \(p_1 \cdot p_2 \cdot \ldots \cdot p_{16} = (1, \ldots, 1)\) as vectors in \(\mathbb{Z}^{16}\) (to ensure that globally we have enough information to determine all the bits of \(s\)). This can be modelled as an integer program

\[\begin{aligned} \mathrm{min} \sum_{i = 1}^{n}&\sum_{j = 1}^{16} w_ip_{i,j}\\ &\mathrm{s.t}\\ \sum_{i = 1}^{n} w_ip_{i, j} &\geq 1, \quad 1 \leq j \leq 16\\ w_{i} &\in \{0, 1\} \end{aligned}\]

where \(p_i = (p_{i, 1}, \ldots, p_{i, 16})\) for \(1 \leq i \leq n\).

In practice, we can also just retry a couple of times until we get lucky 😛.

#!/usr/bin/env python3
from pwn import *
import claripy
import cvxpy as cp
import numpy as np
import time


def parse_conn(conn, prefix):
    line = conn.readline()
    print(line)
    line = eval(line[len(prefix) :].decode("ascii"))
    return line


MENU = b"What can I do for you? 1. get random bits 2. use f 3. predict next bit 4. guess seed "


def get_random_bits(conn):
    conn.sendafter(MENU, b"1\n")
    # pprng(rand_pred)
    pprng_rand = conn.readline().strip()
    pred = parse_conn(conn, "Using predicate")
    print(conn.readline())
    return pprng_rand, pred


def predict_next_bit(conn, stream, pred):
    print(conn.sendafter(MENU, b"3\n"))
    print(conn.sendafter("Gimme the bitstream you want to predict! ", stream + b"\n"))
    print(conn.sendafter("Oh, can you also give a predicate with that? ", pred + "\n"))
    mod = parse_conn(conn, "")
    return mod


def use_f(conn, number):
    print(conn.sendafter(MENU, b"2\n"))
    print(conn.sendafter("Gimme a number! ", number + "\n"))
    f_num = parse_conn(conn, "")
    return f_num


def guess_seed(conn, seed):
    print(conn.sendafter(MENU, b"4\n"))
    print(conn.sendafter("Well, let's see it!", str(seed) + "\n"))
    print(conn.recvall())


# https://stackoverflow.com/questions/15540798/count-number-of-ones-in-a-given-integer
def count_ones(n):
    w = 0
    while n:
        w += 1
        n &= n - 1
    return w


def predict_bits(conn, pred, bitstream):
    shortened_bitstream = bitstream  # shorten bitstream by 1 bit to get one less f_inverse. nevermind i can't count
    assert len(shortened_bitstream) == 32
    pred_string = str(pred)
    parity = predict_next_bit(conn, shortened_bitstream, pred_string)

    return pred, parity


def collect_and_solve(conn):
    # One variable for each bit
    ZERO = claripy.BVV(int(0), int(32))
    ONE = claripy.BVV(int(1), int(32))
    TWO = claripy.BVV(int(2), int(32))

    pred_parity_pairs = {}
    s = claripy.Solver()
    bv = [claripy.BVS(f"bit_{i}", 32) for i in range(16)]

    # Binary constraint
    for b in bv:
        s.add(b >= ZERO)
        s.add(b <= ONE)

    while True:
        bitstream, pred = get_random_bits(conn)
        parity = predict_next_bit(conn, bitstream, str(pred))
        pred_parity_pairs[pred] = parity

        # Parity Constraint
        binary_repr = format(pred, "b").zfill(16)  # big endian. 16-bit padded

        hamming_weight = sum(bv[i] for (i, b) in enumerate(binary_repr) if b == "1")  # big endian
        if parity == 1:
            parity_bv = ONE
        else:
            parity_bv = ZERO
        s.add(claripy.SMod(hamming_weight, TWO) == parity_bv)

        if len(pred_parity_pairs) <= 8:  # Don't try to solve yet. very low chance of success
            continue

        solutions = ["".join(str(bit) for bit in sol) for sol in s.batch_eval(bv, 100)]
        if len(solutions) > 10:
            print(f"Found {len(solutions)} solutions using {len(s.constraints)} constraints. More solutions than we can verify.")
        else:
            print(f"Found CSP solutions {solutions}")
            return solutions


def query_f_oracle(conn, f_seed, solutions):
    f_uses = 0
    for solution in solutions:
        sol_num = int(solution, 2)  # TODO: Endianess?

        if sol_num is None:  # ???
            print(f"None sol_num for {solution}")
        if f_uses == 15:  # 15 numbers before me were invalid, so this one must be the solution
            print("15 numbers before me were invalid, so this one must be the solutione")
            return sol_num

        f_num = use_f(conn, str(sol_num))
        f_uses += 1
        if f_num == f_seed:
            return sol_num

    print("Reached bottom??")


# Initial contact
start = time.time()
first_connect = 0
try:
    conn = connect("chall.lac.tf", int(31173))
    print(conn.readline())
    first_connect = time.time()

    # f(seed)
    f_seed = parse_conn(conn, "The output on today's secret seed is:")

    # Do all together
    solutions = collect_and_solve(conn)
    # Query the f-oracle to find the correct solution
    seed = query_f_oracle(conn, f_seed, solutions)
    print(f"Found seed {seed}")
    guess_seed(conn, seed)


except EOFError:
    print("Terminated due to EOF")
    end_time = time.time()
    print(f"{end_time - start} elapsed from connect to end")
    print(f"{first_connect - start} elapsed from first contact to end")

Flag

lactf{we_love_blum-micali_generators_h1MNZuJSFjlAEwc1}

shuffler

Fix \(L = 617\). In this challenge, we have a long secret message \(M\) of length \(L\) and a hidden permutation \(\sigma \in S_L\). We are provided the value of \(\sigma(M)\), and our goal is to invert the permutation so we can read the plaintext \(M\).

#!/usr/local/bin/python3

from secrets import randbits
import math
from base64 import b64encode

MESSAGE_LENGTH = 617


class LCG:
    def __init__(self, a, c, m, seed):
        self.a = a
        self.c = c
        self.m = m
        self.state = seed

    def next(self):
        self.state = (self.a * self.state + self.c) % self.m
        return self.state


def generate_random_quad():
    return randbits(64), randbits(64), randbits(64), randbits(64)


initial_iters = randbits(16)


def encrypt_msg(msg, params):
    global initial_iters
    a, c, m, seed = params
    L = LCG(a, c, m, seed)
    for i in range(initial_iters):
        L.next()
    l = len(msg)
    permutation = []
    chosen_nums = set()
    while len(permutation) < l:
        pos = L.next() % l
        if pos not in chosen_nums:
            permutation.append(pos)
            chosen_nums.add(pos)
    output = "".join([msg[i] for i in permutation])
    return output


# period is necessary
secret = b64encode(open("secret_message.txt", "rb").read().strip()).decode() + "."
length = len(secret)
assert length == MESSAGE_LENGTH

a, c, m, seed = params = generate_random_quad()
enc_secret = encrypt_msg(secret, params)

while True:
    choice = input("What do you want to do?\n1: Shuffle a message.\n2: Get the encrypted secret.\n3: Quit.\n> ")
    if choice == "1":
        message = input("Ok. What do you have to say?\n")
        if len(message) >= length:
            print("I ain't reading allat.\n")
        elif math.gcd(len(message), m) != 1:
            print("Are you trying to hack me?\n")
        else:
            print(f"Here you go: {encrypt_msg(message,params)}\n")
    elif choice == "2":
        print(f"Here you go: {enc_secret}\n")
    elif choice == "3":
        print("bye bye")
        exit(0)
    else:
        print("Bad choice.\n")

The permutation in question is derived from the outputs of a linear congruential generator, and has the form

\[ \sigma = \begin{pmatrix} 1 &2 &3 &\ldots &L\\ x_1 &x_2 &x_3 &\ldots &x_L\\ \end{pmatrix} \]

where the \(x_i\) are the remainders after division by \(L\) of a fixed element \(Z = (z_1, z_2, \ldots, z_L) \in (\mathbb{Z}/m\mathbb{Z})^L\) with \(z_i = az_{i-1}+c \pmod m\).

In addition to \(\sigma(M)\), we are also given to an oracle which effectively reveals to us the first \(n\) coordinates modulo \(n\) of the element \(Z\) for \(n < L\) coprime to \(m\). This oracle can be accessed by passing a message consisting of \(n\) unique characters (e.g all UTF-8 codepoints from 0x47 to 0x47+n) to the server's encrypt_msg function.

If \(L = a_1 a_2 \cdots a_k\) was composite, then we could use the oracle to find the remainders of the LCG outputs modulo \(a_1,a_2, \ldots,a_k\) and lift these to relations modulo \(L\) using the Chinese remainder theorem. Unfortunately \(L\) is prime, so we need to modify the idea slightly.

Instead of attempting to lift to relations in \(L\), we will attempt to lift to relations modulo \(N\) for some \(N > m\) which will reveal to us a sample of raw outputs from the LCG. We can do this by glueing together oracle outputs for primes \(p_1, p_2, \ldots,p_k\) such that \(p_i < L\) and \(\prod p_i > m\). This reveals to us the first \(\mathrm{min}\{p_1, p_2, \ldots, p_k\}\) outputs of the LCG. We can then use these outputs to crack the LCG parameters via standard techniques, and from there determine all coordinates of the vector \(Z\).

using Primes
using Base64

const MENU = "What do you want to do?\n1: Shuffle a message.\n2: Get the encrypted secret.\n3: Quit.\n> "
function sendlineafter(conn, prefix, line)
  print(readuntil(conn, prefix))
  write(conn, string(line) * "\n")
end

function get_enc_msg(conn)
  sendlineafter(conn, MENU, "2")
  enc_msg = readline(conn)
  enc_msg = only(match(r"Here you go: (.*)", enc_msg))

  enc_msg
end

function chosen_pt(conn, msg)
  sendlineafter(conn, MENU, "1")
  sendlineafter(conn, "Ok. What do you have to say?", msg)
  print(readline(conn))
  s = readline(conn)
  if occursin("Are you trying to", s)
    return nothing
  else
    return only(match(r"Here you go: (.*)", s))
  end
end

function unique_alphabet_string(len)
  msg = collect('A':('A'+len-1))

  return join(msg)
end

function get_sample(msg_len)
  pt = unique_alphabet_string(msg_len)
  ct = chosen_pt(conn, pt)

  pt = collect(pt)
  ct = collect(ct)

  perm = sortperm(pt; lt = (a, b) -> findfirst(==(a), ct) < findfirst(==(b), ct))
  @assert pt[perm] == ct

  return perm
end

function crt(res1::BigInt, res2::BigInt, mod1::BigInt, mod2::BigInt)
  d, u, v = gcdx(mod1, mod2)
  @assert d == 1
  @assert u * mod1 + v * mod2 == 1
  x = res1 * v * mod2 + res2 * u * mod1
  m = mod1 * mod2

  @assert mod(x, mod1) == mod(res1, mod1)
  @assert mod(x, mod2) == mod(res2, mod2)

  return x, m
end

function reverse_lcg(outputs)
  t = [outputs[i] - outputs[i-1] for i = 2:length(outputs)]
  u = [t[i+2] * t[i] - t[i+1]^2 for i = 1:length(t)-2]

  m = nothing
  for i = 1:10
    m = gcd(u[1:i]...)
    @show m
    if m < big(2)^64
      break
    end
  end
  # Now we know m, then a = mod(t*modinv(outputs[1] - outputs[2], m), m)
  a = mod((outputs[3] - outputs[2]) * invmod(outputs[2] - outputs[1], m), m)
  c = mod(outputs[2] - a * outputs[1], m)

  return a, c, m
end

function decrypt_msg(enc_msg, a, c, m, initial)
  enc_msg = collect(enc_msg)
  perm = [mod(initial, length(enc_msg))]
  state = initial
  while length(perm) < length(enc_msg)
    state = mod(a * state + c, m)

    if !in(mod(state, length(enc_msg)), perm)
      push!(perm, mod(state, length(enc_msg)))
    end
  end
  perm = perm .+ 1              # switch to julia 1-index
  @assert isperm(perm)
  inv = invperm(perm)

  pt = join(enc_msg[inv])
  print(String(Base64.base64decode(pt)))
end

const MESSAGE_LENGTH = 617
function solve()
  conn = connect("chall.lac.tf", 31172)
  enc_msg = get_enc_msg(conn)


  msg_len = modulus = big(613)
  perm = get_sample(msg_len)
  residues = big.(copy(perm))
  moduli = fill(msg_len, msg_len)

  msg_len -= big(1)
  while modulus <= big(2)^64
    if !isprime(msg_len)
      msg_len -= big(1)
      continue
    end
    perm = big.(get_sample(msg_len))


    for (i, (res1, mod1, res2, mod2)) in
        enumerate(zip(residues, moduli, perm, fill(msg_len, msg_len)))
      residues[i], moduli[i] = crt(res1, res2, mod1, mod2)
    end

    modulus = lcm(modulus, msg_len)
    @show modulus
    msg_len -= 1
  end
  residues = mod.(residues, moduli)
  residues = residues[moduli.==modulus]

  a, c, m = reverse_lcg(residues)

  decrypt_msg(enc_msg, a, c, m, residues[1])
end

Flag

lactf{th3_h0us3_c0uld_n3v3r_l0se_r1ght}

any%-ssg

This challenge takes place inside a graphical Java application which mimics Minecraft, so for brevity I will not reproduce the challenge code here, and instead dive straight into the mathematics.

Let \(m \in \mathbb{Z}\) and \(a, c \in \mathbb{Z}/m\mathbb{Z}\) be given. Let \(x_0 \in \mathbb{Z}/m\mathbb{Z}\) be fixed, and consider 16 consecutive outputs \(x_1, x_2, \ldots, x_{16}\) from the linear congruential generator

\[ x \mapsto ax + c \pmod m, \]

with initial value \(x = x_0\). Then we have the following relations in \(\mathbb{Z}/m\mathbb{Z}\),

\[\begin{aligned} x_1 &= ax_0 + c \pmod m\\ x_2 &= ax_1 + c \pmod m\\ &\vdots\\ x_{16} &= ax_{15} + c \pmod m,\\ \end{aligned}\]

or equivalently,

\[\begin{aligned} x_1 &= ax_0 + c \pmod m\\ x_2 &= a^2x_0 + ac + c \pmod m\\ &\vdots\\ x_{16} &= a^{16}x_{0} + c\sum_{i = 0}^{15}a^i \pmod m.\\ \end{aligned}\]

Let \(\mathbf{x} = (x_1, x_2, \ldots, x_{16})\), \(\mathbf{v} = (a, a^2, \ldots, a^{16})\) and \(\mathbf{p} = (p_1, \ldots, p_{16})\) where

\[ p_j = c\sum_{i = 0}^{j - 1}a^i, \quad 1 \leq j \leq 16. \]

Then we have \(\mathbf{x} = \mathbf{p} + x_0\mathbf{v}\). Fix an integer \(0 < L < m\). To obtain the flag for this challenge, we need to find a value of \(x_0\) for which each \(x_i\) is congruent modulo \(m\) to \(b_i\) for some \(b_i \in (L, m) \subseteq \mathbb{Z}\).

The solution proceeds much the same as the solution for budget-bag, albeit with vector spaces swapped out for modules since \(m\) is composite. The possible values of \(\mathbf{x}\) forms a 1-dimensional affine submodule of \((\mathbb{Z}/m\mathbb{Z})^{16}\). We can lift to a lattice in \(\mathbb{Z}^{16}\) by appending arbitrary linear combinations of \(m\mathbf{e}_1,\ldots,m\mathbf{e}_{16} \in \mathbb{Z}^{16}\). Under this lifting, the problem reduces to finding a lattice vector with all coordinates lying in the interval \((L, m)\), and this can solved using a CVP solver such as fplll.

using LinearAlgebra

function to_fplll(matrix::AbstractMatrix)
  ret = "["
  for row in eachrow(matrix)
    ret *= "[" * join(string.(row), " ") * "]"
  end
  ret *= "]"

  ret
end

function to_fplll(v::AbstractVector)
  ret = "[" * join(string.(v), " ") * "]"
  ret
end

function from_fplll(v::AbstractString)
  reshape(permutedims(Meta.eval(Meta.parse(v))), :)
end

function invert_lcg(x, a, c, m)
  mod((x - c) * invmod(a, m), m)
end

const m = big(1) << 52
const c = big(4164880461924199)
const a = big(2760624790958533)
const B = big(4053239664633446)

function solve()
  # want an initial value x0 such that
  # x_1 = a*x_0 + c mod m > B
  # x_2 = a*x_1 + c mod m
  #    = a^2x_0 + a*c + c mod m > B
  # x_3 = a^3x_0 + a^2*c + ac + c mod m > B
  #
  # and in general
  # x_k = a^k x_0 + c*\sum_{i = 0}^{k - 1}a^i mod m
  #
  # Note: in rhs, the c*\sum_{i = 0}^{k - 1}a^i mod m term is constant
  # and the lefthand side is x_0 * (a, a^2, a^3, a^4, .., a^n)
  n = 16
  particular_sol = [mod(c * sum(mod(a^i, m) for i = 0:(k-1)), m) for k = 1:n]
  homogenous_sol = [mod(big(a)^i, m) for i = 1:n]
  @assert length(particular_sol) == length(homogenous_sol)

  # Lift to Z by appending combinations of (e_1, e_2, ... e_n)
  homogenous_sol = [
    homogenous_sol m*diagm(ones(BigInt, n))
  ]

  # LLL-reduce lattice bassis
  reduced_basis = open(`fplll`; read = true, write = true) do fplll
    write(fplll, to_fplll(transpose(homogenous_sol))) # tranpose becuase fplll expects row vectors
    reduced_basis = read(fplll, String)
    reduced_basis = "[" * join(split(reduced_basis, "\n")[2:end]) # first row is all zeros, remove it
  end

  midpoint = fill(fld(B + m, 2), n)
  target = to_fplll(reshape(midpoint - particular_sol, :))

  closest_vector = open(`fplll -a cvp`; read = true, write = true) do cvp
    write(cvp, reduced_basis * target)
    closest = from_fplll(read(cvp, String))
  end

  solution = mod.(particular_sol + closest_vector, m)


  @assert all(solution[i] == mod(a * solution[i-1] + c, m) for i = 2:length(solution))

  x_1 = first(solution)
  x_0 = invert_lcg(x_1, a, c, m)
  for _ = 1:26
    x_0 = invert_lcg(x_0, a, c, m)
  end

  rng_i = 3473400794307473
  seed = xor(x_0, rng_i)
  @show seed
  seed
end

Flag

lactf{c0ngr4ts_0n_th3_w0rld_r3c0rd}
Last modified: April 14, 2024. Website built with Franklin.jl and the Julia programming language.