4、常见漏洞模式(上):重入攻击、整数溢出、访问控制问题、未检查的外部调用

各位同学,今天我们来啃几块硬骨头。

智能合约的漏洞模式,说白了就是黑客最常下刀子的地方。我这些年审计过的合约,十有八九都栽在这几个坑里。咱们一个一个过,争取今天就把它们彻底搞明白。

4.1 重入攻击:最经典的"借刀杀人"

重入攻击,以太坊历史上最臭名昭著的漏洞。2016年的The DAO事件,直接导致以太坊硬分叉,损失超过6000万美元。

它的原理其实很简单:攻击者在被攻击合约执行回调函数时,再次调用攻击合约,形成递归调用。就像你借钱给别人,别人拿着你的借条又来借,循环往复,直到把你掏空。

核心问题:合约在更新状态之前,先执行了外部调用。

来看一个典型的漏洞合约:

// 漏洞合约
contract VulnerableBank {
    mapping(address => uint) public balances;

    function withdraw(uint _amount) public {
        require(balances[msg.sender] >= _amount);
        // 先转账,后更新余额 —— 这就是漏洞
        (bool success, ) = msg.sender.call{value: _amount}("");
        require(success);
        balances[msg.sender] -= _amount;
    }
}

攻击者合约长这样:

contract Attacker {
    VulnerableBank public bank;

    constructor(address _bank) {
        bank = VulnerableBank(_bank);
    }

    // fallback函数,每次收到ETH都会触发
    receive() external payable {
        if (address(bank).balance >= 1 ether) {
            bank.withdraw(1 ether);
        }
    }

    function attack() external payable {
        bank.withdraw(1 ether);
    }
}

为什么会这样?
攻击者调用withdraw,合约先转ETH,触发攻击者的receive()。在receive()里,攻击者再次调用withdraw。此时余额还没扣减,所以又能通过检查。如此循环,直到合约被掏空。

我的经验:我个人习惯在写提现函数时,永远遵循"先检查-再更新-后交互"的顺序。这个顺序救过我很多次。

修复方案:

  • 方法一:检查-生效-交互模式(Checks-Effects-Interactions)
  • 方法二:使用重入锁(如OpenZeppelin的ReentrancyGuard)
// 修复后的合约
contract SafeBank {
    mapping(address => uint) public balances;
    bool private locked;

    modifier noReentrant() {
        require(!locked, "No reentrancy");
        locked = true;
        _;
        locked = false;
    }

    function withdraw(uint _amount) public noReentrant {
        require(balances[msg.sender] >= _amount);
        balances[msg.sender] -= _amount; // 先更新状态
        (bool success, ) = msg.sender.call{value: _amount}("");
        require(success);
    }
}

注意:重入攻击不限于ETH转账。任何外部调用都可能成为攻击入口,包括ERC20的transfer、NFT的safeTransferFrom等。

4.2 整数溢出:被低估的算术陷阱

整数溢出,说白了就是数字太大或太小,超出了变量能表示的范围。在Solidity 0.8.0之前,这个问题非常普遍。

举个例子:
uint8的范围是0到255。如果你给255加1,结果会变成0。这就是上溢。
如果你给0减1,结果会变成255。这就是下溢。

我记得有一次审计一个代币合约,项目方为了省gas,用了uint8来记录用户余额。结果一个用户通过下溢,凭空给自己转了255个代币。嗯,那场面挺尴尬的。

// 漏洞合约(Solidity 0.7.x)
contract Token {
    mapping(address => uint8) public balances;

    function transfer(address to, uint8 amount) public {
        require(balances[msg.sender] >= amount);
        balances[msg.sender] -= amount; // 如果amount > 余额,这里会下溢
        balances[to] += amount;
    }
}

修复方案:

  • 使用Solidity 0.8.0+:内置了溢出检查,溢出会自动revert
  • 使用SafeMath库:OpenZeppelin的SafeMath,适用于旧版本
  • 使用更宽的数据类型:比如用uint256代替uint8

避坑指南:我曾经见过一个项目,明明用了Solidity 0.8.x,但在unchecked块里手动做了减法,结果还是出了下溢。记住,unchecked会关闭溢出检查,用的时候千万小心。

