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.
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.
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.
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;
}
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:
USDC
tokens to Euler, which inflates the result of computeExchangeRate()
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.
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%).
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);
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:#2347description
= Instructions for 1inch about how the tokens need to be swappedabi.encode(0x1)
= Arbitrary data for the callbackfunction 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:
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).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);
}
An attacker would incur the following risks when executing this exploit:
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 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.
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.
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.