拒绝服务

拒绝服务指的是攻击者通过某种方式阻止合约正常执行其预期功能,使得合约无法继续服务合法用户

简单来说,这是一种”我让你无法正常工作”的攻击方式。在智能合约中,DoS攻击通常通过操纵合约状态或利用执行限制来实现。

示例

一个代币分发合约,负责收集投资并向投资者分发回报:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
contract DistributeTokens {
address public owner; // gets set somewhere
address[] investors; // array of investors
uint[] investorBalances; // the amount investor gets
bool public isFinalized = false;

// ...

function invest() public payable {
investors.push(msg.sender);
investorBalances.push(msg.value * 5); // 5 times the wei sent
}

function distribute() public {
require(msg.sender == owner); // only owner
for(uint i = 0; i < investors.length; i++) {
investors[i].transfer(investorBalances[i]);
}
isFinalized = true;
}
}

DistributeTokens 合约中的拒绝服务漏洞

这个合约在代币分发功能中存在严重的DoS风险:

1
2
3
4
5
6
7
function distribute() public {
require(msg.sender == owner); // only owner
for(uint i = 0; i < investors.length; i++) {
investors[i].transfer(investorBalances[i]);
}
isFinalized = true;
}

攻击方式:

  1. 恶意投资者合约攻击

    • 攻击者可以部署一个恶意合约来调用 invest() 函数
    • 该恶意合约的 receive()fallback() 函数被设计为执行失败或消耗所有Gas:
      1
      2
      3
      4
      5
      6
      7
      8
      9
      contract MaliciousInvestor {
      receive() external payable {
      revert(); // 直接回滚,或者...
      // 或者执行消耗大量Gas的操作
      while(true) {
      // 无限循环或复杂计算
      }
      }
      }
  2. 循环阻塞

    • distribute() 函数执行到恶意投资者时,transfer() 调用会失败
    • 由于整个循环在一个交易中执行,一个失败的 transfer() 会导致整个 distribute() 函数回滚
    • 结果:所有合法投资者都无法收到应得的代币
  3. Gas限制攻击

    • 即使恶意合约不直接回滚,而是执行复杂操作消耗Gas
    • 由于区块Gas限制,循环可能在完成前耗尽所有Gas
    • 特别是当投资者列表很长时,这个问题会更加严重

漏洞影响:

  • 资金冻结:所有投资者的资金被永久锁定在合约中
  • 功能瘫痪distribute() 函数永远无法成功执行
  • 状态死锁isFinalized 永远无法设置为 true,合约处于不一致状态

结论与安全建议

核心要点: 避免在循环中进行外部调用,特别是当循环次数不可控或可能包含恶意合约时。

如何避免拒绝服务漏洞?

  1. 提款模式:让投资者自己提取资金,而不是主动分发:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    mapping(address => uint) public balances;
    bool public isFinalized = false;

    function withdraw() public {
    require(isFinalized, "Distribution not finalized");
    uint amount = balances[msg.sender];
    require(amount > 0, "Nothing to withdraw");
    balances[msg.sender] = 0;
    payable(msg.sender).transfer(amount);
    }
  2. 批量处理与检查点

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    uint public distributionIndex = 0;

    function distributeBatch(uint batchSize) public {
    require(msg.sender == owner);
    uint end = distributionIndex + batchSize;
    if (end > investors.length) {
    end = investors.length;
    }

    for(uint i = distributionIndex; i < end; i++) {
    // 即使某个失败,也可以继续后续批次
    (bool success, ) = investors[i].call{value: investorBalances[i]}("");
    if (!success) {
    // 记录失败,继续执行
    }
    }
    distributionIndex = end;

    if (distributionIndex >= investors.length) {
    isFinalized = true;
    }
    }
  3. 使用推送模式的替代方案

    • 考虑使用拉取模式而非推送模式
    • 分离状态更新和资金转移
    • 为每个投资者提供独立的提款函数
  4. Gas限制考虑

    • 预估循环执行的最大Gas消耗
    • 避免在单次交易中处理过多项目
    • 使用批量操作和状态检查点

