The Notorious Bug Digest #4: Deflationary Token Risks, ERC4626 Override Gaps, and Rust Shift Overflows

Co-authors: Ionut-Viorel Gingu & Jainil Vora

Table of contents

Introduction

Welcome to The Notorious Bug Digest #4—a curated compilation of insights into recent Web3 bugs and security incidents. When our security researchers aren’t diving into audits, they dedicate time to staying up-to-date with the latest 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 Web3 security. Join us as we explore this batch of bugs together!


Incident Analysis: AI Misdiagnoses Exploit in KRC/BUSD AMM Pool

A KRC/BUSD liquidity pool on Binance Smart Chain was drained in an exploit. An AI-powered blockchain transaction analysis agent flagged a transaction as a "complex exploit involving flash loans and repeated skim() calls", attributing the root cause to "missing checks in skim/reserve logic, enabling the attacker to extract residual tokens repeatedly." But was this diagnosis correct?

Let's dive into the attack process, as shown in images A and B:

  1. The attacker flash-loaned BUSD tokens and swapped them for KRC tokens in the pool.
  2. The attacker performed multiple direct transfers of KRC tokens to the pool, followed by calling pool.skim(). The skim function forces the pool's balances to match its reserves by transferring out the difference between the pool's reserves and token balances, allowing the attacker to retrieve the transferred KRC tokens.
  3. The pool's KRC balance continuously decreases during these transfer-skim operations.
  4. When only 0.2 KRC tokens remained in the pool, the attacker swapped 2.9 KRC tokens for nearly all the BUSD tokens, securing their profits.

Image AImage ABImage B

A key aspect of the attack is why the pool's KRC balance continuously decreases during the transfer-skim operations.

Examining the KRC token's transfer implementation (Images C and D), we see that it incorporates deflationary logic. Specifically, when the pool is the recipient (i.e., swap for BUSD), 9% of the transfer amount is burned from the pool's balance, which won't be noticed and handled by the pool. Additionally, the token sender loses 10% of the transferred tokens to a specific address.CImage CDImage D

Through repeated transfer-skim operations, the attacker continuously triggers the deflationary logic, causing the KRC balances of both the attacker and the pool to decrease at a linear rate. This persists until the KRC token price becomes extremely high, enabling the attacker to swap their remaining KRC tokens for nearly all the BUSD in the pool, effectively draining it. Notably, in order to ensure that the profits exceed the costs, the attacker must use KRC tokens obtained from the initial swap for the attack instead of flash-loaned or externally purchased tokens.

A similar vulnerability was exploited in the case of FIRE token, where token deflation temporarily similarly decreased the liquidity pool's reserves. This allowed an attacker to swap tokens at an advantageous price, slowly draining the pool.

In summary, deflationary tokens pose critical risks to AMM pools, especially when attackers can directly manipulate pool balances or reserves through token burns. The AI agent's misdiagnosis highlights the need for more nuanced analysis of AMM-specific mechanics. By examining attack transactions against typical swap or arbitrage activities at a finer granularity, the AI could better detect anomalies like the deflationary behavior exploited here.


Incident Analysis: Overriding Internal Function Allowed Free Mints in MetaPool’s Staking Contract

