Bonus — Non-EVM Security: CosmWasm & Move (Aptos / Sui)

“Every chain re-invents the same handful of failure modes in new vocabulary. CosmWasm calls reentrancy ‘reply-handler confusion’. Move calls reentrancy ‘impossible’ — until you find that capabilities, generic-type dispatch, and dynamic fields recreate the same primitive. The auditor’s job, ported across runtimes, is to keep asking: ‘what does this primitive actually guarantee?‘”

Tags: web3-security non-evm cosmwasm move aptos sui cosmos vulnerability anti-pattern Learner: Auditor confident with EVM bugs (post Tuan-05-Vulnerability-Classes-Part-1 / Tuan-06-Vulnerability-Classes-Part-2); now porting the mental model to non-EVM runtimes Time: 5–7 days (4–6h/day) — depth optional; treat sections as menu Related: Tuan-Bonus-Non-EVM-Solana · Tuan-01-Web3-Blockchain-Crypto-Fundamentals · Tuan-05-Vulnerability-Classes-Part-1 · Tuan-15-Audit-Methodology-Tooling


1. Context & Why

1.1 Why an EVM auditor must learn non-EVM

Multi-chain protocols deploy the same product across EVM, Cosmos, Aptos, Sui and Solana, and they pay auditors per platform. A surprising fraction of senior EVM auditors refuse non-EVM work because the syntax looks foreign. That refusal leaks money and signals brittle understanding — the bugs are the same bugs.

What changes across runtimes is not the kind of mistakes humans make. It’s the vocabulary the runtime gives those mistakes:

EVM bugCosmWasm shapeMove (Aptos/Sui) shape
Reentrancy via external callSubmessage reply re-entryGenerally prevented by linear types; reappears via dynamic dispatch / function_values / hot-potato patterns
Unprotected initializeUnprotected instantiate / unguarded migrateUnprotected init_module (Aptos) / module initializer (Sui)
Delegatecall / proxy collisionmigrate with attacker-chosen codeModule upgrade with compatible-but-malicious bytecode; package upgrade policy (Sui)
Signature replayOff-chain message verification on-chain (rare in CosmWasm; common in IBC channels)Same as EVM: nonce + chain id + module id
Integer overflowRust panics by default in release; overflow checks must be onMove arithmetic aborts on overflow by default
Oracle manipulationSame as EVM, plus Cosmos-specific oracle modulesSame as EVM, plus Sui/Aptos oracle module quirks
Access control via msg.senderinfo.sender (CosmWasm)Capability objects (Sui) / signer (Aptos)

Half of non-EVM auditing is recognising the EVM analogue. The other half is the new failure modes the runtime introduces — submessage reply parsing, capability leakage, object-vs-shared dispatch, witness abuse. This chapter covers both.

1.2 What you’ll be able to do by Friday

  • Read a CosmWasm contract (execute, instantiate, query, reply, migrate, sudo) and identify trust boundaries in under 10 minutes.
  • Write a CosmWasm contract with a vulnerable reply handler and exploit it via submessage re-entry.
  • Recognise the Move resource model and explain why “ERC-20 in Move” looks nothing like “ERC-20 in Solidity”.
  • Identify Sui object types from a function signature (&mut SharedObject vs OwnedObject vs hot-potato) and predict the attack surface.
  • Spot capability leakage in a Sui module on first read.
  • Run a basic Aptos Move Prover spec against a buggy module and watch it fail.
  • Articulate the CosmWasm-vs-Move-vs-EVM checklist when scoping a new audit.

1.3 Primary references

SourceURLWhy
CosmWasm Bookhttps://book.cosmwasm.com/Authoritative tutorial for CosmWasm contract authors; covers entry points + storage
CosmWasm Docs (Confio)https://docs.cosmwasm.com/API docs, cw-multi-test, IBC, sudo, governance proposals
cosmwasm-std crate docshttps://docs.rs/cosmwasm-std/The actual types you’ll be reading (MessageInfo, Response, SubMsg, Reply)
OtterSec CosmWasm auditshttps://osec.io/blog (filter “CosmWasm”)Real findings; read 3–5 of these before you audit production code
Halborn / Zellic Cosmos / Move reportshttps://halborn.com/research, https://zellic.io/blogMore public-facing audit war stories
Aptos Move Bookhttps://aptos.dev/move/move-on-aptosAccount-based Move with global storage
Sui Move Bookhttps://docs.sui.io/concepts/sui-move-conceptsObject-centric Move; differs from Aptos meaningfully
The Move Bookhttps://move-book.com/Generic Move language reference; used by both Aptos and Sui
Aptos Move Prover Guidehttps://aptos.dev/move/prover/move-prover/Built-in formal verifier; you should at least read examples
Sui security audits indexhttps://docs.sui.io/guides/developer/getting-started/sui-environment ; OtterSec / MoveBit / Zellic blogsPublic reports on Sui ecosystem exploits
Levana Finance flash-spam post-mortemhttps://medium.com/@LevanaProtocol — Dec 2023 / Jan 2024 [verify]A real CosmWasm-adjacent operational attack on Sei
Cetus Protocol incident (Sui, May 2025)Cetus blog / Sui Foundation statement [verify — May 2025]Largest Sui-native DEX exploit to date

[verify] flags: incidents and dates of major non-EVM exploits move quickly; double-check post-mortems before citing them in audit reports.


2. CosmWasm — The Runtime in One Sitting

2.1 What CosmWasm is

CosmWasm is a smart-contract platform that:

  • Runs on Cosmos SDK chains (Juno, Osmosis, Neutron, Stargaze, Sei, Archway, XPLA, Terra Classic and others).
  • Compiles contracts from Rust to WebAssembly (Wasm).
  • Uses a message-passing execution model — contracts don’t call each other synchronously; they emit messages that the chain dispatches in subsequent execution stages.
  • Persists state in a key-value store accessed through typed wrappers (Item, Map, IndexedMap).

From an auditor’s perspective the most important property is the message-passing model. It looks unfamiliar, but every CosmWasm bug class maps to it.

2.2 The six entry points

A CosmWasm contract exports up to six entry points. Each is a Rust function the runtime invokes:

#[entry_point]
pub fn instantiate(deps: DepsMut, env: Env, info: MessageInfo, msg: InstantiateMsg)
    -> Result<Response, ContractError>;
 
