Minting Money from Nothing

How I found and responsibly disclosed a critical soundness bug in Octra's zero-knowledge proof verifier, where forged encrypted balance proofs could be used to mint counterfeit public OCT through the decrypt path on the L1 chain.

The issue was reported to Octra and patched within hours. Huge thanks to @octralex and @lambda0xE at Octra, and to Kaushik Swaminathan (@proofofk) at Zellic, for the fast response, coordination, and cooperation throughout the disclosure process.

Illustration of an OCT money printer

What is Octra?

Octra is a privacy focused L1 blockchain built around a fully homomorphic encryption scheme they call HFHE (Hypergraph FHE).

The pitch is that the network can run computation directly on encrypted data without ever decrypting it, with the work distributed across hypergraph structures.

Why Hypergraphs?

In a hypergraph, each connection can link many data points at once. This makes it easier to group and work with encrypted data without doing a lot of extra steps. This structure can make private computation much faster.

Diagram of a hypergraph structure

Architecture

Structurally, the confidential layer looks a lot like Zcash's shielded pool. Every account has a public balance and an encrypted balance, and we can move value across those two states. The interesting part is how the protocol lets users decrypt encrypted balances and move value back into the public state.

From the docs

Octra documentation on public and encrypted balances

To encrypt, decrypt, or spend from an encrypted balance, the protocol relies on an R1CS proof. Reading further:

Octra docs noting that spending encrypted balance uses the same R1CS proof path as decrypting

They note that spending encrypted balance needs the same R1CS proof path as decrypting the balance.

What is important here is that when we spend from encrypted balance, we will update the encrypted state with only proofs, and send an encrypted payload.

We are generating an R1CS zero-knowledge proof that attests the operation is correct.

The proof convinces the verifier that the remaining encrypted balance is still 0\geq 0, without revealing the balance itself.

So my mind began to wonder.

What if the verifier accepts a lie?

Octra docs describing the decrypt path

Hunting for a Zero Day

When looking for bugs in a cryptographic system, I usually start with one assumption: if the implementation is complex enough, there is probably a place where the code and the math disagree.

So I downloaded the webcli, which contains the custom pvac-hfhe crypto library used by the protocol, and started tracing the proof verification path.

Discovery

To target PVAC Bulletproofs/R1CS code where the decode proof is happening I looked for where decode shows up in the webcli

ripgrep output showing decode references in the client

When looking closer at the decoder function for R1CS

rist_decode implementation

rist_decode() returns a bool indicating whether the 32-byte input is a valid Ristretto encoding. If the input is not a valid Ristretto point, it returns false.

A point that fails to decode is left as all zero

ExtPoint{0,0,0,0}\mathrm{ExtPoint}\{0,0,0,0\}
ExtPoint left zero-initialized when decoding fails

The ignored return value

In the Bulletproof verifier, multi_scalar_mul discards the return value from rist_decode. It calls the function, but never checks whether decoding succeeded.

Ignored rist_decode return value in multi_scalar_mul

The pts variable is initialized, so a decode failure gives us

pts[i]=ExtPoint{0,0,0,0}\mathrm{pts}[i] = \mathrm{ExtPoint}\{0,0,0,0\}

Not the identity it pretends to be

This is not the group identity in these twisted Edwards coordinates. The genuine identity is a different point:

ext_identity={0,1,1,0}\mathrm{ext\_identity}=\{0,1,1,0\}
ext_identity, the genuine group identity point

So the failed decode leaves behind {0,0,0,0}\{0,0,0,0\}, which is a different point from the identity {0,1,1,0}\{0,1,1,0\}, and the two behave very differently under addition.

In ext_add:

ext_add point-addition implementation

the points are defined as:

A=(PYPX)(QYQX),B=(PY+PX)(QY+QX),C=PTQT2d,D=2PZQZ,E=BA,F=DC,G=D+C,H=B+A,\begin{aligned} A &= (P_Y - P_X)(Q_Y - Q_X),\\ B &= (P_Y + P_X)(Q_Y + Q_X),\\ C &= P_T \cdot Q_T \cdot 2d,\\ D &= 2\,P_Z Q_Z,\\ E &= B - A,\\ F &= D - C,\\ G &= D + C,\\ H &= B + A, \end{aligned}

