智能合约常见漏洞

概述

智能合约安全主要关注智能合约及其与区块链内部要素交互过程中的安全性,如智能合约架构设计安全、代码安全、运行安全等。本小节将简要介绍基于以太坊智能合约 Solidity 目前主要的安全问题以及解决方案。

Ethereum平台及Solidity语言的安全漏洞,基于语言本身、执行环境以及系统可以分为3个级别:SolidityEVMblockchain

  • Solidity 级别的漏洞主要涵盖调用不明确(call to unkown)、没有足够的 Gas 发送(gasless send)、异常障碍(exception disorder)、类型转换(type cast)、重入攻击(reentrancy)和保密(keeping secret);
  • EVM级别的漏洞主要包括传输过程中网络数据丢失(ether lost in transfer)和堆栈容量限制(stack size limit)等
  • Blockchain级别的漏洞主要涉及不可预测的状态(unpredictable state)、产生随机性(generating randomness)和时间限制(time constraint)。

智能合约的典型安全属性主要包含以下几个类别:

  • 调用完整性(call integrity)智能合约调用完整性可以防止重入攻击和调用不明确等安全问题。
  • 原子性(atomicity)当智能合约不满足原子性时,将出现不可预知异常(mishandled exceptions)等类型的安全问题。
  • 可变账户状态的独立性(independence of mutable account state)当智能合约不满足可变账户状态的独立性时,将出现交易顺序依赖(transaction order dependency)和不可预测状态等类型的安全问题
  • 交易环境独立性(independence of transaction environment)当智能合约不满足交易环境独立性时,将出现时间戳依赖(timestamp dependency)、时间限制(time constraints)和产生随机性等类型的安全问题

基于开发设计模式安全,为了解决Ethereum和Solidity相关的安全性问题,社区和开发人员提出了检查效果交互(CEI, checks-effect-interaction)、紧急停止(emergency stop)、减速带(speed bump)、速率限制(rate limit)、互斥(mutex)和余额限制(balance limit) 6 种设计模式用于处理开发智能合约过程中的典型安全问题和漏洞。

  • 检查效果交互模式通过一定的代码顺序,在最后一步调用外部智能合约,阻止外部智能合约发动重复调用攻击,解决恶意代码劫持控制流的漏洞。
  • 紧急停止模式将紧急停止功能集成到智能合约代码中,由认证方触发以禁用某些敏感功能。
  • 减速带模式通过延长执行敏感任务的智能合约的完成时间,解决短时间内某项任务请求执行频率过高的问题。
  • 速率限制模式通过降低智能合约一段时间内的执行速率,缓解任务请求繁忙状况,实现智能合约正常运行。
  • 互斥模式利用互斥死锁阻止外部调用重新输入调用方函数,防止重入攻击。
  • 余额限制模式通过限制智能合约中风险资金的最高金额,降低智能合约受到攻击后造成的金融风险。

访问控制权限

合约没有设置合理的访问控制模型,没有对合约方法进行有效的校验。主要体现在以下方面:

  • 函数和变量可见性:未能合理限制其所能被修改或调用的作用域
  • 权限漏洞:指应用在检查授权时存在纰漏(甚至漏写检查),使得攻击者在获得低权限用户账户后,利用一些方式绕过权限检查,访问或者操作其他用户或者更高权限。

可见性

Solidity的函数和状态变量有四种可见性:public、external、internal、private。函数的可见性在0.5.0以前默认是public,之后被强制要求声明可见性,不允许留空否则会编译报错。

public: 其修饰的函数对所有智能合约可见,可以被外部调用也可以被内部调用;

external: 其修饰的函数智能被外部合约调用,不允许内部调用(不能使用this.a()在同一个合约内调用);

internal: 其修饰的函数只允许被本合约和派生合约内部调用;

private:其修饰的函数只允许被当前合约调用,其派生合约不可见。

函数修饰器

修饰器是合约的可继承属性,可以被派生合约覆盖,但前提是它们被标记为virtual(早期版本没有virtual修饰符不需要声明为virtual)

浮点数和精度

目前 Solidity 对浮点的支持并不好,需要借助整型来实现浮点运算。

定长浮点型

目前 Solidity 还没有完全支持定长浮点型,可以声明定长浮点型的变量,但不能给它们赋值或把它们赋值给其他变量。定长浮点型的关键字是 fixed/ufixed,表示各种大小的有符号和无符号的定长浮点型。

在关键字 ufixedMxN 和 fixedMxN 中,“M” 表示该类型占用的位数,“N” 表示可用的小数位数。“M” 必须能整除8,即8位到256位。“N” 则可以是从0到80之间的任意数。ufixed 和 fixed 分别是 ufixed128x18 和 fixed128x18 的别名。

