Permissionless Treasury Drain: Empty-Key Account Updates Are Pruned
Description. Anyone can withdraw funds from the Treasury Owner by calling executeProposal() with the empty public key as the Proposal address. The root cause is in o1js: it drops account updates at PublicKey.empty() from the final transaction, along with everything the caller proved about them.
Mina represents a zkApp transaction as a forest of AccountUpdates. For a nested smart-contract call, the caller proof commits to the callee AccountUpdate and its descendants. Mina then verifies the authorization attached to each update in the serialized transaction. This composition is sound only if every update committed to by the caller remains in the serialized call forest.
o1js uses AccountUpdates at PublicKey.empty() as internal dummy values. AccountUpdate.isDummy() identifies them solely by their public key:
isDummy() {
// ZKSECURITY: the empty public key alone marks an update as dummy
return this.body.publicKey.isEmpty();
}
When o1js finalizes the call forest, it omits every update for which isDummy() returns true. Omitting a parent also removes its descendants:
for (let { isDummy, ...tree } of [...nodes].reverse()) {
// ZKSECURITY: an update at the empty key is dropped together with its subtree
finalForest.pushIf(isDummy.not(), tree);
}
This test does not establish that the update is the canonical internal dummy. An update at the empty public key is removed even if it contains account preconditions, state changes, authorization data, or child updates. Application code can therefore prove statements about an AccountUpdate that does not appear in the resulting transaction.
This affects TreasuryOwner.executeProposal(). The method accepts a Proposal public key and constructs the corresponding Proposal under the Owner’s derived token ID. It relies on nested Proposal calls to check the lifecycle, approval status, recipient, and payout amount. The Owner debit occurs outside those calls:
// ZKSECURITY: proposalPublicKey is caller-supplied and may be PublicKey.empty()
const proposal = new TreasuryProposalSmartContract(
proposalPublicKey,
this.deriveTokenId(),
);
const proposalLifecycleId = await proposal.getLifecycleId();
// ZKSECURITY: ...
// ZKSECURITY: the debit lives on the Owner update, which is retained
this.self.balance.subInPlace(amountToPayOut);
// ZKSECURITY: all Proposal checks live on the Proposal updates, which are dropped
await proposal.execute(amountToPayOut, recipient);
this.approve(proposal.self);
An attacker can pass PublicKey.empty() as proposalPublicKey and provide a malicious local account witness while constructing the transaction. For getLifecycleId(), the witness supplies a lifecycle old enough to satisfy the check that remains in the Owner circuit.
The approval, recipient, and remaining-payout checks in proposal.execute() are not part of the Owner circuit. They run while o1js computes the witnessed callee update.
The Proposal updates contain the preconditions that would normally bind this state to an on-chain account. They also contain the Proposal authorization, the payout-accounting update, and the recipient payment. o1js classifies both Proposal updates as dummy and removes them along with their descendants and enforced protocol logic.
The nonempty Owner update remains, including its proof and balance debit. The attacker can add a separate top-level credit for the same amount. Mina then sees a balanced transaction with a valid Owner proof, but no Proposal update or Proposal proof.
Proof of concept. We reproduced this attack on an o1js LocalBlockchain with proofs enabled. The attacker called executeProposal(PublicKey.empty(), attacker, 2 MINA). The serialized transaction contained the 2 MINA Owner debit and a matching attacker credit, but no update for the Proposal.
The transaction was accepted with a genuine Owner proof and only the attacker’s fee-payer signature. The Owner lost 2 MINA and the attacker received 2 MINA. No Proposal account existed.
Impact. Anyone can permissionlessly drain the full balance of an unpaused Treasury Owner.
The issue is present in upstream o1js v3.0.0 and in the reviewed fork. It is not specific to Treasury: the same behavior can remove a nested AccountUpdate and its complete descendant subtree in other o1js applications.
Recommendation. We reported the underlying library issue to o1Labs, and it is being addressed in o1js. Mina Treasury must upgrade to the fixed o1js release before going live.
Client Response. Fixed in revision 479bdc2 by upgrading to o1js 3.1.0.