去中心化金融平台Lendf.Me黑客攻击事件分析

 

1. 背景

2020年4月19日,以太坊 DeFi 平台dForce的 Lendf.Me协议遭受重入漏洞攻击。目前损失约2500万美元。这是继4月18日Uniswap被黑客攻击损失1278枚ETH(价值约22万美元)之后又一DeFi安全事件。这两起攻击事件都是 Defi 应用与 ERC777 标准组合不当导致的重入漏洞攻击。

DeFi是Decentralized Finance(去中心化金融)的缩写,也被称做Open Finance。DeFi希望借助于区块链的分布式、公开透明、不可篡改、可信等特点,建立点对点的金融,提供更加轻松便捷的金融服务。

dForce 开放式金融平台是著名的DeFi平台,Lendf.Me是由dForce主导开发的去中心化借贷市场协议。

imBTC 是与 BTC 1:1 锚定的 ERC-777 代币,由 Tokenlon 负责发行和监管,将 BTC 转账到 Tokenlon 的安全账户锁定,能够获得同等数量的 imBTC。imBTC是采用ERC77标准发行的代币。

 

2. 漏洞原理剖析

以太坊token最常见的标准是ERC-20标准,但也有ERC-223,ERC-721和ERC-777标准。ERC-777旨在改进ERC-20 token标准,并向后兼容。ERC777 定义了以下两个 hook 接口:

interface ERC777TokensSender {
    function tokensToSend(
        address operator,
        address from,
        address to,
        uint256 amount,
        bytes calldata userData,
        bytes calldata operatorData
    ) external;
}
interface ERC777TokensRecipient {
    function tokensReceived(
        address operator,
        address from,
        address to,
        uint256 amount,
        bytes calldata data,
        bytes calldata operatorData
    ) external;
}

ERC777 的转账实现一般类似下面这样:

function transfer(address to, uint256 amount) public returns (bool) {

  if (sender) {
      sender.tokensToSend(operator, from, to, amount, userData, operatorData);
  }

    _move(from, from, to, amount, "", "");

  if (Recipient) {
      Recipient.tokensReceived(operator, from, to, amount, userData, operatorData);
  }
  return true;
}

在 DeFi 合约调用 Token 的 transferFrom 时,Token 合约会调用 tokensToSend 和 tokenReceived 以便发送者和接收者进行相应的响应。这里 tokensToSend 由用户实现,tokenReceived 由 Defi 合约实现。在ERC777中,代币持有者可以通过 ERC1820 接口注册表合约注册自己的合约并通过在 tokensToSend这个钩子函数中定义一些操作来处理代币转账的过程中的某些流程。本次攻击实质就是攻击者利用DeFi平台处理不当构造了恶意的tokensToSend。

 

3. 攻击分析

攻击者地址:0xA9BF70A420d364e923C74448D9D817d3F2A77822。2020年4月19日12点43分,攻击者地址创建了攻击合约0x538359785a8d5ab1a741a0ba94f26a800759d91d。

在etherscan上查看攻击合约地址的交易记录。发现前两条都是测试,从第三条开始攻击。

选择 0xf8ed32d4a4aad0b5bb150f7a0d6e95b5d264d6da6c167029a20c098a90ff39b4这条交易来分析攻击过程。在下图中,可以看见,攻击者的合约地址向Lendf.Me存入0.00086375imBTC,取出的是存入的两倍。

到bloxy.info上查看该交易 ,我们能知道完整的交易流程。如下图,有两个supply()函数,其中一个是存入0.00086375imBTC。下面一个supply()函数先存入0.00000001个imBTC,在transferFrom下的withdraw()函数提取了0.00172752imBTC

 

4. 源码追踪分析

Lendf.Me合约地址:https://etherscan.io/address/0x0eee3e3828a45f7601d5f54bf49bb01d1a9df5ea#code

