Sui Move Smart Contract Security: How Shared Objects Leak and Ownership Breaks

Introduction
Earlier parts of this series dealt with the Move language itself. This one deals with the chain underneath it. Sui's object-centric execution model produces a security surface with little in common with Aptos's account-based model, even though both chains run Move. They differ on where state lives, who enforces ownership, how transactions compose, and how much of that the runtime handles on your behalf.
That difference has been expensive. Sui lost more than $226M to DeFi exploits in its first five months of serious TVL, most of it the ~$223M Cetus incident in May 2025, whose arithmetic root cause we took apart in Part 3, plus $2.4M from the Nemo Protocol exploit that September. The Sui security model documentation states the core design principle directly: "Everyone can operate on shared or immutable objects, but additional access control logic can be implemented by a smart contract." The VM guarantees ownership of owned objects at the transaction level. For shared objects, the developer is entirely responsible for access control.
1. Sui-Specific Attack Surface
Sui's attack surface follows directly from how objects are owned. Ownership decides who may touch an object, whether the VM enforces that decision or leaves it to your code, and whether the object executes in parallel or through consensus. The findings below move from the sharing and access-control mistakes that have caused real losses to the framework, ability, and composition pitfalls that surface repeatedly in audits.
flowchart TD
subgraph OWNED["Owned Objects"]
direction TB
O1["Only owner can use<br>in transactions"] --> O2["VM enforces ownership<br>at transaction level"]
O2 --> O3["Parallel execution<br>(no sequencing needed)"]
end
subgraph SHARED["Shared Objects"]
direction TB
S1["Anyone can reference<br>in transactions"] --> S2["Developer must enforce<br>all access control"]
S2 --> S3["Sequential execution<br>(consensus ordering)"]
end
subgraph IMMUTABLE["Immutable Objects"]
direction TB
I1["Anyone can read"] --> I2["Cannot be modified<br>or deleted"]
I2 --> I3["Parallel execution<br>(read-only)"]
end
style OWNED fill:#2E7D32,color:#fff,stroke:#1B5E20
style SHARED fill:#C62828,color:#fff,stroke:#B71C1C
style IMMUTABLE fill:#1565C0,color:#fff,stroke:#0D47A1
1.1 Unintended public_share_object Conversion (Critical/Sui)
The most dangerous Sui-specific bug is converting an owned object into a shared object by calling transfer::public_share_object instead of transfer::transfer. This happens because both functions accept the same object type and the compiler raises no warning. The consequences are irreversible: once an object is shared, it cannot be unshared, and every account on the network can interact with it. If the shared object contains funds, admin capabilities, or protocol configuration, the protocol is immediately compromised.
This vulnerability exists because Sui's transfer module exposes both transfer (sends to a specific owner) and public_share_object (makes globally accessible) as equally callable functions. Developers porting from Aptos, where all resources live under accounts, have no equivalent concept of "accidentally making something public" and frequently misuse the sharing primitive. The Monethic Sui Move security workshop documents this as one of the seven most common vulnerability patterns found in real Sui protocol audits.
flowchart TD
NEW["New Vault created in init()"] --> FORK{"Which transfer<br>primitive?"}
FORK -->|"transfer::transfer"| OWNED["Address-owned:<br>VM enforces ownership"]
FORK -->|"transfer::public_share_object"| SHARED["Shared: any account can pass it<br>to any public function"]
OWNED -->|"one-way: can still be shared later"| SHARED
SHARED --> LOSS["Funds, capabilities, and config<br>open to the network"]
style FORK fill:#EF6C00,color:#fff,stroke:#E65100
style OWNED fill:#2E7D32,color:#fff,stroke:#1B5E20
style SHARED fill:#C62828,color:#fff,stroke:#B71C1C
style LOSS fill:#8E0000,color:#fff,stroke:#B71C1C
// Context: module initializer that creates the protocol's Vault.
// VULNERABLE: Vault shared instead of owned -- anyone can interact
public entry fun initialize(ctx: &mut TxContext) {
let vault = Vault {
id: object::new(ctx),
balance: balance::zero<USDC>(),
owner: tx_context::sender(ctx),
};
transfer::public_share_object(vault); // BUG: should be transfer::transfer
}
// SAFE: Vault sent to deployer as a privately owned object
public entry fun initialize(ctx: &mut TxContext) {
let vault = Vault {
id: object::new(ctx),
balance: balance::zero<USDC>(),
owner: tx_context::sender(ctx),
};
transfer::transfer(vault, tx_context::sender(ctx));
}1.2 Shared Object Access Control (Critical to High/Sui)
When an object is correctly shared (global access is the design intent, such as a DEX pool or lending market), every function that mutates it must have explicit access control. Sui's runtime does not enforce any permissions on shared objects beyond "the object exists." Any transaction can pass a shared object as an argument to any public function that accepts it. This is fundamentally different from Ethereum, where msg.sender is implicitly available, and from Aptos, where borrow_global_mut requires an address. On Sui, the developer must add authorization logic to every state-changing function.
The Typus Finance exploit ($3.44M, October 2025) was precisely this pattern: a shared oracle object with an update_v2 function that anyone could call because the whitelist check was missing. The Hacken Move audit checklist explicitly warns: "Every Object<T> can potentially be passed to a function by anyone, so the code must validate that object::owner(&obj) equals the caller's address."
A related misconception is the public(package) entry fun visibility trap. As Monethic's workshop documents, developers assume public(package) restricts external access, but the entry modifier makes the function directly callable as a transaction entry point by anyone.
// Context: fee configuration held in a shared ProtocolConfig object.
// VULNERABLE: Any caller can update the shared config
public entry fun update_fee(config: &mut ProtocolConfig, new_fee: u64) {
config.fee_bps = new_fee; // No check on who is calling
}
// VULNERABLE: public(package) entry is still callable by anyone
public(package) entry fun emergency_withdraw(vault: &mut Vault, ctx: &mut TxContext) {
let balance = balance::value(&vault.balance);
let coin = coin::take(&mut vault.balance, balance, ctx);
transfer::public_transfer(coin, tx_context::sender(ctx));
// Dangerous: anyone can call this despite public(package)
}
// SAFE: Require admin capability
public entry fun update_fee(
_admin_cap: &AdminCap,
config: &mut ProtocolConfig,
new_fee: u64,
) {
assert!(new_fee <= MAX_FEE_BPS, E_FEE_TOO_HIGH);
config.fee_bps = new_fee;
}1.3 Dynamic Field Injection and Key Collisions (High/Sui)
Sui's dynamic fields allow attaching arbitrary key-value pairs to objects after construction. This is powerful for extensibility but creates three attack vectors when applied to shared objects: unauthorized writes (anyone can add fields if the function lacks access control), key collisions (writing to the same key twice aborts the transaction, enabling griefing), and unbounded growth (unlimited dynamic field creation increases storage costs for all interactions with the object).
Dynamic fields are especially dangerous because they are not visible in the object's struct definition. An auditor reviewing the struct sees a fixed set of fields, but dynamic fields can silently alter the object's effective state. On Sui, dynamic field children are also globally addressable by their object ID, so leaking the ID of a dynamic field child is equivalent to leaking access to it.
// Context: attaching metadata to a shared object via dynamic fields.
// VULNERABLE: Anyone can add dynamic fields to a shared object
public entry fun set_metadata(obj: &mut SharedObject, key: String, value: String) {
dynamic_field::add(&mut obj.id, key, value);
// No check on who is allowed to write metadata
// No check on whether key already exists (will abort -- griefing)
}
// SAFE: Access-controlled + bounded + collision-safe
public entry fun set_metadata(
_admin_cap: &AdminCap,
obj: &mut SharedObject,
key: String,
value: String
) {
assert!(obj.metadata_count < MAX_METADATA_FIELDS, E_TOO_MANY_FIELDS);
if (dynamic_field::exists_(&obj.id, key)) {
*dynamic_field::borrow_mut(&mut obj.id, key) = value;
} else {
dynamic_field::add(&mut obj.id, key, value);
obj.metadata_count = obj.metadata_count + 1;
};
}1.4 Kiosk and Transfer Policy Bypass (High/Sui)
Sui's Kiosk system enforces transfer policies for NFTs and assets. When an item is placed in a Kiosk, it can only be transferred if a TransferPolicy<T> exists and all rules attached to it are satisfied (e.g., royalty payments, lock rules). The TransferRequest must collect receipts from every rule before it can be confirmed. This is Sui's approach to enforceable royalties.
The vulnerability arises when protocols implement custom transfer paths that bypass the TransferPolicy check. Common bypass patterns include: a list_with_purchase_cap path that does not enforce the full policy, a batch transfer function that skips per-item policy checks, a migration or emergency function that calls transfer::public_transfer directly (bypassing the Kiosk entirely), or transferring the KioskOwnerCap itself (the holder of the cap controls the Kiosk and can withdraw items without going through the policy).
// Context: an admin escape hatch for moving items out of a Kiosk.
// VULNERABLE: Direct transfer bypasses Kiosk policy entirely
public entry fun emergency_transfer<T: key + store>(
kiosk: &mut Kiosk,
cap: &KioskOwnerCap,
item_id: ID,
recipient: address,
) {
let item = kiosk::take<T>(kiosk, cap, item_id);
transfer::public_transfer(item, recipient); // Bypasses TransferPolicy!
}
// SAFE: Use kiosk::list + kiosk::purchase flow that enforces TransferPolicy
public entry fun purchase_item<T: key + store>(
kiosk: &mut Kiosk,
item_id: ID,
policy: &TransferPolicy<T>,
payment: Coin<SUI>,
ctx: &mut TxContext,
) {
let (item, request) = kiosk::purchase<T>(kiosk, item_id, payment);
// request must satisfy all policy rules before it can be confirmed
// e.g., royalty_rule::pay(..., &mut request, ...);
transfer_policy::confirm_request(policy, request);
transfer::public_transfer(item, tx_context::sender(ctx));
}1.5 Sui Currency Standard Security (High/Sui)
The Sui Currency Standard security documentation defines five security-critical controls over the coin lifecycle. Each one becomes a liability if it leaks, lands in the wrong object, or is used carelessly.
TreasuryCapcontrols minting and burning. Leaking it enables unlimited token creation. Never store in shared objects; never return from public functions. Treat it like a private key.MetadataCapcontrols coin branding (name, symbol, icon URL). If leaked, an attacker can rebrand a token to impersonate another, enabling phishing. Usecoin::delete_metadata_cap()to permanently lock metadata if immutability is desired.DenyCapV2enables a global pause for regulated coins via the deny list. If leaked, the attacker can freeze the entire token network-wide. If lost, the pause mechanism is permanently unavailable.- Supply state (Fixed, BurnOnly, Unknown) is a one-way transition. Setting supply to Fixed means no more minting is possible. This cannot be reverted.
- Regulatory status is also irreversible. Setting a coin to Regulated enables deny list functionality but cannot be undone.
// Context: where the coin's TreasuryCap lives after currency creation.
// VULNERABLE: TreasuryCap stored in a shared object
public struct Protocol has key {
id: UID,
treasury: TreasuryCap<TOKEN>, // Anyone interacting with Protocol gets minting power
}
// SAFE: TreasuryCap privately owned by deployer
fun init(witness: TOKEN, ctx: &mut TxContext) {
let (treasury_cap, metadata) = coin::create_currency(
witness, 9, b"TKN", b"Token", b"", option::none(), ctx
);
transfer::public_freeze_object(metadata);
transfer::public_transfer(treasury_cap, tx_context::sender(ctx));
}1.6 Object Ability Misconfiguration (High to Medium/Sui)
Every Sui struct carries an ability set (key, store, copy, drop) that decides which operations the verifier will allow. Get the combination wrong, and you widen the attack surface in a way that never shows up as a compiler error. The most common mistake is adding store to a struct that should only ever exist as a top-level object. A key + store struct can be wrapped inside other objects, frozen by anyone who holds it, or transferred through paths the developer did not anticipate.
// Context: a Vault that should only ever exist as a standalone top-level object.
// VULNERABLE: store allows nesting, freezing, and unexpected transfer paths
public struct Vault has key, store {
id: UID,
balance: Balance<USDC>,
}
// Anyone holding a Vault can call transfer::public_freeze_object(vault)
// Then pass the frozen vault as &Vault to functions expecting mutable access
// Ensure mutable-only operations reject frozen objects
// SAFE: key-only prevents nesting and external transfer
public struct Vault has key {
id: UID,
balance: Balance<USDC>,
}Critical ability rules for Sui security:
- Never add
copyto tokens, capabilities, or receipts (enables duplication) - Never add
dropto flashloan receipts or pending actions (enables silent discard, bypasses repayment) - Adding
storeto a privileged struct enables wrapping, freezing, and transfer throughpublic_transferinstead of module-controlledtransfer - If a parent object is deleted, all dynamic-field children must be explicitly transferred out or deleted first
1.7 Shared Object Contention DoS (Medium/Sui)
Sui's parallel execution model processes transactions on owned objects in parallel without consensus. Transactions that touch shared objects must go through consensus-based ordering (Mysticeti), which means they execute sequentially relative to each other. An attacker who spams low-cost transactions referencing a critical shared object (like a lending pool or DEX pool) forces all legitimate transactions on that object to queue behind the spam, creating a denial-of-service condition.
This is a design tradeoff, not a bug: Sui chose shared objects for composability at the cost of contention. But protocols must design around it. High-value shared objects should minimize the number of fields that change per transaction, and protocols should consider splitting state across multiple objects where possible.
1.8 Object Wrapping, Unwrapping, and ID Leaks (High/Sui)
Sui objects can be stored inside other objects, a pattern called object wrapping. A wrapped object no longer exists independently on-chain: it cannot be looked up or passed by its ID, and every access must go through the wrapper. This is a useful encapsulation tool, but it interacts dangerously with two Sui-specific facts:
UIDs are never reused, and the bytecode verifier's ID-leak check prevents extracting a freshUIDfrom a struct, but it does not stop a developer from returning theID(the plain address) of a privileged child.- Dynamic-field and object-owned children remain globally addressable by that
ID, so a leaked handle can be targeted directly inside a programmable transaction block.
The SlowMist Sui auditing primer and MoveBit's object security guide both flag ID exposure of capabilities and wrapped assets as a recurring finding. Two rules cover it: never return the ID of a privileged child, and prove authority by holding a capability rather than by naming an object.
// Context: a read-only getter that returns the admin capability's ID.
// VULNERABLE: Exposes a globally addressable handle to a privileged child
public fun admin_cap_id(vault: &Vault): ID {
object::id(&vault.admin_cap) // Leaks the child's address
}
// If admin_cap is an object-owned or dynamic-field child, an attacker
// can now reference it directly in a programmable transaction block.
// SAFE: Authority is proven by holding the capability, never by naming it
public fun rotate_admin(_admin: &AdminCap, vault: &mut Vault, new_admin: address) {
vault.pending_admin = new_admin; // No ID is ever returned or logged
}1.9 One-Time Witness and init Assumptions (High to Medium/Sui)
The one-time witness (OTW) is a struct with only the drop ability, named identically to its module in uppercase, that can be created exactly once, by the runtime, when it is passed into the module's init function. Sui uses it to guarantee "there is exactly one of these," which is how coin::create_currency guarantees a single TreasuryCap per coin type. The Sui bytecode verifier enforces the witness invariant: a function that takes a type parameter T can only be called by T's defining module, so no other package can forge your witness.
Two assumptions break protocols here. First, init runs once per publish, not once per upgrade: logic added in a later package version is not initialized by re-running init, so one-time setup placed there silently never happens. Second, developers sometimes emulate the witness pattern with a normal struct and a public constructor, which removes the single-instance guarantee and lets anyone mint the witness. The Monethic workshop documents both mistakes.
// Context: one-time setup that trusts a witness type for its single-use guarantee.
// VULNERABLE: A forgeable "witness" with a public constructor
public struct Registry has drop {}
public fun new_registry(): Registry { Registry {} } // Anyone can mint it
public fun init_protocol(_w: Registry, ctx: &mut TxContext) { /* trusts _w */ }
// SAFE: A true one-time witness, creatable only by the runtime in init
public struct PROTOCOL has drop {}
fun init(witness: PROTOCOL, ctx: &mut TxContext) {
let publisher = package::claim(witness, ctx); // Consumes the unique OTW
transfer::public_transfer(publisher, tx_context::sender(ctx));
}1.10 PTB Composition and Argument Injection (Medium/Sui)
A programmable transaction block (PTB) chains up to 1,024 commands into a single atomic transaction, and the output of one command can feed directly into the next. This composability is what lets Sui express flash loans as a language-level "hot potato" rather than a callback. The security consequence is that an attacker does not need a malicious contract to compose your functions in a hostile order: they can assemble your own public entry functions inside one PTB.
If a protocol exposes update_price, borrow, and a price-reverting path as independent public functions on shared objects, an attacker can atomically raise a price, borrow against it, and unwind it in a single block, with no flash loan required. Because caller-supplied Coins can be split and merged arbitrarily and owned inputs are attacker-chosen, every public function must enforce its invariants on its own, never assuming it runs in isolation or in a fixed sequence. Enforce intra-transaction ordering with hot-potato receipts, and re-check invariants (such as a health factor) at the end of the sensitive path rather than trusting state read at the start.
flowchart LR
A["Attacker-built PTB<br>(atomic, up to 1,024 cmds)"] --> C1["1. update_price()"]
C1 --> C2["2. borrow() against<br>inflated price"]
C2 --> C3["3. revert price"]
C3 --> OUT{"All-or-nothing<br>commit"}
OUT -->|"invariants checked<br>per call"| SAFE["Reverts: attack fails"]
OUT -->|"invariants assumed"| DRAIN["Funds drained"]
style A fill:#EF6C00,color:#fff,stroke:#E65100
style DRAIN fill:#C62828,color:#fff,stroke:#B71C1C
style SAFE fill:#2E7D32,color:#fff,stroke:#1B5E20
2. The Sui VM: Bytecode Verifier and the 2026 Rewrite
Everything in Section 1 assumes the VM enforces Move's guarantees faithfully. That assumption is worth examining directly, both because the verifier is the root of trust for every contract on Sui and because, in 2026, Sui shipped the largest change to that execution layer since the network launched.
flowchart LR
SRC["Move source"] --> COMP["Move compiler<br>(type-checks)"]
COMP --> BC["Bytecode"]
BC --> VER["Bytecode verifier<br>type / resource / reference /<br>ability + Sui extensions"]
VER -->|"rejects unsafe bytecode"| PUB["Published package"]
PUB --> VM{"Sui VM"}
VM --> OLD["Legacy interpreter"]
VM --> NEW["New VM (2026):<br>JIT + per-package cache"]
OLD --> STATE["Object state"]
NEW --> STATE
style VER fill:#1565C0,color:#fff,stroke:#0D47A1
style NEW fill:#2E7D32,color:#fff,stroke:#1B5E20
style STATE fill:#EF6C00,color:#fff,stroke:#E65100
2.1 What the Verifier Guarantees
Before any package is published, the Move bytecode verifier runs a static pass over the bytecode (not just the source) and rejects anything that violates type safety, resource conservation, reference safety, or the declared ability set. Sui layers additional checks on top of the core verifier:
- ID-leak and
UIDfreshness: new objects always receive a freshUID, andUIDs cannot be extracted and reused. - Internal constraint: a function taking type parameter
Tmust be called byT's defining module, which is what makes witnesses,event::emit, and one-time witnesses unforgeable. - One-time-witness and entry-point rules that constrain how privileged types are created and how transactions enter a module.
Because these checks run at the bytecode level, hand-crafted or miscompiled bytecode cannot smuggle in an unsafe program. This is the mechanism that turns the hot-potato flash-loan pattern from Section 1 into a hard guarantee rather than a convention.
2.2 When the VM Itself Has a Bug
The verifier is only as strong as its own correctness. In the "Billion Dollar Bug", Zellic disclosed a flaw in how the Move verifier built a function's control-flow graph. The bug let an attacker bypass the locals-safety and reference-safety passes and Sui's ID-leak verifier, which in turn allowed dropping a value without the drop ability, holding multiple mutable references to the same object, and retaining a reference to a moved object, all violations of Move's most fundamental rules. It affected Sui and every other Move chain, including Aptos, and was fixed in coordination with Mysten Labs.
Move's guarantees are real, but they are implemented in software, and software has bugs. That is the argument for defense in depth: keep explicit assert! checks on your critical invariants and verify them formally where the stakes justify it, rather than treating the type system as the last word.
2.3 The New Sui VM (2026 Rewrite)
In March 2026, the Sui Foundation announced the most significant execution-layer upgrade since launch: a full rewrite of the VM aimed at higher throughput, lower memory overhead, and groundwork for future Move language features. According to Four Pillars' analysis of the published code, the rewrite introduces just-in-time compilation and per-package caching, a genuine paradigm shift from the legacy interpreter rather than an incremental tune-up. The code went public on GitHub in March 2026, with mainnet rollout beginning in April 2026, and the Sui bug bounty on HackenProof was extended to cover the new VM at full mainnet rates before it even reached testnet.
Security-wise, the rewrite cuts two ways. The Move bytecode verifier and its semantics are preserved, so contract-level guarantees (abilities, resource safety, the checks in Section 2.1) continue to hold, and existing packages need no code changes. But a new interpreter and execution engine also mean new attack surface: memory safety in the refactored interpreter, JIT correctness, and cache invalidation between package versions are all things the platform now has to get right. OtterSec has already published assessments of both the Move VM rewrite and the Sui adapter, linked from the Sui security page. What this asks of protocol teams is narrow but real: re-run your gas-sensitive and timing-sensitive tests after the upgrade, because performance and gas metering can move even when observable semantics do not.
Real-World Case Study: Nemo Protocol ($2.4M, September 2025)
In September 2025, Sui-based yield-trading platform Nemo Protocol was exploited for $2.4M in stablecoins. The attacker bridged the stolen USDC from Arbitrum to Ethereum before the team could respond. Nemo suspended all smart contract activity and disclosed the incident via Telegram. PeckShield first reported the fund movement.
The specific vulnerability was never fully disclosed, but the operational shape of the incident is familiar from EVM: the exploit executed in seconds, the funds were bridged before anyone could respond, and a pause switch only helps if it was deployed and reachable beforehand.
Add the Cetus exploit (~$223M, May 2025), an arithmetic overflow-check failure dissected in Part 3 of this series, and the Typus Finance exploit ($3.44M, October 2025), and Sui's ecosystem lost over $228M inside a single year. Mirage Audits' Sui vs Aptos deep dive puts it well: same Move language, same Byzantine fault tolerance, entirely different vulnerability profiles, all driven by the chain-level execution model.
Sui-Specific Audit Checklist
Object Ownership & Sharing
- All
transfer::public_share_objectcalls are intentional and documented - All shared object mutations have explicit access control (capability or admin check)
- No
public(package) entry funfunctions are assumed to be internal-only
Dynamic Fields
- Dynamic field writes are permissioned and bounded in size
- Dynamic field children are cleaned up before parent object deletion
- Key collisions are handled (check
exists_beforeadd)
Kiosk & Transfer Policy
- All transfer paths enforce
TransferPolicy,including edge cases KioskOwnerCapis properly protected- No admin override transfer path bypasses royalty or compliance requirements
Currency Standard
TreasuryCapis privately held, not in shared objects, not returned from public functionsMetadataCapis either held by a trusted party or deletedDenyCapV2is access-controlled and monitored (for regulated coins)- Supply state (Fixed/BurnOnly/Unknown) and regulatory state choices are reviewed (both irreversible)
Abilities & Object Safety
- No
copyon tokens, capabilities, or receipts - No
dropon flashloan receipts or pending actions key + storestructs handle frozen-object edge cases- Flashloan receipts have no abilities (hot potato pattern enforced)
Wrapping & ID Exposure
- No public function returns the
IDof a privileged child or wrapped object - Authority is proven by holding a capability, never by naming an object
One-Time Witness & Initialization
- Witness types are true OTWs with no public constructor
- No one-time setup depends on
initrunning again after a package upgrade
PTB Composition
- Every public function enforces its own invariants without assuming call order
- Sensitive paths re-check invariants at the end, not only at entry
- Intra-transaction ordering is enforced with hot-potato receipts where required
DoS & Contention
- Shared objects critical to protocol function have DoS mitigation
- Minimum transaction costs make contention spam economically infeasible
Conclusion
Sui's object-centric model eliminates entire vulnerability classes that plague account-based chains: there is no global reentrancy, no storage collision between unrelated contracts, and owned objects have VM-enforced access control. That safety is not free. Shared objects, dynamic fields, and the Kiosk system open attack surfaces with no analogue on Aptos or Ethereum. And the bugs that actually cost money are rarely exotic: a missing assert! on a shared-object mutation, a TreasuryCap stored in the wrong object type, a public_share_object call where transfer was meant.
Notably, none of the incidents above broke Sui itself. Cetus was arithmetic in a shared math library, Typus was a missing whitelist check, and Nemo was application logic. The VM enforced exactly what it promised to enforce in all three cases, and the 2026 rewrite is built to keep it that way while running faster. Every one of those bugs sat in code the team owned, which is where the next one will sit too.
Up Next: Aptos-Specific Issues (+ Supra)
In Part 5, we examine the vulnerability classes unique to Aptos's account-based resource model: unchecked borrow_global_mut targets, mutable reference escapes to untrusted modules, Block-STM parallel execution races, randomness security (test-and-abort, undergasing), CoinStore registration failures, and Supra's batch transaction assumption violations.





