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/
**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
- The OR-proof challenge
c = H(ctx, Y0, Y1)leaves out the commitmentst0, t1, so it's fixed before you commit. - With
cknown up front, pickc0, s0, s1freely, setc1 = c - c0, and solveti = g^si * Yi^(-ci)for each commitment. - Both branches simulate with no witness, the deposition verifies for the forbidden docket, and the clerk admits it.