有四个关键点:1. 将用户当前余额存入临时变量。2. 调用了token的TransferFrom函数。3. 可以在supply()函数里调用withdraw() 4. TransferFrom函数调用之后更新存入临时变量的余额。supply函数源码参考附录。

1.supply()函数中,将用户当前余额存入临时变量。此处将用户余额存入到localResults.userSupplyUpdated。

2.调用了token的TransferFrom函数。这里,同样是在supply函数中,第1583行,使用了doTransferIn()。

追踪doTransferIn函数,发现调用了token的tansferFrom函数。

由于黑客使用的是token是imBTC,于是找到imBTC合约中的transferFrom函数来分析。imBTC合约地址:https://etherscan.io/address/0x3212b29E33587A00FB1C83346f5dBFA69A458923#code

imBTC的transferFrom代码如下图。这里callTokensToSend与callTokensReceived就是前面在ERC777协议里讲到的tokensToSend 和tokenReceived 。tokensToSend由用户实现,攻击者利用callTokensToSend的hook功能,调用了LendF.Me的withdraw()函数(由前面的bloxy.info分析知)

3.可以在supply()函数里调用withdraw() 。withdraw()函数是用户用来取款的,攻击者构造了攻击合约,攻击合约利用ERC777钩子函数TokensToSend重入supply()调用withdraw()。withdraw()函数源码太多,参考附录。

4.前面说到supply()将用户当前余额存入临时变量,由于withdraw()重入supply,当withdrw()执行完时,攻击者余额已经为0,这时候再看supply()函数末尾,如下图,将之前临时变量localResults.userSupplyUpdated的的值拿来更新余额,这就是问题所在,本身余额已经是0,又将之前的余额写上,导致攻击者在取出全部余额后,余额任然不变。攻击者由此可以滚雪球一般盗取imBTC:假如攻击者原先余额为100,存入100,取出200后,账号还是200,攻击者再存入取出的200,取出400,再将取出的400存入,取出800……

 

5. 攻击手法分析

正常的存取流程

正常用户是不会在supply()中重入withdraw()的

黑客攻击

黑客通过部署合约利用TokensToSend将调用的withdraw()重入supply()。

 

6. 修复

  1. 给Supply函数和withdra()函数中加入 OpenZeppelin 的 ReentrancyGuard 函数,防止重入问题。

 

7. 关于Uniswap攻击事件

Uniswap攻击事件也是由于 DeFi平台和ERC777未做好兼容造成的。攻击方式与Lendf.Me本质是一样的,实现方式不一样而已。

附录:

supply()函数

