Co-authors: Ionut-Viorel Gingu and Victor Xie
Table of contents
- Defending Sandwich Attacks with Transfer Fees? Not This Time
- Lessons From the zkLend Hack
- Exploits in Unverified Contracts
- Stellar Contracts Library - Attribute Macros Omit Subsequent Attributes
- Uniswap Hooks - Non-Explicit Multiple-Pool Support Allows Overwriting Hook State
Introduction
Welcome to The Notorious Bug Digest #2—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 help newcomers navigate the world of Web3 security. Join us as we explore this batch of bugs together!
Incident Analyses
Defending Sandwich Attacks with Transfer Fees? Not this Time
On February 7th, the contract at 0x2d70d62
was attacked [1] [2] because its depositBNB
function did not employ slippage protection when buying ADAcash
tokens with WBNB
.
However, the attack is not that simple.
ADAcash
is a fee-on-transfer token which charges a 15% fee to the token contract for each transfer. Thus, if the attacker simply sandwiches the depositBNB
function with a flashloan, the combined fees (15% on the buy, 15% on the sell) could easily eat up any profits.
So, what happens to those transfer fees that
ADAcash
collects? The contract swaps them, and unfortunately, without any slippage protection. According to the code, when the from
address isn’t an AMM (like when selling ADAcash
) and enough fees are collected, most of the collected fees get swapped for ADA
, while about 13% get converted into WBNB
to boost liquidity in the ADAcash/WBNB
pool.
Here’s where it gets clever: the attacker can reclaim those fees by launching a second sandwich attack on the ADAcash token contract itself. Here’s how it went down:
- Grab a flashloan of
WBNB
. - Buy
ADAcash
withWBNB
, pushing up its price (15% of theADAcash
becomes fees). - Buy
ADA
withWBNB
, drivingADA
’s price higher. - Trigger the
depositBNB
function on contract0x2d70d62
. This is the first sandwiched swap, spikingADAcash
’s price further. - Sell
ADAcash
to profit from the first sandwiched swap. Note that sellingADAcash
also triggers a 15% fee. To minimize the fees, the attacker splits the selling into multiple swaps, since each selling triggers a swap using all the cumulative fees. These sells constitute the second sandwiched swap, increasingADA
’s price. - Sell
ADA
to lock in profits from the second swap. - Repay the
WBNB
flashloan and secure the profits.
Moral of the story? Make sure you are always considering and setting reasonable values for the slippage protection. Otherwise, swaps can be sandwiched in complex, unexpected manners.
Lessons From the zkLend Hack
On February 12th, the zkLend
lending protocol got hacked due to a rounding-down error in the withdraw
function. SlowMist released a thorough breakdown of the exploit. Here’s how it went down:
- Empty Market Setup: The attacker deposited 1 wei of an asset and got 1 share with the
lending_accumulator
at a 1:1 ratio. - Amplifying the
lending_accumulator
: Using flash loans, they repaid extra fees to pump up thelending_accumulator
. - Victims deposited assets: Other users deposited assets.
- Repeated Exploitation: The attacker continuously deposited assets and withdrew amounts larger than the ones deposited. Due to the large
lending_accumulator
and corresponding precision loss from rounding down the burn amount, each withdrawal operation burned the same number of shares as the deposit operation. This discrepancy between the assets deposited and withdrawn resulted in profits for the attacker.
This pattern of exploitation isn’t a one-off and the method used in zkLend
is similar to other lending protocol exploits:
- Radiant Capital: Hit an empty USDC market.
- Silo Finance: Pumped up the utilization rate on an empty market.
- Raft Finance: Blew up a rounding error via liquidation.
- Wise Finance: A sneaky exploit on empty markets. It rounded in favor of users, but the attacker used a stealth donation to inflate shares, then flipped it to create bad debt across markets.
Auditor Takeaways
Rounding errors only drain a protocol if they can be amplified. Without inflation, you’re stuck extracting 1 wei per withdrawal — way less than gas fees.
Why lending protocols? Empty markets or vaults are prime targets for inflating rounding errors. These attacks are like vault inflation exploits on steroids. In most setups, an empty vault just screws the first depositor. But in lending protocols, an empty market lets you borrow or yank collateral from other markets, draining the whole system.
A key question to spot this bug is “What variables can an attacker inflate in an empty market?” These aren’t just rounding bugs—they’re rounding + inflation attacks. Two of the exploits above didn’t even use bad rounding directions: they all leaned on inflating an accounting variable in an empty market.
For zkLend
, an auditor could’ve flagged the lending_accumulator
as inflatable. Pair this with rounding that favors users and you’ve got a recipe for drainage. Checking the rounding direction would have stood out as critical.
This question also digs up non-rounding bugs. Consider the Silo Finance bug — it used donations to inflate a variable in empty vaults, no rounding trick needed. In the Raft Finance exploit, a user-favoring rounding bug was reported as a low-severity issue and was not addressed in the fix review. Further analysis into how an error can be inflated might have uncovered the critical and avoided the hack.
Exploits in Unverified Contracts
This month, multiple unverified smart contracts were compromised due to weak access controls, inadequate input validation, or uninitialized states. Below is a breakdown of the incidents.
Contract 0xeffca1: 20 ETH Lost via Unchecked Fallback Call [1] [2]
The image below highlights a key issue: insufficient access controls. The victim, a MEV bot, failed to verify the caller of its uniswapV2
function. However, deeper analysis of the decompiled code and msg.data
reveals more details:
- No
uniswapV2
function exists— the call hits the bot’s fallback logic instead. - The
delegatecall
destination (0xcc85e
), parameters (e.g., anapprove
call to nonexistent0x24ec8
), and a subsequentWETH
transfer are all controlled bymsg.data
. - Notably, the
delegatecall
destination was whitelisted hours before the exploit—a privileged action.
This raises a question: was the exploit automated? A skilled attacker could have bypassed 0x24ec8
, directly approved WETH
, and drained it via the swapIn
fallback. Given the decompiled code’s complexity, the hacker might have mutated historical msg.data
, injecting their address, asset details, and a profitable return value. This remains speculative but plausible.
Contract 0x378c6: 151 BNB Stolen via Fake Pool Swap [1] [2]
This exploit is straightforward. As shown in the image, the 0xb9d384fa
function lacks access controls, enabling token swaps on an unverified Uniswap V3 pool. The attacker created a pool with minimal WBNB and a flood of fake tokens, then triggered 0xb9d384fa
to swap WBNB for the worthless tokens. They subsequently withdrew liquidity, profiting in WBNB. A key detail: the slippage protection parameter sqrtPriceLimitX96
(not attacker-controlled) was set to MIN_SQRT_RATIO, ensuring that the swap succeeded at a low WBNB price.
Contract 0xd4f1a: 23 BNB Drained Due to Uninitialized State [1] [2]
This case is similarly clear-cut. The victim, an initializable contract, was deployed without calling _disableInitializers
or initialize
during its deployment, leaving it uninitialized. After two days of accumulating fees, the attacker called initialize
to claim ownership and invoked withdrawFees
to siphon the funds. Interestingly, fees continued flowing in post-exploit, which the attacker redirected to Tornado.cash.
Audit Issues
Stellar Contracts Library - Attribute Macros Omit Subsequent Attributes
This bug was uncovered in one of our Rust audits. It highlights how procedural macros work and can be defined in Rust. A procedural macro is a compile-time code-generation tool: when the compiler sees that function doAction
has been annotated with macro
, it will look up macro
’s definition, and based on that, will add to, remove, or replace the code in the doAction
function.
This serves as a powerful tool to develop functionality like Solidity’s modifiers. We can create a when_not_paused
procedural macro which, when written above a function, will copy the function code and add additional logic at the beginning. The additional logic would revert if the paused
variable is set to true
.
Similarly, the client intended to create a when_not_paused
procedural macro. This macro receives as parameter the function code (together with all annotations) parsed into tokens, which can later be accessed through:
fn_vis
(the function visibility: e.g.,pub
)fn_sig
(the function signature: e.g.,fun doAction(number: u32) -> u32
)fn_block
(the function body: e.g.,return number;
)
At compile-time, the compiler copies the function visibility, function signature, function body, and it adds an openzeppelin_pausable::when_paused(#env_arg);
call at the beginning of the function, as shown below:
Something is missing. But what? The rest of the annotations! If our function were also annotated with other procedural macros (think
only_owner
), the when_not_paused
macro would change the code such that only_owner
was lost in the process. The fix for this issue would be to also copy the other annotations, resulting in the following:
Uniswap Hooks - Non-Explicit Multiple-Pool Support Allows Overwriting Hook State
During our audit of the Uniswap Hooks library, we identified a critical vulnerability in the BaseCustomAccounting hook
. This hook is designed to fully control liquidity in its associated pool, ensuring that all liquidity modifications pass through it. Therefore, it must be permanently linked to a single pool. Otherwise, liquidity could become inaccessible.
However, due to an asymmetry in pool-hook awareness, any valid hook address can be attached to any pool unless explicitly rejected by the hook itself. The BaseCustomAccounting
hook lacked safeguards against reinitialization, allowing an attacker to redirect it from a victim’s pool to a maliciously created Uniswap pool. This effectively locked the victim pool’s liquidity, preventing access to funds.
The fix ensures that once directed to a pool, a hook that inherits BaseCustomAccounting
will not be able to be reinitialized and point to another pool.
It’s 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.