10分钟智能合约:进阶实战-3.8 地址检查漏洞

86 阅读6分钟

欢迎订阅专栏10分钟智能合约:进阶实战

地址检查漏洞:定义、原理与防御

地址检查漏洞 指智能合约在与外部地址(EOA 或合约)交互时,缺乏对地址合法性、有效性或预期属性的必要验证,导致攻击者可以利用特殊地址(如零地址、恶意合约地址、未初始化地址)绕过业务逻辑、窃取资金或造成状态破坏。

地址是智能合约中的第一类资产,任何涉及地址的操作(转账、授权、所有权转移、外部调用)都应谨慎验证。


1. 核心原理

EVM 中的地址是 20 字节的标识符(如 0x...)。开发者常错误地假设:

  • 传入的地址一定是一个有效的 EOA 或可接收 ETH 的合约;
  • 地址不会被恶意操控;
  • 地址对应的合约一定实现了预期的接口。

漏洞本质:未对地址进行确定性检查,依赖隐含假设。


2. 常见地址检查漏洞类型

类型描述后果
零地址检查缺失address(0) 转账或授权,或允许将关键角色(如 owner)设为零地址。资金永久丢失,合约失去控制权。
合约存在性检查缺失在调用外部合约函数前,未验证目标地址是否包含代码(extcodesize)。调用失败但未回滚,或调用成功但无代码(账户无逻辑)。
地址硬编码错误硬编码的奖励池、代币地址等可被攻击者预知并抢先部署恶意合约。资金被重定向或逻辑被篡改。
伪随机地址验证依赖地址的一部分作为随机性(如 address(this)keccak256(addr) 取模)。攻击者可操纵地址参与抽奖或投票。
tx.origin 地址冒充使用 tx.origin 进行身份验证,攻击者可诱导用户调用恶意合约。钓鱼攻击,用户 unknowingly 授权转账。
地址重放攻击跨链或跨合约签名验证时,未检查地址的链 ID 或合约标识,签名可被重放。资金在另一条链上被窃取。

3. 典型漏洞示例

3.1 零地址检查缺失

// 漏洞合约:转移所有权未检查零地址
contract Ownable {
    address public owner;

    constructor() {
        owner = msg.sender;
    }

    function transferOwnership(address newOwner) public {
        require(msg.sender == owner, "Not owner");
        owner = newOwner;   // ❌ 未检查 newOwner != address(0)
    }
}

后果:管理员误操作或恶意调用将 owner 设为 0x0,所有 onlyOwner 函数永久不可用。

3.2 合约存在性检查缺失

// 漏洞合约:直接调用外部合约,未检查其是否存在
contract PaymentGateway {
    function withdrawTo(address payable recipient, uint amount) public {
        (bool success, ) = recipient.call{value: amount}("");
        require(success, "Transfer failed");   // 假设总是成功,但如果 recipient 没有 receive/fallback 呢?
    }
}

虽然 call 在目标地址无代码且不接受 ETH 时会成功(只是不会执行任何代码,ETH 会留在合约中?实际上无代码的 EOA 总是可以接收 ETH,但合约必须实现 receivefallback 才能接收。更典型的错误是调用另一个合约的函数而未检查地址是否为合约。

// 预期调用代币合约的 transfer,但传入地址可能不是 ERC20 合约
function swap(address token, uint amount) public {
    (bool success, ) = token.call(abi.encodeWithSignature("transfer(address,uint256)", msg.sender, amount));
    require(success, "Call failed");
    // 可能成功,但实际没有 ERC20 逻辑
}

后果:传入任意地址,call 成功但未执行有效逻辑,资金状态不一致。

3.3 可预测地址硬编码

contract Lottery {
    address constant rewardPool = 0x123...;  // ❌ 硬编码地址

    function distribute() public {
        payable(rewardPool).transfer(address(this).balance);
    }
}

如果该地址对应的私钥丢失或合约自毁,资金将永久锁定。同时,攻击者可提前在该地址部署恶意合约。

3.4 tx.origin 钓鱼

contract Wallet {
    address public owner;

    function transfer(address payable to, uint amount) public {
        require(tx.origin == owner, "Not owner");   // ❌ 使用 tx.origin
        to.transfer(amount);
    }
}

攻击:用户(owner)被诱导调用恶意合约:

contract Malicious {
    Wallet wallet;
    constructor(address _wallet) { wallet = Wallet(_wallet); }
    function attack() public {
        wallet.transfer(msg.sender, address(wallet).balance);
    }
}

当 owner 调用 attack() 时,tx.origin 还是 owner,检查通过,资金被转出。

3.5 重放攻击中的地址检查缺失

// 消息签名验证
function permit(address owner, address spender, uint value, uint deadline, uint8 v, bytes32 r, bytes32 s) public {
    bytes32 digest = keccak256(abi.encodePacked(owner, spender, value, deadline));
    // ❌ 未包含 chainId 或合约地址,签名可跨链重放
    address recovered = ecrecover(digest, v, r, s);
    require(recovered == owner, "Invalid signature");
    allowances[owner][spender] = value;
}

在以太坊主网有效签名可在 Polygon 或其他 EVM 链上重复使用,造成资金损失(如 Uniswap Permit 早期问题)。


4. 防御措施

检查项实现方式
零地址检查require(addr != address(0), "Zero address not allowed")
合约存在性检查require(addr.code.length > 0, "Not a contract") 或使用 addr.code.length == 0 禁止合约调用。
EOA 与合约区分若函数应仅由 EOA 调用:require(tx.origin == msg.sender, "No contracts")(但需注意 Gas 成本,且现代通常由前端保证)。
地址白名单/黑名单维护合法地址集合,只允许预设地址交互。
使用 OpenZeppelin AddressAddress.functionCall 等函数会检查目标地址是否有代码。
签名中加入 nonce 和 chainIdEIP-712 标准,包含 block.chainid 和合约地址,防止重放。
避免硬编码地址使用构造函数参数或可变状态,确保地址可调整且不可预测(如用 CREATE2 预生成)。
所有权转移前检查两步转移:先提名新 owner,再接受,确保新地址有效。
审计重点检查所有外部调用、权限赋值、代币转账的目标地址是否经过充分验证。

安全代码示例:

import "@openzeppelin/contracts/utils/Address.sol";

contract SafeContract {
    using Address for address;

    address public owner;

    function setOwner(address newOwner) public onlyOwner {
        require(newOwner != address(0), "Zero address");
        require(newOwner.isContract() == false, "Owner must be EOA"); // 可选
        owner = newOwner;
    }

    function callToken(address token, bytes calldata data) public {
        require(token.isContract(), "Target not a contract");
        (bool success, ) = token.call(data);
        require(success, "Call failed");
    }
}

5. 真实案例

  • Parity 多签钱包(2017):初始化函数未检查零地址,攻击者成为库合约 owner。
  • SushiSwap 迁移:masterchef 合约中硬编码的奖励池地址错误导致奖励无法提取。
  • Poly Network 攻击(2021):部分原因是合约未验证执行者的地址是否为合法合约。
  • 各种代币 transfer 零地址销毁:若代币未禁止向零地址转账,用户可能误操作永久丢失代币。

6. 总结

地址检查漏洞往往源于忽视边界条件(如零地址、未初始化合约)或对 EVM 特性的误解(如 tx.origin、合约存在性)。防御核心是:永远不要信任外部输入的地址,对每个地址进行显式验证,包括零地址、合约存在性、是否符合预期角色。结合 OpenZeppelin 的安全库和 EIP-712 签名规范,可以大幅降低此类风险。