主流的合约方案如solidity是禁止使用浮点数的。

通过整型实现

如果使用不当,会导致意想不到的漏洞

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
pragma solidity 0.8.7;

contract FunWithNumbers {

uint public tknPerEther = 10;
uint public weiPerEther = 1e18;

mapping(address => uint) public balance;

function buyToken() external payable {
// converting wei to eth, then multiplying by the token rate
// 如果msg.value小于 1 个以太币,无论它多么接近 1 个以太币,在除以weiPerEther后都会得到 0。
uint token = msg.value/weiPerEther*tknPerEther;
balance[msg.sender] += token;
}
function sellTokens(uint tokens) public {
require(balance[msg.sender] >= tokens);
// 任何小于10的tokens的数值都将导致eth为0
uint eth = tokens/tknPerEther;
balance[msg.sender] -= tokens;
payable (msg.sender).transfer(eth*weiPerEther);
}
}

防御措施

  1. 注意操作顺序的区别,如果除法无法避免,乘法一定要在除法前,以避免精度丢失。在上面的示例中,可以修改为 msg.value*tknPerEth/weiPerEther 结果会更加精确。

  2. 最好在执行任何必要的数学运算之前将值转换为更高的精度,最终转换回输出所需的精度。

  3. 最好在 Solidity 中保持所有变量的高精度,并在第三方应用程序中将它们转换回较低的精度。

成熟的开源的浮点运算库:

image-20230618195016791

合约变量存储机制

之前的文章 合约变量存储机制

溢出

整型溢出

原理简介

通常来说,在编程语言里由算数问题导致的整数溢出漏洞屡见不鲜,在区块链的世界里,智能合约的Solidity语言中也存在整数溢出问题,整数溢出一般分为又分为上溢和下溢,在智能合约中出现整数溢出的类型包括三种:

  • 乘法溢出
  • 加法溢出
  • 减法溢出

在Solidity语言中,变量支持的整数类型步长以8递增,支持从uint8到uint256,以及int8到int256。例如,一个 uint8类型 ,只能存储在范围 0到2^8-1,也就是[0,255] 的数字,一个 uint256类型 ,只能存储在范围 0到2^256-1的数字。

在以太坊虚拟机(EVM)中为整数指定固定大小的数据类型,而且是无符号的,这意味着在以太坊虚拟机中一个整型变量只能有一定范围的数字表示,不能超过这个制定的范围。

如果试图存储 256这个数字 到一个 uint8类型中,这个256数字最终将变成 0,所以整数溢出的原理其实很简单,为了说明整数溢出原理,这里以 8 (uint8)位无符整型为例,8 位整型可表示的范围为 [0, 255],255 在内存中存储按位存储的形式为下图所示:

img

8 位无符整数 255 在内存中占据了 8bit 位置,若再加上 1 整体会因为进位而导致整体翻转为 0,最后导致原有的 8bit 表示的整数变为 0。

上图即说明了智能合约中整数上溢的原理,同样整数下溢也是一样,如 (uint8)0 - 1 = (uint8)255

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
pragma solidity ^0.6.12;
contract POC{
uint256 public add = 2**256 - 1;
uint256 public sub = 0;
uint256 public mul = 2**255;
//加法溢出
//如果uint256 类型的变量达到了它的最大值(2**256 - 1),如果在加上一个大于0的值便会变成0
function add_overflow() public view returns (uint256) {
return add + 1;
}

//减法溢出
//如果uint256 类型的变量达到了它的最小值(0),如果在减去一个小于0的值便会变成2**256-1(uin256类型的最大值)
function sub_underflow() public view returns (uint256) {
return sub - 1;
}

//乘法溢出
//如果uint256 类型的变量超过了它的最大值(2**256 - 1),最后它的值就会回绕变成0
function mul_overflow()public view returns (uint256) {
return mul * 2;
}
}

image-20220806235658226

防御方式

为了防止整数溢出的发生,一方面可以在算术逻辑前后进行验证,另一方面可以直接使用 OpenZeppelin 维护的一套智能合约函数库中的 SafeMath 来处理算术逻辑。

注意:0.8版本的solidity默认使用 SafeMath,一旦溢出直接回退

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/utils/math/SafeMath.sol";
contract safe{
using SafeMath for uint256;
uint256 public add = 2**256 - 1;
uint256 public sub = 0;
uint256 public mul = 2**255;

function add_overflow() public view returns (uint256) {
return add + 1;
}

function sub_underflow() public view returns (uint256) {
return sub - 1;
}

function mul_overflow()public view returns (uint256) {
return mul * 2;
}
}

此时将调用失败

image-20220807000410789

案例分析

Ethernaut 5.Token

数组溢出

