Introduction

Welcome to The Notorious Bug Digest #9 - a curated compilation of insights into recent blockchain bugs and security incidents. When our security researchers aren’t diving into audits, they dedicate time to staying up-to-date with the latest in the security space, analyzing audit reports, and dissecting onchain incidents. We believe this knowledge is invaluable to the broader security community, offering a resource for researchers to sharpen their skills and helping newcomers navigate the world of blockchain security. Join us as we explore this batch of bugs together!


Bug Analysis: Rule Selection Downgrade in Stellar Smart Account Authorization

This high-severity bug was identified during an audit of the OpenZeppelin Stellar Contracts Library (RC v0.7.0) performed by OpenZeppelin.

Understanding the Soroban Authorization Model

Soroban is Stellar’s Rust-based smart contract platform. Its authorization model differs from Ethereum’s msg.sender approach: when a contract calls require_auth(), the Soroban host intercepts it and invokes __check_auth on the account contract, passing three arguments:

  • signature_payload: a SHA-256 hash of (network_id, nonce, expiration_ledger, invocation_tree), computed entirely by the host and cryptographically fixed at submission time.
  • auth_payload: user-supplied data (the signatures and any metadata), which the account contract is responsible for validating.
  • auth_contexts: the list of operations being authorized.

In a multi-signature flow, signers sign offline and a sponsor (coordinator wallet, relayer, or fee-bump payer) assembles and submits the final transaction. Signers commit to signature_payload; the sponsor controls the auth_payload.

This separation is intentional: the host guarantees the integrity of what is being executed, while the account contract is responsible for validating who authorized it and under what conditions.

The OpenZeppelin Smart Account and Context Rules

The OpenZeppelin Stellar Contracts library builds a programmable account on top of this model. An account stores named context rules, each binding a set of signers and optional policy contracts (e.g., spending limits, multisig thresholds) to a specific operation scope. For example, a treasury account might have a strict rule requiring 3-of-5 signers plus a daily spending cap, alongside a session rule that permits a single key to make small transfers within a limited time window.

Because multiple rules can cover the same scope, the caller must explicitly pick one rule per context by supplying a context_rule_ids vector in the AuthPayload:

pub struct AuthPayload {
    pub signers: Map,       // signer → signature bytes
    pub context_rule_ids: Vec,        // which rule to use per auth_context
}

The do_check_auth function authenticates each signer, validates every auth_context against its chosen rule, and enforces each rule’s policies.

The Vulnerability

In the vulnerable version, signatures were verified directly against the raw signature_payload, before consulting context_rule_ids:

// context_rule_ids are NOT included in what signers sign.
authenticate(e, signature_payload, &signatures.signers);

// Rule IDs are taken at face value with no cryptographic binding.
let context_rule_id = signatures.context_rule_ids.get_unchecked(i as u32);
get_validated_context_by_id(e, &context, &all_signers, context_rule_id)

Since context_rule_ids was never part of the signed data, a sponsor could silently swap the rule IDs after collecting signatures, and the signatures over the unchanged signature_payload would still verify. get_validated_context_by_id trusted the supplied rule ID, and do_check_auth only enforced the policies attached to the selected rule, not the ones the signers expected.

Authorization therefore succeeded under a downgraded rule without the signers’ knowledge or consent, defeating protections like spending limits or threshold policies they believed were in effect. The following walk-through illustrates a concrete exploitation scenario.

Attack Walk-Through

Imagine an account with two rules covering the same token contract:

  • Rule 1 (strict): requires 3-of-5 signers and enforces a daily spending limit policy.
  • Rule 2 (loose): requires only 1 signer with no policies.
  1. Three signers sign the transaction’s signature_payload, expecting Rule 1 to apply.
  2. The sponsor collects signatures and builds AuthPayload with context_rule_ids = [1].
  3. Before submitting, the sponsor replaces context_rule_ids = [1] with context_rule_ids = [2].
  4. do_check_auth runs: signatures verify (the signature_payload is unchanged), Rule 2 is applied, the spending limit is never consulted, and the transfer succeeds uncapped.

In a more damaging variant, the sponsor could select a rule that requires only a signer under their own control, effectively reducing a robust multisig to a single point of compromise, with the remaining signers completely unaware their configured protections were bypassed.

The Fix

The fix computes an auth_digest that commits to both the host payload and the rule selection, then authenticates signers against that combined digest:

// auth_digest = sha256(signature_payload || context_rule_ids.to_xdr())
let mut preimage = signature_payload.to_bytes().to_bytes();
preimage.append(&signatures.context_rule_ids.clone().to_xdr(e));
let auth_digest = e.crypto().sha256(&preimage);

// Any post-collection change to context_rule_ids invalidates all signatures.
authenticate(e, &auth_digest, &signer, &sig_data);

Replacing context_rule_ids now changes auth_digest, causing signature verification to fail. Signers must therefore produce their signatures over auth_digest rather than the raw signature_payload.

The fix also added an explicit UnauthorizedSigner check that rejects any signer in AuthPayload whose key does not appear in the selected rules, closing a related vector where an attacker could inject an arbitrary verifier contract into the signer map.

Takeaway: In systems that separate offline signature collection from transaction assembly, every security-critical parameter must be cryptographically bound into what signers sign. Leaving rule selection outside the signed payload creates a silent downgrade vector controlled entirely by the submitting party. More broadly, any configurable authorization scope that is not independently verifiable by signers is a candidate for this class of attack.


Bug Analysis: ZK Circuit Misconstraint Permanently Halts PrivacyBoost

This critical bug was identified during an audit of Sunnyside Labs’ PrivacyBoost protocol performed by OpenZeppelin.

Understanding PrivacyBoost

Sunnyside Labs built PrivacyBoost as an epoch-based shielded pool on EVM-compatible blockchains, enabling users to conduct confidential token transfers. The core idea follows the UTXO model familiar from Zcash: rather than tracking balances, the protocol issues private notes, each representing a commitment of the form Poseidon(npk, tokenId, value, rnd). Spending a note requires proving ownership through a zero-knowledge proof, which hides the sender’s identity and transaction amount from public view.

To store these commitments onchain, PrivacyBoost uses append-only Merkle trees with a depth of 20 and a capacity of up to 2^20 leaves. When a tree fills up, the protocol performs a rollover: the full tree becomes permanently historical, and a new empty tree becomes active. Each tree receives a monotonically increasing 15-bit integer as its identifier, meaning the protocol can theoretically accommodate up to 32,768 trees over its lifetime.

A trusted execution environment (TEE) operates as a relay, collecting user transactions and bundling them into epochs. For each epoch, the relay generates Groth16 zero-knowledge proofs attesting to the validity of the batch and submits them onchain. Three independent ZK circuits handle different operations: epoch submission, deposit, and forced withdrawal. The forced withdrawal circuit deserves special attention: it serves as the protocol’s censorship-resistance escape hatch, allowing any user to exit without relay cooperation after a time delay, by submitting their own ZK proof directly.

One important property of ZK circuits is that their constraints are compiled into a fixed constraint system. Updating a circuit is not a matter of pushing a code change: it requires recompiling the circuit, running a new multi-party trusted setup ceremony to generate fresh proving and verifying keys, and redeploying the new verifier contract. This makes ZK circuit bugs significantly more expensive to resolve than ordinary smart contract bugs.

The Vulnerability

All three circuits contain a range check that constrains active tree numbers to be less than Shape.MaxTrees, a constant set to 16:

// circuit constraint present in all three circuits
assert activeTreeNumber < Shape.MaxTrees   // Shape.MaxTrees = 16

The problem is that Shape.MaxTrees is a shape parameter: it defines how many tree roots the proof array can accommodate per batch, a structural sizing detail about the circuit’s data layout. It has nothing to do with the valid domain of tree identifiers. The contract, correctly, defines tree numbers as a 15-bit monotonically increasing identifier with a maximum value of 32,767.

This mismatch creates a time-bomb. For the first 16 trees (identifiers 0 through 15), everything works normally. When the 16th tree fills up and a rollover occurs, currentTreeNumber becomes 16. The contract now requires any valid proof to reference tree number 16, but the circuit flatly rejects any proof where activeTreeNumber >= 16. No valid proof for any operation can be constructed. Deposits, transfers, withdrawals, and forced withdrawals all halt permanently.

The constants file compounds the problem further. Two constants define bit widths for tree number range checks:

NoteTreeNumberBits  = 5   // range check: 5-bit → max value 31
AuthTreeNumberBits  = 5   // range check: 5-bit → max value 31
TreeNumberBitsPerSlot = 15 // packing: 15-bit → max value 32767

