0%

solidity编程安全

在编写 Solidity 智能合约时,安全性是最重要的考虑因素之一。以下是开发中常见的安全问题及其解决方法:

重入攻击(Reentrancy Attack)

https://zhoubofsy.github.io/2024/11/28/blockchain/ethereum/solidity-security-reentrancy-attack/

溢出与下溢(Overflow and Underflow)

溢出(Overflow)和下溢(Underflow)是指在处理整数运算时,当结果超出数据类型表示范围时,值会“环绕”到另一端。例如,对于 uint8 类型:

  • 溢出: uint8 的最大值是 255,如果执行 255 + 1,值会变为 0。
  • 下溢: uint8 的最小值是 0,如果执行 0 - 1,值会变为 255。

在早期的 Solidity 版本中,这种现象常导致严重漏洞,攻击者可以利用这种行为达到意想不到的目的。自 Solidity 0.8 开始,溢出和下溢会触发异常,但了解它们的历史以及如何防范仍然很重要。虽然Solidity 0.8开始增加了溢出检查,这也会导致Gas费用的增加。

在 Solidity 0.8+ 中,每次整数加减运算都会隐式包含溢出检查逻辑,而这种检查需要额外的操作指令,因此会增加运行时消耗的 gas。

Solidity 0.8+

1
2
3
4
5
6
7
pragma solidity ^0.8.0;

contract OverflowCheck {
function add(uint256 a, uint256 b) public pure returns (uint256) {
return a + b; // 含溢出检查
}
}
1
2
3
4
5
6
7
8
0x00    PUSH1  0x60
0x02 MSTORE
0x03 CALLDATALOAD
0x04 CALLDATALOAD
0x05 ADD // 加法
0x06 JUMPI // 检查溢出
0x07 REVERT // 如果溢出,回滚
0x08 RETURN

Solidity 0.7

1
2
3
4
5
6
7
pragma solidity ^0.7.0;

contract NoOverflowCheck {
function add(uint256 a, uint256 b) public pure returns (uint256) {
return a + b; // 无溢出检查
}
}
1
2
3
4
5
6
0x00    PUSH1  0x60
0x02 MSTORE
0x03 CALLDATALOAD
0x04 CALLDATALOAD
0x05 ADD // 直接加法
0x06 RETURN
  1. Solidity 0.8 的溢出检查增加了 ~50 gas 的开销。
  2. 增加的开销源于额外的汇编指令(JUMPI 和 REVERT)。
  3. 如果对 gas 成本敏感,可以使用 unchecked 块绕过检查,但需确保逻辑安全。

未检查的外部调用(Unchecked External Call)

“未检查的外部调用”漏洞(Unchecked External Call)是 Solidity 智能合约中的一种常见安全问题。如果智能合约与外部地址交互(比如转账或调用另一个合约的函数)时未检查调用的结果,可能会引发意外的后果和漏洞利用。

潜在问题

  1. 失败的调用未被检测
    如果调用失败但未进行检查,合约可能会继续执行后续逻辑,从而导致不一致状态或意外行为。
    示例问题:转账操作失败,但余额已经从发送方扣除。
  2. 错误处理被忽略
    外部合约调用可能由于异常(例如合约不存在或代码逻辑错误)而失败。如果不检查返回值,调用方将无法正确处理这些失败。
  3. 逻辑漏洞导致资金损失
    如果未检查调用结果的返回值,攻击者可以通过故意设计失败的合约逻辑,扰乱调用方合约的资金或状态管理。
  4. 影响合约的可组合性
    在 DeFi 等应用场景中,不同合约之间通常会进行复杂交互。如果未正确检查外部调用的结果,会影响合约间的协作,甚至破坏整个生态系统的安全性。

漏洞示例代码

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: MIT
pragma solidity ^0.8.0;

contract UncheckedExternalCall {
mapping(address => uint256) public balances;

// 存款功能
function deposit() public payable {
balances[msg.sender] += msg.value;
}

// 提现功能,发送资金给用户
function withdraw(uint256 amount) public {
require(balances[msg.sender] >= amount, "Insufficient balance");

// 发送资金,但未检查返回值
payable(msg.sender).call{value: amount}("");

// 更新余额
balances[msg.sender] -= amount;
}
}

恶意示例代码
攻击者可以部署一个恶意合约,故意使 call 失败(例如,使用耗尽 gas 的回退函数),从而导致 balances 状态不一致。

1
2
3
4
5
6
7
8
9
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Malicious {
fallback() external payable {
// 消耗所有 gas,故意让调用失败
while (true) {}
}
}

