News | OpenZeppelin

The Notorious Bug Digest #5: Post EIP-7702 Pitfalls, JIT Penalty Rebates, and Manipulation of Recursive Functions

Written by OpenZeppelin Security | September 17, 2025

Authors: Jainil Vora, Frank Lei, Ionut-Viorel Gingu, Dario Lo Buglio & Henrique Scocco

Introduction

Welcome to The Notorious Bug Digest #5-a curated compilation of insights into recent Web3 bugs and security incidents. When our security researchers aren’t diving into audits, they dedicate their time to staying up-to-date with the latest in the security space, analyzing audit reports, and dissecting exploits. We believe that 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: Attacker Utilizes EIP-7702 Delegated EOAs to Bypass Flashloan Protection

EIP-7702, introduced in the Pectra upgrade, allows EOAs to set code in their account. This change breaks backward compatibility by deprecating the "EOA only" check (i.e., msg.sender == tx.origin) that was commonly used to ensure that the caller was an EOA. Prior to EIP-7702, a transaction EVM could only be initiated by an EOA, and tx.origin was always guaranteed to be an EOA.

The "EOA only" check effectively confirmed that the caller was not a contract and reduced concerns around attack vectors such as flashloan- or reentrancy-based exploits. However, after EIP-7702, the EOA initiating the transaction could behave like a contract with EIP-7702 delegated code which can break the assumption for the contracts relying on msg.sender == tx.origin as an EOA-only safeguard.

In the incident under review, the attacker exploited this backward-compatibility issue to hack an unverified contract on the BSC Chain on 24th Aug, 2025. The victim contract was a staking contract that allowed users to stake Wrapped POT Tokens (POT) and generated time-based rewards denominated in the same POT tokens. It also relied upon PancakeSwap's BSC-USD/POT pool to fetch the price of the POT token at the time of staking.

The victim contract used the aforementioned "EOA only" check to ensure that the 0x93649277(stake) function was safe from any flashloan attacks during staking, and that the on-chain Uniswap V2–type pool price (via getReserves()) would be fetched without manipulation (as shown in Image A). After fetching the POT/BSC-USD price, the function would calculate and later store the staked POT-token value in BSC-USD (as shown in Image B).

The attacker manipulated the price of the POT token while simultaneously staking in the victim contract and generating a profit of $85k by the end of the attack using the following steps:

  1. The attacker deployed a malicious delegation contract and authorized its delegation to an EOA using a 7702-type transaction.
  2. The attacker used this delegated EOA to transfer ~13.9 BNB tokens to itself(see Image C). Since it was a native-token transfer, the delegated EOA's fallback function was activated. This step ensured that the tx.origin == msg.sendercheck passed, making the victim contract believe the caller was an EOA, whereas in reality, it was a delegated malicious contract working under the hood.
  1. In the fallback function of the delegated contract, the attacker took a flash loan of $3.5M BSC-USD tokens and bought an equivalent amount of POT tokens from the PancakeSwap BSC-USD/POT pool to increase its price.
  2. Then, the attacker called the 0x93649277 function to stake ~220k of POT tokens. Here, the 0x93649277 function utilized the manipulated price of the POT token and added the inflated value to the user's stake position.
  3. The attacker then swapped the remaining POT tokens back to BSC-USD tokens to repay the flashloan and almost reset the BSC-USD/POT price. Note that the ~13.9 BNB sent earlier were also swapped to BSC-USD tokens to fill in the gap left by the ~220k POT tokens staked.
  1. In the following transaction, the attacker called the unstake function in a similar fashion - by calling the withdraw function on the attacker's own delegated EOA address, which then called the unstake function. This unstake function transferred 3.3M POT tokens to the attacker, which was far more than the 220k POT tokens originally staked due to the inflated staked value stored and the POT price being reset. The attacker swapped these POT tokens to BSC-USD tokens and was able to generate $85K in profit.

An interesting thing to note is that the victim contract had a delay of 1 day between the stake and unstake operations (as shown in Image E). The attacker smartly timed the stake transaction at 2025-08-24 23:59:06 (UTC) and the unstaketransaction at 2025-08-25 00:00:14 (UTC) to avoid the delay

In summary, this was one of the first EIP-7702-based exploits where the assumption that only EOAs can initiate the transaction was subverted using delegated EOAs.

Bug Analysis: Penalty Bypass - When the Donation Receiver Is the Attacker

