Sherlock Yield Strategy Bug Bounty Post-Mortem

Sherlock Yield Strategy Bug Bounty Post-Mortem

NOTE FOR SHERLOCK LIQUIDITY PROVIDERS: This type of bug bounty payout does not impact any of the staking pool, and all staked funds used for Sherlock coverage remain unaffected.

Overview

  • On July 14th, 2022, GothicShanon89238 reported an issue to Sherlock through Immunefi concerning Sherlock's yield strategy integration with Euler. The issue has since been fixed.
  • No funds were immediately at risk, but the issue could eventually have resulted in a material amount of Sherlock's staking pool being drained if a 6-month waiting period and other conditions had been met.
  • The exploit was only possible on withdrawal, meaning a hacker would first be required to lock up millions of dollars in Sherlock's staking pool for 6 months to execute the exploit. In addition, the hacker would need access to billions of dollars in flashloans on withdrawal to make the hack possible.
  • The hack is a sophisticated cross-protocol reentrancy and involves intimate knowledge of contracts in 3 protocols: Sherlock, Euler and 1inch.
  • The Sherlock integration contract involved had recently been audited by Trail of Bits.
  • The Euler swap function involved was audited by Sherlock and Certora.
  • Sherlock is very thankful to GothicShanon89238 and has paid out a $250k bounty as a reward, shy of the maximum payout due to the amount of time required (>6 months), non-flashloan capital required (>$2M), flashloan capital required (>$1Bn) and specific market conditions (detailed below) required to enable a profitable exploit.
  • Sherlock is also thankful to WatchPug and Chris Michel, two Sherlock "Watsons" who helped the Sherlock core team better understand the exploit.

Technical TL;DR

Sherlock's EulerStrategy.balanceOf() (which uses the result of the balanceOfUnderlying() call on Euler's eUSDC contract) can be manipulated in Euler's eUSDC contract, by first manipulating the exchangeRate in Euler, since balanceOfUnderlying = shares * exchangeRate.

The core issue is that Euler allows a swap to mint eUSDC, but in a non-atomic way when using 1inch. Usually, a mint should increase the underlying balance (USDC) and mint new eTokens (eUSDC in the Sherlock integration) atomically. However, using 1inch swaps, an attacker gets a callback before the eUSDC totalSupply is increased.

The attacker can redeem their staked Sherlock position in the callback (provided by 1inch) at an increased exchange rate, because the asset cache (storage) uses the current (inflated) balance of the Euler pool.

Background

GothicShanon89238 submitted a report to Sherlock via the Immunefi bug bounty program on July 14th, 2022 concerning a possible exploit that could lead to a significant loss of Sherlock staker funds. GothicShanon89238 also provided a robust proof of concept demonstrating the exploit in an Ethereum mainnet fork.

The issue was triaged by Immunefi and escalated to Sherlock's security team, who quickly verified the correctness of the disclosure. Luckily, it was clear that no funds were immediately at risk.

The issue pertained to Sherlock's integration contract with Euler, which allowed Sherlock staker funds to earn lending fees in Euler's USDC pool.

Root cause

As part of a staker's withdrawal action, EulerStrategy.sol#_balanceOf() calls EUSDC.balanceOfUnderlying(address(this)) for the current value held by the EulerStrategy. The result is then used to calculate the price per share of the withdrawing user's position in Sherlock.sol.

function _balanceOf() internal view override returns (uint256) {
    return EUSDC.balanceOfUnderlying(address(this));
}

However, EUSDC.balanceOfUnderlying() may not reflect the correct underlying value in certain circumstances. Specifically, it is not always reflected after an amount of underlying tokens is transferred to the Euler contract for the deposit, but before the deposit is processed. Only when the deposit is processed does Euler call increaseBalance(), which is equivalent to mint() and increases totalSupply for a regular vault contract.

In this case, before the deposit is processed totalAssets increases (by the newly added tokens), but the totalBalances remains the same old value, making the result of computeExchangeRate() higher than the actual value.