数组数据位于起始位置 keccak256(p),其布局方式与静态大小的数组数据相同:一个元素接一个元素,如果元素不超过 16 字节,则可能共享存储槽。动态数组的动态数组递归地应用此规则。

理论上数组长度是不受限制的,我们可以不断向a、b添加元素。然而EVM存储空间不是无限的,只有2**256-1个slot,所以当动态数组下标是用户可控的且数组长度不受限的情况下,攻击者可以根据虚拟机的插槽深度构造对应的参数,使得参数指向虚拟机中的任意内存位置,从而修改对应插槽的状态变量,这是相当危险的。

类型混淆导致的溢出

Solidity允许类型之间进行相互转换,转换时必须符合一定条件,不能导致信息丢失。例如,uint8可以转换为uint16,但是int8不可以转换为uint256,因为int8可以包含uint256中不允许的负值。

在0.7.0版本之前,将对常量使用移位或指数运算是,会使用非常量的类型(例如:250 << x,或者250 ** x中,其结果是x的类型);而在0.7.0版本之后,将使用常量的类型来操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
pragma solidity ^0.4.23;
contract DemoContract1 {
uint8 x = 2;
uint public shift = 250 << x; // result: 232.
uint public exp = 250 ** x; // result: 36.

}

pragma solidity ^0.7.0;
contract DemoContract2 {
uint8 x = 2;
uint public shift = 250 << x; // result: 1000.
uint public exp = 250 ** x; // result: 62500.

}

在solidity中位运算的最大值为256,超过256的位运算将返回0,且即使在0.8.0以上的版本都不会报错

拒绝服务攻击

攻击者试图通过暂时或无限期地中断连接到网络的主机的服务,使其目标用户无法使用机器或网络资源。换句话说,系统无法处理用户需要的正常服务请求。例如,当计算机系统崩溃或带宽耗尽或硬盘已满而无法提供正常服务时,就构成了DoS。

在以太坊智能合约中,DoS 漏洞可以简单理解为“不可恢复的恶意操纵或不受控制的无限资源消耗”,即对以太坊合约进行 DoS 攻击,可能导致大量消耗 Ether 和 Gas,甚至导致异常的合约逻辑。

利用非预期的回滚的 DoS

依赖外部调用状态

  1. 在 fallback 函数中简单的通过 revert 函数回滚

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    contract Auction {
    address currentLeader;
    uint highestBid;

    function bid() payable {
    require(msg.value > highestBid);

    require(currentLeader.send(highestBid)); // Refund the old leader, if it fails then revert

    currentLeader = msg.sender;
    highestBid = msg.value;
    }
    }
  2. 一个合约通过一个数组遍历来向用户支付,如果其中任何一个支付失败,将导致整个支付回滚,这个循环永远不会遍历完,没有任何人能够从中得到应有的付款。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    address[] private refundAddresses;
    mapping (address => uint) public refunds;

    // bad
    function refundAll() public {
    for(uint x; x < refundAddresses.length; x++) { // arbitrary length iteration based on how many addresses participated
    require(refundAddresses[x].send(refunds[refundAddresses[x]])) // doubly bad, now a single failure on send will hold up all funds
    }
    }

权限操作

在智能合约中,有一些特权的地址是很常见的,比如 owner 地址,它负责管理合约的参数调整、紧急关停等敏感操作。如果 owner 地址丢失或无法正常工作(如私钥丢失),则会导致整个合约无法运行,从而导致非主观的 DoS 攻击。

利用区块 Gas Limit 的 DoS

每个区块都有可以消耗的 gas 上限(Gas Limit),这是Block Gas Limit。如果消耗的 gas 超过此限制,交易将失败。

不受控制的操作在合约层进行

如果数组的大小不受控制,在一个函数中一次性向所有人进行支付,可能会遇到区块 gas 上限的限制,导致整个交易无法完成。

尝试控制数组的大小,或将对它的遍历分到多个区块进行执行(使用一个变量来跟踪当前的进度,并从该点继续)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct Payee {
address addr;
uint256 value;
}

Payee[] payees;
uint256 nextPayeeIndex;

function payOut() {
uint256 i = nextPayeeIndex;
while (i < payees.length && gasleft() > 200000) {
payees[i].addr.send(payees[i].value);
i++;
}
nextPayeeIndex = i;
}

在两次 payOut 函数执行的间隔时间内,该合约其他交易的执行不会受到这个模式带来的负面影响。因此,仅在绝对必要时,使用此模式。

通过区块填充在网络层进行

即使你的合约中不包含无限循坏,攻击者也可以通过以足够高的 Gas Price 广播计算密集型交易来阻止其他交易被包含在区块链中几个区块。为此,攻击者可以发出多个交易,这些交易将消耗整个Block GasLimit,并在下一个区块被打包时立即给出足够高的 Gas Price。当然,没有一个固定的 Gas Price 值能够保证你的交易被包含在区块中,但GasPrice 越大,机会就越大。