function supply(address asset, uint amount) public returns (uint) {
    if (paused) {
        return fail(Error.CONTRACT_PAUSED, FailureInfo.SUPPLY_CONTRACT_PAUSED);
    }

    Market storage market = markets[asset];
    Balance storage balance = supplyBalances[msg.sender][asset];

    SupplyLocalVars memory localResults; // Holds all our uint calculation results
    Error err; // Re-used for every function call that includes an Error in its return value(s).
    uint rateCalculationResultCode; // Used for 2 interest rate calculation calls

    // Fail if market not supported
    if (!market.isSupported) {
        return fail(Error.MARKET_NOT_SUPPORTED, FailureInfo.SUPPLY_MARKET_NOT_SUPPORTED);
    }

    // Fail gracefully if asset is not approved or has insufficient balance
    err = checkTransferIn(asset, msg.sender, amount);
    if (err != Error.NO_ERROR) {
        return fail(err, FailureInfo.SUPPLY_TRANSFER_IN_NOT_POSSIBLE);
    }

    // We calculate the newSupplyIndex, user's supplyCurrent and supplyUpdated for the asset
    (err, localResults.newSupplyIndex) = calculateInterestIndex(market.supplyIndex, market.supplyRateMantissa, market.blockNumber, getBlockNumber());
    if (err != Error.NO_ERROR) {
        return fail(err, FailureInfo.SUPPLY_NEW_SUPPLY_INDEX_CALCULATION_FAILED);
    }

    (err, localResults.userSupplyCurrent) = calculateBalance(balance.principal, balance.interestIndex, localResults.newSupplyIndex);
    if (err != Error.NO_ERROR) {
        return fail(err, FailureInfo.SUPPLY_ACCUMULATED_BALANCE_CALCULATION_FAILED);
    }

    (err, localResults.userSupplyUpdated) = add(localResults.userSupplyCurrent, amount);
    if (err != Error.NO_ERROR) {
        return fail(err, FailureInfo.SUPPLY_NEW_TOTAL_BALANCE_CALCULATION_FAILED);
    }

    // We calculate the protocol's totalSupply by subtracting the user's prior checkpointed balance, adding user's updated supply
    (err, localResults.newTotalSupply) = addThenSub(market.totalSupply, localResults.userSupplyUpdated, balance.principal);
    if (err != Error.NO_ERROR) {
        return fail(err, FailureInfo.SUPPLY_NEW_TOTAL_SUPPLY_CALCULATION_FAILED);
    }

    // We need to calculate what the updated cash will be after we transfer in from user
    localResults.currentCash = getCash(asset);

    (err, localResults.updatedCash) = add(localResults.currentCash, amount);
    if (err != Error.NO_ERROR) {
        return fail(err, FailureInfo.SUPPLY_NEW_TOTAL_CASH_CALCULATION_FAILED);
    }

    // The utilization rate has changed! We calculate a new supply index and borrow index for the asset, and save it.
    (rateCalculationResultCode, localResults.newSupplyRateMantissa) = market.interestRateModel.getSupplyRate(asset, localResults.updatedCash, market.totalBorrows);
    if (rateCalculationResultCode != 0) {
        return failOpaque(FailureInfo.SUPPLY_NEW_SUPPLY_RATE_CALCULATION_FAILED, rateCalculationResultCode);
    }

    // We calculate the newBorrowIndex (we already had newSupplyIndex)
    (err, localResults.newBorrowIndex) = calculateInterestIndex(market.borrowIndex, market.borrowRateMantissa, market.blockNumber, getBlockNumber());
    if (err != Error.NO_ERROR) {
        return fail(err, FailureInfo.SUPPLY_NEW_BORROW_INDEX_CALCULATION_FAILED);
    }

    (rateCalculationResultCode, localResults.newBorrowRateMantissa) = market.interestRateModel.getBorrowRate(asset, localResults.updatedCash, market.totalBorrows);
    if (rateCalculationResultCode != 0) {
        return failOpaque(FailureInfo.SUPPLY_NEW_BORROW_RATE_CALCULATION_FAILED, rateCalculationResultCode);
    }

    /////////////////////////
    // EFFECTS & INTERACTIONS
    // (No safe failures beyond this point)

    // We ERC-20 transfer the asset into the protocol (note: pre-conditions already checked above)
    err = doTransferIn(asset, msg.sender, amount);
    if (err != Error.NO_ERROR) {
        // This is safe since it's our first interaction and it didn't do anything if it failed
        return fail(err, FailureInfo.SUPPLY_TRANSFER_IN_FAILED);
    }

    // Save market updates
    market.blockNumber = getBlockNumber();
    market.totalSupply =  localResults.newTotalSupply;
    market.supplyRateMantissa = localResults.newSupplyRateMantissa;
    market.supplyIndex = localResults.newSupplyIndex;
    market.borrowRateMantissa = localResults.newBorrowRateMantissa;
    market.borrowIndex = localResults.newBorrowIndex;

    // Save user updates
    localResults.startingBalance = balance.principal; // save for use in `SupplyReceived` event
    balance.principal = localResults.userSupplyUpdated;
    balance.interestIndex = localResults.newSupplyIndex;

    emit SupplyReceived(msg.sender, asset, amount, localResults.startingBalance, localResults.userSupplyUpdated);

    return uint(Error.NO_ERROR); // success
}

withdraw()函数

