conero was a blockchain challenge in the Codegate 2026 qualifiers: a private Ethereum dev chain running a patched geth that adds a native "Conero" transaction format — a shielded pool with Monero-style notes, key images and ring signatures, implemented as consensus code rather than as a contract.
The patch source was not shipped. The vulnerability is one missing function call, and finding it meant reading the binary.
What winning looks like
The flag endpoint checks balances, and reduces to two conditions:
- the pool at
0x…10c0must hold exactly0; -
0x…1337must hold more than the pool was bootstrapped with.
From the instancer, the bootstrap is 7 ETH in a single note, and the player account starts with 1.1 ETH. So the shape of a winning state is forced: put 1 ETH in, take 8 ETH out, leave nothing behind. There is also a hard cap of ten eth_sendRawTransaction calls per instance, which rules out anything iterative.
The RPC proxy allows a curated subset of methods, but the subset includes eth_sendRawTransaction, the nonce and balance getters, receipts and gas pricing. The intended surface is plainly "send hand-built native transactions and see what the patched node accepts."
Reading a patch that was not shipped
Only a Linux geth binary is in the archive. It is a Go binary with symbols and DWARF intact, which is enough: the custom package paths come straight out of the symbol table as core/conero/native.go and core/conero/conero.go, and DWARF gives the struct layouts without guessing at field offsets.
Two native opcodes matter — 0x01 deposit and 0x04 withdraw. The deposit payload is visible from its encoder:
0x01 || notePublicKey[32] || txPublicKey[32]
and the withdraw parser reads a list of input rings, then a recipient address, a little-endian amount, and a one-byte "has change" flag. The ring itself:
type nativeInputRing struct {
Amount uint64
Members [][32]byte
KeyImage [32]byte
Challenge0 [32]byte
Responses [][32]byte
}
Key images and per-ring Fiat–Shamir challenges: this is a Monero-style construction, so the security questions are the Monero ones. Are key images canonical? Is the double-spend set keyed on something an attacker cannot vary? Does the challenge hash cover everything it needs to?
The bug: a check that is written but not called
The binary contains canonicalKeyImage(), and it is a careful function. It rejects the identity point, rejects points whose cofactored form collapses to the identity, and rejects anything that is not of prime order after canonicalisation. Exactly the check this construction needs.
verifyRingSignature() does not call it.
The verifier only:
- decodes
ring.KeyImagewithPoint.SetBytes(), - runs the ring equation with whatever point that produced,
- compares the recomputed challenge against
Challenge0.
SetBytes answers "is this a valid encoding of a curve point", not "is this point in the subgroup we require". On Ed25519 the full group is the large prime-order subgroup times a factor of 8, so a valid encoding may carry a component of order 8 — and I and I + T, for T in that 8-torsion subgroup, are different byte strings naming different group elements that each decode without error.
The double-spend ledger stores spent key images as bytes. Eight byte strings for one note means eight separate slots, none of which has been used.
Why a one-member ring makes it trivial
The withdraw path accepts a ring of any size, including one, and nothing requires a real anonymity set. With one member the ring equation collapses to a single transition, and with I replaced by I + T the verifier computes
L = s·G + c·P
R = s·H(P) + c·(I + T)
where the honest signature would have produced R = r·H(P). The two agree exactly when
c·T = 0
and since T has order 8, that holds whenever c ≡ 0 (mod 8).
c is a hash output, so it cannot be chosen — but it does not need to be. Resample the signing nonce, recompute the challenge, and keep the attempt where it comes out divisible by eight: one try in eight succeeds, so a handful of attempts per signature is the whole cost. Everything else about the signature stays honest, because the note secret is ours.
That yields eight valid key images for one note — the honest one and seven torsion shifts — and eight proofs that each verify and each land in a different spent-key-image slot.
Getting the hashed bytes right
The one part that cost real time was not the attack but matching the byte layout of the Fiat–Shamir context, and the shape of it is worth noting. The withdraw context hash does not cover the whole serialised proof. It hashes the withdraw domain string, the ring count, and per ring the member count, the amount and the member public keys, then the recipient, the withdraw amount, and the change flag with its optional output.
KeyImage, Challenge0 and Responses are in the calldata and are not in the withdraw context. They enter one level down, where the per-ring challenge hashes a challenge domain string, the withdraw context hash, and canonical encodings of the key image, L and R.
Both layers had to match the binary byte for byte. Until they did, locally generated proofs verified against my own re-implementation and were rejected on chain — which is the usual signature of a domain-separation or serialisation mismatch rather than a wrong attack.
The solve
Spending the bootstrap 7 ETH note would mean recovering a secret we never saw. Depositing our own note is strictly easier, and the budget allows it:
- solve the proof of work at
/powand create an instance with/new; - read the issued
uuid, player address and key, and the current pool balance; - derive a note secret deterministically from those instance parameters;
- deposit
1 ETHinto the pool under that note — the pool is now8 ETH; - build eight one-member withdraws of
1 ETHeach to0x…1337, one per torsion-shifted key image; - sign nine legacy transactions with nonces
0..8and submit them; - read
/flag.
Nine sends, inside the limit of ten. The pool ends at zero and 0x…1337 holds 8 ETH, which is more than the 7 ETH it started with.
One practical detail: the private chain still has a nonzero base fee, and submitting at the raw eth_gasPrice left transactions sitting in the pool. A modestly higher legacy gas price clears them and stays far inside the 0.1 ETH left over after the deposit.
codegate2026{acdb27306fba0f4512ad33352c4f9e340498eb98d23e773f49ba3cfc069d7256}
TIP
When a target implements a known cryptographic construction, read it against that construction's published failure list rather than looking for novelty. Monero's own history supplies the list here — the 2017 key-image double-spend bug was precisely a missing check that key images lie in the prime-order subgroup. Searching the binary for where that check is, and then for who calls it, is a faster path than auditing the ring equation line by line.
What to take from it
The patch author knew about torsion. canonicalKeyImage() is not a stub or an afterthought; it handles the identity case, the cofactor case and the prime-order case, which is more than many real implementations do. The code was right. Its placement was not.
- A validation function is worth nothing until it is on every path that consumes the value. A grep for the definition finds reassurance; a grep for the call sites finds the bug. This generalises past cryptography: the same shape is a sanitiser applied on one of two write paths, or an authorisation check in the handler but not in the batch endpoint that also reaches it.
- Canonicalise at the boundary, not at the point of use. The robust structure is to decode untrusted bytes once, into a type that cannot exist unless it is canonical, and to make every downstream function take that type. Then the check cannot be skipped, because there is no way to obtain the value without it. Scattering checks at use sites means the next function added will not have one.
- A replay-prevention set must be keyed on the canonical form, not the wire form. Even with a correct subgroup check at verification, keying the spent-key-image ledger on raw bytes is fragile: any future encoding with more than one representation of the same element reopens the same hole. Key the set on the canonical encoding of the group element and the question disappears.
- Small anonymity sets are an attack surface, not just weak privacy. A one-member ring made the algebra easy enough to control by hand. A minimum ring size would not have fixed the missing check, but it would have made this considerably harder to exploit — and the challenge imposed none.
The shape of this bug is the most portable thing about it. Security code that exists, is correct, and is not reached is indistinguishable from security code that was never written — and it is worse, because reviewers who find it stop looking.