如果攻击成功的话,则该区块无法包含除攻击者的交易以外的其他交易,一般来说,这用于在特定时间之前阻止对特定合约的交易。

简单来说,就是填充足够多的垃圾交易,消耗区块的gas,使得正常交易无法在此区块内进行

预防 DoS 攻击

  1. pull payment system

    外部调用可能会意外或故意的失败,因此,为了最大限度地减少此类故障造成的损害,通常最好将每个外部调用隔离到它自己的交易中,该交易可以由调用的接收者启动。尤其是在支付相关的场景,最好让用户自己提取资金而不是自动将资金支付给他们(这也减少了gas limit 出现问题的可能性)。避免在单个交易中合并多个转账操作。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    contract auction {
    address highestBidder;
    uint highestBid;
    mapping(address => uint) refunds;

    function bid() payable external {
    require(msg.value >= highestBid);

    if (highestBidder != address(0)) {
    refunds[highestBidder] += highestBid; // 记录需要退款的金额
    }

    highestBidder = msg.sender;
    highestBid = msg.value;
    }

    // 用户自行提取退款
    function withdrawRefund() external {
    uint refund = refunds[msg.sender];
    refunds[msg.sender] = 0;
    (bool success, ) = msg.sender.call.value(refund)("");
    require(success);
    }
    }
  2. 避免单点故障

    • 特权地址不要单纯使用外部拥有地址(EOA),而使用多签钱包地址或 DAO 地址来代替。
    • 预留备用方案,来避免单点故障

未初始化的storage变量

代码中的数据存储是实现一些代码功能不可或缺的流程,智能合约也不例外,但是如果存储数据时不细心,就可能会造成未初始化的存储指针漏洞,该漏洞产生的主要原因是没有对存储变量进行初始化,造成之前位置的数据被意外覆盖。

Solidity目前对复杂的数据类型或者说是引用类型有array数组和struct(结构体)以及mapping(映射),在函数中作为局部变量时,会默认储存在Storage当中。Solidity 允许定义一个指向外部存储 storage 的指针(引用),这个引用在未初始化的情况下等于 0,而在 storage 地址为 0 的位置存放着有意义的数据因为Solidity对于状态变量的存储次序一般是按照出现的先后顺序依次排列的。如果此时直接对「未初始化的 storage 引用」进行赋值,那么就会错误覆盖合约存储在 storage 上面的状态变量。

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
struct BorrowAgreement { //为初始化的
address lender;
address borrower;
uint256 tokenAmount;
uint256 collateralAmount;
uint32 collateralRatio; // Extra collateral, in integer percent.
uint expiration;
}

IERC20Token constant public bancorToken =
IERC20Token(0x1F573D6Fb3F13d689FF844B4cE37794d79a7FF1C);
BancorChanger constant public bancorChanger =
BancorChanger(0xb72A0Fa1E537c956DFca72711c468EfD81270468);
BorrowAgreement[] public agreements; //数组

function isCollateralWithinMargin(
uint256 tokenAmount, uint256 collateralAmount, uint32 collateralRatio)
returns(bool) {
IERC20Token etherToken = bancorChanger.getQuickBuyEtherToken();
uint256 collateralInTokens =
bancorChanger.getPurchaseReturn(etherToken, collateralAmount);
uint256 minCollateral = tokenAmount * (100 + collateralRatio) / 100;
return (collateralInTokens > minCollateral);
}

function offerToLend(
uint256 _amount, uint256 _collataral_ratio, uint _expiration) {
assert(bancorToken.transferFrom(msg.sender, this, _amount));
BorrowAgreement agreement; //agreements 变量占据了 storage 的开始位置(slot 0x00),并按照动态数组的规则存放在 storage 上。
agreement.lender = msg.sender; //slot 0x00 处原本存储数组大小的值被设为 msg.sender
agreement.borrower = 0;
agreement.tokenAmount = _amount;
agreement.expiration = _expiration;
agreements.push(agreement);
}

以及数组类型

1
2
3
4
5
6
7
8
9
10
11
12
13
pragma solidity ^0.4.26;

contract bet {
uint public num1;
uint[] public feng;

function number(uint _num) public {
uint[] tmp;
tmp.push(_num);
feng=tmp;
}
}

蜜罐