解决方案

显式检查 call 返回的布尔值,以确保调用成功:

1
2
3
4
5
6
7
8
9
function withdraw(uint256 amount) public {
require(balances[msg.sender] >= amount, "Insufficient balance");

// 检查调用结果
(bool success, ) = payable(msg.sender).call{value: amount}("");
require(success, "Transfer failed");

balances[msg.sender] -= amount;
}

随机数生成不安全

为什么Solidity 中随机数生成不安全?

在 Solidity 中,常见的随机数生成方式(如依赖区块哈希、时间戳等)容易被攻击者预测或操控:

  1. 区块哈希依赖(blockhash): 区块哈希是公开信息,矿工可以选择不挖某些区块,进而影响生成的随机数。
  2. 时间戳依赖(block.timestamp): 矿工可以小幅调整时间戳,使得随机数变为对其有利的值。
  3. 合约状态依赖(msg.sender, block.difficulty 等): 合约状态和交易上下文的信息可以被预测和操控。

这些方式在确定性(公开信息和上下文)和可操控性(矿工或攻击者干预)方面不安全。

如何安全生成随机数?

  1. Chainlink VRF:
    • 可证明公平且可验证的随机数生成器(RNG),它使智能合约能够在不影响安全性或可用性的情况下访问随机值。
    • 建立区块链游戏和NFT
    • 随机分配职责和资源。例如,随机分配法官到案件。
    • 为共识机制选择一个具有代表性的样本。
      用法:https://docs.chain.link/vrf/v2-5/getting-started
  2. Off-chain Randomness (链下随机数):
    • 使用链下生成随机数(如通过服务器或 API),然后将结果提交到链上。
    • 缺点:需要信任链下服务,可能会被操控。
    • 改进:通过多方签名或可信执行环境(如 Intel SGX)生成。
  3. Threshold Signature Schemes (TSS):
    • 多方合作生成随机数,每方只持有部分秘密信息。
    • 优势:随机数完全由多方参与生成,无法单点操控。
    • 使用场景:像 Chainlink 的去中心化预言机网络。
  4. VDF(Verifiable Delay Function):
    • 一种不可预测的随机数生成方式,需要一定时间来计算结果。
    • 优势:矿工无法提前操控随机数。
    • 实现:Ethereum 2.0 的随机数生成设计中引入了 VDF。

智能合约升级问题

智能合约在部署后通常无法直接修改,这可能导致升级和维护困难。如果设计不当,可能需要完全重启项目,从而浪费资源并导致用户信任危机。

解决方案 1:代理合约(Proxy Pattern)

工作原理

通过代理模式将合约分为两部分:

  1. 代理合约(Proxy): 存储状态变量,负责将用户请求委托给逻辑合约。
  2. 逻辑合约(Logic/Implementation): 包含实际的业务逻辑,可以升级和替换。

用户总是与代理合约交互,代理通过 delegatecall 将调用转发到逻辑合约,使用代理合约的存储保持状态一致。

具体实现

  1. UUPS Proxy
    • 使用 upgradeTo 方法直接升级逻辑合约。
    • 更加轻量,但需要实现升级逻辑。
  2. Transparent Proxy
    • 避免管理员调用代理时出现冲突。
    • 管理员可升级逻辑合约,用户调用时自动转发。
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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Proxy {
uint256 public number;
address public implementation;
address public admin;

constructor(address _implementation) {
admin = msg.sender;
implementation = _implementation;
}

fallback() external {
(bool success, ) = implementation.delegatecall(msg.data);
require(success, "Delegatecall failed");
}

function upgrade(address newImplementation) external {
require(msg.sender == admin, "Only admin can upgrade");
implementation = newImplementation;
}

function getData(uint256 _num) public pure returns (bytes memory) {
return abi.encodeWithSignature("setNumber(uint256)", _num);
}
}

// Logic contract (v1)
contract LogicV1 {
uint256 public number;
address public implementation;
address public admin;
uint256 public privateNum;

function setNumber(uint256 _number) public {
number = _number;
privateNum = _number;
}
}

// Logic contract (v2)
contract LogicV2 {
uint256 public number;
address public implementation;
address public admin;
uint256 public privateNum;

function setNumber(uint256 _number) public {
number = _number * 2; // Updated logic
privateNum = _number * 2;
}
}

优点

  • 无需重新部署存储状态,升级逻辑灵活。
  • 用户无感知升级操作。

缺点

  • 增加代码复杂度。

解决方案2:模块化合约(Modular Contract)

工作原理