Uniswap v4 hooks let pools run custom logic at lifecycle points (e.g., after adding/removing liquidity). During our audit of Uniswap Hooks, we reported a subtle bug related to the penalty mechanism for just-in-time (JIT) liquidity.

JIT is an AMM arbitrage strategy, where a liquidity provider (LP) temporarily adds concentrated liquidity to a pool right before a large swap transaction occurs, earns fees from that swap, and then immediately removes the liquidity afterward. This is typically done by spotting a pending swap in the public mempool, bundling transactions (e.g., via Flashbots) to add liquidity, letting the swap get processed, and burning the position within the same block.

To defend such arbitrage activities, the Uniswap team developed the LiquidityPenaltyHook contract whereby fees are withheld on fresh positions and, if liquidity is removed within a short window (blockNumberOffset), a penalty is applied that is then donated to the in-range LPs at removal time. As shown in the code below, the donation is made through the poolManager.donate function and increases the accumulated fees of LPs.

However, there was a subtle flaw that allowed for bypassing the arbitrage-protection measures. If the attacker controls the in-range LPs set at removal, the “donated” penalty simply returns to the attacker. For example, an attacker can use a secondary account to position liquidity in an otherwise empty tick range, and the attack will proceed as follows:

  1. Setup: The attacker uses a secondary account B to provide a small amount of liquidity in a low-traffic or distant tick range where no other liquidity exists.
  2. JIT Execution: After some blocks, the attacker's main account, A, adds a large liquidity position around the current tick, anticipating the incoming user swaps. These swaps generate fees that accrue to A, but are withheld by the hook during the JIT window.
  3. Fee Redirection: After the fees are generated, A moves the pool’s price into B’s tick range via a swap. When A removes liquidity, the hook penalizes the action by donating fees to the in-range position (B’s position). B can then immediately withdraw the donated fees.
  4. Optional Reversion: A swaps again to return the pool to its original state and avoid arbitrage.

The practical risk of such an attack materializing is low in real-world pools with general liquidity, as moving the price into a specific tick range, withdrawing liquidity, and reverting the price is costly, often exceeding the potential gains from donated fees. In addition, the attacker would need sufficient user swaps within the JIT window.

Although the severity of this issue was deemed to be low, such an attack is more likely to be profitable in low-liquidity pools, and the exploit pattern offers an important takeaway for audits: donation-based penalties can be manipulated if the receivers are controlled by the attacker. In such cases, the "penalty" becomes a rebate, effectively bypassing the protection.

Bug Analysis: Manipulating a Recursive Function to Lock User Funds

This critical bug was identified in a recent f(x) v2 protocol audit performed by OpenZeppelin. We will first understand how the f(x) protocol manages multiple positions and then proceed to unravel the bug.

Understanding How f(x) Protocol Handles Multiple Positions

The f(x) Protocol is a unique system that offers two products:

  1. Leveraged long positions — xPosition
  2. A stablecoin — fxUSD

When a user opens a long position, they provide collateral, borrow fxUSD, and create an overcollateralized position. The borrowed fxUSD tokens are newly minted as debt to the protocol and then sold in the market via flashloans to create xPositions for users, as explained here.

As long as the price of the underlying collateral rises, the protocol remains healthy since all positions stay overcollateralized. However, if the price falls, positions are required to be either rebalanced or liquidated. Rebalancing can be thought of as a partial liquidation, where only part of the position is adjusted to restore collateralization.

To maximize efficiency, the f(x) Protocol manages positions using a price band system. Each price band has a width of 0.15%, and positions within the same band are rebalanced or liquidated together. This system is implemented internally through a "tick-tree" mechanism using Disjoint-Set Data Structure, where each tick represents a price band. In the context of the protocol, a tick reflects the debt-to-collateralization ratio: the higher the debt of a position, the higher its tick, and consequently, the greater its liquidation risk.

Each tick is assigned a default root node. A root node always has both debtRatioand collRatio set to 100%. These two values represent the percentage of debt and collateral that remain unliquidated for that node, respectively. Whenever a new position is created, its tick is calculated based on the position’s debt and collateral, and it is always added to the root node of that tick. As can be seen in Image G, positions P1, P2, P3, and P4 are attached to the root nodes of each tick, with totalDebt and totalColl being updated as new positions get added to each node.

