前言
2022 年 10 月 15 日,以太坊链上收益协议 earning.farm 遭受攻击。通过简单分析,其原因主要在于合约内没有对闪电贷的来源操作者进行检查,导致了攻击的发生。然而,除此之外,通过分析,发现其实攻击中还有一个细节,这个细节可能是大多数开发者所忽略的。先卖个关子,下面我们开始分析。
攻击细节分析
此次攻击的交易哈希为:https://etherscan.io/tx/0x160c5950a01b88953648ba90ec0a29b0c5383e055d35a7835d905c53a3dda01e
按照老惯例,把交易放到 ethtx.info
下进行分析,直接跳到最关键的部分,结果如下:
通过观察交易细节图,不难发现攻击者在调用了 Balancer
的 flashloan
之后,直接把闪电贷的接收人设置为 EFVault
合约,然后触发了 EFVault
的 receiveFlashLoan
函数,再继续就是攻击者调用 withdraw
函数,然后钱就神奇的去了攻击者的攻击合约里。那么这里头究竟发生了啥呢?首先根据这个流程来看。攻击者直接调用了 EFVault
的 receiveFlashLoan
函数,而这个函数一般是不允许合约以外的用户直接调用的,这就有点像是之前关于 uniswapV2Call
被任意调用导致的问题一样。但是光从这一点,还不能完全推断出究竟是什么问题导致的进一步的损失,所以,我们还需要结合 receiveFlashLoan
和 withdraw
的逻辑来继续具体看看。我们先看 receiveFlashLoan
的逻辑
function receiveFlashLoan(
IERC20[] memory tokens,
uint256[] memory amounts,
uint256[] memory feeAmounts,
bytes memory userData
) public payable {
require(msg.sender == balancer, "only flashloan vault");
uint256 loan_amount = amounts[0];
uint256 fee_amount = feeAmounts[0];
if (keccak256(userData) == keccak256("0x1")){
_deposit(loan_amount, fee_amount);
}
if (keccak256(userData) == keccak256("0x2")){
_withdraw(loan_amount, fee_amount);
}
}
通过观察该函数,不难发现 这个函数仅在 #L7 行对 msg.sender
进行了检查,而没有对调用该 flashloan
的地址进行检查。然后再根据 userData
来决定是调用 _deposit
函数还是 _withdraw
函数。到了这里,就很明显能发现,这是一个类似 uniswapV2Call
未鉴权的问题。通过查看攻击者在调用 flashloan
时传入的参数,可以判断出最后是调用了 _withdraw
函数
细心的同学可能就会发现了,这里图里明明写的是 0x2
到你这里就 0x307832
了?会不会分析啊?且慢,回顾上面的 receiveFlashLoan
逻辑,细心看看,检查的逻辑里用的是 keccak256("0x2")
而不是 keccak256(0x2)
这里是不一样的,前者相当于对 "0x2"
这个字符做了哈希,而不是对 0x2
这个 16 进制进行哈希,而 "0x2"
对应的 16 进制正好就是 0x307832
。
回到分析,我们继续看 _withdraw
里的逻辑,如下:
function _withdraw(uint256 amount, uint256 fee_amount) internal{
uint256 steth_amount = amount.safeMul(IERC20(asteth).balanceOf(address(this))).safeDiv(getDebt());
if (IERC20(weth).allowance(address(this), aave) != 0) {IERC20(weth).safeApprove(aave, 0);}
IERC20(weth).safeApprove(aave, amount);
IAAVE(aave).repay(weth, amount, 2, address(this));
IAAVE(aave).withdraw(lido, steth_amount, address(this));
if (IERC20(lido).allowance(address(this), curve_pool) != 0) {IERC20(lido).safeApprove(curve_pool, 0);}
IERC20(lido).safeApprove(curve_pool, steth_amount);
ICurve(curve_pool).exchange(1, 0, steth_amount, 0);
(bool status, ) = weth.call.value(amount.safeAdd(fee_amount))("");
require(status, "transfer eth failed");
IERC20(weth).safeTransfer(balancer, amount.safeAdd(fee_amount));
}
通过分析代码逻辑, _withdraw
函数实际上就是根据参数中 amount
的大小把合约所持 stETH
从 aave
中提取出来(L2-7),然后再把取出来的 stETH
再换成 ETH
,再次存入到合约中。然后归还闪电贷。到这里,_withdraw
的代码逻辑我们就梳理完了。结合上文提到的,这里还不是攻击的终点,所以我们还要继续分析 withdraw
函数。如下:
function withdraw(uint256 _amount) public nonReentrant{
require(IERC20(ef_token).balanceOf(msg.sender) >= _amount, "not enough balance");
//...省略不重要逻辑
uint256 loan_amount = getDebt().safeMul(_amount).safeDiv(IERC20(ef_token).totalSupply());
address[] memory tokens = new address[](1);
uint256[] memory amounts = new uint256[](1);
bytes memory userData = "0x2";
tokens[0] = weth;
amounts[0] = loan_amount;
//uint256 user_eth_before = msg.sender.balance;
IBalancer(balancer).flashLoan(address(this), tokens, amounts, userData);
uint256 to_send = address(this).balance;
(bool status, ) = msg.sender.call.value(to_send)("");
require(status, "transfer eth failed");
TokenInterfaceERC20(ef_token).destroyTokens(msg.sender, _amount);
emit CFFWithdraw(msg.sender, to_send, _amount, getVirtualPrice());
}
通过分析 withdraw
函数的逻辑。可以发现其实就是根据用户的 ef_token
的数量,通过 #L5 算出可以提取的份额,再通过发起闪电贷,并指定 userData
为 "0x2"
。进行还款后,通过 #L15 的 address(this).balance
来确定剩余在合约中的 ETH
发还给用户。为什么这么判断呢?回顾上面上文,在 withdraw
函数被调用后,会调用 _withdraw
函数 在函数逻辑中,在归还闪电贷之后的资金会留在合约当中。那么根据这个逻辑,闪电贷结束后剩余的资金理应就是用户的资产。所以直接采用 address(this).balance
来判断是没有问题的。
但是问题在于攻击者打破了这个流程,正常的流程应该是先 withdraw
, 通过 withdraw
触发 _withdraw
最后再用 address(this).balance
来判断剩余资金。但由于合约在 receiveFlashLoan
函数中并没有检查闪电贷的发起者,导致 receiveFlashLoan
这个接口是可以被任意调用的,这个时候只需要构造控制参数中的 amount
变量,就可以把合约中的 stETH
全部提出,而由于归还闪电贷后的资金滞留在合约里,导致在调用 withdraw
函数的时候,合约通过 address(this).balance
判断的时候直接把资金判断成是用户的。最终导致了被盗。所以这里其实存在两个问题。receiveFlashLoan
被任意调用和余额判断用 address(this).balance
进行判断。其中后者是很多开发者容易忽略的问题,如果这里用的是用户真实的提现余额来判断的话,此次攻击事件中,就算在 receiveFlashLoan
存在问题的情况下,攻击者也无法把所有的资金全部提走。
事件背后的思考
全文一直在提的就是 receiveFlashLoan
中没有检查闪电贷的发起者导致接口被任意调用,那么是不是可以通过检查发起人就可以避免这个问题呢?像 uniswapV2Call
那样?我们来看看 Balancer Vault
的逻辑:
function flashLoan(
IFlashLoanRecipient recipient,
IERC20[] memory tokens,
uint256[] memory amounts,
bytes memory userData
) external override nonReentrant whenNotPaused {
//..省略不重要逻辑
for (uint256 i = 0; i < tokens.length; ++i) {
IERC20 token = tokens[i];
uint256 amount = amounts[i];
_require(token > previousToken, token == IERC20(0) ? Errors.ZERO_TOKEN : Errors.UNSORTED_TOKENS);
previousToken = token;
preLoanBalances[i] = token.balanceOf(address(this));
feeAmounts[i] = _calculateFlashLoanFeeAmount(amount);
_require(preLoanBalances[i] >= amount, Errors.INSUFFICIENT_FLASH_LOAN_BALANCE);
token.safeTransfer(address(recipient), amount);
}
recipient.receiveFlashLoan(tokens, amounts, feeAmounts, userData);
//..省略不重要逻辑
通过观察 Balancer Vault
的 flashloan
函数,可以发现函数里没有任何可以用于给资金接收人用于判断闪电贷发起人的参数,最类似的 userData
其实也是从函数参数里获取的,即是用户可控的。也就是说。这个函数如果你写在合约里,就是没办法写成是一个指定发起人才能调用的操作。那怎么办呢?难道就只能任人宰割了吗?并不,这里有几个方法:
确保有实现该方法的合约里,不存有任何资金 设置流程锁,以这次攻击为例子,必须在 withdraw
函数调用后,设置withdrawInProcess = true
,然后flashloan
的时候检查到这个状态才允许继续提现。
个人认为第二个方案会比第一个要好,但防范的方法肯定是不止这两个,更多的方法交由读者来思考啦。在分析的过程中,笔者同时注意到 earing.farm
是经过多轮审计的,但最后还是出现了这次的事件,由此我们可以得出,安全是一个动态变化的过程,并不是说某个版本进过了审计就表明已经是没有安全问题了。在安全世界里,每个项目方的状态应该都是已经被黑和在被黑的路上 😀
原文始发于微信公众号(蛋蛋的区块链笔记):钥匙是对的,开门的是贼而已 — earning.fam 被黑分析