蜜罐合约是开发者故意利用各种技巧使代码部分特殊用途不易被参与者发现,利用当中的信息不对称,使参与者产生错误判断,从而被骗取本金。未初始化的 storage 指针正是“蜜罐合约”部署者最常用的一种技巧。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
contract Honeypot {
uint256 luckyNum = 42;
uint256 public last;
struct Game {
address player;
uint256 number;
}
Game[] public gameHistory;
address owner = msg.sender;

function guess(uint256 _number) public payable {
Game game;
game.player = msg.sender;
game.number = _number;
gameHistory.push(game);
if (_number == luckyNum) {
msg.sender.transfer(msg.value * 2);
}
last = now;
}
}

应该填入调用者自己的地址

image-20230619114938489

修复

实际上,这个问题只存在于solidity0.5.0之前的版本,编译器版本为0.4.26的话,报的还只是一个warning,不影响deploy;在0.5.0里面就变成了报error:

在0.5.0以前的版本中,对于结构体,使用mapping进行结构体的初始化,通过创建一 个新的临时 memory 结构体,然后将它拷贝到 storage 中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
pragma solidity ^0.4.26;

contract bet {
uint public num1;
uint public num2;
uint public num3;
struct Feng{
uint nn1;
uint nn2;
uint nn3;
}

mapping (uint => Feng) fengs;

function number(uint _id, uint _num1, uint _num2, uint _num3) public {
Feng storage feng = fengs[_id];
feng.nn1=_num1;
feng.nn2=_num2;
feng.nn3=_num3;
}
}

对于数组,在函数中声明的时候进行初始化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
pragma solidity ^0.4.26;

contract bet {
uint public num1;
uint public num2;
uint public num3;
uint[] public feng;

function number(uint _num) public {
uint[] storage tmp= feng;
tmp.push(_num);
}
}

call调用

原理简介

call 与 static call 是 EVM 中合约内调用其他合约的两个方式,其对应的底层操作码是 CALLSTATICCALL CALL 是在被调用者的上下文中执行,只能修改被调用者的状态。 STATICCALLCALL 类似,但它不会修改被调用者的。

未检查的调用返回值

适用于call、send、transfer等底层消息调用

call

1
2
3
4
5
6
7
8
9
10
11
12
13
14
pragma solidity 0.4.25;

contract ReturnValue {

function callchecked(address callee) public {
require(callee.call());
}

function callnotchecked(address callee) public {
callee.call(); // 没有检查返回值,将导致代码会继续执行,即便被调用的 callee 合约中发生了 revert
// do something
}
}

send、transfer

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
pragma solidity 0.8.18;

contract Receiver1 {
receive() external payable {}

function balance() public view returns(uint) { return address(this).balance; }
}

contract Receiver2 {
receive() external payable { revert(); }

function balance() public view returns(uint) { return address(this).balance; }

function test() public { revert(); }
}

contract EtherTransfer {
address r1;
address r2;

constructor() {
r1 = address(new Receiver1());
r2 = address(new Receiver2());
}

function bal1() public view returns(uint) {
return r1.balance;
}

function bal2() public view returns(uint) {
return r2.balance;
}

modifier ethersRequired() {
require(msg.value > 0, "ethers required");
_;
}

function testSuccessOnTransfer() public payable ethersRequired {
payable(r1).transfer(msg.value);
}

// 会停止执行并抛出异常
function testFailureOnTransfer() public payable ethersRequired {
payable(r2).transfer(msg.value);
}

function testSuccessOnSend() public payable ethersRequired {
payable(r1).send(msg.value);
}

// 即使send调用失败也会成功执行
function testFailureOnSend() public payable ethersRequired {
payable(r2).send(msg.value);
}

//正确处理方法
function testFailureOnSendWithRecommendedPattern() public payable ethersRequired {
bool sucess = payable(r2).send(msg.value);
if (!sucess) { revert("send failed"); }
}
}

调用深度限制

消息调用的深度被限制在1024,这意味着对于更复杂的操作,循环应该优先于递归调用。在 EIP150 应用前,存在调用深度攻击

在EIP150中,对消息调用时使用的 gas 做了更新:在消息调用中只能转发当前可用 gas 的 63/64 用于子调用(在 EIP150 提出)。实际上最大调用栈深度限制在 ~340(低于 ~1024),但1024的调用栈深度限制仍然存在。

解决了两个问题:

调用深度攻击

减轻依赖调用的任何进一步潜在的 DoS 攻击所造成的危害

Call方法注入漏洞

Call方法注入漏洞,顾名思义就是外界可以直接控制合约中的call方法调用的参数,按照注入位置可以分为以下三个场景:

  1. 参数列表可控

<address>.call(bytes4 selection, arg1, arg2, ...)

  1. 函数选择器可控

<address>.call(bytes4selection, arg1, arg2, ...)

  1. Bytes可控

<address>.call(bytesdata)

<address>.call(msg.data)

例如

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