设计原则: “信任最小化” - 不要假设外部调用总是成功,也不要在一个交易中处理所有用户的操作。

时间戳操纵

时间戳操纵,也称为Block Timestamp Manipulation,指的是矿工(或验证者)通过操纵区块时间戳来影响依赖时间戳的智能合约逻辑

简单来说,这是一种”我可以控制时间,从而控制游戏结果”的攻击方式。矿工在打包区块时有一定程度的时间戳控制权,这会影响依赖 block.timestamp(旧版本中为 now)的合约。

示例

一个轮盘赌合约,根据区块时间戳来决定中奖者:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
contract Roulette {
uint public pastBlockTime; // Forces one bet per block

constructor() public payable {} // initially fund contract

// fallback function used to make a bet
fallback() external payable {
require(msg.value == 10 ether); // must send 10 ether to play
require(block.timestamp != pastBlockTime); // only 1 transaction per block
pastBlockTime = block.timestamp;

if (block.timestamp % 15 == 0) { // winner
msg.sender.transfer(address(this).balance);
}
}
}

Roulette 合约中的时间戳操纵漏洞

这个合约使用区块时间戳作为随机性来源,存在严重的安全风险:

1
2
3
if (block.timestamp % 15 == 0) { // winner
msg.sender.transfer(address(this).balance);
}

攻击方式:

  1. 矿工操纵攻击

    • 矿工在打包包含下注交易的区块时,可以调整区块的时间戳
    • 通过选择特定的时间戳值,使得 block.timestamp % 15 == 0 条件成立
    • 矿工可以让自己或关联地址成为中奖者,赢取合约中的所有资金
  2. 时间戳预测攻击

    • 即使不是矿工,攻击者也可以预测未来几个区块可能的时间戳范围
    • 在有利的时间窗口提交交易,增加中奖概率
    • 结合Gas价格竞争,提高交易被包含在目标区块的机会
  3. 时间戳范围利用

    • 矿工可以在合理范围内(通常在前一个区块时间戳+1到当前时间+900秒之间)选择时间戳
    • 这个范围足够让矿工找到满足 % 15 == 0 条件的时间戳值

漏洞影响:

  • 资金损失:合约资金被矿工或攻击者盗取
  • 公平性破坏:赌博游戏的随机性和公平性完全丧失
  • 信任崩塌:用户对合约的信任被彻底破坏

结论与安全建议

核心要点: 区块时间戳不应作为随机数源或关键游戏逻辑的唯一决定因素,因为矿工对其有相当程度的控制权。

如何避免时间戳操纵漏洞?

  1. 使用区块哈希

    1
    2
    // 结合区块哈希,增加操纵难度
    uint random = uint(keccak256(abi.encodePacked(blockhash(block.number - 1), block.timestamp)));
  2. 使用承诺-揭示方案

    1
    2
    3
    4
    5
    6
    7
    8
    // 用户先提交承诺,然后揭示随机数
    bytes32 commitment;
    uint revealBlock;

    function commit(bytes32 _commitment) public {
    commitment = _commitment;
    revealBlock = block.number + 10; // 10个区块后揭示
    }
  3. 使用预言机服务

    1
    2
    3
    4
    // 使用Chainlink VRF等可验证随机函数
    function requestRandomness() public returns (bytes32 requestId) {
    return requestRandomness(keyHash, fee);
    }
  4. 时间戳容忍设计

    1
    2
    // 使用时间范围而非精确值
    require(block.timestamp >= startTime && block.timestamp <= endTime);
  5. 多数据源混合

    1
    2
    3
    4
    5
    6
    7
    // 结合多个难以操纵的区块链属性
    uint seed = uint(keccak256(abi.encodePacked(
    block.difficulty,
    blockhash(block.number - 1),
    block.timestamp,
    msg.sender
    )));