#[entry_point]
pub fn execute(deps: DepsMut, env: Env, info: MessageInfo, msg: ExecuteMsg)
    -> Result<Response, ContractError>;
 
#[entry_point]
pub fn query(deps: Deps, env: Env, msg: QueryMsg)
    -> Result<Binary, ContractError>;
 
#[entry_point]
pub fn reply(deps: DepsMut, env: Env, msg: Reply)
    -> Result<Response, ContractError>;
 
#[entry_point]
pub fn sudo(deps: DepsMut, env: Env, msg: SudoMsg)
    -> Result<Response, ContractError>;
 
#[entry_point]
pub fn migrate(deps: DepsMut, env: Env, msg: MigrateMsg)
    -> Result<Response, ContractError>;
Entry pointWho calls itTrust modelAudit angle
instantiateAnyone who instantiates the code idCaller chooses constructor args; contract code is fixedValidate every input; assert ownership early; never trust info.sender here is your “admin”
executeAnyone, via a MsgExecuteContract TxCaller is info.sender; funds attached are in info.fundsThe main attack surface — every variant of ExecuteMsg is a public function
queryAny client (off-chain or on-chain via another contract)Read-only; cannot mutate stateRead-only re-entry possible from cross-contract queries (analogous to EVM read-only reentrancy)
replyThe runtime, after a submessage finishesOnly the runtime; info.sender semantics N/AMatch on reply.id; incorrect or missing match = bug; parse reply.result.data carefully
sudoThe chain itself via governance / module hooksOnly the chain (no user-space caller); very high trustUsed for governance-triggered actions and IBC; auditing chains, not users
migrateWhoever holds the admin field on the contractIf admin is unset, no one; if set to a multisig/DAO, that groupPay attention — if migrate is unprotected or admin is a single key, this is the proxy-upgrade hijack

2.3 Messages, submessages, replies

CosmWasm execution does not let contract A synchronously call contract B and read the return value in one stack frame. Instead, A returns a Response listing messages for the runtime to dispatch:

  • Message (.add_message): fire-and-forget; failure reverts the whole tx.
  • SubMsg (.add_submessage): runtime dispatches, then synchronously invokes the current contract’s reply entry with Reply { id, result }. The id is a u64 the dispatcher chooses.

Submessages carry a ReplyOn variant:

  • ReplyOn::Success — invoke reply only on success
  • ReplyOn::Error — invoke reply on failure and swallow the failure (the tx does not revert!)
  • ReplyOn::Always — either way
  • ReplyOn::Never — equivalent to add_message
flowchart TD
  A[Contract A execute] -->|Response with SubMsg id| RT[Runtime]
  RT -->|dispatch SubMsg| B[Contract B execute]
  B -->|return| RT
  RT -->|invoke reply id and result| A2[Contract A reply]
  A2 -->|optional more SubMsg| RT

This is the central CosmWasm mental model. Every reply-handler bug class follows from “your reply is a callback the runtime invokes into your own contract, with state in whatever shape your execute left it.”

2.4 Storage abstractions

CosmWasm’s cw-storage-plus provides three storage types that wrap the underlying key-value store:

TypeShapeCost modelEVM analogue
Item<T>Single typed valueOne key, deserialised on readSingle storage slot
Map<K, V>Typed key → typed valueOne key per (k), deserialised on readmapping(K => V)
IndexedMap<K, V, I>Map with secondary indexes maintained alongsideMore writes per insert/removeOpenZeppelin EnumerableMap with extra indexes
const STATE: Item<State> = Item::new("state");
const BALANCES: Map<&Addr, Uint128> = Map::new("balances");

Audit angles on storage:

  • Two Item/Map declarations with the same string key silently alias. Modules composed via Rust use re-exports can produce hard-to-spot duplicate-key bugs. Grep all storage keys early in the audit.
  • Map<&Addr, _> keyed on Addr is fine only if every write goes through deps.api.addr_validate(...) first — otherwise you can have separate entries for Addr::unchecked("Cosmos1...") and Addr::unchecked("cosmos1...") (mixed case) due to the chain’s case-sensitivity. More on Addr below.
  • IndexedMap writes the primary and every secondary index. A mid-execute panic between primary write and index update would corrupt indexes — but CosmWasm reverts the whole tx on panic, so this is usually safe; verify.

2.5 Determinism and the float ban

CosmWasm forbids floating-point arithmetic at the Wasm-validation layer. The runtime rejects any contract whose Wasm contains f32 / f64 opcodes. Why: floats are non-deterministic across hardware, and consensus requires every validator to compute the same state.

Audit angle: developers occasionally pull in a Rust dependency that uses floats internally (e.g., a stats crate, a curve-math helper, a log/exp approximation). Compilation succeeds; chain deployment fails with a cryptic Wasm-validation error. The team patches by reimplementing in fixed-point, often wrong. Look for hand-rolled fixed-point math under feature-gated // no floats allowed comments — that’s where the rounding bugs hide.

2.6 Gas, panics, and ? propagation

  • Every operation costs gas; the chain has a per-tx gas limit. Gas accounting differs per chain.
  • Rust’s panic!() inside a contract aborts the Wasm module and reverts the whole transaction.
  • The idiomatic error path is Result<_, ContractError> with ? propagation; this is not a panic — it returns an error response and reverts cleanly.
  • Integer overflow: in --release Rust the default is to wrap silently. CosmWasm contracts therefore must enable overflow-checks = true in Cargo.toml. Verify this in every audit:
    [profile.release]
    overflow-checks = true
    Missing this line is a recurring finding in OtterSec and Zellic reports.

3. CosmWasm Vulnerability Classes

This is the core auditor catalog. Each class has an EVM analogue, a CosmWasm-specific shape, a recognition signal, and a mitigation.

3.1 Submessage reply re-entry

CosmWasm has no CALL opcode, but SubMsg + reply is functionally equivalent. Re-entry shape: contract A’s state is mid-update when contract B finishes; the runtime calls back into A’s reply. If A’s reply reads state that A will write later, you have re-entry.

Mental shift from EVM: in Solidity, re-entry is “external code runs in the middle of my function”. In CosmWasm, re-entry is “the runtime invokes my reply entry point in the middle of my logical operation”. Same bug class; different mechanism.