contract ContractA {
uint public a;
function doSomething(bytes memory data) public {
address(this).call(data);
}

function doSomething2(string memory func_name) public {
address(this).call(bytes4(keccak256(func_name)), 888);
}

function sensitiveOperation(uint _a) public {
require(msg.sender == address(this));
a = _a;
}

function attack() public returns(bytes memory) {
bytes memory payload = abi.encodeWithSignature("sensitiveOperation(uint256)", 666);
doSomething(payload);
}

function attack2() public returns(bytes memory) {
doSomething2("sensitiveOperation(uint256)");
}
}

预防:

  1. 可以指定函数选择器字符串,避免直接使用 bytes 进行底层的 call 调用。

  2. 对于包含特权地址判断的敏感操作,不要轻易将合约自身的地址作为可信地址。

案例分析

Ethernaut闯关 6.Delegation

Ethernaut闯关 16. Preservation

重入攻击

单函数重入

之前的文章 智能合约重入漏洞

跨函数重入

当一个易受攻击的函数与一个可被攻击者利用的函数共享状态时,就会发生跨函数重入。

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
enum ProposalStatu {
Pending,
Success
}

mapping (uint256 => ProposalStatu) proposalStatus;
mapping (address => uint256) public balance:
mapping (address => uint256) public weight;
mapping (uint256 => uint256) public votes;
mapping (address => bool) public isStake;

function vote(uint256 _proposalId, uint256 _valve) external {
require (proposalStatus[_proposalId] == Pending);
require (weight[msg.sender] >= _value);
votes[_proposalid] += _value;
weight[msg.sender] -= _value;
}