重要提醒: 在以太坊合并(The Merge)后,PoS机制下的时间戳操纵风险有所变化,但时间戳仍然不完全可信。对于需要真正随机性的应用,应该使用专业的外部随机数服务。

未初始化的存储指针

未初始化的存储指针,也称为Uninitialized Storage Pointers,指的是在函数内声明结构体或数组时未显式指定数据位置,导致其意外地指向存储槽0

简单来说,这是一种”我以为我在操作内存,但实际上我在修改关键存储变量”的认知错误。在Solidity中,局部变量如果未初始化数据位置,会默认指向存储,从而可能覆盖重要状态变量。

示例

一个名称注册合约,允许用户注册名称到地址的映射:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
contract NameRegistrar {
bool public unlocked = false; // registrar locked, no name updates

struct NameRecord { // map hashes to addresses
bytes32 name;
address mappedAddress;
}

mapping(address => NameRecord) public registeredNameRecord; // records who registered names
mapping(bytes32 => address) public resolve; // resolves hashes to addresses

function register(bytes32 _name, address _mappedAddress) public {
// set up the new NameRecord
NameRecord newRecord;
newRecord.name = _name;
newRecord.mappedAddress = _mappedAddress;

resolve[_name] = _mappedAddress;
registeredNameRecord[msg.sender] = newRecord;

require(unlocked); // only allow registrations if contract is unlocked
}
}

NameRegistrar 合约中的未初始化存储指针漏洞

这个合约在函数内声明结构体时存在严重的数据位置问题:

1
2
3
4
5
6
7
function register(bytes32 _name, address _mappedAddress) public {
// set up the new NameRecord
NameRecord newRecord; // 未指定数据位置!
newRecord.name = _name;
newRecord.mappedAddress = _mappedAddress;
// ...
}

漏洞原理:

  1. 存储指针默认指向槽0

    • 在旧版本Solidity中,未指定数据位置的结构体/数组默认指向存储
    • NameRecord newRecord 实际上指向存储槽0
    • 槽0对应的是 unlocked 状态变量
  2. 状态变量被意外覆盖

    • newRecord.name = _name 实际上在修改存储槽0(unlocked 的位置)
    • newRecord.mappedAddress = _mappedAddress 在修改存储槽1
    • 这意外地修改了合约的关键状态变量
  3. 访问控制绕过

    • 由于 unlocked 被意外修改,require(unlocked) 检查可能被绕过
    • 攻击者可以通过精心构造的 _name 值将 unlocked 设置为 true

攻击方式:

攻击者可以调用 register 函数并精心选择 _name 参数:

  • 前32字节用于覆盖 unlocked 变量
  • 通过设置特定值,可以将 unlockedfalse 改为 true
  • 一旦合约解锁,攻击者可以执行本应被限制的操作

结论与安全建议

核心要点: 在函数内声明引用类型(结构体、数组、映射)时,必须显式指定数据位置(memorystoragecalldata)。

如何避免未初始化存储指针漏洞?

  1. 显式指定数据位置

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    function register(bytes32 _name, address _mappedAddress) public {
    // 正确:显式指定为memory
    NameRecord memory newRecord;
    newRecord.name = _name;
    newRecord.mappedAddress = _mappedAddress;

    resolve[_name] = _mappedAddress;
    registeredNameRecord[msg.sender] = newRecord;

    require(unlocked);
    }
  2. 直接初始化映射

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    function register(bytes32 _name, address _mappedAddress) public {
    require(unlocked);

    // 直接设置映射,避免中间变量
    registeredNameRecord[msg.sender] = NameRecord({
    name: _name,
    mappedAddress: _mappedAddress
    });

    resolve[_name] = _mappedAddress;
    }
  3. 使用现代Solidity版本

    • Solidity 0.5.0及以上版本要求显式指定数据位置
    • 编译器会对未初始化数据位置报错
  4. 代码审查重点

    • 检查所有函数内的引用类型声明
    • 确保每个都有明确的数据位置说明符
    • 特别注意结构体和数组的声明

