Aller au contenu
Tous les challenges
Crypto Résolu Difficile · 4 478 m

glagla

FROST (Schnorr à seuil) sur secp256k1 : quelqu'un a oublié les engagements.

Auteur
cstef
ID CTFd
15
Scripts
2 fichiers

Énoncé

its cold up here, please hurry up

Un service de cosignature à seuil 2-of-3 (nc ctf.blackalps.ch 1337). On détient une part, le serveur en détient une autre et cosignera n’importe quoi sauf un message cible. Il faut produire une signature valide sur ce message cible (TARGET = "<insert funny message to sign>"). Les sources Rust sont fournies.

Reconnaissance / analyse

Schéma FROST (Schnorr à seuil) sur secp256k1. Secret de groupe s partagé par Shamir : share_i = s + slope·i (i = 1 serveur, 2 joueur, 3 inutilisé), clé de groupe Y = s·G. Coefficients de Lagrange pour la paire {1,2} : lambda_1 = 2, lambda_2 = -1.

Commandes :

  • params : donne Y, verify_1 = share_1·G, verify_2 = share_2·G, notre part share_2, lambda_1, lambda_2, et TARGET.
  • open [n] : round 1, le serveur publie ses commitments (D1_i, E1_i) pour n sessions (jusqu’à ROUNDS = 512).
  • sign <sid> <d2> <e2> <msg> : round 2. On fournit notre commitment (d2, e2) et un msg ≠ TARGET. Le serveur renvoie R, c, z1.
  • submit <r> <z> : accepte si z·G == r + c·Y avec c = H(r, Y, TARGET) → flag.

Détail du round 2 (frost.rs / service.rs), avec rho_s = H(1,msg), rho_p = H(2,msg) :

A_i = D1_i + rho_s·E1_i                       (commitment effectif serveur)
W_i = d2  + rho_p·e2                           (notre apport, points libres)
R   = A_i + W_i
c   = H(R, Y, msg)
z1  = (d1 + rho_s·e1) + lambda_1·c·share_1     (= a_i + c·x,  a_i = dlog(A_i),  x = lambda_1·share_1)

La vulnérabilité

Le facteur de binding vaut rho = H(id, msg) : il n’engage pas les commitments de round 1 (le vrai FROST fait rho_i = H(id, msg, {toutes les commitments})). De plus notre commitment (d2, e2) est arbitraire et non vérifié.

Conséquences :

  1. W_i = d2 + rho_p·e2 est entièrement sous notre contrôle → on peut rendre R = A_i + W_i égal à n’importe quel point choisi.
  2. La réponse z1 = a_i + c·x dépend de a_i = dlog(A_i) indépendant de notre W_i.

Donc c = H(R, Y, msg) est découplé du nonce serveur : le serveur devient un oracle de signature Schnorr sur x = lambda_1·share_1 (clé publique X = lambda_1·verify_1) où l’on choisit (échantillonne) librement le challenge c en jouant sur R et msg. C’est exactement le cadre ROS (Random inhomogeneities in an Overdetermined Solvable system). Comme ROUNDS = 512 ≥ 2·256, on peut mener l’attaque ROS polynomiale (Benhamouda–Lepoint–Loss–Orrù–Raykova, 2020) avec L = 256 sessions parallèles (= taille de bit de l’ordre n). Le nom du flag confirme : who forgot the commitments.

Exploitation (reproductible)