MetaPool's mpETH contract inherited OpenZeppelin's ERC4626Upgradeable contract to support its vault-based staking mechanism and provided mpETH tokens as shares upon staking ETH/WETH assets. It is worth noting that both the public mint and deposit functions of the ERC4626Upgradeable contract invoke the internal _deposit function, which contains the ultimate check to ensure that assets have been transferred to the contract from the caller (let's call this the "receipt check").

In the mpETH contract, the public deposit function and the internal _deposit function of ERC4626Upgradeable had been overridden, and the receipt check had been moved out of the _deposit function and into the deposit function. This was done to support new functionalities including design changes such as moving token transfers outside the internal _deposit function.

However, the development team apparently failed to account for the fact that in addition to the deposit function, the mint function also relied on _deposit for the receipt check. Thus, the mint function was not overridden and the receipt check was not implemented in it. As a result, if someone were to call mint, the receipt check would never be performed, and they would be able to mint tokens for free.EFWhile the vulnerability was quite straightforward, the attack vector was not. The new _deposit function, instead of minting mpETH directly, first performed a swap using MetaPool's LiquidUnstakePool - mpETH/ETH pool and returned those mpETH tokens. It only minted new mpETH tokens when the aforementioned pool could not provide the necessary amount of shares.GTo exploit this vulnerability, the attacker performed the following steps:

  1. Took out a flashloan of 200 WETH from Balancer and called the depositETH function on the staking contract to stake a large amount of ETH such that the Metapool mpETH/ETH pool was completely drained
  2. Called the mint function with a large amount of assets - 9701 ETH. Since the _deposit function used was the new overridden one and the mpETH/ETH pool was already drained, new mpETH tokens were minted for free to the attacker
  3. Swapped mpETH for ETH on the Metapool mpETH/ETH pool to repay the flashloan and subsequently swapped the newly minted mpETH tokens in the Uniswap V3 pool to generate a net profit of 8.89 ETH

As a side note, the attacker was front-run by an MEV bot which made a profit of 45.79 ETH.

In summary, overrides of internal functions and their dependency on parent contracts must be thoroughly reviewed to prevent such unintended consequences.


Across Protocol - DoS When Swapping Using Permit2

The Permit2 contract offers two key features:

  • Signature-based transfers, where permissions last throughout the duration of the transaction
  • Signature-based allowances, where permissions last for a specified amount of time

Permit2 authorizes a signature either through ECDSA signature verification if the caller is not a smart contract, or through ERC-1271 signature verification if the caller is a smart contract. To prevent signature-replay attacks, Permit2 tracks nonces for each unique (ownertoken, and spender) combination, and ensures that the nonce tracked by the contract matches the one inside the signature or supplied by the caller. If they do not match, the allowance is not granted and the transaction is reverted.

The SpokePoolPeriphery contract by Across Protocol facilitates token swaps through direct transfers, ERC-20 approvals, or via Permit2. Since SpokePoolPeriphery is a contract, the authorization method used will be ERC-1271, and allowance will be granted if the nonce of the SpokePoolPeriphery matches that of Permit2, causing the isValidSignature call to pass.

We discovered a vulnerability in the performSwap function that arose due to flexibility in arbitrarily specifying the exchange and routerCalldata parameters.

HImage H

If a malicious user sets exchange to the Permit2 contract and routerCalldata to the invalidateNonces function, they could artificially increase the expected nonce, effectively desynchronizing the nonce values between the Permit2 and SpokePoolPeriphery contracts.

IImage I

This desynchronization leads to transaction failures that cannot be rectified since nonce adjustments in SpokePoolPeriphery depend on successful transaction completions.

This issue underscores the critical need for scrutinizing underlying assumptions — such as the nature of the exchange and routerCalldata parameters — and for thorough examination of external dependencies (i.e., the existence of the Permit2.invalidateNonces function, which had been out of scope for that audit).


Arbitrum Stylus Library - Shift Overflows in Shift Implementations

In Rust, shift operations (<<>>) on a value of width N can accept a shift value >= N, and the shift value will be masked to be modulo N before the shift occurs. For example, u32 >> 65 is equivalent to u32 >> 1 (65 % 32 = 1). This behavior is defined in Rust RFC 560 - Integer Overflow:

Shifts were specified to mask off the bits of over-long shifts.

When compiled in debug mode, such shifts trigger overflow panics. In release mode, however, the shift value is masked to be modulo N, as mentioned. This differs from Go and Solidity, where the result is truncated to zero.

During an audit of the Stylus crypto library, we identified an issue related to this shift overflow behavior. The shr_assign and shl_assign functions implement the shift-and-assign operators (<<= and >>=) for the Uint<N> type, which represents a multi-limb unsigned integer with an array of N limbs, each represented as a u64 type. These functions iterate over all limbs to handle bit shifts.

The following is a simplified version of the >>= operation:

JImage J

Since each limb can be split into two parts across a boundary after the shift, the code uses two separate logical paths. The first part (index1) handles the least significant bits, which may be placed in the most significant bits of a lower limb with index index1. The second part (index2) handles the most significant bits, which may be placed in the least significant bits of a lower limb with index index1+1. The conditions index_shift < index and index_shift <= index ensure that the shifted bits remain within the valid bit range.

The image below illustrates a 76-bit right shift on a uint256 integer, represented as [u64; 4]. For the limb at index 1, the least significant 12 bits (index1) are shifted out of range, while the most significant 52 bits (index2) become the least significant 52 bits of the new limb at index 0.Captura de pantalla 2025-07-24 a la(s) 1.29.52 p. m.However, a shift overflow issue can occur in the index1 logic. Specifically, current_limb << (64 - limb_shift) will become current_limb << 64 when limb_shift is zero. Since current_limb is a u64 value, the shift amount wraps to zero, leaving current_limb unchanged and incorrectly setting limbs[index1]. The expected behavior is that current_limb should be zero after the shift, leaving limbs[index1] unchanged.

This issue can lead to incorrect arithmetic results when code using the Stylus contracts library is compiled in release mode and shifts a Uint<N> variable by 64 * x bits. For example:

let num: Uint<4> = Uint::::new(limbs: [
    0xffffffffffffffff,
    0xffffffffffffffff,
    0,
    0xffffffffffffffff,
]);

assert_eq!(
    num >> 64, // use >>= internally
    Uint::<4>::new([0xffffffffffffffff, 0, 0xffffffffffffffff, 0])
);

The test fails, showing that the limb at index 1 was incorrectly set to the value of the old limb at index 2 (0xFF..FF) on the left side, while the right side is the expected behavior:ultTo fix this issue, checked_shl and checked_shr can be used to mitigate the possible overflows.


It is important to emphasize that the intent behind this content is not to criticize or blame the affected projects but to provide objective overviews that serve as educational material for the community to learn from and better protect projects in the future.

Talk to an Expert