function computeExchangeRate(AssetCache memory assetCache) private pure returns (uint) {
    uint totalAssets = assetCache.poolSize + (assetCache.totalBorrows / INTERNAL_DEBT_PRECISION);
    if (totalAssets == 0 || assetCache.totalBalances == 0) return 1e18;
    return totalAssets * 1e18 / assetCache.totalBalances;
}

Attack vector

A key to exploiting this vulnerability is the ability to execute arbitrary calls using Swap.sol#swap1Inch(). During this call, the control can be given back to the attacking contract, allowing the attacker to:

  1. Transfer a considerable amount of USDC tokens to Euler, which inflates the result of computeExchangeRate()
  2. Redeem Sherlock shares even though the EulerStrategy only holds a small portion of Sherlock's total invested funds. When the inflation of EulerStrategy's price per share is large enough, the overall price per share can still grow by a lot.

This is where the attacker profits from this attack: they make the Sherlock contract believe the price per share is higher, therefore redeeming more underlying (USDC) to the attacker on withdrawal than it should.

After the redemption, Swap.sol#finalizeSwap() will be called, which will process the deposit of USDC, minting eUSDC to the attack contract, which will later be used to withdraw back the funds used in step 1.

Proof of Concept (PoC) walkthrough

The PoC was originally created by GothicShanon89238, but has been lightly modified by Sherlock to include things like flashloan costs, etc.

The example to follow includes assumptions that only 5M USDC is available to be staked for 6 months (with no borrow cost), and only 1Bn USDC is able to be flashloaned (at a cost of 0.09%).

Preparation

The PoC mints 15M USDC, 5M of which (mintAmount - donateAmount) can NOT be a flash loan and needs to be staked in Sherlock for at least 6 months. The other 10M USDC is included in the 1Bn USDC total flash loan assumption.

mintUsdc(address(this), mintAmount);

The attacker mints a flashloanAmount of 990M USDC (= pumpAmount * 2); this is in line with the 1Bn USDC flash loan assumption, minus the donateAmount in the paragraph above which is also flash loan capital. usdcAmount is 1 USDC, which is minted as an initial deposit in Euler to enter the market.

mintUsdc(address(this), pumpAmount * 2 + usdcAmount);

The Sherlock position is minted by calling initialStake(), staking 5M USDC (mintAmount - donateAmount) for 6 months.

(uint id, uint shares) = ISherlockStake(sherlock).initialStake(mintAmount-donateAmount, period, address(this));

At this point in the PoC, the mainnet fork is fast-forwarded >6 months using Foundry-specific instructions.

vm.warp(block.timestamp + period + 10);
vm.roll(block.number + 1);

Capital efficiency of the exploit can be increased by donating eUSDC (donateAmount) to the EulerStrategy. This will increase Sherlock's exposure to the Euler yield strategy.

The optimal amount to donate (as seen in the Exploit Profitability section below) is ~10M USDC.

depositEuler(usdc, donateAmount);
ERC20 eUSDC = ERC20(markets.underlyingToEToken(usdc));
eUSDC.transfer(eulerStrategy, eUSDC.balanceOf(address(this)));

Deposit 1 ETH and 1 USDC into Euler to create a position from which Swap.sol#swap1Inch can be executed.

depositEuler(WETH, ethDeposit);
depositEuler(usdc, usdcAmount);

Execution

Pre-swap

The internal swap() function is called in the exploit contract. The function is instructed to swap 0.5 ETH to USDC, with a minimum return of 0.000001 USDC once the swap is executed.

swap(WETH, usdc, ethDeposit / 2);

The internal swap() function will call the internal generatePayload() function, which prepares the payload for the swap1Inch call.