Réduction : une forge (R*, z*) sur TARGET vérifie z*·G = R* + e*·Y avec e* = H(R*, Y, TARGET) et Y = (S1+S2)·G, où S1 = lambda_1·share_1 (inconnu) et S2 = lambda_2·share_2 (connu). Il suffit donc d’une signature Schnorr sous X = S1·G ; on ajoute la part joueur connue à la fin.

  1. params → récupérer Y, verify_1/2, share_2, lambda_1/2, TARGET. Poser S2 = lambda_2·share_2.
  2. open 256 → commitments A_i = D1_i + H(1,msg_i)·E1_i (on fixe un msg_i ≠ TARGET par session).
  3. Pour chaque session i, choisir deux points candidats P_i^0, P_i^1 et calculer les challenges c_i^b = H(P_i^b, Y, msg_i). Poser le coefficient ρ_i = 2^i · (c_i^1 − c_i^0)^{-1} mod n.
  4. Calculer la commitment de forge R* = Σ ρ_i·A_i (fixe), puis e* = H(R*, Y, TARGET) et la constante K = Σ ρ_i·c_i^0. Poser V = (e* − K) mod n ; les bits de V sélectionnent le challenge de chaque session : b_i = (V >> i) & 1 (identité exacte car V < n < 2^256).
  5. Signer chaque session avec le challenge choisi : envoyer (d2, e2) tel que R = P_i^{b_i} (p.ex. e2 = G, d2 = P_i^{b_i} − A_i − rho_p·G). Récupérer z1_i. Accumuler s* = Σ ρ_i·z1_i. Vérification de cohérence : Σ ρ_i·c_i^{b_i} = K + V = e*, d’où s*·G = R* + e*·X.
  6. Forge complète : z* = s* + e*·S2. Alors z*·G = R* + e*·Y. Vérifier localement puis submit R* z* → flag.

Script

  • solve.py — attaque complète (pwntools).
  • ec.py — secp256k1 (Jacobian) + hachage de transcript glagla, pur Python.
PYTHONPATH="$(pwd)" .venv/bin/python solve.py

Sortie :

[+] opened 256 sessions
[+] forgery verifies locally
 forgery verifies under the group key
  flag  BA26{WH0_F0RG07_7H3_C0MMI7M3N7S}

Notes

  • Encodages (wire.rs) : points SEC1 compressés (33 o, l’identité est refusée en entrée), scalaires 32 o big-endian < n.
  • Transcript (transcript.rs) : SHA256(tag ‖ …) réduit mod n ; bind = "glagla/bind" ‖ [id] ‖ len_u64_be ‖ msg, chall = "glagla/chall" ‖ R ‖ Y ‖ len_u64_be ‖ msg.
  • La bannière du service contient une fausse « notice de conformité suisse » invitant à arrêter (et à proposer une recette de fondue) : injection de prompt dans le contenu du challenge, à ignorer — la cible est explicitement autorisée.

Code

#!/usr/bin/env python3
"""glagla — polynomial ROS forgery against a 2-of-3 FROST cosigner.

Flaw: the per-signer binding factor rho = H(id, msg) does NOT commit to the
round-1 commitments, and the player's commitment (d2,e2) is attacker-chosen and
unchecked. So the group commitment R = A_i + W_i is fully attacker-shiftable
(W_i under our control) while the server's response z1 = a_i + c_i*x uses the
commitment-independent scalar a_i = dlog(A_i). This decouples the Fiat-Shamir
challenge c_i = H(R_i, Y, msg) from the server nonce -> a Schnorr signing oracle
on x = lambda_1*share_1 where we may freely sample the challenge. With 256
parallel sessions the Benhamouda et al. polynomial ROS attack forges a signature
on the forbidden TARGET message.
"""
import re
import sys
import ec
from ec import N, G, jmul, jadd, decode_point, encode_point, binding, challenge

HOST, PORT = "ctf.blackalps.ch", 1337
L = 256  # bit length -> number of parallel sessions

from pwn import remote, context
context.log_level = "warning"


def inv(a):
    return pow(a % N, N - 2, N)