安全影响: 这个漏洞可能导致:

  • 访问控制被绕过
  • 关键状态变量被意外修改
  • 合约逻辑被完全破坏
  • 资金损失或未授权访问

在Solidity 0.5.0之后,这个漏洞主要通过编译器强制检查来预防,但理解其原理对于维护旧合约和编写安全代码仍然很重要。

浮点和数据精度

浮点和数据精度,也称为Floating Point and Precision,指的是在Solidity中处理小数和数值运算时,由于缺乏原生浮点数支持而导致的精度丢失和计算错误

简单来说,这是一种”我以为数学计算是精确的,但实际上整数除法会截断小数部分”的认知错误。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
25
26
27
28
29
30
31
32
33
34
35
contract TokenDistributor {
uint public totalSupply = 1000000; // 1,000,000 tokens
uint public totalEther = 1000 ether; // 1000 ETH
mapping(address => uint) public balances;

// 错误的百分比计算方式
function calculateTokens(uint percentage) public pure returns (uint) {
// 问题:percentage 是百分比值(如50表示50%)
return totalSupply * percentage / 100;
}

// 错误的代币价格计算
function calculateTokenPrice() public view returns (uint) {
// 问题:整数除法会截断小数
return totalEther / totalSupply;
}

// 错误的利润分配
function distributeProfits() public {
uint profit = address(this).balance;
uint ownerShare = profit * 30 / 100; // 30% for owner
uint investorShare = profit * 70 / 100; // 70% for investors

// 问题:可能存在剩余资金 due to 截断
payable(owner).transfer(ownerShare);
payable(investors).transfer(investorShare);
}

// 错误的小数处理
function calculateWithFee(uint amount, uint feePercentage) public pure returns (uint) {
// 问题:feePercentage 是百分比(如5表示5%),但计算可能不精确
uint fee = amount * feePercentage / 100;
return amount - fee;
}
}

数据精度问题的类型和影响

1. 整数除法截断

1
2
3
function badDivision() public pure returns (uint) {
return 5 / 2; // 返回2,而不是2.5
}

2. 百分比计算错误

1
2
3
4
function badPercentage(uint amount, uint percentage) public pure returns (uint) {
return amount * percentage / 100;
// 当 amount * percentage < 100 时返回0
}

3. 汇率计算不精确

1
2
3
function badExchangeRate(uint tokens, uint rate) public pure returns (uint) {
return tokens * rate; // 可能精度不足
}

攻击方式和风险:

  1. 资金计算错误

    • 由于除法截断,用户可能收到比应得数量少的资金
    • 累计误差可能导致大量资金被锁定在合约中
  2. 套利机会

    • 攻击者可以利用计算误差进行套利
    • 例如,在汇率计算中,由于精度问题产生价差
  3. 治理投票操纵

    • 在基于代币权重的投票中,精度问题可能影响投票结果
    • 大额持有者可能获得不成比例的影响力

结论与安全建议

核心要点: 在Solidity中处理数值计算时,必须考虑整数运算的特性,采用适当的方法来保持精度。

如何避免浮点和数据精度问题?

1. 使用固定点数表示法

1
2
3
4
5
6
7
8
9
10
// 使用放大因子来处理小数
uint constant PRECISION = 10**18;

function preciseDivision(uint a, uint b) public pure returns (uint) {
return a * PRECISION / b; // 返回放大后的结果
}

function calculatePercentage(uint amount, uint percentage) public pure returns (uint) {
return amount * percentage * PRECISION / 100 / PRECISION;
}

2. 采用”先乘后除”原则