Even if the < Shape.MaxTrees bound were corrected to a larger value, the 5-bit decomposition used for range verification would fail for any tree number exceeding 31. Both layers of the bug need to be fixed together.

The consequence is total: all user funds become inaccessible, and the forced withdrawal mechanism designed precisely to handle adversarial relay behavior also breaks. The escape hatch fails at the exact moment users would need it most.

The Fix

The audit recommended either removing the < Shape.MaxTrees range checks entirely, since tree validity is already enforced through (treeNumber, root) matching logic, or replacing them with a constraint against the correct 15-bit domain: activeTreeNumber < 32768. The bit-width constants would also need to be updated to 15 to match the packing logic.

Neither option is a simple hotfix. Both require recompiling all three circuits and running a new trusted setup ceremony to generate fresh proving and verifying keys before the updated verifier contracts can be deployed onchain.

The Sunnyside Labs team resolved this finding prior to mainnet deployment.

Takeaway: In ZK systems, shape parameters and value domain constraints look similar in code but mean completely different things. A parameter that sizes an array for proof layout has no business constraining the range of identifiers that array indexes. Beyond the conceptual error, this case illustrates the asymmetry of ZK circuit bugs: they compile without error, pass all tests within the valid range, and only surface after the protocol has been running long enough to cross the threshold. The forced withdrawal circuit breaking alongside the main circuits is a reminder that safety mechanisms sharing the same flawed assumption offer no protection when that assumption fails.


Incident Analysis: Signed-Integer Bit-Smuggling in Aftermath Finance’s Perpetuals on Sui

On April 29, 2026, an attacker exploited Aftermath Finance’s perpetual futures protocol on Sui. The team has since published a detailed post-mortem identifying the root cause as a signed-integer flaw in the integrator-fee accounting logic, and committed to making affected users whole. This analysis complements that disclosure with a code-level look at how a u256 storage field, a hand-rolled two’s-complement library, and an unguarded public setter combined to invert collateral accounting in a single transaction.

A note on source: Aftermath’s contracts are deployed without verified source, so all code below was reconstructed from onchain bytecode using the Revela decompiler (via SuiVision). Identifiers such as v3, v8, v40, v57 are decompiler-generated placeholders; function signatures, struct field names, and assertion error names are preserved by the compiler and are reliable.

Background: Signed Arithmetic Without Native Signed Types

Move provides only unsigned integers (u8 through u256) and no signed types yet. Protocols that need signed quantities (PnL, funding rates, deltas, anything that can go below zero) typically implement them as a library of functions operating on u256 values under two’s-complement interpretation. The bit at position 255 acts as a sign bit: values below 2^255 are non-negative, and values at or above 2^255 are negative (with magnitude 2^256 − raw). Standard signed operations are then implemented on these encodings with bit-manipulation tricks.

Aftermath’s ifixed library does exactly this, with a fixed-point scale of 10^18 (so 1.0 is the raw u256 10^18). Every public function takes and returns u256. The library is internally correct: its conversion functions that cross from unsigned-integer space into signed-fixed-point space (from_balance, from_u256, the to_* family) carry sign-consistency assertions on inputs that could land in the negative half of the range. Smaller-width converters like from_u64 and from_u128 skip the explicit assert because their inputs cannot reach the negative half after multiplication by 10^18. The protection is real, though only where the library expects it to matter.

The vulnerability lies not in ifixed itself, but in how the perpetuals package, deployed separately and depending on the library, integrated with it. The integrator-fee path bypasses the boundary discipline the library assumes.

The Vulnerability

Each exploit cycle was a single Programmable Transaction Block (PTB) that opened two accounts, registered the attacker as their own integrator with a negative taker fee, crossed a market order against a real counterparty’s maker order, and withdrew the resulting synthetic collateral. The attacker repeated the cycle 17 times over roughly 36 minutes (11 successful, 6 failed). The relevant payload sits in three calls:

MoveCall  9: add_integrator_config(addr, max_taker_fee = 0)
MoveCall 13: create_integrator_info(addr, taker_fee = 2^256 − 10^23)
MoveCall 15: place_market_order(...)

The IntegratorInfo struct in the clearing house stores the fee as a raw u256, set directly from user input with no boundary check:

struct IntegratorInfo has copy, drop {
    integrator_address: address,
    taker_fee: u256,
}