Vulnerable shape: execute_withdraw saves PENDING, dispatches BankMsg::Send as a SubMsg, decrements BALANCES only in reply. If the recipient is a contract that can trigger a callback into execute_withdraw (via CW-721/CW-1155 hooks, IBC packet receive, or a hook-based architecture), the second call sees the un-decremented balance and double-withdraws.

Bank sends to plain accounts can’t directly callback, but CW20 receive hooks, CW721 callbacks, and IBC packet-receive flows all create the opportunity.

Mitigation: CosmWasm-style CEI. Decrement BALANCES before the submessage. Reserve reply for actions that genuinely require the inner result (e.g., capturing a contract address from instantiate2, or rolling back state on ReplyOn::Error).

3.2 Reply ID parser confusion

Each submessage carries a u64 id; reply matches on it to identify the logical operation. Three bug shapes:

  • Missing default arm: _ => Ok(Response::new()) silently swallows unknown ids. Any downstream logic that depended on the prior submessage’s side effects runs against wrong state.
  • Numeric reuse: same REPLY_ID = 1 for two distinct flows; handler runs the wrong branch.
  • Collision with chain-injected ids: IBC and Wasm hooks attach their own reply ids. Picking id = 0 can collide with chain-injected replies.

Mitigation: named constants in one place, no magic numbers; strict default arm _ => Err(ContractError::UnknownReplyId { id }); reserve and document id ranges.

3.3 Unprotected migrate

migrate is CosmWasm’s upgrade hook. When the admin calls MsgMigrateContract, the chain swaps the code id and invokes the new code’s migrate. The new code can read and rewrite all state — set new admin, mint to itself, drain.

Two bug classes:

  • admin is a single key — equivalent to a single-EOA-owned EVM contract. Key compromise = total drain. admin should be a multisig/DAO timelock, or None (immutable).
  • migrate trusts MigrateMsg — even an honest admin signing a crafted MigrateMsg is a risk. Validate inputs as strictly as execute.

Audit checklist:

  • What is admin? Look up via wasmd query wasm contract <addr> or explorer.
  • Is migrate body empty (suspect — what’s the upgrade strategy?) or fully validating?
  • What protects users from a compromised admin in a single upgrade?

3.4 Address validation: addr_validate vs Addr::unchecked

Cosmos addresses are bech32 (cosmos1..., juno1..., sei1...). cosmwasm-std distinguishes:

  • String — raw, untrusted.
  • Addr — wrapper the contract author asserts is valid; the runtime does not check.

To validate a String into a real Addr:

let validated: Addr = deps.api.addr_validate(&raw_string)?;

Confirms bech32 decoding, matches the chain HRP, returns canonical form.

Bug shape:

let recipient = Addr::unchecked(&msg.recipient);   // lies that caller validated
BALANCES.save(deps.storage, &recipient, &amount)?; // stored under arbitrary string

Addr::unchecked("BoB") and Addr::unchecked("bob") are different keys. State fragments across address variants; later flows that use validated Addr miss the unchecked entries; funds lock.

Related: addr_canonicalize (bech32 → bytes) vs addr_humanize (bytes → bech32). Older contracts store canonical form to save space; confusing the two during reads is a recurring bug.

Audit checklist:

  • Every user-supplied address string: addr_validate called before storage?
  • Storage mixing Addr and CanonicalAddr keys: consistent?
  • update_admin(new_admin: String) style functions: validated before save?

3.5 Funds handling: info.funds vs balance queries

CosmWasm bundles attached tokens in info.funds: Vec<Coin>. Three bug shapes:

(a) Trusting info.funds[0]. Attackers attach multiple denoms; [0] is order-dependent. Empty vector panics on indexing — DoS for callers who forget funds. Always iterate funds and explicitly check len == 1 && denom == expected.

(b) Trusting info.funds reflects balance change for CW20. The bank module transfers native coins before execute runs, but CW20 tokens don’t appear in info.funds at all — they require explicit Cw20ExecuteMsg::Send or TransferFrom.

(c) Unvalidated CW20 receive hooks:

// CW20 token contract calls this on YOUR contract after a Send
pub fn execute_receive(
    deps: DepsMut, info: MessageInfo, msg: Cw20ReceiveMsg
) -> Result<Response, ContractError> {
    // info.sender == the CW20 contract; msg.sender == original user
    // BUG: no allowlist — any malicious CW20 can call this
}

Attacker deploys a malicious CW20 that calls execute_receive with amount = u128::MAX; contract credits attacker for tokens it doesn’t hold. Mitigation: maintain Map<&Addr, TokenConfig> of accepted tokens; reject info.sender not in the map.

3.6 Cross-contract query reentrancy (read-only re-entry analog)

The class. Contract A’s query reads from contract B (cross-contract query, QuerierWrapper). If contract B is mid-execute and its state is inconsistent, A reads stale or invalid values.

Cross-contract queries are read-only in CosmWasm — B’s query cannot mutate state and cannot dispatch submessages. But:

  • B’s query may compute values from B’s storage that’s mid-update during a multi-step execute.
  • B’s query may itself call another contract’s query in a chain — at the bottom, an attacker-controlled mock contract can lie freely.

Audit angle: oracles often expose a query interface; consumers must understand whether the oracle’s published value is updated atomically with state writes, or interim. Many DeFi-on-Cosmos protocols read prices via cross-contract query — same risk profile as EVM “read-only reentrancy”, different mechanism.

3.7 Float-in-deterministic-context (and fixed-point reimplementation bugs)

Covered in §2.5. The float ban is structural; the real bugs are in hand-rolled fixed-point math:

  • Uint128 and Uint256 are CosmWasm primitives. Decimal is a fixed-point type (18 decimals).
  • Operator overloading often masks panic-on-overflow vs wrap-on-overflow ambiguity.
  • Division loses precision. Multiplying-before-dividing is the safe order; many DeFi-on-Cosmos contracts get this wrong in fee/share math.
// Order matters
let share = (deposit_amount * total_shares) / pool_balance;  // safer
let share = (deposit_amount / pool_balance) * total_shares;  // truncates first → 0

3.8 Response.attributes are not access control

