Vishvamitra: The Broken Invocation

By Team Cryptonite · 2 min read

About the CTF: Pinaka CTF

Exploit a flaw in a Fiat-Shamir non-interactive ZK grant-proof protocol. Forge a valid proof for the forbidden Brahmastra using only public parameters and a leaked lesser-boon grant. Validator:…

Vishvamitra: The Broken Invocation

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


Challenge

> Exploit a flaw in a Fiat-Shamir non-interactive ZK grant-proof protocol. Forge a valid proof for the forbidden Brahmastra using only public parameters and a leaked lesser-boon grant.

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

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

It's a Chaum-Pedersen equal-discrete-log proof made non-interactive with Fiat-Shamir. A boon tuple (A, B) is valid when there's a single w with A = g^w and B = h^w mod p. A proof (t1, t2, s) is accepted for (A, B) iff:


g^s == t1 * A^c  (mod p)      and      h^s == t2 * B^c  (mod p)

with c the Fiat-Shamir challenge. We have a leaked proof for the lesser boon bala-atibala, and we need to forge a valid proof for the forbidden brahmastra tuple with no witness.

The two public facts

Two things in the params set this up.

First, the elevation relation. Each supreme tuple is just the lesser one shifted by a public offset d, the same d on both sides:


brahmastra.A = bala.A * g^d  (mod p)
brahmastra.B = bala.B * h^d  (mod p)

I checked this against the actual numbers and it holds.

Second, and this is the bug, the Fiat-Shamir challenge is hashed from the commitments (t1, t2) alone:


def fiat_shamir_challenge(t1, t2, q):
    m = hashlib.sha256(b"PINAKA-A03-FS")
    m.update(b"|t1|"); m.update(str(t1).encode())
    m.update(b"|t2|"); m.update(str(t2).encode())
    return int(m.hexdigest(), 16) % q

The statement (A, B) is not in the hash. So the challenge isn't bound to which weapon you're proving. That's the unfastened syllable the README keeps hinting at.

The forge

Keep the leaked proof's t1 and t2. Because the challenge only depends on those, the brahmastra proof gets the exact same c. The leaked proof satisfies g^s = t1 A0^c. I want a proof for A1 = A0 g^d:


t1 * A1^c = t1 * (A0 * g^d)^c = t1 * A0^c * g^(d*c) = g^s * g^(d*c) = g^(s + d*c)

So s' = s + dc makes g^s' == t1 A1^c. The B side works out identically with the same d and c, which is exactly why the offset had to be the same on both components. One new scalar and the whole thing verifies.

pinaka_invocation.py is public arithmetic with no secrets, so I used its challenge and verifier directly. I ran its self-check first, then forged:


import pinaka_invocation as C

P = C.load_params("params.json")
I = C.load_instance("instance.json")
q, d = P["q"], P["elevation_offset_d"]
lg = I["leaked_grant"]["proof"]
bra = I["weapons"]["brahmastra"]["boon_tuple"]

c = C.fiat_shamir_challenge(lg["t1"], lg["t2"], q)
forge = {"t1": lg["t1"], "t2": lg["t2"], "s": (lg["s"] + d * c) % q}
print("verifies:", C.verify(P, bra["A"], bra["B"], forge))

python3 forge.py
leaked grant for 'bala-atibala' verifies: True
verifies: True

So the forged proof passes both equations for the brahmastra tuple, with no witness anywhere.

Submitting


curl -X POST "https://pinaka-a03-cyber-xo-x.vercel.app/armory/invoke" \
  -H "Content-Type: application/json" \
  -d @body.json

where body.json is {"weapon": "brahmastra", "proof": {"t1": ..., "t2": ..., "s": <s + d*c>}}. The armory grants the Brahmastra and returns the flag:


PINAKA{vishvamitra_bala_atibala_one_syllable_unbound}

Summary

  1. The Fiat-Shamir challenge c is hashed from (t1, t2) only, not the statement (A, B). So the same c applies to any tuple reusing those commitments.
  2. The forbidden tuple is the leaked one elevated by a public offset d: A1 = A0g^d, B1 = B0h^d.
  3. Keep the leaked (t1, t2), set s' = s + d*c, and the proof verifies for brahmastra. Submit it to get the flag.

Read this post on CyberHx →