When the price moves down, positions are not liquidated individually. Instead, the entire tick is liquidated. In Image H, we can see that as the price decreases, tick 103 is liquidated and assigned a completely new node (Node ID: 2045), with both totalDebt and totalColl in the node reset to 0 and ratios reset to 100%. The older node is discarded from the tree along with its attached positions.

When a tick is partially liquidated, the root node is shifted to a new tick by recalculating the remaining collateral and debt. The root node then becomes the child node of the new tick’s root node. In Image H, we can see that Node ID: 1024 is shifted from tick 100 to tick 80 and becomes the child node of the tick 80 root node (Node ID: 1031). Note that the collRatios and debtRatios values for node 1024 are updated to 75% of the collateral and debt. This means that 25% of this node’s value has been liquidated, while tick 100 gets a new Node 2048, similar to tick 103.

When a position needs to be updated, it calculates all the collRatios and detbRatios values up to the last root node to derive the exact amount of its remaining collateral and debt. For this purpose, it utilizes thepath compression algorithm with fixed-point multiplicative accumulation.

Understanding The Bug And Its Impact

This bug combines 3 different issues located in different parts of the codebase:

  • The _operate function, which updates/manages a position, relied on a path compression recursive function _getRootNodeAndCompress to fetch the current root node of a position and update the position node's collRatios and debtRatios in case of any partial liquidations. However, it was observed that if the tree contains around 150 child nodes, this recursive function can trigger a "Stack Too Deep" error.
  • The redeem, rebalance, and liquidate functions did not have any minimum amount requirement for partial liquidations. This allowed anybody to liquidate the top tick with debt as small as 2 wei. This small amount guaranteed the partial liquidation of the top tick onto the same tick. The redeem function, which can be unpaused during extreme condition, allowed liquidation of multiple ticks without any price movement.
    • This admin-controlled redeem functionality, once activated, could liquidate up to 20% from the top tick (i.e., it starts reducing high-leverage positions until the given amount of fxUSD are successfully redeemed). Under extreme conditions, due to this functionality, highly leveraged positions may suffer loss in unrealized profits, but the peg of fxUSD is maintained.
  • The _liquidateTick function did not verify whether the new tick assigned to a partially liquidated tick is the same as the current one. This creates a scenario like the one illustrated in Image J, where tick 99 is partially liquidated and the older root node (Node ID: 1002) ends up pointing to the new root node of the same tick (Node ID: 2050). Image K shows the error code in the _liquidatefunction.

An attacker could leverage the above-mentioned recursion property, the missing minimum rawDebt requirement, and the liquidate-tick design to execute the following steps:

  1. Repeatedly call the redeem function with a minimal amount of rawDebt(fxUSD) to burn (e.g., burning 2 wei around 150 times). This ensures that the top tick is never updated and that 150 child nodes are created.
  2. Call the redeem function again with a calculated high amount to shift to new top tick to target more positions.
  3. Once the targeted tick has become the top tick again, repeat step 1.

The rebalance and liquidate functions could also be utilized to execute the attack vector, but could only target the top tick that is liquidatable in a given price movement. The redeem function, once activated, could target multiple ticks.

At the end of the attack vector, the tree structure would look something like the one shown in Image L, where tick 80 is targeted to produce 150+ child nodes attached to the same tick. Whenever the user tries to close or update one of those affected positions(P1, P2, or P3) using the operate function, the _getRootNodeAndCompressfunction will fail with a stack-overflow error as the child nodes would be above certain limit. The users won't be able to close or update their positions as they can only be rebalanced or liquidated, causing user funds to get locked.

Even without the redeem function, the "Stack Too Deep" error suggests that any position that is rebalanced more than 150 times won't be able to update or close.

To resolve this issue, the team shifted to an iterative version of the _getRootNodeAndCompress() function, introduced a minimum rawDebtsrequirement for the redeem, rebalance, and liquidate functionalities, and added a check to ensure that the tick always moves during the redeem function. An admin function was also introduced to compress the node chain in case any unintended behavior gets detected through off-chain monitoring of the tree structure. The pull request containing these changes can be viewed here.

In summary, recursive functions are always prone to "Stack Overflow" errors. Moreover, potential manipulation vectors that put these functions in a Denial-of-Service (DoS) state must be thoroughly checked.

Note: the OpenZeppelin audit specifically covered long positions (xPosition). At the time, short positions (sPosition) had not been introduced and were therefore outside the scope of the audit. Since then, the protocol has been significantly updated to support short positions.


Disclaimer

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