Janaka's Court: The False Witness

By Team Cryptonite · 2 min read

About the CTF: Pinaka CTF

Forge a 1-of-2 Schnorr OR-proof deposition for a forbidden docket. The Fiat-Shamir challenge omits the prover's commitments, so both OR branches can be simulated. Validator: https://pinaka-m02-cyber-xo-x.vercel.app/

Janaka's Court: The False Witness

**Pinaka CTF 2026** · Cryptography · Team Cryptonite · All 44 Pinaka write-ups


Challenge

> Forge a 1-of-2 Schnorr OR-proof deposition for a forbidden docket. The Fiat-Shamir challenge omits the prover's commitments, so both OR branches can be simulated.

> Validator: https://pinaka-m02-cyber-xo-x.vercel.app/

We're given params.json, instance.json, pinaka_testimony.py and a README.md.

It's a 1-of-2 Schnorr OR-proof (a "deposition"). To testify for a docket with identity pair (Y0, Y1), you prove you know the discrete log of Y0 OR Y1, without saying which. We need to get the court to admit a deposition for the forbidden false-witness docket, whose two identities are nullified seals nobody holds a key to.

The protocol

A deposition is (t0, t1, c0, c1, s0, s1), and verify accepts it when:


c0 + c1 == c  (mod q)
g^s0 == t0 * Y0^c0  (mod p)
g^s1 == t1 * Y1^c1  (mod p)

The whole security of an OR-proof rests on how c is computed. In an honest one, the challenge binds the commitments t0 and t1, which forces the prover to actually know one of the two witnesses: you can simulate a branch only after c is fixed, and c isn't fixed until you commit.

The bug

The default binding is bindingmode = 0, which calls h_bind:


def _h_bind(ctx, Y0, Y1, q):
    m = hashlib.sha256(b"PINAKA-M02-FS")
    for lbl, v in (("ctx", ctx), ("Y0", Y0), ("Y1", Y1)):
        m.update(b"|"); m.update(lbl.encode()); m.update(b"|"); m.update(str(v).encode())
    return int(m.hexdigest(), 16) % q

c is hashed from ctx, Y0 and Y1 only. The commitments t0 and t1 aren't in it. So c is completely determined before you pick any commitment, which is exactly the thing the README means by "a question settled before the witness has said a single word".

(The module also ships hbindfull, which does fold in t0, t1, and verifystrict uses it. That would be the correct version. But the deployed verify uses mode 0.)

Once c is fixed up front, you can simulate both branches with the standard Schnorr simulator and no witness at all.

The forge

Compute c = H(ctx, Y0, Y1) first. Then for each branch, pick ci and si freely and solve for the commitment t_i that makes the equation hold:


t_i = g^s_i * Y_i^(-c_i)  (mod p)   =>   g^s_i == t_i * Y_i^c_i

The only constraint left is c0 + c1 == c, so pick c0 at random and set c1 = c - c0. Both verification equations hold by construction. pinaka_testimony.py is public arithmetic with no secrets, so I used its challenge and verifier directly:


import pinaka_testimony as T
import secrets, json

P = T.load_params(); I = T.load_instance()
p, g, q, ctx = P["p"], P["g"], P["q"], P["ctx"]
Y0, Y1 = (int(I["dockets"]["false-witness"]["identities"][k]) for k in ("Y0", "Y1"))

c = T._select_binding(P, ctx, Y0, Y1, 1, 1, q)   # mode 0 ignores the commitments
c0 = secrets.randbelow(q); c1 = (c - c0) % q
s0 = secrets.randbelow(q); s1 = secrets.randbelow(q)
t0 = pow(g, s0, p) * pow(Y0, -c0 % q, p) % p
t1 = pow(g, s1, p) * pow(Y1, -c1 % q, p) % p

dep = {"t0": t0, "t1": t1, "c0": c0, "c1": c1, "s0": s0, "s1": s1}
print("verify:", T.verify(P, Y0, Y1, dep))
json.dump({"docket": "false-witness", "deposition": dep}, open("dep.json", "w"))

Passing t0 = t1 = 1 into the challenge call is fine, because mode 0 ignores those arguments anyway.


python3 pinaka_testimony.py
specimen deposition for 'enrolled-nobles' verifies: True

python3 forge.py
verify: True

The forged deposition passes both equations for the forbidden identity pair, with no seal exponent anywhere.

Submitting


curl -sS -X POST https://pinaka-m02-cyber-xo-x.vercel.app/court/depose \
  -H "Content-Type: application/json" -d @dep.json
{"docket":"false-witness","flag":"PINAKA{janaka_false_witness_the_question_asked_before_the_answer}","proclamation":"The court records a witness where none could stand.","result":"admitted"}

Flag


PINAKA{janaka_false_witness_the_question_asked_before_the_answer}

Summary

  1. The OR-proof challenge c = H(ctx, Y0, Y1) leaves out the commitments t0, t1, so it's fixed before you commit.
  2. With c known up front, pick c0, s0, s1 freely, set c1 = c - c0, and solve ti = g^si * Yi^(-ci) for each commitment.
  3. Both branches simulate with no witness, the deposition verifies for the forbidden docket, and the clerk admits it.

Read this post on CyberHx →