returning (E ⁣ ⁣F,  G ⁣ ⁣H,  F ⁣ ⁣G,  E ⁣ ⁣H)(E\!\cdot\!F,\; G\!\cdot\!H,\; F\!\cdot\!G,\; E\!\cdot\!H).

Where the zero point gets in

Every point sum in the verifier funnels through this. ext_scalarmul is plain double and add.

It starts its running point R at the real identity (0,1,1,0)(0,1,1,0), copies the input point into Q, then loops over the 256 bits of the scalar, doubling Q each step and calling ext_add(R, Q) on every set bit, so that Q runs through P,2P,4P,P, 2P, 4P, \dots and the returned value is

R=sP=i=0255si(2iP),s=i=0255si2i,  si{0,1}.R = s \cdot P = \sum_{i=0}^{255} s_i\,(2^i P), \qquad s = \sum_{i=0}^{255} s_i\,2^i,\; s_i \in \{0,1\}.

Here, ss is the coefficient each point gets multiplied by in the MSM.

ext_scalarmul double-and-add implementation

So hand it our degenerate point as Q and substitute Q=(0,0,0,0)Q = (0,0,0,0) into ext_add:

A=(PYPX)(00)=0,B=(PY+PX)(0+0)=0,C=PT02d=0,D=2PZ0=0,E=BA=0,F=DC=0,G=D+C=0,H=B+A=0,\begin{aligned} A &= (P_Y - P_X)(0 - 0) = 0,\\ B &= (P_Y + P_X)(0 + 0) = 0,\\ C &= P_T \cdot 0 \cdot 2d = 0,\\ D &= 2\,P_Z \cdot 0 = 0,\\ E &= B - A = 0,\\ F &= D - C = 0,\\ G &= D + C = 0,\\ H &= B + A = 0, \end{aligned}

so the result is (E ⁣ ⁣F,  G ⁣ ⁣H,  F ⁣ ⁣G,  E ⁣ ⁣H)=(0,0,0,0)(E\!\cdot\!F,\; G\!\cdot\!H,\; F\!\cdot\!G,\; E\!\cdot\!H) = (0,0,0,0).

So we never have to control or even know ss.

Our point is P=(0,0,0,0)P = (0,0,0,0), and multiplying it by anything just gives (0,0,0,0)(0,0,0,0) back:

R=s(0,0,0,0)=i=0255si(2i(0,0,0,0))=(0,0,0,0)for every s.R = s \cdot (0,0,0,0) = \sum_{i=0}^{255} s_i\,\bigl(2^i \cdot (0,0,0,0)\bigr) = (0,0,0,0) \quad \text{for every } s.

Whatever scalar the verifier multiplies our poisoned point by, the term it produces is zero.

Absorbing, not neutral

That was just one term. The real damage is what it does to the rest of the sum. We just showed

ext_add(P,(0,0,0,0))=(0,0,0,0)\mathrm{ext\_add}(P,(0,0,0,0)) = (0,0,0,0)

for any P, and that's the whole game.

The zero point isn't neutral. A neutral element would leave the running sum untouched.

It's absorbing. Drop it into a multi-scalar sum and it wipes out every honest term, the ones before it and the ones after. The accumulator collapses to zero and stays there. A single poisoned point anywhere in an MSM is enough to drag the entire result to zero.

Let's go!

Why it looks innocent

The collapsed value doesn't look wrong. To see why, I went back to how a point gets serialized to bytes in rist_encode:

rist_encode serialization implementation

In short, the function computes one field element ss and writes it out as 32 bytes.

When I feed it our degenerate point, every input to ss falls to zero:

u1=(Z+Y)(ZY)=0,u2=XY=0,den1=den2=zinv=0,s=abs(den_inv(ZY))=0,\begin{aligned} u_1 &= (Z+Y)(Z-Y) = 0,\\ u_2 &= X\cdot Y = 0,\\ \Rightarrow\quad \mathrm{den}_1 = \mathrm{den}_2 &= z_{\mathrm{inv}} = 0,\\ \Rightarrow\quad s &= \mathrm{abs}\bigl(\mathrm{den\_inv}\cdot(Z-Y)\bigr) = 0, \end{aligned}

so it serializes to the all-zero 32 bytes. And when I run the genuine identity (0,1,1,0)(0,1,1,0) through the exact same arithmetic, I get s=0s = 0 as well. That's just the documented Ristretto fact that the identity encodes to the zero string. So the two are indistinguishable on the wire:

rist_encode(0,0,0,0)=0x00..00=rist_encode(0,1,1,0)\mathrm{rist\_encode}(0,0,0,0) = \texttt{0x00..00} = \mathrm{rist\_encode}(0,1,1,0)

So when the verifier serializes its accumulated point and does the final byte-for-byte comparison, my poisoned, zeroed-out MSM is indistinguishable from a perfectly ordinary identity element.

The equality check reads 0x00..00==0x00..00\texttt{0x00..00} == \texttt{0x00..00} and returns true, and my lie sails straight through.

cat vibing

The math, in full

By this point, I had everything I needed to test the attack against the verifier's real equations.

For the attack to work, it rests on three facts about the degenerate point a failed decode leaves behind:

OdegOidnot the identity,ext_add(P,Odeg)=Odegabsorbing,rist_encode(Odeg)=rist_encode(Oid)same 32 bytes.\underbrace{O_{\mathrm{deg}} \neq O_{\mathrm{id}}}_{\text{not the identity}}, \qquad \underbrace{\mathrm{ext\_add}(P, O_{\mathrm{deg}}) = O_{\mathrm{deg}}}_{\text{absorbing}}, \qquad \underbrace{\mathrm{rist\_encode}(O_{\mathrm{deg}}) = \mathrm{rist\_encode}(O_{\mathrm{id}})}_{\text{same 32 bytes}}.

And making one costs nothing, about half of all 32-byte strings fail to decode, so I just use =(0x01,0,,0)\ell^\ast = (\texttt{0x01}, \texttt{0}, \dots, \texttt{0}) and the verifier quietly stores it as OdegO_{\mathrm{deg}}.

Why absorbing is the whole trick

The honest terms in each sum (the commitment VV and the generators B,Gi,HiB, G_i, H_i) have coefficients fixed by the protocol, so I have no way to zero them.

A neutral identity wouldn't help here: poisoning my own points would just drop them, leaving those honest terms standing and the check failing.

The critical difference is that OdegO_{\mathrm{deg}} is absorbing. A single poisoned point doesn't remove itself; it drags the entire sum to zero with it, honest terms and all, and we land on a satisfying 0=00 = 0.

Forging the proof

r1cs_verify comes down to two equality checks. Each side is a sum of points (the MSM), and the verifier accepts only if both sides serialize to the same bytes: Tlhs=TrhsT_{\mathrm{lhs}} = T_{\mathrm{rhs}} and expected=P\mathrm{expected} = P.

r1cs_verify two checks

Written out, those four points are:

Tlhs=Pedersen(tx,tx,bl)=txB+tx,blB,Trhs=x2(δwc)Bjx2wV,jVj+xT1+x3T3+x4T4+x5T5+x6T6,P=i(xyiwR,i)Gi+ihiHi+xAI1+x2AO1+x3S1eblB+(txw)B,expected=i(asi)Gi+i(bsi1yi)Hi+(ab)Qkuk2Lkkuk2Rk.\begin{aligned} T_{\mathrm{lhs}} &= \mathrm{Pedersen}(t_x, t_{x,\mathrm{bl}}) = t_x B + t_{x,\mathrm{bl}} B',\\ T_{\mathrm{rhs}} &= x^2(\delta - w_c) B - \sum_j x^2 w_{V,j} V_j + x T_1 + x^3 T_3 + x^4 T_4 + x^5 T_5 + x^6 T_6,\\ P &= \sum_i (x\,y^{-i} w_{R,i}) G_i + \sum_i h_i H_i + x A_{I1} + x^2 A_{O1} + x^3 S_1 - e_{\mathrm{bl}} B' + (t_x w) B,\\ \mathrm{expected} &= \sum_i (a s_i) G_i + \sum_i (b s_i^{-1} y^{-i}) H_i + (ab) Q - \sum_k u_k^2 L_k - \sum_k u_k^{-2} R_k. \end{aligned}

I zero the scalar openings and poison every attacker point with \ell^\ast:

tx=tx,bl=ebl=a=b=0,AI1,AO1,S1,T1,T3,,T6,{Lk},{Rk}:=.t_x = t_{x,\mathrm{bl}} = e_{\mathrm{bl}} = a = b = 0, \qquad A_{I1}, A_{O1}, S_1, T_1, T_3, \dots, T_6, \{L_k\}, \{R_k\} := \ell^\ast.