将合约分解为多个独立模块,所有模块通过核心合约(Registry 或 Router)进行管理。每个模块可单独替换而不影响整体功能。

具体实现

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: MIT
pragma solidity ^0.8.0;

interface ILOGIC {
function setNumber(uint256 _number) external ;
}

contract Router {
mapping(string => address) public modules;
address public admin;

constructor() {
admin = msg.sender;
}

function updateModule(string memory moduleName, address moduleAddress) external {
require(msg.sender == admin, "Only admin can update");
modules[moduleName] = moduleAddress;
}

function setNumber(string memory moduleName, uint256 _num) external {
address module = modules[moduleName];
require(module != address(0), "Module not found");
ILOGIC(module).setNumber(_num);
}
}

// Logic contract (v1)
contract LogicV1 is ILOGIC {
uint256 public number;
uint256 public privateNum;

function setNumber(uint256 _number) public {
number = _number;
privateNum = _number;
}
}

// Logic contract (v2)
contract LogicV2 is ILOGIC {
uint256 public number;
uint256 public privateNum;

function setNumber(uint256 _number) public {
number = _number * 2; // Updated logic
privateNum = _number * 2;
}
}

优点

  • 便于扩展新功能或模块。
  • 每个模块的更新不会影响其他部分。

缺点

  • 初始设计复杂度较高。
  • 需要额外存储模块地址。

解决方案 3:数据分离(Storage Contract)

工作原理

将状态数据和业务逻辑分离为两个独立合约:

  1. 存储合约(Storage Contract): 负责存储状态变量。
  2. 逻辑合约(Logic Contract): 包含具体逻辑,可单独升级。

具体实现

在上一个实现示例的基础上,进行存储合约和逻辑合约的分离。

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

interface ISTORE {
function setNumber(uint256 _number) external ;
}

contract Store is ISTORE {
uint256 public number;

function setNumber(uint256 _number) external {
number = _number;
}
}

interface ILOGIC {
function setNumber(uint256 _number, ISTORE store) external ;
}

contract Router {
mapping(string => address) public modules;
address public admin;
ISTORE public store;

constructor() {
admin = msg.sender;
store = new Store();
}

function updateModule(string memory moduleName, address moduleAddress) external {
require(msg.sender == admin, "Only admin can update");
modules[moduleName] = moduleAddress;
}

function setNumber(string memory moduleName, uint256 _num) external {
address module = modules[moduleName];
require(module != address(0), "Module not found");
ILOGIC(module).setNumber(_num, store);
}
}

// Logic contract (v1)
contract LogicV1 {
uint256 public privateNum;

function setNumber(uint256 _number, ISTORE store) public {
store.setNumber(_number);
privateNum = _number;
}
}

// Logic contract (v2)
contract LogicV2 is ILOGIC {
uint256 public privateNum;

function setNumber(uint256 _number, ISTORE store) public {
store.setNumber(_number * 2);
privateNum = _number * 2;
}
}

优点

  • 清晰的数据和逻辑分离。
  • 升级逻辑时不会影响存储。

缺点

  • 初次部署复杂。
  • 存储和逻辑合约间的交互增加调用成本。

ERC20中“approve”无限授权的问题

竞争环境

ERC20 的授权与竞争

假设场景

假设有一个用户 Alice 和一个智能合约 Token,以及一个攻击者 Mallory。Alice 想通过 approve 函数授权 Mallory 代她消费代币。

初始状态:

Alice 的账户余额为 1000 个 Token。
Mallory 尚未获得任何授权。
Token 合约中的 approve 函数允许 Alice 授权 Mallory一定额度的代币。
Alice 执行以下两个交易:

approve(Mallory, 100); —— 授权 100 个代币。
想撤销授权后重新授权:approve(Mallory, 200); —— 授权 200 个代币。

第一种竞争:在授权更新中被抢占

在区块链网络中,交易可能存在提交和打包的时间差。

  1. 交易流程:
    • Alice 提交第一笔交易 approve(Mallory, 100)。
    • 此时 Mallory 知道 Alice 即将授权 100,便迅速构造一个调用 transferFrom 的交易,试图转移代币。
    • 在 Alice 提交第二笔交易 approve(Mallory, 200) 之前,Mallory 的交易被矿工打包处理。
  2. 结果:
    • Mallory 调用了 transferFrom,并转移了 100 个代币。
    • Alice 的第二笔交易(授权 200)覆盖了第一笔交易。
    • 最终,Mallory不仅得到了 100,还能再次调用 transferFrom 转移额外的 200 个代币。

第二种竞争:替代攻击

