“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?‘”
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 bug
CosmWasm shape
Move (Aptos/Sui) shape
Reentrancy via external call
Submessage reply re-entry
Generally prevented by linear types; reappears via dynamic dispatch / function_values / hot-potato patterns
Module upgrade with compatible-but-malicious bytecode; package upgrade policy (Sui)
Signature replay
Off-chain message verification on-chain (rare in CosmWasm; common in IBC channels)
Same as EVM: nonce + chain id + module id
Integer overflow
Rust panics by default in release; overflow checks must be on
Move arithmetic aborts on overflow by default
Oracle manipulation
Same as EVM, plus Cosmos-specific oracle modules
Same as EVM, plus Sui/Aptos oracle module quirks
Access control via msg.sender
info.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.
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:
Caller chooses constructor args; contract code is fixed
Validate every input; assert ownership early; never trust info.sender here is your “admin”
execute
Anyone, via a MsgExecuteContract Tx
Caller is info.sender; funds attached are in info.funds
The main attack surface — every variant of ExecuteMsg is a public function
query
Any client (off-chain or on-chain via another contract)
Read-only; cannot mutate state
Read-only re-entry possible from cross-contract queries (analogous to EVM read-only reentrancy)
reply
The runtime, after a submessage finishes
Only the runtime; info.sender semantics N/A
Match on reply.id; incorrect or missing match = bug; parse reply.result.data carefully
sudo
The chain itself via governance / module hooks
Only the chain (no user-space caller); very high trust
Used for governance-triggered actions and IBC; auditing chains, not users
migrate
Whoever holds the admin field on the contract
If admin is unset, no one; if set to a multisig/DAO, that group
Pay 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:
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 BALANCESbefore 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 validatedBALANCES.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 beforeexecute 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 Sendpub 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.
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.
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.
Object 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:
Category
Who can mutate
Concurrency
Owned
Only the owner
Parallel scheduling
Shared
Any address (via tx)
Forced through consensus ordering
Immutable
No 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 mistake — freeze_object is permanent; bricks upgrade paths and admin flows.
Object-ID confusion — pool: &mut Pool accepts anyPool; 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 typepublic 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 allpublic 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
Tool
Purpose
Audit relevance
cw-multi-test
In-Rust simulation of multiple contracts + bank + IBC
Write exploit PoCs as Rust unit tests; no chain needed
cosmwasm-check
Static 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 tests
Trace execution paths
wasmd (junod, osmosisd, seid etc.)
Run a local Cosmos chain with CosmWasm enabled
Live-fork or integration testing
OtterSec / Halborn fuzzing harnesses
Custom fuzzers from audit firms; not all public
Inquire when scoping audits
cargo-audit, cargo-deny
Rust dependency vulnerability scanning
Catch supply-chain risk in third-party crates
cargo-tarpaulin, cargo-llvm-cov
Coverage measurement
Ensure tests touch every branch
7.2 Move tooling
Tool
Chain
Purpose
Aptos CLI (aptos)
Aptos
Build, publish, test, prover invocation
Sui CLI (sui)
Sui
Build, publish, test, transaction submission
Move Prover
Aptos
Formal verification of spec blocks; built into the aptos toolchain
Sui Move Analyzer (VS Code extension)
Sui
Linting, type-flow analysis
MoveBit’s fuzzer / OtterSec internal tooling
Both
Property-based testing; partially closed-source
forge / Foundry
N/A
Mentioned 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:
Question
EVM
CosmWasm
Move (Aptos/Sui)
What runs first when a tx targets contract X?
Function selector → matching function
execute entry with deserialised ExecuteMsg::Variant
Module entry function in target module
Who is “caller”?
msg.sender
info.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 hook
Coin objects passed as arguments (Sui) or coin store under signer (Aptos)
Function call within same tx; no equivalent of EVM CALL across modules with new context
Reentrancy?
Yes, via CALL semantics
Yes, via submessage replies (and IBC)
Generally prevented by linear types; possible via dynamic dispatch / function_values (Sui)
Upgrade mechanism?
Proxy + delegatecall + impl swap
migrate entry, admin-gated
Module upgrade (Aptos), package upgrade (Sui), each with policy variants
Access control primitive?
msg.sender == owner / role-based
info.sender == admin / role-based
Capability objects / signer ownership
Integer overflow?
Reverts in Solidity 0.8+
Wraps in release Rust unless overflow-checks = true; must verify
Aborts on overflow at language level
Cross-chain primitive in scope?
Bridges (separate audit)
IBC (standardised); audit ibc_* entry points
Aptos bridge contracts / Sui bridge — both bespoke, treat like EVM bridges
Standard libraries?
OpenZeppelin
cw-storage-plus, cw20, cw721, cw-utils
aptos_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.
Drop PENDING entirely (no longer needed) or use Map<&Addr, Uint128> if you still need it.
_ => 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.
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.
Fetch source from the project’s GitHub (most publish alongside deployed Wasm).
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.
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?
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 anyAdminCap:
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
Decision
Option A
Option B
Auditor view
CosmWasm: protect migrate with multisig vs immutable
Multisig admin
admin: None (immutable)
Immutable for low-risk; multisig with timelock + transparency for upgradeable. Single-key admin is rarely justifiable.
CosmWasm: synchronous queries vs cached state
Read peer contract via cross-contract query
Cache values locally with periodic updates
Cross-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 reference
Pass 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 objects
Shared (all users trade)
Owned by a “manager” (manager dispatches)
Shared is standard for DEX/AMM; owned is rare and signals centralisation.
Move (Aptos): spec coverage
Light, top-level invariants only
Heavy, function-by-function
Heavy spec catches more bugs but rots fast under code churn. Recommendation: heavy on invariants that protect funds; light on internal helpers.
Cross-chain testing
Mock peer with cw-multi-test / Move test framework
Live testnet
Mocks for invariant testing; testnet for end-to-end and gas. Both required for production audits.
12. Quiz (≥80% to advance)
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.
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.
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.
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.
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.
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).
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.
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".
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.
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).
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.