public fun create_integrator_info(arg0: address, arg1: u256): Option {
    let v0 = IntegratorInfo {
        integrator_address: arg0,
        taker_fee: arg1,            // raw u256, no boundary check
    };
    option::some(v0)
}

Nothing validates that the bit pattern is sensible under the signed interpretation that downstream code will apply.

Attack Walk-Through

The malicious value: The attacker passed taker_fee = 2^256 − 10^23. As a u256, this is a very large positive number near the top of the range. As a signed fixed-point value under ifixed’s interpretation, it decodes to −100,000.0 in user-space (raw magnitude 10^23, divided by the 10^18 scale, negative because the high bit is set). The same 256 bits mean two different things depending on the interpretation rule. The bug is that the value is written under one rule and read under another, with nothing enforcing consistency.

Boundary bypass: The write path stores the raw u256 directly. The library’s sign-consistency asserts are never invoked, because no conversion function is ever called. The value enters as raw u256 and stays raw until it is read. Fee math runs later, at session close (end_sessionprocess_session_hot_potatoprocess_fill_takercalculate_taker_fees), where the stored value is consumed by ifixed::less_than_eq and ifixed::mul. Both operate in signed space and decode the bit pattern as two’s-complement. They do not, and should not, re-validate a value already typed as ifixed. The validation was supposed to happen at the boundary, and the protocol bypassed the boundary.

The cap was set to zero: As clean evidence of the structural nature of the bug: the attacker set their max_taker_fee cap to 0, the most restrictive possible unsigned bound, and the bypass still worked. Under signed less_than_eq, zero sits in the middle of the representable range: the entire upper half of u256 decodes as negative, and any negative value is trivially ≤ 0. The check signed_lte(−100,000, 0) returns true. Only the comparator’s interpretation rule mattered; the cap value was irrelevant. Even a sensible positive cap would have provided no defense.

Collateral inflation: In calculate_taker_fees, the decoded −100,000 is multiplied by the trade’s quote size:

let v3 = v5.taker_fee;            // attacker's −100,000
ifixed::mul(v3, arg2)             // arg2 = quote_filled  → large negative ifixed

That large negative integrator fee (v8) then flows into the position update:

position::add_to_collateral_usd(
    position,
    ifixed::sub(v6, ifixed::add(v7, v8)),    // v6: pre-fee collateral delta
                                              // v7: base (protocol) taker fee
                                              // v8: integrator taker fee (malicious)
    collateral_price,
);

The intended semantics: take the collateral delta v6, subtract the two fees, apply the result. With both fees small and positive, this debits the trader’s collateral by roughly the fee total. With v8 a large negative, it inverts: v7 + v8 is a large negative, and v6 − (large negative) becomes v6 + |v8|, a large positive. The path that should have deducted a small fee instead injected a large synthetic USD credit into the position. The collateral is credited a large positive amount, while the FilledTakerOrder event records the integrator fee itself (same magnitude, opposite sign) as a large negative ifixed value in integrator_taker_fees.

Cash out: With the inflated collateral in place, compute_free_collateral honestly reported a large surplus over required margin, and the attacker called deallocate_free_collateral followed by withdraw_collateral to pull real USDC against a tiny seed deposit. The withdrawn liquidity had been posted by the counterparties whose maker orders matched the attacker’s market orders.

 

Why the Existing Safeguards Did Not Catch It

  1. The library’s boundary asserts were bypassed because the protocol never crosses the boundary: Every ifixed conversion that needs a sign-consistency check carries one, but the integrator-fee path invokes none of them. The u256 parameter goes straight into the struct field and is consumed in signed space without revalidation. The instrumentation was real and correctly placed; the value simply never passed through it.
  2. The max_taker_fee cap is enforced under signed comparison: As shown by the cap = 0 choice, the cap is not restrictive against negative inputs once the comparator decodes the value as signed. The most restrictive unsigned bound offers no protection.
  3. The negative_fees_accrued assert guards the wrong variable: In process_session_hot_potato:
let v57 = ifixed::add(v11, v40);
assert!(!ifixed::is_neg(v57), errors::negative_fees_accrued());

The assert’s name encodes a defense against exactly this threat class, but v57 aggregates the session’s running maker-fee total (v11) and the base taker fee (v40), neither of which includes the malicious integrator fee. The integrator fee inflates collateral via add_to_collateral_usd (before this assert runs) and accumulates into IntegratorVault.fees afterward. Both paths sidestep the check. The protection existed; it just guarded an adjacent variable.

