Welcome to The Notorious Bug Digest #8—a curated compilation of insights into recent onchain bugs and security incidents. When our security researchers aren’t diving into audits, they dedicate time to staying up-to-date with the latest in the security space, analyzing audit reports, and dissecting on-chain incidents. We believe this knowledge is invaluable to the broader security community, offering a resource for researchers to sharpen their skills and helping newcomers navigate the world of onchain security. Join us as we explore this batch of bugs together!
On January 28, 2026, Pawtato Finance, a GameFi platform on Sui, disclosed that its Cross-Smart-Contract package had been exploited. The attacker generated an unauthorized admin capability, escalated privileges through a three-step delegation chain, and minted arbitrary in-game resource tokens.
To understand how this happened, we need to look at how authorization works on Sui in the first place.
Sui Move uses an object-capability authorization model that differs fundamentally from EVM's msg.sender-based access control. Instead of checking who is calling, functions check what objects the caller can present. Authorization is enforced by requiring callers to pass specific capability objects as function parameters. If you hold the right object, you have the right to act. Crucially, Sui's runtime enforces this at the transaction level: to pass an owned object as a parameter, the caller must actually own it. There is no way to present an object you don't own.
This model relies on a critical invariant enforced by the Move VM: a struct can only be created by the module that defines it. Importantly, every struct's identity is fully qualified by its package address, module name, and struct name. Two packages can each define a struct called AdminCap, but the resulting types (e.g. 0xabc::mod::AdminCap vs 0xdef::mod::AdminCap) are completely distinct. The VM will never confuse one for the other.
This combination of runtime ownership enforcement and module-level type exclusivity is what makes capability-based auth work on Sui. If a function requires &MyAdminCap, only someone who received an instance from that specific package's logic and currently owns it can call the function. The type system itself becomes the access control layer.
The Pawtato contract was deployed without verified source code. Based on decompiled bytecode analysis, the vulnerable function reconstructs to something equivalent to:
The function uses &UpgradeCap as its authorization gate. UpgradeCap is a type defined in Sui's sui::package framework module and its purpose is to control package upgrades: if you hold the UpgradeCap for a package, you can publish new versions of it. The framework mints a fresh UpgradeCap for every developer who publishes a package on Sui. This doesn't break Move's type guarantees, every instance is the same sui::package::UpgradeCap type, correctly owned by its respective publisher. But unlike 0xabc::mod::AdminCap vs 0xdef::mod::AdminCap, where the type system alone distinguishes access, here every publisher holds a valid instance of the same type. Requiring UpgradeCap as a parameter only proves the caller is a publisher, not which publisher they are.
This is where the vulnerability lies. Sui's runtime verifies that the caller owns the UpgradeCap they pass, but the function does not verify which UpgradeCap it is. Every UpgradeCap carries an internal package field that identifies the package it belongs to. Sui provides package::upgrade_package(&UpgradeCap): ID specifically to read this field and validate it. But the function never calls it. Without that check, any developer's UpgradeCap from any package satisfies the requirement, and the function unconditionally creates a NewAdminCap and sends it to whichever address the caller specifies.
There are two clean ways to prevent this. The simplest is to use a custom capability type defined in the package itself (e.g. &PawtatoAdminCap). Because Move's type exclusivity guarantees that no other package can create instances of that type, requiring it as a parameter is airtight by construction, with no additional validation needed. Alternatively, if there is a reason to use UpgradeCap specifically (e.g. to prove package publisher status), the function should validate which package the cap belongs to:
This single assert! bridges the gap between a globally distributed framework type available to every Sui developer and package-specific authorization.
We confirmed this reconstruction by deploying both versions and comparing the resulting bytecode. The vulnerable version produces no instruction that reads the UpgradeCap parameter (Arg0): no MoveLoc[0], CopyLoc[0], or ImmBorRef[0] anywhere. The secure version immediately loads Arg0, extracts the package ID via package::upgrade_package, and compares it against the expected address before proceeding. The deployed Pawtato bytecode matches the vulnerable pattern exactly.
The exploit was not a single function call but a cascade through Pawtato's capability delegation chain:
Step 1: Obtain a NewAdminCap: The attacker called create_new_admin_cap with an UpgradeCap they owned (obtainable by deploying any trivial contract to the network, costing fractions of a cent in gas) and their own address as the recipient. Since the function did not validate which UpgradeCap was presented, it minted a NewAdminCap and transferred it to the attacker.
Step 2: Escalate to a PawtatoPackageCap: The attacker called create_and_transfer_pawtato_package_cap, passing the NewAdminCap obtained in Step 1. This function's authorization was sound: NewAdminCap is a custom type exclusive to Pawtato's package, so under normal circumstances only a legitimately issued instance could be presented. But the attacker held exactly that, obtained through the unvalidated gate in Step 1.
Step 3: Mint arbitrary tokens: The attacker called mint_reward<Ty0> with the PawtatoPackageCap from Step 2. The function verified the caller held a valid PawtatoPackageCap (again, a custom type with correct authorization in isolation). The attacker controlled the generic type parameter (choosing which resource token to mint), the amount, and the recipient address. This mint_reward function is called multiple times for multiple resource tokens (one example), which led to token devaluations. Attacker utilized those tokens to drain liquidity pools, thus pushing the tokens’ value down to zero.
During an audit of OpenZeppelin's Contracts for Sui math library, we identified a bug in the mul_mod function: the wide-integer code path for computing (a * b) mod modulus silently returned 0 instead of the correct remainder when both operands were large.
mul_mod WorksWhen a or b exceeds 128 bits, a direct a * b would overflow u256. The implementation avoids this by widening the multiplication to 512 bits, then dividing by the modulus to extract the remainder:
The division helper div_rem_u256 divides a 512-bit numerator by a 256-bit divisor and returns a triple: (overflow: bool, quotient: u256, remainder: u256). When the quotient fits in u256, everything works. The problem is what happens when it doesn't.
div_rem_u256 implements long division bit by bit. At each step, it checks whether the current quotient bit would land at position 256 or above, meaning the quotient has overflowed u256. If so, it returns immediately:
This is the bug. The function correctly detects that the quotient cannot be represented, but it also abandons the remainder computation, returning 0 for a value that is always representable in u256 regardless of quotient size. The division is mathematically product = quotient * modulus + remainder, where 0 ≤ remainder < modulus ≤ u256::MAX. The remainder always fits. But the early return discards it along with the quotient.
The caller in mul_mod_impl destructures the result as:
The overflow flag, the first element of the tuple, is bound to _ and discarded. This is idiomatic Move: the underscore pattern silently drops a value with no compiler warning. There is no friction, no lint, no signal that a critical error indicator is being ignored. When the overflow triggers, the function receives (true, 0, 0), discards true, and returns 0 as the modular product.
In practice, this means mul_mod(a, b, modulus) returns 0 instead of the correct remainder whenever both operands are large and the modulus is small enough for the quotient to exceed u256, which covers any modulus below u256::MAX when both operands are near the upper bound.
_ pattern, common across many languages, makes it easy to discard tuple elements, including critical error indicators, without any friction.mul_mod_impl nor div_rem_u256 is buggy in a way that is obvious from reading either function alone. The bug only emerges at the boundary: one function zeroes the remainder on overflow, the other discards the overflow flag. Auditing internal helper functions in isolation is not enough; the contract between caller and callee, especially around error handling and edge-case return values, must be reviewed as a unit.On Feb 9th, 2026, OpenZeppelin identified a high-severity issue in Neutron's in-production DEX orderbook. The issue lived in the interaction between rounding, shared accounting, and a later cancellation path. The bug was reported to the project and fixed.
Neutron is a Cosmos-SDK chain with a DEX module that implements an orderbook. Users place limit orders (maker side) that get filled by swaps (taker side). Multiple GTC (Good-Til-Cancelled) orders at the same price tick share a single tranche, which is a bucket that tracks:
| Field | Role |
|---|---|
TotalMakerDenom |
Total maker tokens deposited (all users) |
DecTotalTakerDenom |
Cumulative taker tokens received from fills |
DecReservesTakerDenom |
Taker tokens still held, available for withdrawal |
MakerPrice |
Taker tokens per maker token = 1.0001^(−tick) |
Each user gets a LimitOrderTrancheUser with:
| Field | Role |
|---|---|
SharesOwned |
Their share of the tranche’s maker side |
SharesWithdrawn |
(Integer) cumulative shares they’ve claimed |
One key metric is RatioFilled: what fraction of maker deposits has been filled. The higher the RatioFilled, the more taker tokens each maker can withdraw.
1. Innocent Ceiling
When a user withdraws filled tokens from an active (partially filled) tranche, the DEX computes how many shares to mark as withdrawn:
There are two key values:
sharesToWithdraw: a ceiling-rounded integer recorded in state.tokenOut: the exact fractional taker tokens actually sent to the user.The comment says: “Round shares withdrawn up to ensure math favors dex.”
2. Accumulation
Each time the user calls WithdrawFilledLimitOrder on an active tranche, the keeper adds the ceiling-rounded shares to SharesWithdrawn:
After N withdrawals, the total SharesWithdrawn is the sum of N ceiling-rounded integers. Each step can add up to 1 extra share. Over N steps:
So far, this only over-counts how many shares the user has claimed. No harm yet...
3. Reconstruction
The damage happens when the user calls CancelLimitOrder. The cancel handler needs to adjust the tranche’s DecTotalTakerDenom to remove everything attributable to this user (both prior withdrawals and the final cancel withdrawal).
But the code doesn’t have a record of actual taker tokens previously sent. It only has SharesWithdrawn. So it reconstructs the taker equivalent:
As we can see, the code subtracts (SharesWithdrawn × MakerPrice) + current_withdrawal from DecTotalTakerDenom. However, SharesWithdrawn × MakerPrice > actual taker previously sent, because SharesWithdrawn was inflated by ceiling rounding. So DecTotalTakerDenom (remaining taker tokens) ends up lower than it should be. Since RatioFilled = DecTotalTakerDenom / (MakerPrice × TotalMakerDenom), this permanently lowers the ratio for all remaining users in the tranche.
The per-step ceiling error is at most 1 share. When the cancel converts shares back to taker tokens, it is multiplied by MakerPrice, which is 1.0001 ^ (−TickIndexTakerToMaker). At near-parity ticks, MakerPrice ≈ 1, so 1 extra share ≈ 1 taker unit – harmless dust.
However, with large tick distances or with many steps (permissionless), the error can be significant. For example:
DecTotalTakerDenom negative, freezing 100% of the funds (see the section below).When someone pushes the DecTotalTakerDenom to a negative value, the setter SetTotalTakerDenom doesn’t check for negativity:
This turned the negative DecTotalTakerDenom into a silent state corruption, making RatioFilled negative and zeroing out every remaining user’s withdrawal.
When we see rounding “favoring the protocol”, we need to trace the rounded impact through every path, not just the immediate one. In this issue, ceiling-rounding of shares on withdrawal seemed protective, but the rounded value was persisted to state, accumulated across calls, and later multiplied by a large price factor in a completely different function.
For PoC, please check this test suite.
What is Sui's object-capability model and why does it matter for security?
Sui Move uses an object-capability authorization model where functions check what objects a caller can present, rather than who is calling. This means authorization is enforced by requiring callers to pass specific capability objects as parameters. When those capability types are not exclusive to a single package, or when their identity isn't validated, attackers can exploit the gap to gain unauthorized privileges, as seen in the Pawtato Finance incident.
How did the Pawtato Finance exploit unfold?
The attacker exploited an unvalidated capability gate to mint an unauthorized admin capability, then escalated privileges through a three-step delegation chain. Each downstream step in the chain had sound authorization in isolation, but the unsound root capability poisoned the entire chain, ultimately allowing the attacker to mint arbitrary in-game tokens and drain liquidity pools.
How did discarding the overflow flag cause the mul_mod bug in the Sui math library?
In modular arithmetic, when the quotient of a wide-integer division overflows the target type, an early exit can incorrectly zero out the remainder as well, even though the remainder always fits. If the caller discards the overflow flag, it receives 0 as the result with no error signal. Silent wrong answers like this are harder to detect than reverts and can propagate unnoticed through dependent computations.
Why is "rounding in favor of the protocol" not always safe?
Rounding in favor of the protocol can seem like a conservative, protective choice, but it becomes dangerous when rounded values are persisted to state, accumulated across multiple calls, and later used in a different context. In the Neutron DEX bug, ceiling-rounded share counts were multiplied by a large price factor during order cancellation, causing significant over-subtraction that could reduce other users' withdrawable funds to zero.
It is important to emphasize that the intent behind this content is not to criticize or blame the affected projects. Instead, the goal is to provide objective analyses of the vulnerabilities that serve as educational material for the onchain community to learn from and better protect itself in the future.