A small but frequent issue: developers attach data as Response::attributes (“events” in CosmWasm terminology) and assume off-chain indexers can be trusted to interpret them. Attributes are useful for indexing but are not consensus-critical. Decisions based on attributes are decisions based on off-chain data — flag it.

3.9 IBC channel security (when in scope)

Inter-Blockchain Communication (IBC) is Cosmos’s cross-chain messaging primitive. CosmWasm contracts can be IBC-enabled, exposing ibc_channel_open, ibc_channel_connect, ibc_packet_receive, ibc_packet_ack, ibc_packet_timeout.

Audit angles (the bridge / cross-chain class from Tuan-10-Bridge-Cross-Chain-Security):

  • ibc_packet_receive must validate the source channel and port — accepting packets from arbitrary channels is the cross-chain Nomad bug class.
  • Packet payloads are attacker-supplied; deserialise carefully.
  • Timeout and ack handlers must restore state on failure (the funds you sent might come back).
  • Finality assumptions of the counterparty chain matter — IBC light clients track the counterparty’s consensus.

4. Move — The Resource Model in One Sitting

4.1 What Move is (and isn’t)

Move is a language originally developed for Diem (Facebook’s stillborn blockchain). It has two production deployments:

AptosSui
Storage modelGlobal key-value, keyed by addressObject-centric; every value has a unique ObjectID and an owner
Move dialectOriginal / “Aptos Move”Heavily forked / “Sui Move”
DispatchAccount-based, similar to DiemObject-based, with shared/owned/immutable categories
Formal verifierMove Prover (first-class)Less mature; community tools
Typical bug classCapability leak, generic confusion, init protectionObject misuse, shared-vs-owned confusion, dynamic field misuse

Most concepts (resources, modules, generics, abilities) are shared. The storage model is where they fundamentally diverge.

4.2 The resource model: linear types in five sentences

In EVM, “owning 100 USDC” is a number in a mapping — it’s data, freely copyable in memory, manipulated via balance arithmetic.

In Move, “owning 100 USDC” is a value of type Coin<USDC> with the amount field set to 100. The value cannot be copied unless its type has the copy ability, and cannot be implicitly dropped unless it has the drop ability. Coins don’t have either. The only way to make a coin disappear is to call a destructor function (coin::destroy_zero for zero-value coins) or to merge it into another coin. The only way to make a new coin is to mint it via a privileged function holding the right capability.

This sounds abstract, but it’s the most important property of Move:

In Move, if your code compiles, you cannot accidentally lose, duplicate, or fabricate an asset. The compiler enforces conservation.

Compare to Solidity, where balances[user] -= amount; balances[user] += amount; in the wrong order silently changes nothing, and balances[user] += attackerAmount; mints out of thin air. In Move, the type system catches both.

4.3 Abilities — the type-system primitive

Every Move type has zero or more of four abilities: copy (implicit duplication), drop (silent destruction allowed), store (can be persisted inside another resource), key (can be a top-level resource / object). A typical asset like Coin<T> has store but neither copy nor drop — the type system enforces conservation. A capability like MintCap<T> has store and maybe drop, never copy.

4.4 Modules, not contracts

A Move module is a namespace of types and functions — not an EVM-style contract holding balance. Compiled code is published at an address (Aptos: publisher account; Sui: package object). Data lives in resources — instances of types defined by the module, stored under user accounts (Aptos) or as objects (Sui). Only the defining module can read or write internal struct fields; there’s no equivalent of EVM’s “anyone can read any slot if they know the layout”.

4.5 Generic types and the witness pattern

Generic types brand otherwise-identical data: Coin<USDC> and Coin<USDT> are different types even with the same fields. The witness pattern uses a type whose only constructor is inside the defining module:

module 0x1::usdc {
    struct USDC has drop {}                            // witness; can be consumed
    public fun initialize(): Coin<USDC> {
        coin::mint<USDC>(0, USDC {})                   // only this module can produce USDC{}
    }
}

Outside 0x1::usdc, no code can produce a USDC {} value, so mint<USDC> is implicitly module-private. This is Move’s “access control via type system”.

4.6 Aptos vs Sui storage

Aptos: each account has a key-value resource map. Coin<USDC> lives under each user account at a slot determined by its type. The auditor’s mental model is EVM-like: “resource X exists at account A; functions in the defining module can manipulate it.”

Sui: every persistent value is an object with a unique ObjectID and one of three ownership categories:

  • Owned by an address — only that owner can mutate; transactions over disjoint owned objects run in parallel.
  • Shared — anyone can mutate via a tx referencing it; forced through consensus ordering.
  • Immutable — frozen via freeze_object; read-only forever; parallel-safe.

The Sui auditor’s mental model adds: “the parallel scheduler sees every shared-object access; the object’s category determines both who can mutate and what concurrency it forces.”


5. Move Vulnerability Classes

5.1 Capability leakage

The class. Capabilities are values whose existence is the right to do something. MintCapability<T> = “the right to mint Coin<T>”; TreasuryCap<T> (Sui) similar. Three bug shapes:

(a) Stored under the wrong account / object:

public fun setup(creator: &signer) {
    let cap = create_mint_cap<USDC>();
    move_to(creator, Caps { mint_cap: cap });    // Aptos
}

If setup is an unprotected entry function, an attacker calls it with their own signer, stores the cap under their own account, and gains minting power. Always assert signer::address_of(creator) == expected_admin early.

(b) Public accessor leaks the inner cap: public fun get_caps(addr): &Caps returning a struct whose fields are accessed elsewhere via cross-module generics can erode field privacy. Trace every &Cap return.

(c) Capability passed by value when it should be by-reference (or vice versa): by-value consumption forces “one mint per held cap” semantics; by-reference allows reuse. Distinguish per call site.

5.2 Object misuse on Sui — shared vs owned vs immutable

Sui’s three object categories drive both access control and concurrency:

CategoryWho can mutateConcurrency
OwnedOnly the ownerParallel scheduling
SharedAny address (via tx)Forced through consensus ordering
ImmutableNo one (after freeze_object)Pure-read; parallel

Four bug shapes:

  • Forgot to share a pool that needs public mutation — only deployer can act; users hit side-channels.
  • Shared what should be owned — a “wallet” object goes public; anyone mutates it. Sui’s “missing access control” archetype.
  • Frozen by mistakefreeze_object is permanent; bricks upgrade paths and admin flows.
  • Object-ID confusionpool: &mut Pool accepts any Pool; if multiple pools exist (one per pair), caller passes the wrong one. Validate IDs or bind via phantom types.

