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.

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.

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

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

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 , without revealing the balance itself.
So my mind began to wonder.
What if the verifier accepts a lie?

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

When looking closer at the decoder function for R1CS

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

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.

The pts variable is initialized, so a decode failure gives us
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:

So the failed decode leaves behind , which is a different point from the identity , and the two behave very differently under addition.
In ext_add:

the points are defined as:
returning .
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 , 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 and the returned value is
Here, is the coefficient each point gets multiplied by in the MSM.

So hand it our degenerate point as Q and substitute into ext_add:
so the result is .
So we never have to control or even know .
Our point is , and multiplying it by anything just gives back:
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
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:

In short, the function computes one field element and writes it out as 32 bytes.
When I feed it our degenerate point, every input to falls to zero:
so it serializes to the all-zero 32 bytes. And when I run the genuine identity through the exact same arithmetic, I get 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:
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 and returns true, and my lie sails straight through.

The math, in full
31. May 1:48 PMBy 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:
And making one costs nothing, about half of all 32-byte strings fail to decode, so I just use and the verifier quietly stores it as .
Why absorbing is the whole trick
The honest terms in each sum (the commitment and the generators ) 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 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 .
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: and .

Written out, those four points are:
I zero the scalar openings and poison every attacker point with :
Now the poison takes hold. , , and are each an MSM that now contains at least one poisoned point, , , and respectively, so each one absorbs to .
The only honestly computed point is . Those two are different points, but they serialize to the same bytes, so both equality checks compare against and pass:
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:
That honest only ever reaches the verifier inside an MSM that also carries a poisoned point; in it sits right next to with :
So the pin sees the real commitments and passes, while the absorbing point still drags the whole sum to zero. Honest , 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 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 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:
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:
An honest range proof should reject that immediately. It is obviously not inside:
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:
- recompute the ct for the remaining encrypted balance,
- verify the "remaining balance is non-negative" range proof with the real
verify_range, - 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 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

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:

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.

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

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.

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.