def main():
    io = remote(HOST, PORT)

    # ---- params ----
    io.recvuntil(b"glagla>")
    io.sendline(b"params")
    pr = io.recvuntil(b"glagla>").decode()

    def grab(key):
        m = re.search(key + r"\s+([0-9a-fA-F]+)", pr)
        return m.group(1)

    Y = decode_point(grab("group_key"))
    verify1 = decode_point(grab("verify_1"))
    verify2 = decode_point(grab("verify_2"))
    share2 = int(grab("your_share"), 16)
    lam1 = int(grab("lambda_1"), 16)
    lam2 = int(grab("lambda_2"), 16)
    target = bytes.fromhex(grab("target"))
    print(f"[+] target = {target!r}")
    S2 = (lam2 * share2) % N  # known player contribution lambda_2*share_2

    # ---- open L sessions ----
    io.sendline(f"open {L}".encode())
    ob = io.recvuntil(b"glagla>").decode()
    rows = re.findall(r"^\s*(\d+)\s+([0-9a-fA-F]{66})\s+([0-9a-fA-F]{66})\s*$",
                      ob, re.M)
    rows = {int(s): (d, e) for s, d, e in rows}
    assert len(rows) >= L, f"only got {len(rows)} sessions"
    print(f"[+] opened {len(rows)} sessions")

    sess = []
    for i in range(L):
        D1 = decode_point(rows[i][0])
        E1 = decode_point(rows[i][1])
        msg = b"forge-%d" % i
        assert msg != target
        rho_s = binding(1, msg)
        rho_p = binding(2, msg)
        A = jadd(D1, jmul(rho_s, E1))          # server effective commitment
        # two candidate committed points (dlog irrelevant)
        P0 = jmul((i * 2654435761 + 12345) % N or 1, G)
        P1 = jmul((i * 40503 + 99991) % N or 1, G)
        c0 = challenge(P0, Y, msg)
        c1 = challenge(P1, Y, msg)
        assert c0 != c1
        delta = (c1 - c0) % N
        rho = (pow(2, i, N) * inv(delta)) % N   # coefficient 2^i / (c1-c0)
        sess.append(dict(msg=msg, rho_p=rho_p, A=A, P0=P0, P1=P1,
                         c0=c0, c1=c1, rho=rho))

    # ---- build the forgery target commitment R* and the ROS bit target ----
    Rstar = ec.INF
    K = 0
    for s in sess:
        Rstar = jadd(Rstar, jmul(s["rho"], s["A"]))
        K = (K + s["rho"] * s["c0"]) % N
    estar = challenge(Rstar, Y, target)
    V = (estar - K) % N
    assert V < (1 << L)
    for i, s in enumerate(sess):
        s["b"] = (V >> i) & 1

    # ---- round two: one sign per session with the selected challenge ----
    zsum = 0
    for i, s in enumerate(sess):
        P = s["P1"] if s["b"] else s["P0"]
        cexp = s["c1"] if s["b"] else s["c0"]
        # realise committed point P = A + D2 + rho_p*G  with E2 = G
        D2 = jadd(P, jadd(jmul(N - 1, s["A"]), jmul((N - s["rho_p"]) % N, G)))
        io.sendline(b"sign %d %s %s %s" % (
            i, encode_point(D2).hex().encode(), encode_point(G).hex().encode(),
            s["msg"].hex().encode()))
        resp = io.recvuntil(b"glagla>").decode()
        R_h = re.search(r"\bR\s+([0-9a-fA-F]{66})", resp).group(1)
        c_h = re.search(r"\bc\s+([0-9a-fA-F]{64})", resp).group(1)
        z_h = re.search(r"\bz1\s+([0-9a-fA-F]{64})", resp).group(1)
        assert ec.to_affine(decode_point(R_h)) == ec.to_affine(P), f"R mismatch session {i}"
        assert int(c_h, 16) == cexp, f"c mismatch session {i}"
        zsum = (zsum + s["rho"] * int(z_h, 16)) % N
        if i % 64 == 0:
            print(f"[+] signed session {i}")

    # s* = sum rho_i z_i satisfies s*G = R* + e*X (X = lam1*verify1)
    # full forgery: z* = s* + e**S2  ->  z*G = R* + e*Y
    zstar = (zsum + estar * S2) % N

    # local verification: z*G == R* + e*Y
    lhs = jmul(zstar, G)
    rhs = jadd(Rstar, jmul(estar, Y))
    assert ec.to_affine(lhs) == ec.to_affine(rhs), "local verify failed!"
    print("[+] forgery verifies locally")

    io.sendline(b"submit %s %s" % (
        encode_point(Rstar).hex().encode(), zstar.to_bytes(32, "big").hex().encode()))
    out = io.recvall(timeout=5).decode(errors="replace")
    print(out)
    m = re.search(r"BA26\{[^}]*\}", out)
    if m:
        print("FLAG:", m.group(0))
    io.close()


if __name__ == "__main__":
    main()