The receiver is set to address(this), the attacking contract. In normal circumstances, the receiver would be the Euler contract itself. The payload generated contains the following data:

  • cast 4byte 0x7c025200 = swap(address,(address,address,address,address,uint256,uint256,uint256,bytes),bytes) = swap(address, SwapDescription, data)
  • address(this) = Address that will receive a callback -- used in AggregationRouterV4:#2347
  • description = Instructions for 1inch about how the tokens need to be swapped
  • abi.encode(0x1) = Arbitrary data for the callback
function generatePayload(address srcToken, address dstToken, address receiver, uint256 balance) internal view returns(bytes memory payload) {
    SwapDescription memory description = SwapDescription(
        srcToken, dstToken, address(this), receiver, balance,
        1, // minreturn
        _SHOULD_CLAIM, ""
    );
    payload = abi.encodeWithSelector(0x7c025200, address(this), description, abi.encode(0x1);
}

Swap with cross-protocol reentrancy

1inch allows an arbitrary call (AggregationRouterV4:#2347) which triggers the fallback function in the attack contract. This happens before the output tokens from the swap are transferred (AggregationRouterV4:#2368).

For the following two reasons, exactly twice the inflated amount is needed:

  1. The computeExchangeRate() function needs to be inflated during the arbitrary callback before any USDC from 1inch is transferred. USDC needs to be sent directly to Euler to manipulate the exchange rate (executed here).
  2. After the USDC from 1inch is transferred, Euler expects the exact output amount of the swapped USDC to be in the contract (verified here as the final check to complete the swap). But because of the steps in reason #1 the USDC is already there. The receiver of the 495M USDC 1inch swap is the attacking contract and the exact output amount is transferred here.

At this point, the exchange rate is manipulated, and the position can be redeemed for an inflated amount.

The steps in reason #1 above allow the attacker to inflate the Euler computeExchangeRate and make a profit from unstaking the Sherlock position. The second step is needed to make the transaction succeed because of further checks in the Euler code. The Sherlock position can be unstaked before or after step 2.

In the attack contract, the receiver of the 1inch swap is the attack contract itself. This is because the tokens gained from the swap are already in the Euler contract (because of the exchange rate inflation). The Euler contract is "tricked" into thinking it received the USDC from the swap, but the USDC was already in the contract before the final step in the swap was executed (sending output tokens).

If you are using the 1inch app UI, it will execute your swap using various liquidity sources (e.g., Uniswap, Balancer). For this specific swap, the attack contract sent USDC directly to the 1inch aggregation contract, simulating a successful token retrieval from a liquidity source.

Post-swap: Euler

The swap is finalized when the Euler contract checks that the exact output amount of the swapped USDC is in the contract. The 495M USDC received is deposited for roughly 495M eUSDC and sent to the attack contract.

function finalizeSwap(SwapCache memory swap) private {
    uint balanceIn = checkBalances(swap);

    processWithdraw(eTokenLookup[swap.eTokenIn], swap.assetCacheIn, swap.eTokenIn, swap.accountIn, swap.amountInternalIn, balanceIn);

    processDeposit(eTokenLookup[swap.eTokenOut], swap.assetCacheOut, swap.eTokenOut, swap.accountOut, swap.amountOut);

    checkLiquidity(swap.accountIn);
}

Post-swap: Attack contract

The attack contract withdraws its positions from Euler (~495M USDC and ~0.5 ETH).

In the simulation, the profits and costs are estimated. On mainnet, the flash loan would be returned at this point.

{
    ETokenLike collateralEToken = ETokenLike(markets.underlyingToEToken(WETH));
    uint256 underlying = collateralEToken.balanceOfUnderlying(address(this));
    collateralEToken.withdraw(0, underlying);

    collateralEToken = ETokenLike(markets.underlyingToEToken(usdc));
    underlying = collateralEToken.balanceOfUnderlying(address(this));
    collateralEToken.withdraw(0, underlying);
}

{
    uint256 postAttack = ERC20(usdc).balanceOf(address(this));
    console.log("post attack:", postAttack);
    console.log("gross profit:", postAttack - preAttack);
    // assuming 0.09% flash loan cost
    console.log("flash loan costs:", (flashloanAmount / 10_000) * 9);
}

Risks and requirements

An attacker would incur the following risks when executing this exploit:

  • 6+ months of Sherlock smart contract risk (staking for 6 months runs the risk of an unrelated exploit)
  • 6+ months of front-running risk (a different attacker executes this exact exploit before an attacker's 6-month stake has matured)
  • 6+ months of Sherlock protocol coverage risk (there is a real risk that Sherlock incurs one or more payouts)
  • Sherlock could move funds away from the Euler strategy at some point during the 6 months if other strategies become more profitable, making the attack impossible
  • 1Bn USDC of flash loan funds might not be available in 6 months or the borrow cost could be too high
  • The Euler USDC market could increase in size during the 6 months, increasing the size and cost of the flash loan needed
  • Sherlock TVL could decrease after 6 months, reducing or eliminating profitability
  • If the attacker didn't have 5M+ USDC sitting around to stake for 6 months, they'd have to account for the borrow cost for that 5M+ USDC over 6 months

Exploit Profitability

  • Donation Amount is the amount of eUSDC sent to EulerStrategy.sol in order to increase Sherlock’s exposure to Euler
  • The flash loan amount used for the 1inch swap is ($1Bn - Donation Amount)
  • Stake Amount is the amount that needs to be staked in Sherlock for 6 months

Audits

Sherlock

Trail of Bits audited the Sherlock V2 update that allowed Sherlock to integrate with Aave, Compound, Euler, Maple and TrueFi yield strategies. The fix review was completed on June 29, 2022 and the contracts were deployed shortly thereafter.

This potential exploit was very much in scope and would likely fall under “correct calculation of the strategy’s balance.”

But this type of cross-protocol re-entrancy is quite novel, so the auditors may not have been familiar with that risk or with the intricacies of how Euler works.

Euler

Euler has received many audits, but the first instance of the swap1inch() function occured in Certora’s formal verification audit in September and October 2021. Subsequently, the function was also present in Sherlock’s audit in December 2021.

In both cases, Euler itself was protected from re-entrancy risk through use of the nonReentrant modifier. And the scope of the audit was not focused on protocols that might be integrating with Euler.

Sherlock’s audit did not include any findings that would be relevant to cross-protocol reentrancy.

However, Certora produced a Medium severity finding that would have prevented the exploit from being possible. But it was not imagined to be a critical vulnerability (by Certora or Euler) so the Euler team acknowledged it and moved forward.

Details of fix

Certain view functions in Euler will verify that reentrancyLock is unlocked. This means Sherlock's _balanceOf() call will revert if the 1inch swap takes place because it calls a view function with the reentrancyLock modifer.

Takeaways

First and foremost, Sherlock is thankful to GothicShanon89238 and Immunefi. Without these two parties, this vulnerability might still be live on mainnet, waiting to exploited. We’re also thankful to Euler for working closely with us to identify and fix the vulnerability. And we’re grateful to all the auditors who spent time looking for vulnerabilities in Sherlock or Euler contracts.

The Sherlock core team has had to think hard about a lot of issues due to this vulnerability. It’s revealed that even some of the best auditors and teams in the space can have blind spots when it comes to security. And it’s become clear that Sherlock needs to be doing even more to prevent blind spots like this, for Sherlock’s own protocol as well as for protocols with Sherlock coverage. More to come on how Sherlock will accomplish this moving forwards.

Despite this bump in the road, the Sherlock team is more convicted than ever that audits should be backed by smart contract coverage and that even the “safest” smart contracts can benefit from “backups” like bug bounties and smart contract coverage.

For those who don’t know, Sherlock is the only auditor that backs audits with up to $10M in smart contract coverage. If you’re a protocol team looking for an auditor whose incentives are aligned with yours, feel free to reach out.

Subscribe to Sherlock
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.