以太坊允许用户在一笔交易未被确认前,用新的交易替换原交易(这被称为“交易替代”)。攻击者可以利用这种特性引发意外行为。

  1. 交易流程:
    • Alice 提交 approve(Mallory, 100)。
    • 在这笔交易确认前,Alice 提交了一笔新的高 gas 交易 approve(Mallory, 0)。
    • Mallory发送了一笔高优先级 transferFrom 交易,与 Alice 的两笔交易竞争。
  2. 结果:
    • 如果矿工按照特定顺序处理,Mallory 的 transferFrom 会先于 Alice 的 approve(Mallory, 0)。
    • 最终,Mallory 成功提取了授权的 100 个代币,尽管 Alice 试图撤销授权。

尽管区块链中交易的执行是严格顺序的,但“竞争”主要源于交易在网络中传播和排序的行为。以下是两个核心原因:

  1. 交易的传播和未确认状态:
    • 在用户提交交易后,交易需要传播到网络中,并等待矿工打包。
    • 在交易被打包前的这段时间,其他用户或智能合约可以通过观察未确认交易,针对交易中暴露的状态进行恶意操作。
  2. 矿工的交易排序权:
    • 矿工可以根据 gas 费优先级或其他策略排序交易。
    • 攻击者可以支付更高的 gas,让他们的交易先执行,从而“抢占”状态更新。

解决竞争

  1. 增加原子性操作:
    • 将 approve 和 transferFrom 的逻辑合并为一个原子操作,避免多笔交易间的时间窗口。

      1
      2
      3
      4
      function safeTransfer(address to, uint256 amount) public {
      approve(to, amount);
      transferFrom(msg.sender, to, amount);
      }
  2. 使用 increaseAllowance 和 decreaseAllowance:
    • 避免覆盖现有授权额度,消除修改竞态。
  3. 限时授权:
    • 设置一个时间锁,授权只能在一段时间后生效。

“approve”无限授权

ERC20中“approve”无限授权的问题,其本质并不是单纯由于区块链上的竞争,而是 ERC20标准的设计缺陷与区块链环境特性结合 导致的。这种问题由两个关键因素共同作用引发:

  1. ERC20 标准中的设计缺陷

    • 在 ERC20 的 approve 方法中,没有提供一种明确的原子操作机制来更新授权额度。
    • 在用户更改授权额度的过程中(如从 100 改为 200),攻击者可以利用时间窗口,通过 transferFrom 操作,在授权额度发生变化的过渡阶段恶意提取代币。

    这个问题根源在于 approve 和 transferFrom 的设计缺乏配合机制,允许在两个操作之间发生不一致的状态。

  2. 区块链上的竞争特性

    • 交易传播时间差:当用户试图提交新授权(如 approve(Mallory, 200)),旧授权(如 approve(Mallory, 100))仍然有效,且存在被利用的时间窗口。
    • 矿工排序自由:攻击者可以观察到用户提交的 approve 交易,通过支付更高的 gas 优先让他们的交易 transferFrom 被打包,抢在用户的状态更新之前执行。

    这两点特性导致 approve 无法原子更新的问题被放大,从而使攻击成为可能。

解决“approve”无限授权问题

  1. 增加 increaseAllowance 和 decreaseAllowance 方法

通过调整现有额度而不是直接覆盖额度,可以避免授权被覆盖的问题。

1
2
3
4
5
6
7
8
9
10
11
12
function increaseAllowance(address spender, uint256 addedValue) public returns (bool) {
allowance[msg.sender][spender] += addedValue;
emit Approval(msg.sender, spender, allowance[msg.sender][spender]);
return true;
}

function decreaseAllowance(address spender, uint256 subtractedValue) public returns (bool) {
require(allowance[msg.sender][spender] >= subtractedValue, "Decreased allowance below zero");
allowance[msg.sender][spender] -= subtractedValue;
emit Approval(msg.sender, spender, allowance[msg.sender][spender]);
return true;
}
  1. 原子操作结合转账

通过单一交易完成授权和转账,避免分离操作引入的时间窗口

1
2
3
4
5
6
function safeTransferFrom(address from, address to, uint256 amount) public {
require(allowance[from][msg.sender] >= amount, "Allowance exceeded");
allowance[from][msg.sender] -= amount;
balanceOf[from] -= amount;
balanceOf[to] += amount;
}

ERC20 中“approve”无限授权的问题,并不完全是由于区块链竞争特性,而是由 授权更新的非原子性设计 和 区块链上交易排序特性 共同导致的。优化方法可以从改进 ERC20 的逻辑(如 increaseAllowance),有效解决这一问题。