5.3 Generic-type confusion

// BUG: Vault doesn't bind to a token type
public fun deposit<T>(coin: Coin<T>, vault: &mut Vault) {
    vault.balance = vault.balance + coin::value(&coin);
}

Caller passes Coin<FakeToken>, credits a balance the protocol thinks is real. Mitigation: struct Vault<phantom T> has key { balance: u64 } so the type system forbids depositing Coin<X> into Vault<Y>.

5.4 Witness pattern misuse

A witness is a one-shot type proving “this code is running on behalf of the defining module”. Two bug shapes:

  • Reusable witness: if the type has copy, it can be summoned by anyone. Witnesses must have drop only.
  • Public constructor: public fun new_witness(): USDC destroys the pattern. Look for accidental constructors.

5.5 Cross-module field privacy

Struct fields are private to the defining module — enforced at compile time. The breakage: generic dispatch that returns &T to an internal field can indirectly leak access to another module’s type. Trace &T returns through generic boundaries.

5.6 Arithmetic overflow

Move arithmetic aborts on overflow (opposite of Rust release default). Structural protection. Still flag:

  • Wide multiplications that may abort under normal use → DoS (not theft, but find).
  • Fee/share math: use math::mul_div(a, b, c) for a * b / c rather than raw a * b then / c to avoid intermediate overflow.

5.7 Hot-potato pattern misuse (Sui)

Sui’s flash-loan idiom uses a value with zero abilities (no copy, no drop, no store, no key): only the defining module’s repay function can consume it.

struct FlashReceipt { amount: u64 }  // no abilities at all
public fun borrow(pool: &mut Pool, amount: u64): (Coin<T>, FlashReceipt) { ... }
public fun repay(pool: &mut Pool, coin: Coin<T>, receipt: FlashReceipt) { ... }

If the receipt accidentally has store, a malicious caller persists it in another resource and never repays. The pattern only works with zero abilities.

5.8 init / init_module misuse

Move modules can have a one-time initializer (Sui init, Aptos init_module). Bug shapes:

  • Re-publish flows that re-invoke init reset state (verify the chain’s upgrade policy).
  • init stores privileged caps under the publisher account; call out the trust assumption explicitly.

5.9 Sui dynamic fields

Sui lets you attach arbitrary typed values to an object via dynamic_field / dynamic_object_field. Audit angles:

  • Type-and-name mismatches at remove/borrow time abort the tx; combinations of attacker-controlled names can cause DoS or unexpected branches.
  • Removing a parent without clearing dynamic-object-field children orphans objects.
  • References returned from dynamic-field access can outlive the type system’s tracked lifetimes in certain patterns — read OtterSec Sui blogs for current detail.

6. Real-World Incidents (study these)

6.1 Levana Finance (Sei, December 2023) [verify]

Operational / DoS, not a contract bug — but instructive. Sei chain congestion (spam txs) prevented Levana’s oracle updates from landing; the perpetuals protocol’s internal accounting opened arbitrage windows during the staleness. Reports cite ~$1.1M LP losses.

Lessons: “smart contract is correct” is not enough — the chain’s mempool and gas market are part of the trust model. Cosmos chains with naive gas markets are more vulnerable to spam congestion than Ethereum. Ask: “what if oracle updates lag N blocks?” Mitigations: conservative pricing requiring fresh data, circuit breakers, non-LP fallbacks.

6.2 Cetus Protocol (Sui, May 2025) [verify]

Integer / share-math vulnerability in CLMM math — close kin to KyberSwap Elastic on EVM. An off-by-one or rounding edge in tick math let an attacker mint LP shares disproportionate to deposit, draining pools. Reported losses around $200M+.

Lessons: CLMM math is universally hard. EVM CLMM expertise transfers directly. Move’s type system catches asset duplication but does not catch numeric edge cases — Aptos Move Prover or property-based testing on Sui modules is essential for math-heavy code.

6.3 Other reference incidents

  • Wormhole on Solana, 2022 — not Move/CosmWasm, but the “missing signer check” archetype recurs across non-EVM chains.
  • Mars Protocol on Terra Classic, 2022 — chain-collapse, not contract bug. Reminder: a fragile chain can outweigh safe code.
  • Various Aptos token bugs, 2023 — fungible-asset migration issues, generic confusion in DEX modules. See Zellic and OtterSec Aptos blogs.

7. Tooling

7.1 CosmWasm tooling

ToolPurposeAudit relevance
cw-multi-testIn-Rust simulation of multiple contracts + bank + IBCWrite exploit PoCs as Rust unit tests; no chain needed
cosmwasm-checkStatic check that a Wasm blob is valid CosmWasm (no floats, valid imports)Verify compiled artifacts
cw-debug / console_log!Print-debug from inside a contract during testsTrace execution paths
wasmd (junod, osmosisd, seid etc.)Run a local Cosmos chain with CosmWasm enabledLive-fork or integration testing
OtterSec / Halborn fuzzing harnessesCustom fuzzers from audit firms; not all publicInquire when scoping audits
cargo-audit, cargo-denyRust dependency vulnerability scanningCatch supply-chain risk in third-party crates
cargo-tarpaulin, cargo-llvm-covCoverage measurementEnsure tests touch every branch

7.2 Move tooling

ToolChainPurpose
Aptos CLI (aptos)AptosBuild, publish, test, prover invocation
Sui CLI (sui)SuiBuild, publish, test, transaction submission
Move ProverAptosFormal verification of spec blocks; built into the aptos toolchain
Sui Move Analyzer (VS Code extension)SuiLinting, type-flow analysis
MoveBit’s fuzzer / OtterSec internal toolingBothProperty-based testing; partially closed-source
forge / FoundryN/AMentioned for reference — there’s no Foundry equivalent in Move yet, though some Aptos community projects approximate it

7.3 The Move Prover in one paragraph

