Co-authors: Ionut-Viorel Gingu & Jainil Vora
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!
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:
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.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.
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.
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._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.
depositETH
function on the staking contract to stake a large amount of ETH such that the Metapool mpETH/ETH
pool was completely drainedmint
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 attackermpETH/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 ETHAs 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.
The Permit2
contract offers two key features:
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 (owner
, token
, 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.
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.
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).
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:
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.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:
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: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.