Co-authors: Ionut-Viorel Gingu & Jainil Vora
Table of contents
- Introduction
- Incident Analysis: AI Misdiagnoses Exploit in KRC/BUSD AMM Pool
- Incident Analysis: Overriding Internal Function Allowed Free Mints in MetaPool’s Staking Contract
- Across Protocol - DoS When Swapping Using Permit2
- Stylus Library - Shift Overflows in Shift Implementations
- Conclusion
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:
- The attacker flash-loaned BUSD tokens and swapped them for KRC tokens in the pool.
- The attacker performed multiple direct transfers of KRC tokens to the pool, followed by calling
pool.skim()
. Theskim
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. - The pool's KRC balance continuously decreases during these transfer-skim operations.
- 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 A
Image 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.Image C
Image 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.While 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.To exploit this vulnerability, the attacker performed the following steps:
- 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 MetapoolmpETH/ETH
pool was completely drained - Called the
mint
function with a large amount of assets - 9701 ETH. Since the_deposit
function used was the new overridden one and thempETH/ETH
pool was already drained, new mpETH tokens were minted for free to the attacker - 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 (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.
Image 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.
Image 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:
Image 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.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:To 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.