Aptos’s Move Prover lets you write spec blocks with requires/ensures/aborts_if clauses describing pre- and post-conditions. The prover (built on Boogie + Z3) attempts to discharge each clause and returns counterexamples on failure. Used well, it catches arithmetic, access-control, and capability-flow invariants. Used poorly, specs paraphrase the code — a circular check that proves nothing. Audit heuristic: when reviewing Aptos modules with specs, ask whether the spec adds an independent invariant beyond what the function already does. If yes, run the prover and study its output; if no, the spec coverage is decorative.


8. Cross-Ecosystem Audit Checklist (EVM vs CosmWasm vs Move)

A compact comparison to reference during scoping or kick-off:

QuestionEVMCosmWasmMove (Aptos/Sui)
What runs first when a tx targets contract X?Function selector → matching functionexecute entry with deserialised ExecuteMsg::VariantModule entry function in target module
Who is “caller”?msg.senderinfo.sender&signer (Aptos) / tx_context::sender(ctx) (Sui)
How are funds attached?msg.value (native only) or pre-approved transferFrom (ERC-20)info.funds (native) or CW20 receive hookCoin objects passed as arguments (Sui) or coin store under signer (Aptos)
External calls?CALL/STATICCALL/DELEGATECALL opcodesReturn Response::add_message / add_submessage; runtime dispatchesFunction call within same tx; no equivalent of EVM CALL across modules with new context
Reentrancy?Yes, via CALL semanticsYes, via submessage replies (and IBC)Generally prevented by linear types; possible via dynamic dispatch / function_values (Sui)
Upgrade mechanism?Proxy + delegatecall + impl swapmigrate entry, admin-gatedModule upgrade (Aptos), package upgrade (Sui), each with policy variants
Access control primitive?msg.sender == owner / role-basedinfo.sender == admin / role-basedCapability objects / signer ownership
Integer overflow?Reverts in Solidity 0.8+Wraps in release Rust unless overflow-checks = true; must verifyAborts on overflow at language level
Cross-chain primitive in scope?Bridges (separate audit)IBC (standardised); audit ibc_* entry pointsAptos bridge contracts / Sui bridge — both bespoke, treat like EVM bridges
Standard libraries?OpenZeppelincw-storage-plus, cw20, cw721, cw-utilsaptos_std, aptos_framework / Sui framework, sui::*

When kicking off a non-EVM audit, walk this checklist with the client. Often they don’t realise they’re using non-standard tools or have skipped baseline protections.


9. Lab

9.1 Lab structure

~/web3-sec-lab/wk-bonus-nonevm/
├── 01-cosmwasm-reply-reentry/   # CosmWasm vulnerable contract + exploit test
├── 02-cosmwasm-real-contract/   # Read a live Osmosis/Neutron contract from explorer
└── 03-sui-capability-pattern/   # Optional: Sui capability good vs bad pattern

9.2 Lab 1 — CosmWasm reply re-entry

Goal: write a vault with a reply-ID-confusion + CEI-violation bug; exploit with cw-multi-test; patch.

Setup:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
rustup target add wasm32-unknown-unknown
cargo install cargo-generate
mkdir -p ~/web3-sec-lab/wk-bonus-nonevm/01-cosmwasm-reply && cd $_
cargo generate --git https://github.com/CosmWasm/cw-template.git --name vuln-vault
cd vuln-vault

In Cargo.toml, confirm [profile.release] contains overflow-checks = true. (Several known-exploited CosmWasm contracts shipped without this line.)

src/contract.rs — vulnerable vault (key portions only; full file in cw-template scaffold):

const BALANCES: Map<&str, Uint128> = Map::new("balances");
const PENDING: Item<(String, Uint128)> = Item::new("pending");  // ← single-item, not per-user
const REPLY_WITHDRAW: u64 = 1;
 
pub fn execute_withdraw(
    deps: DepsMut, info: MessageInfo, amount: Uint128
) -> StdResult<Response> {
    let bal = BALANCES.load(deps.storage, info.sender.as_str())?;
    if bal < amount { return Err(StdError::generic_err("insufficient")); }
    // VULN 1: state change deferred to reply (no CEI)
    PENDING.save(deps.storage, &(info.sender.to_string(), amount))?;
    let msg = SubMsg::reply_on_success(
        BankMsg::Send { to_address: info.sender.to_string(),
                        amount: vec![coin(amount.u128(), "uatom")] },
        REPLY_WITHDRAW,
    );
    Ok(Response::default().add_submessage(msg))
}
 
pub fn reply(deps: DepsMut, _env: Env, msg: Reply) -> StdResult<Response> {
    match msg.id {
        REPLY_WITHDRAW => {
            let (user, _claimed) = PENDING.load(deps.storage)?;
            // VULN 2: zero out the full balance regardless of withdrawn amount
            BALANCES.save(deps.storage, user.as_str(), &Uint128::zero())?;
            PENDING.remove(deps.storage);
            Ok(Response::default())
        }
        // VULN 3: unknown ids silently no-op
        _ => Ok(Response::default()),
    }
}

The bugs:

  1. PENDING is a single Item, not a Map<&Addr, _> — concurrent withdraw flows clobber each other.
  2. reply reads _claimed but zeros the entire balance — partial withdraws confiscate remaining funds.
  3. The default match arm hides id reuse / chain-injected replies.

Exploit: write a cw-multi-test test in src/integration_tests.rs:

  • Two users deposit 10 ATOM each.
  • User A withdraws 1 ATOM; observe that A’s balance is now 0 (lost 9 ATOM).
  • Stretch: trigger concurrent submessage trees and demonstrate PENDING clobber.

Patch (apply CEI + per-user pending + strict default arm):

  1. Decrement BALANCES[sender] -= amount before add_submessage.
  2. Drop PENDING entirely (no longer needed) or use Map<&Addr, Uint128> if you still need it.
  3. _ => Err(StdError::generic_err(format!("unknown reply id {}", msg.id))).

Re-run the exploit test; it must now fail. This is your CosmWasm “before / after” PoC artifact.

9.3 Lab 2 — Read a real CosmWasm contract on Osmosis or Neutron