Now the poison takes hold. TrhsT_{\mathrm{rhs}}, PP, and expected\mathrm{expected} are each an MSM that now contains at least one poisoned point, T1T_1, AI1A_{I1}, and L1L_1 respectively, so each one absorbs to OdegO_{\mathrm{deg}}.

The only honestly computed point is Tlhs=Pedersen(0,0)=OidT_{\mathrm{lhs}} = \mathrm{Pedersen}(0,0) = O_{\mathrm{id}}. Those two are different points, but they serialize to the same bytes, so both equality checks compare 0x0000\texttt{0x00}\dots\texttt{00} against 0x0000\texttt{0x00}\dots\texttt{00} and pass:

  Tlhs=Trhs    expected=P00  \boxed{\;T_{\mathrm{lhs}} = T_{\mathrm{rhs}} \;\wedge\; \mathrm{expected} = P \quad\Longleftrightarrow\quad 0 \equiv 0\;}

No key, no witness, and the verifier's challenges do not matter, since the poisoned sums collapse to zero regardless of their values.

The only real obstacle is on the decrypt path, where the range proof pins the proof's commitments to the ciphertext's. So I keep those as the real commitments and poison only the bulletproof points:

Vj:=PCj(ct)V[proof]=PC(ct)V_j := \mathrm{PC}_j(ct) \quad\Longrightarrow\quad V_{[\mathrm{proof}]} = \mathrm{PC}(ct)

That honest VV only ever reaches the verifier inside an MSM that also carries a poisoned point; in TrhsT_{\mathrm{rhs}} it sits right next to xT1x T_1 with T1=OdegT_1 = O_{\mathrm{deg}}:

Trhs=x2(δwc)Bjx2wV,jVjhonest, nonzero+xT1Odeg+x3T3+=Odeg.T_{\mathrm{rhs}} = \underbrace{x^2(\delta-w_c)B - \sum_j x^2 w_{V,j} V_j}_{\text{honest, nonzero}} + \, x\,\underbrace{T_1}_{O_{\mathrm{deg}}} + x^3 T_3 + \dots = O_{\mathrm{deg}}.

So the pin sees the real commitments and passes, while the absorbing point still drags the whole sum to zero. Honest VV, collapsed MSM.

Exploit

A failed rist_decode() leaves an ExtPoint{0,0,0,0}, so I build 32 bytes that are guaranteed to fail decoding and smear that one invalid point across every group element in a proof, zeroing the scalar openings as I go. Every point in the proof is set to that single bad encoding, and every scalar opening is set to zero.

poison.hpp: the entire forgery in one helper
poison.hpp in full. bad() is the whole attack: 32 bytes that fail to decode. poison() just smears that one point across every element of the proof and zeroes the openings.

With that, I built a proof for a false statement like: "this commitment opens to 0" while actually committing to NN or some other arbitrary data, and ran it through the real r1cs_verify, no node yet, just the verifier straight out of the library, and with the poisoned points, it returned TRUE.

From Forged Proof to Mainnet Tokens

r1cs_verify was only the first crack in the wall. The real money path was not a single proof in isolation, but the aggregated range proof Octra uses when deciding whether an encrypted balance is allowed to move back into the public state.

That range proof is built from 64 single-bit R1CS proofs plus one linear combination proof. In other words, it is not one statement saying "this value is small enough". It is a bundle of smaller statements that together are meant to prove:

0v<2640 \leq v < 2^{64}

So I applied the same forgery to every piece of the bundle.

Take a genuine proof with the right shape, keep the ciphertext commitments where the verifier expects them, and poison the proof points everywhere the verifier later feeds them into the MSM. Each sub-proof still has the correct structure. Each sub-proof still lands in the same verifier logic. And each sub-proof collapses in the same way.

The result is a range proof for a value it was never built for.

The value I used was an overspend. After subtracting more than the encrypted account actually held, the "remaining balance" should become negative. But inside the field, that negative value wraps around into a huge number:

1(modp)-1 \pmod p

An honest range proof should reject that immediately. It is obviously not inside:

[0,264)[0, 2^{64})

But the forged proof says it is.