1
2
3
4
5
6
7
8
9
// 错误的顺序
function badOrder(uint a, uint b, uint c) public pure returns (uint) {
return a / b * c; // 可能过早截断
}

// 正确的顺序
function goodOrder(uint a, uint b, uint c) public pure returns (uint) {
return a * c / b; // 先乘法,后除法
}

3. 使用安全的数学库

1
2
3
4
5
6
7
import "@openzeppelin/contracts/utils/math/SafeMath.sol";

using SafeMath for uint;

function safeCalculation(uint a, uint b) public pure returns (uint) {
return a.mul(b).div(100);
}

4. 处理剩余资金

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function distributePrecisely(uint total, uint[] memory ratios) public {
uint sum;
uint remaining = total;

for (uint i = 0; i < ratios.length - 1; i++) {
uint share = total * ratios[i] / 100;
sum += share;
remaining -= share;
// 分配前N-1份
}

// 最后一份获得剩余所有,避免资金锁定
uint lastShare = remaining;
// 分配最后一份
}

5. 使用适当的数据类型

1
2
3
4
5
6
7
// 对于高精度需求,使用足够的位数
uint256 public constant TOKEN_DECIMALS = 10**18;

// 价格计算使用足够精度
function calculatePrice(uint tokens, uint pricePerToken) public pure returns (uint) {
return tokens * pricePerToken / TOKEN_DECIMALS;
}

最佳实践:

  • 始终使用”先乘后除”的顺序
  • 为关键计算使用足够大的精度因子
  • 使用经过审计的数学库(如OpenZeppelin的SafeMath)
  • 测试边界情况,特别是小数值和边界值
  • 考虑使用专门处理小数的库(如ABDKMath、DS-Math)

安全影响:

  • 资金损失给用户或合约所有者
  • 资金被永久锁定在合约中
  • 计算错误导致的经济损失
  • 治理系统被操纵

tx.origin 判定

tx.origin 判定,也称为tx.origin Authentication,指的是**开发者错误地使用 tx.origin 进行身份验证,而不是使用 msg.sender**。

简单来说,这是一种”我以为我在验证交易发起者,但实际上我在验证整个调用链的原始发起者”的认知错误。tx.origin 返回的是整个调用链的原始发送者,这可能导致网络钓鱼攻击。

示例

一个可钓鱼合约,使用 tx.origin 进行所有权验证:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
contract Phishable {
address public owner;

constructor(address _owner) {
owner = _owner;
}

receive() external payable {} // collect ether

function withdrawAll(address _recipient) public {
require(tx.origin == owner);
_recipient.transfer(address(this).balance);
}
}

一个攻击合约,利用 tx.origin 漏洞:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import "Phishable.sol";

contract AttackContract {
Phishable phishableContract;
address attacker; // The attacker's address to receive funds.

constructor(Phishable _phishableContract, address _attackerAddress) {
phishableContract = _phishableContract;
attacker = _attackerAddress;
}

fallback() external payable {
phishableContract.withdrawAll(attacker);
}
}

Phishable 合约中的 tx.origin 漏洞

这个合约使用 tx.origin 进行身份验证,存在严重的安全风险:

1
2
3
4
function withdrawAll(address _recipient) public {
require(tx.origin == owner);
_recipient.transfer(address(this).balance);
}

攻击原理:

  1. tx.originmsg.sender 的区别

    • msg.sender:当前函数的直接调用者
    • tx.origin:整个交易链的原始发起者(通常是EOA)
  2. 攻击流程

    • 合法所有者(EOA)调用 AttackContract 的某个函数
    • AttackContract 在回调函数中调用 Phishable.withdrawAll(attacker)
    • Phishable 合约中:
      • tx.origin = 合法所有者的地址(整个交易的发起者)
      • msg.sender = AttackContract 的地址(直接调用者)
    • 验证 require(tx.origin == owner) 通过,因为所有者确实发起了交易
    • 资金被转移到攻击者指定的地址