function withdraw(address asset, uint requestedAmount) public returns (uint) {
    if (paused) {
        return fail(Error.CONTRACT_PAUSED, FailureInfo.WITHDRAW_CONTRACT_PAUSED);
    }

    Market storage market = markets[asset];
    Balance storage supplyBalance = supplyBalances[msg.sender][asset];

    WithdrawLocalVars memory localResults; // Holds all our calculation results
    Error err; // Re-used for every function call that includes an Error in its return value(s).
    uint rateCalculationResultCode; // Used for 2 interest rate calculation calls

    // We calculate the user's accountLiquidity and accountShortfall.
    (err, localResults.accountLiquidity, localResults.accountShortfall) = calculateAccountLiquidity(msg.sender);
    if (err != Error.NO_ERROR) {
        return fail(err, FailureInfo.WITHDRAW_ACCOUNT_LIQUIDITY_CALCULATION_FAILED);
    }

    // We calculate the newSupplyIndex, user's supplyCurrent and supplyUpdated for the asset
    (err, localResults.newSupplyIndex) = calculateInterestIndex(market.supplyIndex, market.supplyRateMantissa, market.blockNumber, getBlockNumber());
    if (err != Error.NO_ERROR) {
        return fail(err, FailureInfo.WITHDRAW_NEW_SUPPLY_INDEX_CALCULATION_FAILED);
    }

    (err, localResults.userSupplyCurrent) = calculateBalance(supplyBalance.principal, supplyBalance.interestIndex, localResults.newSupplyIndex);
    if (err != Error.NO_ERROR) {
        return fail(err, FailureInfo.WITHDRAW_ACCUMULATED_BALANCE_CALCULATION_FAILED);
    }

    // If the user specifies -1 amount to withdraw ("max"),  withdrawAmount => the lesser of withdrawCapacity and supplyCurrent
    if (requestedAmount == uint(-1)) {
        (err, localResults.withdrawCapacity) = getAssetAmountForValue(asset, localResults.accountLiquidity);
        if (err != Error.NO_ERROR) {
            return fail(err, FailureInfo.WITHDRAW_CAPACITY_CALCULATION_FAILED);
        }
        localResults.withdrawAmount = min(localResults.withdrawCapacity, localResults.userSupplyCurrent);
    } else {
        localResults.withdrawAmount = requestedAmount;
    }

    // From here on we should NOT use requestedAmount.

    // Fail gracefully if protocol has insufficient cash
    // If protocol has insufficient cash, the sub operation will underflow.
    localResults.currentCash = getCash(asset);
    (err, localResults.updatedCash) = sub(localResults.currentCash, localResults.withdrawAmount);
    if (err != Error.NO_ERROR) {
        return fail(Error.TOKEN_INSUFFICIENT_CASH, FailureInfo.WITHDRAW_TRANSFER_OUT_NOT_POSSIBLE);
    }

    // We check that the amount is less than or equal to supplyCurrent
    // If amount is greater than supplyCurrent, this will fail with Error.INTEGER_UNDERFLOW
    (err, localResults.userSupplyUpdated) = sub(localResults.userSupplyCurrent, localResults.withdrawAmount);
    if (err != Error.NO_ERROR) {
        return fail(Error.INSUFFICIENT_BALANCE, FailureInfo.WITHDRAW_NEW_TOTAL_BALANCE_CALCULATION_FAILED);
    }

    // Fail if customer already has a shortfall
    if (!isZeroExp(localResults.accountShortfall)) {
        return fail(Error.INSUFFICIENT_LIQUIDITY, FailureInfo.WITHDRAW_ACCOUNT_SHORTFALL_PRESENT);
    }

    // We want to know the user's withdrawCapacity, denominated in the asset
    // Customer's withdrawCapacity of asset is (accountLiquidity in Eth)/ (price of asset in Eth)
    // Equivalently, we calculate the eth value of the withdrawal amount and compare it directly to the accountLiquidity in Eth
    (err, localResults.ethValueOfWithdrawal) = getPriceForAssetAmount(asset, localResults.withdrawAmount); // amount * oraclePrice = ethValueOfWithdrawal
    if (err != Error.NO_ERROR) {
        return fail(err, FailureInfo.WITHDRAW_AMOUNT_VALUE_CALCULATION_FAILED);
    }

    // We check that the amount is less than withdrawCapacity (here), and less than or equal to supplyCurrent (below)
    if (lessThanExp(localResults.accountLiquidity, localResults.ethValueOfWithdrawal) ) {
        return fail(Error.INSUFFICIENT_LIQUIDITY, FailureInfo.WITHDRAW_AMOUNT_LIQUIDITY_SHORTFALL);
    }

    // We calculate the protocol's totalSupply by subtracting the user's prior checkpointed balance, adding user's updated supply.
    // Note that, even though the customer is withdrawing, if they've accumulated a lot of interest since their last
    // action, the updated balance *could* be higher than the prior checkpointed balance.
    (err, localResults.newTotalSupply) = addThenSub(market.totalSupply, localResults.userSupplyUpdated, supplyBalance.principal);
    if (err != Error.NO_ERROR) {
        return fail(err, FailureInfo.WITHDRAW_NEW_TOTAL_SUPPLY_CALCULATION_FAILED);
    }

    // The utilization rate has changed! We calculate a new supply index and borrow index for the asset, and save it.
    (rateCalculationResultCode, localResults.newSupplyRateMantissa) = market.interestRateModel.getSupplyRate(asset, localResults.updatedCash, market.totalBorrows);
    if (rateCalculationResultCode != 0) {
        return failOpaque(FailureInfo.WITHDRAW_NEW_SUPPLY_RATE_CALCULATION_FAILED, rateCalculationResultCode);
    }

    // We calculate the newBorrowIndex
    (err, localResults.newBorrowIndex) = calculateInterestIndex(market.borrowIndex, market.borrowRateMantissa, market.blockNumber, getBlockNumber());
    if (err != Error.NO_ERROR) {
        return fail(err, FailureInfo.WITHDRAW_NEW_BORROW_INDEX_CALCULATION_FAILED);
    }

    (rateCalculationResultCode, localResults.newBorrowRateMantissa) = market.interestRateModel.getBorrowRate(asset, localResults.updatedCash, market.totalBorrows);
    if (rateCalculationResultCode != 0) {
        return failOpaque(FailureInfo.WITHDRAW_NEW_BORROW_RATE_CALCULATION_FAILED, rateCalculationResultCode);
    }

    /////////////////////////
    // EFFECTS & INTERACTIONS
    // (No safe failures beyond this point)

    // We ERC-20 transfer the asset into the protocol (note: pre-conditions already checked above)
    err = doTransferOut(asset, msg.sender, localResults.withdrawAmount);
    if (err != Error.NO_ERROR) {
        // This is safe since it's our first interaction and it didn't do anything if it failed
        return fail(err, FailureInfo.WITHDRAW_TRANSFER_OUT_FAILED);
    }

    // Save market updates
    market.blockNumber = getBlockNumber();
    market.totalSupply =  localResults.newTotalSupply;
    market.supplyRateMantissa = localResults.newSupplyRateMantissa;
    market.supplyIndex = localResults.newSupplyIndex;
    market.borrowRateMantissa = localResults.newBorrowRateMantissa;
    market.borrowIndex = localResults.newBorrowIndex;

    // Save user updates
    localResults.startingBalance = supplyBalance.principal; // save for use in `SupplyWithdrawn` event
    supplyBalance.principal = localResults.userSupplyUpdated;
    supplyBalance.interestIndex = localResults.newSupplyIndex;

    emit SupplyWithdrawn(msg.sender, asset, localResults.withdrawAmount, localResults.startingBalance, localResults.userSupplyUpdated);

    return uint(Error.NO_ERROR); // success
}

 

参考:

(完)