function stake() public payable {
require(isStake[msg.sender] == false);
balance[msg.senderl = msg.value;
weight[msg.sender] += msg.value;
}

function unstake(uint256 _value) external {
require(balance[msg.senderl >= _value && weight[msg.sender) >= _value);
// 触发重入调用vote将其weight减为0,前面做的eight[msg.sender] >= _value`检查将不起作用
msg.sender.call(value: _value) (""); // send eth to user
balance[msg.sender] -= _value;
weight[msa.sender] -= _value; //使这里发生下溢
}

跨合约重入

跨函数重入和单函数重入在同一个合约中,也有不在同一个合约,重入可以发生在跨多个合约,便是多个合约共享同一个状态。当一个合约中的一个状态在另一个合约中使用,但在被调用之前未完全更新时,可能会发生跨合约重入。

tx.origin与msg.sender

介绍

tx.origin 目前仅适用于校验 msg.sender 是否是 EOA 地址,不适用于做权限的校验,需要使用 msg.sender 来进行权限校验。

tx.origin和msg.sender很直观的区别

tx.origin 指的是创建初始交易的 EOA。这个全局值通过不同的函数调用传递给其他合约。

msg.sender 指我们调用函数的最后一个实例。

image-20220814210003053

tx.origin 不应用于授权。例如,如果所有者设置为

1
address owner = msg.sender;

但是函数的访问检查是用

1
require(owner == tx.origin);

这可能会被利用。

钓鱼

1
2
3
4
5
6
7
8
9
10
11
12
// SPDX-License-Identifier: MITpragma 
solidity ^0.8.13;
contract Wallet { address public owner;
constructor() payable {
owner = msg.sender;
}
function transfer(address payable _to, uint _amount) public {
require(tx.origin == owner, "Not owner");
(bool sent, ) = _to.call{value: _amount}("");
require(sent, "Failed to send Ether");
}
}

可以看到, Wallet 合约是一个合约钱包,创建者可以在部署合约时将自己的以太转入合约中。当你想花钱的时候可以调用 Wallet.transfer() 将任意数量的存款转移。当然,钱包里的钱并不是任何人都能碰的,所以这里需要通过 tx.origin == owner 的检查才能转账。问题也就出现在这里,前置知识中说到 tx.origin 会读取启动交易的原始地址,所以我们可以伪造一个钓鱼合约来欺骗受害者发起交易从而窃取他的身份转走他的以太。接下来我们看看攻击合约是如何完成身份窃取的。

Tips:这里还存在重入漏洞。被 fallback 回调函数调用时 tx.origin 依然是最初调用者的 EOA 地址。

攻击合约

1
2
3
4
5
6
7
8
9
10
11
contract Attack {    
address payable public owner;
Wallet wallet;
constructor(Wallet _wallet) {
wallet = Wallet(_wallet);
owner = payable(msg.sender);
}
function attack() public {
wallet.transfer(owner, address(wallet).balance);
}
}

攻击流程:

  1. Alice 部署了 Wallet 合约并向合约中转入十个以太将该合约作为自己的钱包合约。

  2. Eve 发现 Wallet 合约中有钱,部署 Attack 合约并在构造函数中传入 Wallet 合约的地址。

  3. Eve 通过社会工程学调查到 Alice 特别喜欢网购包包,部署一个假的购物网站并将链接发送至 Alice 的邮箱。

  4. Alice 购买的时候发现需要连接钱包完成签名才能注册成功,于是直接签名了这笔交易。

  5. 签名成功后 Alice 发现自己在 Wallet 合约中的所有以太已经被转移。

Alice 在注册时的签名并不是用于注册的,而是签名了调用 Attack.attack() 这笔交易。Attack.attack() 调用了 Wallet.transfer() 并传入 owner 也就是 Eve 的 EOA 地址,以及 Wallet 合约中的以太余额。因为签名这笔交易的地址为 Alice 的 EOA 地址,所以对于 Wallet 合约来说 tx.origin 就是 Alice 的 EOA 地址,所以 Eve 成功利用钓鱼伪造了 Alice 的身份,通过了权限检查并成功将 Wallet 合约中的以太转移到了自己的账户中。

案例

Ethernaut闯关 4.Telephone

随机数漏洞

一般情况下智能合约获取随机数有两种方式:通过链上信息生成或者通过链下喂养

介绍

* block.coinbase 当前区块的矿工地址

* block.difficulty 当前区块的挖掘难度(其值由上一个区块产生的时间,上一个区块的难度以及当前区块产生的时间、当前区块的高度决定)

* block.gaslimit 区块内交易的最大限制燃气消耗量

* block.number 当前区块高度

* block.basefee gas的基础费用(basefee是动态变化的,其值由和之前区块的gas实际消耗以及gas target值(gasLimit / ELASTICITY_MULTIPLIER 乘数)决定)

* block.timestamp 当前区块挖掘时间

* block.blockhash(block.number) :当前区块的区块哈希[在EVM中本值为0,区块上链后才会赋值]

之前的文章 智能合约错误随机性

案例

block.blockhash(block.number-1):Ethernaut 3.Coin Flip

delegatecall

委托调用(delegatecall)是一个低级函数,其功能与call类似,区别在于delegatecall是使用指定地址的代码,而其他信息(存储数据)则是使用当前合约。

image-20230619144835429

call

image-20220814213108897

delegateCall

image-20220814213131853

代理合约的简单实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
contract Flag {
bool public flag;
address public sender;
function setFlag() public {
flag = true;
sender = msg.sender;
}
}

contract Delegate {
bool public flag;
address public sender;

function setFlagByDelegatecall (address addr) public {
(bool success, bytes memory data) = addr.delegatetcall(abi.encodeWithDignature("setFlag()"));
}
}

更多可升级合约知识

注意点

  1. 大多数时候我们使用代理的入口是在fallback函数,我们看下面的代码片段,直接调用delegatecall,其返回值data其实是无法返回给外部调用者的
1
2
3
4
fallback () external payable (
(bool success, bytes memory data) = processor.delegatecall(msg.data);
require(success,"error");
}

因此,通常我们使用的解决方法是利用汇编重写一个_delegate函数

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
/**
* @dev Delegates the current call to `implementation`.
*
* This function does not return to its internal call site, it will return directly to the external caller.
*/
function _delegate(address implementation) internal virtual {
assembly {
// Copy msg.data. We take full control of memory in this inline assembly
// block because it will not return to Solidity code. We overwrite the
// Solidity scratch pad at memory position 0.
calldatacopy(0, 0, calldatasize())

// Call the implementation.
// out and outsize are 0 because we don't know the size yet.
let result := delegatecall(gas(), implementation, 0, calldatasize(), 0, 0)

// Copy the returned data.
returndatacopy(0, 0, returndatasize())

switch result
// delegatecall returns 0 on error.
case 0 {
revert(0, returndatasize())
}
default {
return(0, returndatasize())
}
}
}

  1. 合约初始化问题

    在代理模式中,由于逻辑合约并不具备构造函数的功能,所以实现函数一般都含有initialize函数用于初始化基本参数,并且保证该函数只能被调用一次

    目前比较常见的三方库实现了initializer修饰器来限制此函数,保证该函数只能被调用一次。但即使这样也是可能出现问题的(initializer修饰器是否通过检查的关键在于关键bool型变量的值,但如果代理合约对应bool型变量的存储位被其他变量所覆盖,那么可能导致initializer修饰器检查失效)

  2. 存储冲突

    代理模式是将实现合约的代码拉取到代理合约中执行,存储数据是使用的代理合约的,在实际业务中,甚至存在多个逻辑合约共用一个代理合约的情况。如果代理合约和逻辑合约存储结构设计不合理,并且在逻辑合约升级后新增了变量,就会可能导致变量存储冲突,使得变量被覆盖,存储数据错乱。

  3. 函数选择器冲突

    通常的代理模式中,调用代理合约函数时,会率先根据调用数据的函数选择器进行查询,如果是代理合约接口,那么就直接执行call调用;但如果是对应的函数选择器在代理合约并未找到,那么将进行fallback函数中,执行delegatacall操作。因此,如果逻辑合约中函数选择器和代理合约一致,那么将始终调用代理合约,而不能进入逻辑合约中(如:transferOwnership)。

  4. 自毁

    由于delegatecall在调用合约时,如果目标合约地址不存在,也会顺利调用返回。如果逻辑合约被通过selfdestruct指令清除,在代理合约层面delegatecall会继续顺利调用,但是合约已经被销毁,逻辑合约应该禁止使用自毁函数。另外如果用于管理升级的逻辑位于逻辑合约中而不位于代理合约中(如使用了UUPS代理模式),则实际上会导致再也无法使用代理。

中心化应用引入的安全隐患

虽然我们已经迎来了划时代的区块链以及智能合约技术,但是交易吞吐量、开发容易程度、以及使用体验等方面仍然需要提高,因此在系统的一部分引入中心化的组件是很常见的做法,但也引入了一些安全隐患。很多做智能合约对接的开发者不一定对智能合约很熟悉,智能合约开发者在开发合约时可能也没有考虑到对接时的风险,因此合约在集成进整个系统时可能会出现一些安全隐患。我们识别,并在开发时时刻提醒自己,做到防患于未然。

短地址攻击假充值就是其中最著名的两个中心化应用引入的漏洞。

短地址攻击

短地址攻击本质上就是由于对智能合约 ABI 的编码规则不够熟悉导致的漏洞。

通常是没有对地址的长度进行校验

例如下列函数

function transfer(address to, uint amount) public returns(bool success);

数据为

1
2
to = 0xdeaddeaddeaddeaddeaddeaddeaddeaddeaddead
amount = 123

ABI 规范要求将参数填充为 32 字节的倍数,因此, 不足 32 字节的部分会被填充。

1
2
3
0xa9059cbb
000000000000000000000000deaddeaddeaddeaddeaddeaddeaddeaddeaddead
000000000000000000000000000000000000000000000000000000000000007b

但如果地址少了一个字节,没有对参数进行校验,就会得到如下数据(第二个参数的第一个字节被补到了第一个参数末尾,而第二个参数少掉的一个字节被自然的填充成了32字节)

1
2
3
0xa9059cbb
000000000000000000000000deaddeaddeaddeaddeaddeaddeaddeaddeadde00
0000000000000000000000000000000000000000000000000000000000007b00

也就会被解码为

1
2
to = 0xdeaddeaddeaddeaddeaddeaddeaddeaddeadde00
amount = 31488 // 也就是十六进制的 0x7b00,导致多转出了token

假充值

当提到“假充值”攻击时,我们通常谈的是攻击者利用公链的某些特性,绕过交易所的充值入账程序,进行虚假充值,并真实入账。

这里指的是智能合约中的假充值

ERC20 的 transfer 接口中,由于接口定义中有一个bool返回值用于返回转账是否成功。因此很多实现在转账失败时,并没有抛出异常,而是通过简单的返回 false 替代。

1
2
3
4
5
6
7
8
9
10
function transfer(address _to, uint256 _value) returns (bool success) {
if (_balances[msg.sender] >= _value && _value > 0) {
_balances[msg.sender] -= _value;
_balances[_to] += _value;
emit Transfer(msg.sender, _to, _value);
return true;
} else{
return false;
}
}

这样的设计在合约层面看起来是合理的,但却很容易与以太坊本身的交易执行结果的状态混淆。在 Tx Receipt 中的 Status 字段由于标识该交易是否抛出了异常(比如使用了 require/assert/revert/throw 等机制),如果未抛出异常,则为0x01(true),否则为0x00(false)。如果中心化交易所在实现充值时,仅判断 Tx Receipt 的状态来区分是否充值成功的话,就会出现调用 transfer 失败返回 false,但 Tx Receipt 的状态却显示 true 的情况,此时中心化交易所认为充值成功(然而实际失败),将款打到了攻击者的账上。示例

我们需要严格的使用 require/assert 的方式限定条件,当不符合条件时,中止合约继续执行

而对于接口使用方来说,除了判断 Tx Receipt 的状态之外,还应该判断收款地址的余额是否增加。一种方式是通过直接检查收款地址的余额来实现;另一种方式是监听相应的 Transfer 事件来实现,但需要注意合约开发者可能作恶,因此在使用第三方合约接口时,需要严格对代码进行审查。


智能合约常见漏洞
http://sissice.github.io/2023/06/19/SmartContractBaseSecurity/
作者
Sissice
发布于
2023年6月19日
许可协议