4.3 访问控制问题:谁动了我的合约?

访问控制,说白了就是"谁能干什么"。这个问题看似简单,但出事的频率高得吓人。

常见的访问控制漏洞包括:

  • 未初始化owner:构造函数没写,任何人都能成为owner
  • 错误的修饰符:把onlyOwner写成了public
  • 权限提升:普通用户能调用管理员函数
  • tx.origin滥用:用tx.origin做身份验证
// 漏洞示例1:未初始化owner
contract NoOwner {
    address public owner;

    // 构造函数忘记写owner初始化
    // 任何人都能调用setOwner
    function setOwner(address _owner) public {
        owner = _owner;
    }

    function withdraw() public {
        require(msg.sender == owner);
        // 提现逻辑
    }
}

// 漏洞示例2:tx.origin滥用
contract Phishable {
    address public owner;

    constructor() {
        owner = msg.sender;
    }

    function withdrawAll() public {
        // 使用tx.origin,容易被钓鱼攻击
        require(tx.origin == owner);
        payable(msg.sender).transfer(address(this).balance);
    }
}

为什么tx.origin危险?
因为tx.origin是整个交易链的原始发起者,而msg.sender是直接调用者。如果用户A调用了攻击合约,攻击合约再调用你的合约,tx.origin还是A,但msg.sender是攻击合约。用tx.origin做验证,等于把钥匙交给了别人。

最佳实践:

  • 始终使用msg.sender进行身份验证
  • 使用OpenZeppelin的OwnableAccessControl
  • 构造函数中初始化owner
  • 敏感函数添加onlyOwner修饰符

4.4 未检查的外部调用:无声的炸弹

未检查的外部调用,指的是合约调用了其他合约,但没有检查调用是否成功。在Solidity中,calldelegatecallstaticcall返回一个布尔值,但很多开发者会忽略它。

你想想看,如果外部调用失败了,你的合约会怎样?
—— 它会继续执行,就像什么都没发生一样。这可能导致状态不一致,甚至资金损失。

// 漏洞示例
contract UncheckedCall {
    function withdraw(address to, uint amount) public {
        // 没有检查call的返回值
        to.call{value: amount}("");
        // 即使转账失败,下面的代码也会执行
        emit Withdrawn(to, amount);
    }
}

如果to是一个合约地址,且它的receive()函数revert了,call会返回false,但不会revert整个交易。结果就是:事件被触发了,但钱没转出去。

我的建议:我个人习惯对所有外部调用都做检查。要么用require(success),要么用Address.sendValue()这样的封装函数。

修复方案:

// 方案1:使用require检查
function safeWithdraw(address to, uint amount) public {
    (bool success, ) = to.call{value: amount}("");
    require(success, "Transfer failed");
    emit Withdrawn(to, amount);
}

// 方案2:使用OpenZeppelin的Address库
import "@openzeppelin/contracts/utils/Address.sol";

function safeWithdrawV2(address to, uint amount) public {
    Address.sendValue(payable(to), amount);
    emit Withdrawn(to, amount);
}

特别提醒:transfersend虽然会自动检查,但它们有gas限制(2300 gas),在合约调用时可能不够用。我个人更推荐使用call + require的组合。

知识体系总览

下面这张图,是我梳理的这四种漏洞的核心逻辑。你可以把它当作一个快速检查清单:

常见漏洞模式(上)知识体系 四种漏洞模式 重入攻击 核心问题 状态更新晚于外部调用 递归调用耗尽资金 The DAO事件(6000万$) 整数溢出 核心问题 上溢:255+1=0 下溢:0-1=255 SafeMath / Solidity 0.8+ 访问控制问题 核心问题 owner未初始化 tx.origin滥用 权限提升风险 未检查外部调用 核心问题 call返回值被忽略 失败后继续执行 require(success)修复

这四种漏洞,每一个都值得你花时间吃透。我在实际审计中,经常看到它们组合出现——比如一个重入攻击加上未检查的外部调用,那简直就是灾难。

好了,今天的内容就到这。记住一句话:写合约的时候,永远假设攻击者就在你身后看着你的代码。下一章我们继续聊剩下的几种漏洞模式,到时候见。


专注资料整理