The Fix

Aftermath’s response was prompt: identification within hours, public disclosure, ecosystem coordination, and a commitment to make affected users whole. The immediate code fix is to validate the fee at its boundary: run the u256 input through the same sign-consistency assertion the rest of the library relies on before it is ever stored as an ifixed value, and enforce the fee cap with a comparison whose interpretation matches how the value will later be read.

The more durable fix is structural. The core defect is that a security-critical value was written under unsigned rules and read under signed rules, with the type system enforcing nothing in between which can be avoided using in below ways:

  • Wrap quantities in nominal struct types with validating constructors: A nominal wrapper such as public struct Amount(u128), or a boundary wrapper over an existing math library where migration is not immediate, makes invalid bit patterns unrepresentable and carries the validation invariant across every use site, including future ones, rather than relying on each call site to remember the check.
  • Make the signed-vs-unsigned decision explicit at every field declaration: Declaring a field with a type that distinguishes signed from unsigned documents the choice in code and makes it visible in review: a distinct unsigned type makes negative values unrepresentable, while a signed type forces any negative to be constructed deliberately.

Takeaway: This bug lives in the gap between two interpretation rules: the protocol writing under one and reading under another, with no language-level enforcement of consistency. Whenever custom signed arithmetic is built on reinterpreted unsigned bit patterns: the discipline that keeps it safe is enforced by convention, and conventions break. Boundary asserts only help on paths that actually cross the boundary, and a cap is meaningless if the comparator’s interpretation differs from the writer’s. Nominal type wrappers with validating constructors move that discipline into the type system, where it cannot be skipped, and force the signed-vs-unsigned choice to be made deliberately at each field declaration rather than inherited by accident.

FAQs

What is a rule-selection downgrade attack on a Stellar smart account?

In Stellar's Soroban authorization model, signers commit to a fixed signature_payload computed by the host. In the vulnerable version of the OpenZeppelin Stellar Contracts library, the context_rule_ids vector that determines which authorization policy applies was not included in the signed data. A transaction sponsor could replace the rule ID after collecting signatures, downgrading a strict multisig-plus-spending-limit rule to a weaker single-signer rule while all signatures still verified. The fix binds context_rule_ids into a combined auth_digest alongside the host payload, so any post-signing modification invalidates every signature.

How did a ZK circuit misconstraint permanently halt PrivacyBoost?

All three of PrivacyBoost's Groth16 circuits constrained the active tree number with assert activeTreeNumber < Shape.MaxTrees, where Shape.MaxTrees = 16 is a proof-array sizing parameter, not the valid upper bound of tree identifiers. The contract correctly defines tree numbers as a 15-bit monotonically increasing identifier with a maximum value of 32,767. After the 16th tree rollover, currentTreeNumber became 16, which the circuit rejected, making it impossible to generate a valid proof for any operation, including deposits, transfers, and the forced-withdrawal escape hatch. Resolving the bug requires recompiling all three circuits and running a new trusted setup ceremony to generate fresh proving and verifying keys.

What caused the Aftermath Finance exploit on Sui in April 2026?

The exploit stemmed from a signed-integer flaw in the perpetual protocol's integrator-fee accounting. The IntegratorInfo struct stored the taker fee as a raw u256 with no boundary validation. The attacker passed 2^256 - 10^23, a value that under the protocol's two's-complement ifixed interpretation decoded as -100,000.0. When fee calculation subtracted that large negative value from the collateral delta, it inverted the accounting, injecting a large synthetic USD credit into the position. The attacker then called deallocate_free_collateral and withdraw_collateral to extract real USDC against a minimal seed deposit.

Why did the existing safeguards in Aftermath Finance fail to catch the exploit?

The ifixed library's sign-consistency assertions were never invoked because the integrator-fee path stored the raw u256 directly into the struct field, bypassing all conversion functions. The max_taker_fee cap was enforced using a signed comparator, so setting the cap to 0 provided no protection since any two's-complement negative value is trivially less than or equal to 0. The negative_fees_accrued assertion checked an aggregated variable covering only the base protocol fees, not the integrator fee path that inflated collateral.


Disclaimer

It is important to emphasize that the intent behind this content is not to criticize or blame the affected projects. Instead, the goal is to provide objective analyses of the vulnerabilities that serve as educational material for the blockchain community to learn from and better protect itself in the future.