At that point the bug stops being a verifier curiosity and becomes a balance bug. A verifier that accepts lies is a finding. A balance that increases from an impossible state is the demo.

So I wired the forged proof into the exact decision a node makes during decrypt:

  1. recompute the ct for the remaining encrypted balance,
  2. verify the "remaining balance is non-negative" range proof with the real verify_range,
  3. move value from encrypted balance to public balance if the proof passes.

Then I asked it to decrypt far more OCT than my account ever held.

With the forged aggregated range proof, the node accepted it.

The encrypted balance check passed. The range proof passed. The decrypt succeeded. And my public balance was credited with coins that were never backed by my encrypted account.

To limit the impact, I chose the arbitrary amount of 100100 OCT for the mainnet test. The exact amount is not the point. Once the verifier accepts a proof of nothing, the value is no longer protected by the cryptography. It is just an argument I get to pick.

Outcome

To validate the impact on mainnet, I bought and bridged 20 wOCT to OCT and sent it to my wallet:

oct3cvWhMs4KWRMwfN3giCjUxASfjWSHdNn6fbJk3kTAjFU

At around 20:30, the wallet held 20 OCT.

At 20:44:04, I ran the exploit against mainnet: 5dbe379aaf1e6afbb97a82dbfce92702e23ef701b4a78c7ce5d34d7f17f913e2

Wallet showing the minted OCT balance after the exploit

The exploit transaction encrypted 5 OCT, then used the forged range proof path to make the node accept a decrypt of 100 OCT in the following transaction: 5fb5f4e076333347758aa30903d6eca5e83fc35d2a237e475db163e62afc8823

I intentionally kept the amount small to avoid inflating the supply more than necessary.

The account started with 20 OCT and ended with 100 OCT, even though only 20 OCT had been funded.

To prove that the funds were not just a display issue, I moved 50 OCT to another address: octGhFMLe2q4H53scpvdSLGQqs9Uo9dVWmuvzbSruSqivT2

I then bridged those 50 OCT back to 50 wOCT, demonstrating that the minted balance could leave the confidential accounting path and become wrapped tokens. From there, the wOCT could theoretically be swapped for ETH through the Uniswap Pool.

20 wOCT in, 50 wOCT out:

Bridge activity: 20 wOCT in, 50 wOCT out

This showed that the unbacked balance was not just internal accounting. It could leave the confidential balance system, be bridged, and reach a liquid external market.

Responsible Disclosure

After confirming the mainnet impact, I contacted a friend at Zellic who quickly put me in touch with Kaushik Swaminathan, Head of Strategy at Zellic. Kaushik added me to a private group chat with both Octra co-founders, @octralex and @lambda0xE at 02:03 AM on June 1.

I disclosed the vulnerability to them at 02:09 AM.

After some initial discussion, we proved that the bug was real, exploitable, and live on mainnet.

Octra team's reaction in the disclosure chat

@lambda0xE needed some time to work through the math, and I replied with a correction at 03:53 AM.

Working through the vulnerability math during disclosure

Throughout the night, @lambda0xE and I worked through the issue together. Within a few hours, Octra had prepared and deployed a patch to the live mainnet nodes, around 05:16 AM.

After the patch was live, they asked me to run the exploit again against mainnet.

I re-ran it at 05:59 AM. This time, the exploit no longer worked.

Exploit failing against the patched mainnet node

Closing thoughts

This bug was a reminder of how small implementation details can completely break the security of a cryptographic system. The protocol logic around the proof was trying to enforce a very simple rule. You should not be able to decrypt or spend more encrypted balance than you actually have. But because one ignored return value turned invalid points into absorbing points, that rule stopped meaning anything.

Acknowledgments

I want to thank Zellic and Kaushik Swaminathan for helping me get in contact with the Octra team so quickly after I confirmed the vulnerability on mainnet.

I also want to give a huge thanks to @octralex and @lambda0xE at Octra for the fast response, clear communication, and cooperation throughout the disclosure process. They took the report seriously from the first message, worked through the math with me in real time, and had a patch live on mainnet within hours.

That is exactly how critical vulnerability response should look.

Finding a bug like this is exciting, but the important part is getting it fixed before anyone else can abuse it. In this case, the issue was confirmed, patched, and re-tested in the same night.

Math is magic 🤝

math magic