Goal: build the reflex of opening an unfamiliar non-EVM contract and identifying trust boundaries in 10 minutes.

  1. Open an explorer (mintscan.io/osmosis, mintscan.io/neutron). Pick a contract — Osmosis swap router, Neutron auction, an Astroport pair. Note code id and contract address.
  2. Fetch source from the project’s GitHub (most publish alongside deployed Wasm).
  3. Map entry points: list all ExecuteMsg variants; identify admin/governance-gated ones; check migrate body and the admin field (wasmd query wasm contract <addr> or explorer); check any sudo entry.
  4. Trust-assumption checklist:
    • Who can call instantiate?
    • Who is admin? Single account / DAO / None?
    • Which execute variants are admin-only? Look for assert_admin-style guards.
    • Funds-accepting variants: denom validated?
    • Submessage flows: where do they dispatch, what does reply do?
    • CW20 receive hooks: sender allowlisted?
  5. Write a one-page trust-seam note — same format as the EVM exercise in Tuan-01-Web3-Blockchain-Crypto-Fundamentals.

9.4 Lab 3 (optional) — Sui capability good vs bad pattern

Goal: experience Sui’s object model by writing a bad capability pattern and a corrected one.

Install: brew install sui (or cargo install --git https://github.com/MystenLabs/sui.git). Then sui move new cap-lab && cd cap-lab.

Bad module (sources/bad_admin.move) — mint accepts any AdminCap:

module cap_lab::bad_admin {
    public struct AdminCap has key, store { id: UID }
    public struct Treasury has key { id: UID, balance: u64 }
 
    fun init(ctx: &mut TxContext) {
        let cap = AdminCap { id: object::new(ctx) };
        let treasury = Treasury { id: object::new(ctx), balance: 0 };
        transfer::public_transfer(cap, tx_context::sender(ctx));
        transfer::share_object(treasury);
    }
    // BUG: any AdminCap authorizes the mint
    public fun mint(_cap: &AdminCap, treasury: &mut Treasury, amount: u64) {
        treasury.balance = treasury.balance + amount;
    }
}

Good module (sources/good_admin.move) — Treasury binds to one cap’s ID:

module cap_lab::good_admin {
    public struct AdminCap has key, store { id: UID }
    public struct Treasury has key { id: UID, balance: u64, admin: ID }
 
    fun init(ctx: &mut TxContext) {
        let cap = AdminCap { id: object::new(ctx) };
        let admin = object::id(&cap);
        let treasury = Treasury { id: object::new(ctx), balance: 0, admin };
        transfer::public_transfer(cap, tx_context::sender(ctx));
        transfer::share_object(treasury);
    }
    public fun mint(cap: &AdminCap, treasury: &mut Treasury, amount: u64) {
        assert!(object::id(cap) == treasury.admin, 0);
        treasury.balance = treasury.balance + amount;
    }
}

Build (sui move build), then write a #[test_only] test that constructs a second AdminCap via test helpers and calls bad_admin::mint — succeeds — vs good_admin::mint — aborts. Pattern: bind capability instances to the specific resource they control.

9.5 Stretch — Run Aptos Move Prover

brew install aptos && aptos move init --name prover-lab. Write add(a, b): u64 { a + b } with a spec block:

spec add {
    aborts_if a + b > MAX_U64;
    ensures result == a + b;
}

Run aptos move prove. Then deliberately weaken the spec to ensures result == a + b + 1 and watch the prover hand back a counterexample. This is the smallest possible formal-verification experience but enough to read prover output in real Aptos audits with confidence.


10. Anti-patterns (extends master checklist)

CosmWasm

  • migrate body is empty / unguarded; admin field is a single EOA, not a multisig or DAO.
  • reply handler’s default arm is Ok(Response::new()) instead of Err.
  • Reply IDs are magic numbers; same number used for two distinct flows.
  • Addr::unchecked(user_input) without addr_validate.
  • info.funds[0] indexed without checking length; multiple denoms not handled.
  • CW20 receive hook accepts any token sender; no allowlist.
  • Storage keys (Item::new("foo")) duplicated across modules.
  • Hand-rolled fixed-point math; mul before div not consistently used; precision loss in fee accounting.
  • overflow-checks = true not set in [profile.release].
  • State mutated after submessage dispatch when CEI would have ordered it before.
  • IBC packet_receive doesn’t validate source channel and port.
  • sudo entry handles user-controllable inputs (should only handle chain-controlled messages).

Move (Aptos / Sui)

  • Capability passed by value or & when it should be &mut (single-instance) or validated against a stored ID.
  • Generic function fun deposit<T>(coin: Coin<T>, vault: &mut Vault) where Vault lacks a phantom type binding it to T.
  • Witness type has copy or drop outside the consumption point.
  • Public constructor for a witness type.
  • init / init_module lacks signer authority check, or re-publish path can re-invoke it.
  • Sui object is share_object’d when it should be owned (or vice versa).
  • Sui hot-potato resource accidentally has store (can be persisted, defeating the pattern).
  • Sui dynamic field name and type combination unchecked when remove/borrow expects specific values.
  • Sui object passed by mutable reference without validating its ID matches the expected one.
  • Aptos move_to writes to attacker-controlled signer.
  • Move arithmetic that can overflow uses raw * instead of math::mul_div.
  • Move Prover spec blocks paraphrase the code rather than expressing meaningful invariants.

11. Trade-offs and Open Debates

DecisionOption AOption BAuditor view
CosmWasm: protect migrate with multisig vs immutableMultisig adminadmin: None (immutable)Immutable for low-risk; multisig with timelock + transparency for upgradeable. Single-key admin is rarely justifiable.
CosmWasm: synchronous queries vs cached stateRead peer contract via cross-contract queryCache values locally with periodic updatesCross-contract query is simpler but reads through the peer’s potentially-interim state; cached state is more resilient but stale. Trade-off is protocol-specific.
Move: capability passed by value vs referencePass AdminCap by value (consumed each call)Pass &AdminCap (immutable shared use)By value is safer for high-privilege ops (forces explicit holding); by reference for routine ops. Distinguish in code review.
Move (Sui): shared vs owned for “pool”-like objectsShared (all users trade)Owned by a “manager” (manager dispatches)Shared is standard for DEX/AMM; owned is rare and signals centralisation.
Move (Aptos): spec coverageLight, top-level invariants onlyHeavy, function-by-functionHeavy spec catches more bugs but rots fast under code churn. Recommendation: heavy on invariants that protect funds; light on internal helpers.
Cross-chain testingMock peer with cw-multi-test / Move test frameworkLive testnetMocks for invariant testing; testnet for end-to-end and gas. Both required for production audits.

12. Quiz (≥80% to advance)

  1. Q: In CosmWasm, what’s the difference between Message and SubMsg? A: A Message (added via .add_message) is fire-and-forget; the runtime executes it after your contract returns, and if it fails the whole transaction reverts. A SubMsg (added via .add_submessage) is dispatched and then the runtime synchronously invokes your contract’s reply entry with a Reply { id, result }. SubMsg enables capturing the inner result (success or error) and conditionally proceeding.

  2. Q: Why is an unprotected migrate entry point analogous to an unprotected EVM proxy upgrade? A: migrate runs after the chain swaps the contract’s code id. The new code runs with full access to existing state — it can read, transform, or replace any of it. If admin (who triggers migrations) is a single key, compromise = total control. Same shape as EVM proxy hijack via leaked admin key.

  3. Q: A CosmWasm contract has let recipient = Addr::unchecked(&msg.to); followed by BALANCES.save(deps.storage, &recipient, &amount). What’s the bug? A: Addr::unchecked does not validate the string. Storage entries are saved under arbitrary, possibly malformed strings. Variants like mixed-case versions of the same bech32 address create distinct keys, fragmenting user state. Always call deps.api.addr_validate first.

  4. Q: In CosmWasm, what should the default arm of a reply handler match block return? A: Err(...) (some UnknownReplyId variant). Returning Ok for unknown ids silently swallows the inner submessage’s result, which can cause the protocol to proceed as if the submessage succeeded when it didn’t — or just leave state inconsistent.

  5. Q: Move’s resource model prevents one major bug class that EVM is famous for. Name it and explain why Move catches it. A: Asset duplication / unintended mint. Move resources like Coin<T> lack the copy ability and the drop ability — the type system forbids implicit copying and silent dropping. The only way to create a Coin<T> is through a privileged mint function (gated by a capability), and the only way to destroy one is to merge it into another coin or destroy a zero-value one explicitly. The compiler enforces conservation.

  6. Q: What’s the witness pattern in Move and what abilities should a witness type have? A: A witness is a (usually unit) struct whose construction is restricted to the module that defines it. By passing a witness value to a generic function, you prove the call is happening inside the defining module’s logic. Witnesses should have drop (so they’re consumed) and never copy (which would let anyone summon them).

  7. Q: On Sui, what’s the practical difference between an owned object and a shared object for the transaction scheduler? A: Owned objects can only be touched by their owner; transactions over disjoint owned objects can run in parallel. Shared objects require consensus ordering — every transaction touching the same shared object must be serialised through Sui’s consensus. Mistakenly sharing an object that should be owned creates contention and an attack surface; mistakenly making an object owned that should be shared blocks usage by other parties.

  8. Q: A CosmWasm contract has let coin = info.funds[0]; followed by assert!(coin.denom == "uatom");. Two bugs — name them. A: (1) info.funds[0] panics if no funds attached, causing DoS for forgetful callers. (2) If multiple denoms are attached, [0] is not deterministic; the attacker can attach uatom plus garbage and hit a code path that’s only “validated” for index 0. Should iterate funds and explicitly check len == 1 && denom == "uatom".

  9. Q: In Aptos Move, you see a function public fun init(creator: &signer) that calls move_to(creator, AdminConfig { ... }). What’s the audit question? A: Who can call init? If it’s entry and accessible to any caller, an attacker calls it with their own signer and stores the privileged config under their own account, gaining admin rights. The pattern must either (a) be init_module (run once at publish time, system-invoked) or (b) explicitly check signer::address_of(creator) == EXPECTED_ADMIN.

  10. Q: You’re scoping an audit for a CosmWasm vault contract and a Sui DEX contract. Name two things you’d add to the scope explicitly that you would not need for an EVM audit. A: For CosmWasm: (a) trust assumptions on the chain’s admin for migrations, (b) IBC channel security if IBC is enabled, (c) overflow-check Cargo settings. For Sui: (a) object-type taxonomy (which objects are owned, shared, immutable; for what reasons), (b) capability flow analysis (who can construct each capability, who can transfer it), (c) dynamic-field usage and lifetime tracking. Either ecosystem: comparison against EVM’s “single-storage-address” mental model is not enough; the storage and dispatch models are fundamentally different.


13. Deliverables

  • CosmWasm Lab 1: vulnerable contract + exploit test + patched version passing.
  • CosmWasm Lab 2: written trust-seam analysis of one live mainnet contract (Osmosis / Neutron / Sei).
  • (Optional) Sui Lab 3: bad-admin module + good-admin module + passing tests demonstrating cap-validation.
  • (Optional) Aptos Prover hello-world.
  • Notes file: one-paragraph answer for each of: “what’s the CosmWasm equivalent of EVM reentrancy?”, “what does Move’s linear type system give and not give an auditor?”, “if you had 60 seconds to scope an audit on an unfamiliar non-EVM chain, what three questions would you ask the team?”
  • Master audit checklist updated with §10 anti-patterns.

14. Where this leads

This is a bonus chapter — there’s no fixed “next week”. Two natural follow-ons:

  • Tuan-Bonus-Non-EVM-Solana — Solana’s account model is yet another runtime; the Anchor framework adds further bug classes around discriminators, signer checks, and CPI security. The combined CosmWasm + Move + Solana coverage prepares you for almost any non-EVM audit in 2026.
  • Tuan-Bonus-Formal-Verification-Deep — if the Move Prover hands-on whetted appetite, the deep FV chapter covers Certora CVL, Halmos, K Framework and when formal proofs beat property-based fuzzing.

The meta-lesson: runtimes change; failure modes don’t. An auditor with the right mental scaffolding ports between ecosystems in a week, not a quarter. The vocabulary changes; the questions stay the same.

“What does this primitive guarantee? What does it not? What’s the trust seam? What breaks if the assumption fails?”

Those four questions, applied to CosmWasm submessages, Move capabilities, Sui shared objects, or any future runtime, define the job.


Last updated: 2026-05-16 See also: Roadmap · References · MOC-Web3-Security-Mastery · Tuan-Bonus-Non-EVM-Solana · Tuan-05-Vulnerability-Classes-Part-1 · Tuan-10-Bridge-Cross-Chain-Security · Tuan-15-Audit-Methodology-Tooling