攻击场景:

  1. 网络钓鱼攻击

    • 攻击者部署 AttackContract
    • 诱骗合约所有者与攻击合约交互(如领取空投、参与活动等)
    • 当所有者调用攻击合约时,攻击合约自动盗取 Phishable 合约中的资金
  2. 恶意DApp

    • 攻击者创建恶意DApp网站
    • 用户连接钱包并与恶意DApp交互
    • DApp在后台调用攻击合约,盗取用户其他合约中的资金

结论与安全建议

核心要点: 在身份验证和授权检查中,应该使用 msg.sender 而不是 tx.origintx.origin 只应用于极少数特殊情况。

如何避免 tx.origin 判定漏洞?

  1. 使用 msg.sender 进行身份验证

    1
    2
    3
    4
    function withdrawAll(address _recipient) public {
    require(msg.sender == owner); // 正确的方式
    _recipient.transfer(address(this).balance);
    }
  2. 如果需要原始调用者信息

    1
    2
    3
    4
    5
    6
    // 明确传递调用者地址作为参数
    function withdrawAll(address _recipient, address _caller) public {
    require(_caller == owner);
    require(msg.sender == _caller); // 额外验证
    _recipient.transfer(address(this).balance);
    }
  3. 使用修饰器进行权限控制

    1
    2
    3
    4
    5
    6
    7
    8
    modifier onlyOwner() {
    require(msg.sender == owner, "Caller is not owner");
    _;
    }

    function withdrawAll(address _recipient) public onlyOwner {
    _recipient.transfer(address(this).balance);
    }
  4. 正确的 tx.origin 使用场景

    1
    2
    3
    4
    5
    // 仅用于防止中间合约调用,而不是身份验证
    function sensitiveOperation() public {
    require(tx.origin == msg.sender, "Contract calls not allowed");
    // 这个操作只能由EOA直接调用,不能通过合约调用
    }

安全影响:

  • 资金被盗
  • 权限绕过
  • 用户被钓鱼攻击

最佳实践: 在99%的情况下,你都应该使用 msg.sender 进行身份验证。只有在明确需要防止合约调用时才考虑使用 tx.origin,并且要清楚理解其安全含义。

问题总结

1. “竞争条件”中,investor参数指的是什么?

在之前讨论的“拒绝服务”漏洞的 DistributeTokens 合约中,investors 并不是一个函数参数,而是一个状态变量

它是一个地址数组,用于记录所有向合约投入了资金的投资者地址。

solidity

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
contract DistributeTokens {
address public owner;
address[] investors; // <-- 这里是状态变量,一个地址数组
uint[] investorBalances; // 与investors数组对应的余额数组
bool public isFinalized = false;

function invest() public payable {
investors.push(msg.sender); // 当用户调用invest()时,其地址被加入investors数组
investorBalances.push(msg.value * 5); // 其应得余额被加入investorBalances数组
}

function distribute() public {
require(msg.sender == owner);
for(uint i = 0; i < investors.length; i++) {
// 循环遍历整个investors数组,向每个地址转账
investors[i].transfer(investorBalances[i]); // <-- 漏洞点
}
isFinalized = true;
}
}

investors 数组的作用与漏洞关系:

  • 作用:它像一个花名册,记录了所有有资格在 distribute() 函数被调用时获得分红的投资者。
  • 与漏洞的关系:攻击者通过 invest() 函数,将一个恶意合约的地址 加入到这个 investors 数组中。当所有者调用 distribute() 进行循环转账时,一旦尝试向这个恶意地址转账,恶意合约的 receivefallback 函数就会执行失败(例如直接 revert()),导致整个分发交易被回滚。这样一来,所有诚实投资者(他们的地址也在 investors 数组里)都无法收到应得的款项。

总结investors 是存储投资者名单的关键状态变量,攻击者通过污染这个名单(加入恶意地址),从而对全体诚实投资者造成“拒绝服务”攻击。