欢迎订阅专栏: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,但合约必须实现 receive 或 fallback 才能接收。更典型的错误是调用另一个合约的函数而未检查地址是否为合约。
// 预期调用代币合约的 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 Address 库 | Address.functionCall 等函数会检查目标地址是否有代码。 |
| 签名中加入 nonce 和 chainId | EIP-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 签名规范,可以大幅降低此类风险。