// AereProofAggregatorV2 — regression tests for INNER VKEYS COUNTED BUT NEVER ENFORCED. // // FINDING (2026-07-17 sweep, code-read): AereProofAggregator 0x6a260238…F84C5 pins the // aggregation vkey and the guest is genuinely recursive — but the contract only COUNTED how // many folded inner vkeys were registered (into a `recognized` field) and recorded the // aggregation regardless. An unregistered inner program was not an error, just a lower number. // // THE ATTACK: an attacker writes an SP1 guest with NO constraints — // fn main() { sp1_zkvm::io::commit_slice(&attacker_chosen_bytes); } // — has it commit the exact AereZKScreen public tuple, proves it (trivial: it asserts nothing), // folds it with the REAL aggregation guest (which honestly verifies it, because it IS a valid // SP1 proof of that vacuous program), and submits. V1 records an aggregation that `exists`, // carries the pinned aggVKey, and is indistinguishable from a genuine one except for // `recognized < count` — a field nothing ever enforced. // // Each attack test is PAIRED with a control on the REAL V1 contract showing the same call // SUCCEEDS on V1, i.e. the bug was real and V2 closes it. // // THE GUEST IS UNCHANGED. The composite the guest commits ALREADY binds every inner vkey // digest, and V1 already recomputed it correctly — so the contract always KNEW which programs // were folded. The fix is purely what the contract DOES with that knowledge. This is verified // below: the composite recomputed by these tests reproduces the REAL live on-chain aggregation // digest 0x8e246396…4e36 byte-for-byte. // // The SP1 verifier is MOCKED (MockSp1Verifier: reverts unless the exact (vkey, publicValues, // proof) triple was pre-registered — mirroring the real gateway). That is the CORRECT model: // the attacker's aggregation proof is CRYPTOGRAPHICALLY VALID (the recursion honestly verified // a real proof of a real — if vacuous — program), so the gateway accepts it. The bug is not a // broken proof; it is a proof about an unvetted program. const { expect } = require("chai"); const { ethers } = require("hardhat"); const PROOF = "0x4388a21c1234"; // opaque; the mock keys acceptance on the triple /* ---------------- REAL live values (chain 2800, read 2026-07-17) ------------------ */ // The pinned aggregation program (registered on the live V1). const AGG_VKEY = "0x003a70854f258b13c6612b0272421f2d7d97f90eee63e124864c80be61a2672c"; // The three REAL registered inner programs: KoalaBear digest (what the recursion binds) // + BN254 on-chain vkey (what is published) + label. const REAL_INNERS = [ { label: "zkscreen", digest: "0x3510880a6bb8ae42705a4cd76f3aa69422a8d836122361ba12a913635de3e5b7", onChain: "0x006a211015aee2b90b82d266bef3aa6944551b06c488d86e895489b1dde3e5b7", pvHash: "0xe4d363f0108963cb592f1c592d855a46b6d9579cccef2d4689b93c2441c78d88", }, { label: "over18", digest: "0x2d54a563446f19740eab67524f3622a67463b716375c230a30d2b0fe633343c5", onChain: "0x005aa94ac711bc65d0755b3a94f3622a6e8c76e2cdd708c29869587f633343c5", pvHash: "0x44d77dcf2f60c490ffad39fb2cbe75648ae3c402ce6cbe1ac29f6525af3c6ce6", }, { label: "zkml-mnist", digest: "0x30a21e711cd157425f9b5e2203ea0a525cb86cbc625b720f3313ce3727c4bceb", onChain: "0x0061443ce273455d0afcdaf1103ea0a52b970d979896dc83d989e71ba7c4bceb", pvHash: "0xa04ed64252a5388bee0a757a0c2f83da7594cfaee6c7c25e6b873f46f43aad7e", }, ]; // The composite the LIVE contract actually recorded for those three (aggregationCount==2). const REAL_COMPOSITE = "0x8e246396a2f40c837501d06c965163db644284ffc877d7cd0d38681b612b4e36"; const FOUNDATION = "0x0243A4f47D44b40b65D33f20329dE20D00c6f3C3"; // live V1 owner // The attacker's unconstrained guest: a real SP1 program, so it has a real vkey digest — // just one that was never vetted or registered. const ATTACKER_DIGEST = "0x" + "de".repeat(32); // composite = sha256( u32_be(n) || (digest_i || pvHash_i)... ) — the guest's formula. function composite(digests, pvHashes) { let packed = ethers.solidityPacked(["uint32"], [digests.length]); for (let i = 0; i < digests.length; i++) packed = ethers.concat([packed, digests[i], pvHashes[i]]); return ethers.sha256(packed); } function marker(vkey, pv, proof) { return ethers.keccak256(ethers.AbiCoder.defaultAbiCoder().encode(["bytes32", "bytes", "bytes"], [vkey, pv, proof])); } describe("AereProofAggregatorV2 — inner-vkey enforcement fix (2026-07-17)", function () { let mock, v1, v2, owner, attacker; const digests = REAL_INNERS.map((p) => p.digest); const pvHashes = REAL_INNERS.map((p) => p.pvHash); async function accept(vkey, pv, proof = PROOF) { await mock.setValid(marker(vkey, pv, proof), true); } async function deployV2(seal = false) { return await (await ethers.getContractFactory("AereProofAggregatorV2")).deploy( await mock.getAddress(), seal ? ethers.ZeroAddress : owner.address, [AGG_VKEY], ["recursive-aggregation agg-program (real, live)"], digests, REAL_INNERS.map((p) => p.onChain), REAL_INNERS.map((p) => p.label), seal ); } beforeEach(async function () { [owner, attacker] = await ethers.getSigners(); mock = await (await ethers.getContractFactory("MockSp1Verifier")).deploy(); await mock.waitForDeployment(); // The REAL V1 contract, registered EXACTLY as the live one is — the control. v1 = await (await ethers.getContractFactory("AereProofAggregator")).deploy(await mock.getAddress()); await v1.waitForDeployment(); await v1.registerAggProgram(AGG_VKEY, "recursive-aggregation agg-program (real, live)"); for (const p of REAL_INNERS) await v1.registerInnerProgram(p.digest, p.onChain, p.label); v2 = await deployV2(false); await v2.waitForDeployment(); }); /* ---------------- the guest is unchanged: composite reproduces live data --------- */ describe("the recursion guest is UNCHANGED (the composite already bound the inner vkeys)", function () { it("recomputes the REAL live on-chain composite 0x8e246396…4e36 byte-for-byte", async function () { expect(composite(digests, pvHashes)).to.equal(REAL_COMPOSITE); }); it("V2's on-chain _composite matches the guest formula on the REAL live inner set", async function () { await accept(AGG_VKEY, REAL_COMPOSITE); // verifyAggregation returns the composite it recomputed on-chain expect(await v2.verifyAggregation(AGG_VKEY, digests, pvHashes, REAL_COMPOSITE, PROOF)).to.equal(REAL_COMPOSITE); }); it("V1 recomputed it correctly too — V1 always KNEW which programs were folded", async function () { await accept(AGG_VKEY, REAL_COMPOSITE); expect(await v1.verifyAggregation(AGG_VKEY, digests, pvHashes, REAL_COMPOSITE, PROOF)).to.equal(REAL_COMPOSITE); }); }); /* ================================================================================ THE ATTACK: fold a proof of an UNCONSTRAINED, unregistered guest. ================================================================================ */ describe("ATTACK — unconstrained attacker guest folded into an aggregation", function () { // The attacker's vacuous guest commits the EXACT zkscreen public tuple (so its pvHash is // the real zkscreen one), but the program that produced it is the attacker's own. const atkDigests = () => [ATTACKER_DIGEST]; const atkPvHashes = () => [REAL_INNERS[0].pvHash]; // the real AereZKScreen public-values hash const atkComposite = () => composite(atkDigests(), atkPvHashes()); it("PAIRED CONTROL: V1 RECORDS the attacker's aggregation as if legitimate", async function () { const c = atkComposite(); await accept(AGG_VKEY, c); await expect(v1.connect(attacker).recordAggregation(AGG_VKEY, atkDigests(), atkPvHashes(), c, PROOF)).to.emit( v1, "AggregationVerified" ); const rec = await v1.getAggregation(c); expect(rec[0]).to.equal(true); // exists <-- a forged aggregation is ON THE BOOKS expect(rec[1]).to.equal(AGG_VKEY); // carries the pinned, legitimate agg vkey expect(rec[2]).to.equal(1); // count expect(rec[3]).to.equal(0); // recognized == 0: the ONLY tell, and nothing enforced it expect(await v1.aggregationCount()).to.equal(1n); }); it("V2 REVERTS with UnregisteredInnerProgram — the attack is closed", async function () { const c = atkComposite(); await accept(AGG_VKEY, c); await expect(v2.connect(attacker).recordAggregation(AGG_VKEY, atkDigests(), atkPvHashes(), c, PROOF)) .to.be.revertedWithCustomError(v2, "UnregisteredInnerProgram") .withArgs(ATTACKER_DIGEST, 0); expect((await v2.getAggregation(c))[0]).to.equal(false); // nothing recorded expect(await v2.aggregationCount()).to.equal(0n); }); it("V2's verifyAggregation view REVERTS too (a view used as an oracle must bind)", async function () { const c = atkComposite(); await accept(AGG_VKEY, c); await expect(v2.verifyAggregation(AGG_VKEY, atkDigests(), atkPvHashes(), c, PROOF)) .to.be.revertedWithCustomError(v2, "UnregisteredInnerProgram") .withArgs(ATTACKER_DIGEST, 0); }); it("PAIRED CONTROL: V1's verifyAggregation view ACCEPTS the attacker's fold", async function () { const c = atkComposite(); await accept(AGG_VKEY, c); expect(await v1.verifyAggregation(AGG_VKEY, atkDigests(), atkPvHashes(), c, PROOF)).to.equal(c); }); }); describe("ATTACK — attacker guest SMUGGLED IN alongside real registered programs", function () { // Harder to spot: 3 real programs + 1 attacker program. V1 reports recognized=3, count=4. const mixDigests = () => [...digests, ATTACKER_DIGEST]; const mixPvHashes = () => [...pvHashes, "0x" + "77".repeat(32)]; const mixComposite = () => composite(mixDigests(), mixPvHashes()); it("PAIRED CONTROL: V1 records it (recognized=3 of count=4) — looks almost legitimate", async function () { const c = mixComposite(); await accept(AGG_VKEY, c); await v1.connect(attacker).recordAggregation(AGG_VKEY, mixDigests(), mixPvHashes(), c, PROOF); const rec = await v1.getAggregation(c); expect(rec[0]).to.equal(true); expect(rec[2]).to.equal(4); // count expect(rec[3]).to.equal(3); // recognized — 3 real programs carry the forged 4th }); it("V2 REVERTS at the attacker's index (3), rejecting the whole aggregation", async function () { const c = mixComposite(); await accept(AGG_VKEY, c); await expect(v2.connect(attacker).recordAggregation(AGG_VKEY, mixDigests(), mixPvHashes(), c, PROOF)) .to.be.revertedWithCustomError(v2, "UnregisteredInnerProgram") .withArgs(ATTACKER_DIGEST, 3); expect(await v2.aggregationCount()).to.equal(0n); }); }); describe("ATTACK — zero inner vkey digest (default value of an unregistered slot)", function () { it("V2 REVERTS: a zero digest can never be allowed, so the gate cannot be no-op'd", async function () { const d = [ethers.ZeroHash]; const h = [REAL_INNERS[0].pvHash]; const c = composite(d, h); await accept(AGG_VKEY, c); await expect(v2.recordAggregation(AGG_VKEY, d, h, c, PROOF)) .to.be.revertedWithCustomError(v2, "UnregisteredInnerProgram") .withArgs(ethers.ZeroHash, 0); }); it("V2 refuses to REGISTER a zero digest (would turn the gate into a no-op)", async function () { await expect(v2.registerInnerProgram(ethers.ZeroHash, "0x" + "11".repeat(32), "evil")) .to.be.revertedWithCustomError(v2, "ZeroVKey"); }); it("PAIRED CONTROL: V1 HAPPILY registers a zero digest", async function () { await expect(v1.registerInnerProgram(ethers.ZeroHash, "0x" + "11".repeat(32), "evil")).to.not.be.reverted; expect((await v1.innerPrograms(ethers.ZeroHash))[0]).to.equal(true); }); }); /* ---------------- V2 does not over-reject: the honest path still works ----------- */ describe("V2 does not over-reject — real aggregations still record", function () { it("records the REAL 3-program aggregation, recognized == count (the V2 invariant)", async function () { await accept(AGG_VKEY, REAL_COMPOSITE); await expect(v2.recordAggregation(AGG_VKEY, digests, pvHashes, REAL_COMPOSITE, PROOF)).to.emit( v2, "AggregationVerified" ); const rec = await v2.getAggregation(REAL_COMPOSITE); expect(rec[0]).to.equal(true); expect(rec[1]).to.equal(AGG_VKEY); expect(rec[2]).to.equal(3); // count expect(rec[3]).to.equal(3); // recognized == count, ALWAYS, by construction expect(rec[5]).to.equal(owner.address); expect(await v2.aggregationCount()).to.equal(1n); }); it("seeds the allowed set at deploy — functional with NO post-deploy signature", async function () { for (const p of REAL_INNERS) { const r = await v2.innerPrograms(p.digest); expect(r[0]).to.equal(true); expect(r[1]).to.equal(p.onChain); expect(r[2]).to.equal(p.label); } expect(await v2.isAggProgram(AGG_VKEY)).to.equal(true); expect(await v2.owner()).to.equal(owner.address); expect(await v2.SP1_VERIFIER()).to.equal(await mock.getAddress()); }); it("areInnerProgramsAllowed() previews the gate without spending gas", async function () { expect(await v2.areInnerProgramsAllowed(digests)).to.equal(true); expect(await v2.areInnerProgramsAllowed([...digests, ATTACKER_DIGEST])).to.equal(false); }); it("a newly-registered program becomes foldable (the governed extension path)", async function () { const NEWD = "0x" + "5a".repeat(32); const d = [NEWD]; const h = ["0x" + "99".repeat(32)]; const c = composite(d, h); await accept(AGG_VKEY, c); await expect(v2.recordAggregation(AGG_VKEY, d, h, c, PROOF)).to.be.revertedWithCustomError( v2, "UnregisteredInnerProgram" ); await v2.registerInnerProgram(NEWD, "0x" + "6b".repeat(32), "new-program"); await expect(v2.recordAggregation(AGG_VKEY, d, h, c, PROOF)).to.emit(v2, "AggregationVerified"); }); }); /* ---------------- inherited protections still hold ------------------------------ */ describe("inherited protections (unchanged from V1)", function () { it("an unregistered AGGREGATION program still reverts", async function () { const bad = "0x" + "01".repeat(32); await expect(v2.recordAggregation(bad, digests, pvHashes, REAL_COMPOSITE, PROOF)) .to.be.revertedWithCustomError(v2, "UnknownAggProgram") .withArgs(bad); }); it("a composite that does not match the committed public values reverts", async function () { const wrong = "0x" + "ee".repeat(32); await accept(AGG_VKEY, wrong); await expect(v2.recordAggregation(AGG_VKEY, digests, pvHashes, wrong, PROOF)).to.be.revertedWithCustomError( v2, "CompositeMismatch" ); }); it("an invalid Groth16 proof reverts InvalidProof", async function () { await expect(v2.recordAggregation(AGG_VKEY, digests, pvHashes, REAL_COMPOSITE, PROOF)) .to.be.revertedWithCustomError(v2, "InvalidProof"); }); it("dropping a folded child changes the composite => rejected", async function () { await accept(AGG_VKEY, REAL_COMPOSITE); await expect( v2.recordAggregation(AGG_VKEY, digests.slice(0, 2), pvHashes.slice(0, 2), REAL_COMPOSITE, PROOF) ).to.be.revertedWithCustomError(v2, "CompositeMismatch"); }); it("empty / mismatched-length / wrong-pv-length all revert", async function () { await expect(v2.recordAggregation(AGG_VKEY, [], [], REAL_COMPOSITE, PROOF)).to.be.revertedWithCustomError( v2, "EmptyAggregation" ); await expect( v2.recordAggregation(AGG_VKEY, digests, pvHashes.slice(0, 2), REAL_COMPOSITE, PROOF) ).to.be.revertedWithCustomError(v2, "LengthMismatch"); await expect(v2.recordAggregation(AGG_VKEY, digests, pvHashes, "0x1234", PROOF)) .to.be.revertedWithCustomError(v2, "BadPublicValuesLength") .withArgs(2); }); }); /* ---------------- registry governance + the seal latch -------------------------- */ describe("registry governance and the one-way seal", function () { it("only the owner may register", async function () { await expect( v2.connect(attacker).registerInnerProgram("0x" + "5a".repeat(32), "0x" + "6b".repeat(32), "evil") ).to.be.revertedWith("Ownable: caller is not the owner"); await expect(v2.connect(attacker).registerAggProgram("0x" + "5a".repeat(32), "evil")).to.be.revertedWith( "Ownable: caller is not the owner" ); }); it("sealRegistry() permanently freezes the set and renounces ownership", async function () { await expect(v2.sealRegistry()).to.emit(v2, "RegistrySealed"); expect(await v2.registrySealed()).to.equal(true); expect(await v2.owner()).to.equal(ethers.ZeroAddress); // no one can extend the set any more — not even the old owner await expect( v2.registerInnerProgram("0x" + "5a".repeat(32), "0x" + "6b".repeat(32), "post-seal") ).to.be.revertedWith("Ownable: caller is not the owner"); await expect(v2.sealRegistry()).to.be.revertedWith("Ownable: caller is not the owner"); }); it("sealed aggregations still record (sealing freezes the SET, not the contract)", async function () { await v2.sealRegistry(); await accept(AGG_VKEY, REAL_COMPOSITE); await expect(v2.recordAggregation(AGG_VKEY, digests, pvHashes, REAL_COMPOSITE, PROOF)).to.emit( v2, "AggregationVerified" ); }); it("can be deployed SEALED at construction: seeded set, zero admin, ever", async function () { const sealed = await deployV2(true); await sealed.waitForDeployment(); expect(await sealed.registrySealed()).to.equal(true); expect(await sealed.owner()).to.equal(ethers.ZeroAddress); expect(await sealed.isAggProgram(AGG_VKEY)).to.equal(true); expect((await sealed.innerPrograms(REAL_INNERS[0].digest))[0]).to.equal(true); // and it still rejects the attack const c = composite([ATTACKER_DIGEST], [REAL_INNERS[0].pvHash]); await accept(AGG_VKEY, c); await expect( sealed.recordAggregation(AGG_VKEY, [ATTACKER_DIGEST], [REAL_INNERS[0].pvHash], c, PROOF) ).to.be.revertedWithCustomError(sealed, "UnregisteredInnerProgram"); }); }); /* ---------------- constructor guards -------------------------------------------- */ describe("constructor guards", function () { let F; beforeEach(async function () { F = await ethers.getContractFactory("AereProofAggregatorV2"); }); it("rejects a code-less SP1 verifier (V1 accepted one)", async function () { await expect( F.deploy("0x000000000000000000000000000000000000dEaD", owner.address, [], [], [], [], [], false) ).to.be.revertedWithCustomError(F, "VerifierHasNoCode"); }); it("rejects the zero verifier / zero owner when not sealed", async function () { await expect(F.deploy(ethers.ZeroAddress, owner.address, [], [], [], [], [], false)).to.be.revertedWithCustomError( F, "ZeroAddress" ); await expect( F.deploy(await mock.getAddress(), ethers.ZeroAddress, [], [], [], [], [], false) ).to.be.revertedWithCustomError(F, "ZeroAddress"); }); it("rejects mismatched seed array lengths", async function () { const m = await mock.getAddress(); await expect(F.deploy(m, owner.address, [AGG_VKEY], [], [], [], [], false)).to.be.revertedWithCustomError( F, "SeedLengthMismatch" ); await expect( F.deploy(m, owner.address, [], [], digests, [], [], false) ).to.be.revertedWithCustomError(F, "SeedLengthMismatch"); }); it("rejects seeding a zero inner digest or zero agg vkey", async function () { const m = await mock.getAddress(); await expect( F.deploy(m, owner.address, [], [], [ethers.ZeroHash], ["0x" + "11".repeat(32)], ["evil"], false) ).to.be.revertedWithCustomError(F, "ZeroVKey"); await expect( F.deploy(m, owner.address, [ethers.ZeroHash], ["evil"], [], [], [], false) ).to.be.revertedWithCustomError(F, "ZeroVKey"); }); }); });