· Solidity 工程 · 第 1 / 1 讲

Solidity Security(合约安全)

底层调用、回调、重入、代理存储冲突、MEV、签名重放、Oracle 操纵与 CEI 防御清单。

Solidity 底层调用与合约安全

本文深入探讨 Solidity 的底层调用机制、回调函数以及智能合约安全问题,特别是重入攻击的原理与防御方法。

一、底层调用

Solidity 提供了一系列底层的、更接近 EVM(以太坊虚拟机)指令的调用方法。这些方法为开发者提供了极大的灵活性,但同时在使用时也带来了更大的安全责任。

ABI 编码方式

函数指定:通过 calldata 来指定要调用的函数的函数签名和参数,即通过 calldata 指定对应的 ABI 编码。在 Solidity 中可以通过以下两种方式来获取 ABI 编码:

  1. 方法一:使用字符串签名
abi.encodeWithSignature("functionName(type1,type2,...)", arg1, arg2, ...)
  1. 方法二:使用函数指针(推荐)

这是从 Solidity 0.8.14 版本开始引入的推荐方法。它接受一个函数指针(Function Pointer)作为参数,而不是字符串,提供了编译时类型检查:

abi.encodeCall(FunctionType.functionName, (arg1, arg2, ...))

示例:假设我们要调用另一个合约的 update(address to, uint256 amount) 函数

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

interface ITarget {
  function update(address to, uint256 amount) external;
}

contract Encoder {
  function getUpdateCalldataSafer(address _to, uint256 _amount)
      public
      pure
      returns (bytes memory)
  {
      // 使用函数指针,更安全,有编译时类型检查
      return abi.encodeCall(ITarget.update, (_to, _amount));
  }
}

1. call 调用

call 是最常用的底层调用方法,可以用它来调用另一个合约的函数,或者向另一个地址(包括普通钱包地址)发送以太币。

(bool success, bytes memory data) = targetAddress.call{value: amount, gas: gasLimit}(calldata);

调用函数

(bool success, bytes memory data) = targetAddress.call{gas: gasLimit}(calldata);

上下文切换:当合约 A 通过 call 调用合约 B 时,执行的上下文环境是合约 B 的。这意味着 msg.sender 会变成合约 A 的地址,msg.value 是 A 发送的以太币数量,并且执行的代码会修改合约 B 的状态(storage)。

Gas 转发:在不指定 gasLimit 的默认情况下,call 会转发所有剩余的 Gas 给被调用合约,也可以手动指定要转发的 Gas 数量上限。

返回值

  1. 错误处理:即便调用的函数执行失败(revert),call 本身不会导致父合约也失败。它会返回 success = false,你需要手动检查这个布尔值来处理错误。
  2. 第二个返回值(bytes memory data):返回被调用的目标函数的返回值的原始字节数据,使用还需要进行解码:
(type1 variable1, type2 variable2, ...) = abi.decode(data, (type1, type2, ...));

发送以太币

(bool ok, ) = payable(to).call{value: amount}(calldata);
require(ok, "transfer failed");

Solidity 中的地址类型默认是普通地址,而不是可支付地址。为了顺利通过编译,payable 关键字可以将地址类型 to 显式转换为可支付地址类型。在 Solidity 0.5 版本之后,地址类型被分为了两种:

  1. address:普通地址,不能直接接收以太币。
  2. address payable:可支付地址,可以接收以太币。

其他发送以太币的函数

以下两个函数均有 Gas 上限(gasLimit = 2300 wei);transfer 会自动 revert,而 send 需要手动检查返回值进行处理。

  1. transferpayable(to).transfer(amount) - 失败时自动回滚
  2. sendbool success = payable(to).send(amount) - 返回布尔值,需手动处理

2. delegatecall 委托调用

(bool success, bytes memory data) = targetAddress.delegatecall(calldata);

实现可升级合约

对于智能合约,一旦被部署到区块链上,就不能够更改,这也导致了后续无法维护相关的逻辑。delegatecall 正是实现可升级合约的核心。

保留上下文delegatecall 执行的上下文是当前合约。通过这个机制,能够让存储和逻辑分离,负责存储相关数据和提供对外调用接口的被称为代理合约(Proxy Contract)。

具体来说,如果此前已经部署上线的代理合约和逻辑合约中,逻辑合约被发现存在 bug,那么通过重新部署一个逻辑合约,并让代理合约执行新部署的逻辑合约便可完成更新,而不涉及数据和用户的迁移。

实现工厂合约

传统方式下,如果需要多个相同功能的合约实例(如多个 ERC-20 代币变体),开发者必须多次部署完整合约代码,这会导致高 Gas 成本和代码冗余。

EIP-1167:Minimal Proxy Contract

EIP-1167 提出了通过最小代理提供一种低成本的克隆机制,即通过标准化一个最小字节码模板,用于创建代理合约。

工厂模式(Factory Pattern)

多个代理共享一个实现合约的代码,但每个代理有自己的存储空间。工厂模式在 DeFi 的运用中极为广泛,如 Uniswap 中通过工厂模式就能够快速创建新的流动性池。

3. staticcall 静态调用

staticcall 是一种只读的底层调用方法,确保被调用的函数不会修改状态。如果目标函数尝试修改状态(写入存储、发送以太币等),调用会失败。

(bool success, bytes memory data) = targetAddress.staticcall(calldata);

特性

最常见的用途是查询外部合约的状态,如价格预言机、余额查询等:

contract Consumer {
    address public oracle;

    function queryPrice(address token) external returns (uint256) {
        bytes memory calldata = abi.encodeWithSignature("getPrice(address)", token);
        (bool success, bytes memory result) = oracle.staticcall(calldata);
        require(success, "Static call failed");
        return abi.decode(result, (uint256));
    }
}

4. 三种底层调用对比

特性calldelegatecallstaticcall
执行上下文被调用合约调用合约被调用合约
msg.sender调用合约地址原始调用者调用合约地址
存储修改被调用合约的存储调用合约的存储❌ 不允许
可发送 ETH✅ 可以❌ 不可以❌ 不可以
主要用途常规外部调用代理合约、库调用只读查询
安全风险重入攻击存储冲突、权限问题较低

5. 底层调用的安全最佳实践

  1. 总是检查返回值
(bool success, bytes memory data) = target.call(calldata);
require(success, "Call failed");
  1. 限制 Gas 使用
(bool success, ) = target.call{gas: 100000}(calldata);
  1. 使用函数签名验证
bytes4 selector = bytes4(keccak256("transfer(address,uint256)"));
require(calldata.length >= 4 && bytes4(calldata) == selector, "Invalid function");

二、回调函数(Callback Functions)

在 Solidity 0.6.0 版本之前,只有一个 fallback 函数,它同时处理接收以太币和不匹配的函数调用。在 Solidity 0.6.0 及更高版本将其分成了两个:receivefallback

一个合约最多只能有一个 receive 函数,同时也最多只能有一个 fallback 函数。

1. receive 函数

专门设计用于接收以太币,即使用 call 转账且没有 calldata,或使用 transfersend 进行转账。

语法receive() external payable { ... }

要求

  1. 必须由 externalpayable 修饰
  2. 不能有任何参数,也不能返回任何值

2. fallback 函数

有以下两种情况会触发它:

  1. 调用了合约中不存在的函数
  2. 向合约发送以太币时:没有定义 receive 函数,或附加了 calldata 但不匹配任何已有的函数

语法

// 简单的 fallback 函数
fallback() external [payable] { ... }

// 带输入和输出的 fallback 函数
fallback(bytes calldata _input) external [payable] returns (bytes memory _output) { ... }

三、重入攻击(Reentrancy Attack)

重入攻击是智能合约中最常见的安全漏洞之一。它利用智能合约在接收 ETH 时的回调机制:一旦攻击合约收到了 ETH,就会自动调用回调函数(receive/fallback),而回调函数中实现了攻击的逻辑。

攻击示例

1. 存在漏洞的合约

contract Vulnerable {
    mapping(address => uint256) public balances;

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

    function withdraw() external {
        require(balances[msg.sender] > 0, "No balance");

        // ❌ 漏洞:先转账
        (bool success, ) = msg.sender.call{value: balances[msg.sender]}("");
        require(success, "Transfer failed");

        // ❌ 漏洞:后更新余额
        balances[msg.sender] = 0;
    }
}

2. 攻击合约

contract Attacker {
    Vulnerable public vulnerable;

    constructor(address _vulnerable) {
        vulnerable = Vulnerable(_vulnerable);
    }

    // fallback 会在接收到 ETH 时被调用
    fallback() external payable {
        if (address(vulnerable).balance >= 1 ether) {
            vulnerable.withdraw(); // 再次调用 withdraw,形成重入
        }
    }

    function attack() external payable {
        require(msg.value >= 1 ether, "Need at least 1 ETH");
        vulnerable.deposit{value: 1 ether}();
        vulnerable.withdraw();
    }
}

攻击原理分析

Vulnerable 合约始终没有更新 Attacker 合约的余额为 0,且其使用的 call 来转账直接转发了所有的 Gas 给攻击合约,导致攻击合约能够进行复杂的操作。那么攻击合约可以反复调用 withdraw 提取资金,直到剩余的 Gas 不够执行 withdraw 操作或合约余额小于 1 ETH。

重入攻击的变种

跨函数重入(Cross-Function Reentrancy)

攻击者在一个函数的回调中调用同一合约的另一个函数:

contract Vulnerable {
    mapping(address => uint256) public balances;
    mapping(address => uint256) public rewards;

    function withdrawBalance() external {
        uint256 balance = balances[msg.sender];
        require(balance > 0, "No balance");

        (bool success, ) = msg.sender.call{value: balance}("");
        require(success, "Transfer failed");

        balances[msg.sender] = 0;  // ❌ 状态更新太晚
    }

    function withdrawReward() external {
        uint256 reward = rewards[msg.sender];
        require(balances[msg.sender] > 0, "Must have balance"); // ❌ 依赖于 balances
        require(reward > 0, "No reward");

        rewards[msg.sender] = 0;
        payable(msg.sender).transfer(reward);
    }
}

防御方法:对所有可能被重入的函数都使用 nonReentrant 修饰符,或确保所有相关函数都遵循 CEI 模式。

四、委托调用相关风险

1. 存储槽冲突(Storage Collision)⚠️ 代理合约常见

在可升级合约中,存储布局不一致会导致严重问题(如 Parity 钱包事件):

// ❌ 危险:存储布局不一致
contract Proxy {
    address public implementation;  // slot 0
    address public owner;           // slot 1
}

contract LogicWithConflict {
    uint256 public value;  // slot 0 - 与 implementation 冲突!
    address public admin;  // slot 1 - 与 owner 冲突!
}

关键点:代理合约和逻辑合约的存储布局必须完全一致,或使用 EIP-1967 标准存储槽。

2. 初始化函数的安全问题

由于构造函数不会在 delegatecall 中执行,需要使用初始化函数,但必须防止重复初始化:

// ✅ 安全:防止重复初始化
contract SafeLogic {
    bool private initialized;
    address public owner;

    function initialize() external {
        require(!initialized, "Already initialized");
        initialized = true;
        owner = msg.sender;
    }
}

3. 自毁函数风险

如果逻辑合约包含 selfdestruct,可能会误删除代理合约。关键点:永远不要在可升级合约中使用 selfdestruct

五、其它安全问题

1. 未检查的外部调用返回值 ⚠️ 最常见

// ✅ 安全:必须检查返回值
contract Safe {
    mapping(address => uint256) public balances;

    function withdraw() external {
        uint256 amount = balances[msg.sender];
        require(amount > 0, "No balance");

        balances[msg.sender] = 0;

        (bool success, ) = payable(msg.sender).call{value: amount}("");
        if (!success) {
            balances[msg.sender] = amount;
            revert("Transfer failed");
        }
    }
}

2. Gas Griefing 攻击 ⚠️ 批量操作常见

在批量转账等操作中,恶意合约可能消耗所有 Gas 导致交易失败:

// ✅ 限制 Gas 防御
contract Safe {
    event TransferFailed(address recipient, uint256 amount);

    function batchTransfer(address[] memory recipients, uint256[] memory amounts) external {
        for (uint i = 0; i < recipients.length; i++) {
            (bool success, ) = recipients[i].call{value: amounts[i], gas: 2300}("");
            if (!success) {
                emit TransferFailed(recipients[i], amounts[i]);
            }
        }
    }
}

3. Call 注入攻击 ⚠️ 用户可控调用时常见

// ✅ 安全:限制可调用的函数
contract Safe {
    mapping(address => mapping(bytes4 => bool)) public allowedCalls;
    address public owner;

    function allowCall(address target, bytes4 selector, bool allowed) external onlyOwner {
        allowedCalls[target][selector] = allowed;
    }

    function execute(address target, bytes memory data) external onlyOwner {
        bytes4 selector = bytes4(data);
        require(allowedCalls[target][selector], "Call not allowed");

        (bool success, ) = target.call(data);
        require(success, "Call failed");
    }
}

4. Front-running / MEV ⚠️ 所有公开交易都暴露

以太坊的交易在被打包前会进入公开的内存池(mempool),任何人都能看到。这意味着攻击者可以在你的交易被确认前,抢先发出一笔自己的交易。

// ❌ 易受攻击:明文提交答案的悬赏合约
contract Bounty {
    bytes32 public hashedAnswer;
    function claim(string calldata answer) external {
        require(keccak256(bytes(answer)) == hashedAnswer, "Wrong");
        payable(msg.sender).transfer(address(this).balance);
    }
}

攻击者在 mempool 里看到某人提交了正确 answer,立刻用更高 Gas 价格发一笔相同 answer 的交易,矿工/排序器优先打包高 Gas 的那笔——奖励被抢走。

典型受害场景:DEX 交易(夹子攻击 sandwich)、清算、NFT 抢铸、悬赏认领。

防御方法

// ✅ commit-reveal 模式
contract SafeBounty {
    mapping(address => bytes32) public commitments;
    mapping(address => uint256) public commitBlock;

    // 第一步:提交 keccak256(answer, salt, msg.sender)
    function commit(bytes32 commitment) external {
        commitments[msg.sender] = commitment;
        commitBlock[msg.sender] = block.number;
    }

    // 第二步:下一个区块后揭示
    function reveal(string calldata answer, bytes32 salt) external {
        require(block.number > commitBlock[msg.sender], "Too early");
        require(
            commitments[msg.sender] == keccak256(abi.encodePacked(answer, salt, msg.sender)),
            "Invalid reveal"
        );
        // ... 发放奖励
    }
}

5. 签名重放攻击(Signature Replay)⚠️ EIP-712 / Permit 必防

链下签名 + 链上验证的模式里,如果签名没有绑定「唯一性」和「上下文」,同一个签名可以被重复使用,或被搬到别的合约/链上使用。

三种重放维度:

重放类型问题防御
同合约重放同一签名被提交多次每个签名带唯一 nonce,用过即作废
跨合约重放给 A 合约的签名被拿到 B 合约用签名内容包含 address(this)
跨链重放主网的签名被拿到测试网/L2 用签名内容包含 block.chainid

EIP-712 的 Domain Separator 正是为解决后两种而设计——它把 chainIdverifyingContract 编进了签名。但 nonce 需要开发者自己维护:

// ❌ 危险:没有 nonce,签名可无限重放
function claim(uint256 amount, uint8 v, bytes32 r, bytes32 s) external {
    bytes32 hash = keccak256(abi.encodePacked(msg.sender, amount));
    require(ecrecover(hash, v, r, s) == signer, "Bad sig");
    token.transfer(msg.sender, amount);  // 同一签名能反复领
}

// ✅ 安全:
once + chainId + 合约地址 + 有效期
mapping(address => uint256) public nonces;

function claim(uint256 amount, uint256 deadline, uint8 v, bytes32 r, bytes32 s) external {
    require(block.timestamp <= deadline, "Expired");
    bytes32 structHash = keccak256(abi.encode(
        CLAIM_TYPEHASH, msg.sender, amount, nonces[msg.sender]++, deadline
    ));
    bytes32 digest = keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, structHash));
    address recovered = ecrecover(digest, v, r, s);
    require(recovered != address(0) && recovered == signer, "Bad sig");
    token.transfer(msg.sender, amount);
}

⚠️ 签名延展性(Malleability):ECDSA 签名 (v, r, s) 中,对于每个合法签名都存在另一个 s' = n - s 的等价签名。如果用签名本身做唯一性标识(如 usedSignatures[sig]),攻击者可构造延展签名绕过。防御:用 nonce 而非签名哈希做标识;或用 OpenZeppelin 的 ECDSA 库,它强制 s 在低半区。

6. Oracle 价格操纵 ⚠️ DeFi / RWA 核心风险

如果合约依赖某个价格源做关键决策(清算、铸币、赎回),而这个价格源可以被低成本操纵,攻击者就能凭空获利。

最经典的攻击:用现货 AMM 池做价格源。 AMM 池(如 Uniswap V2)的即时价格 = 两种代币储备的比值,攻击者用闪电贷在一个区块内:

  1. 借入大量资金,砸进 AMM 池,把池子价格瞬间拉高/砸低;
  2. 在被操纵的价格下与受害合约交互(如用虚高的抵押品价值借走超额资金);
  3. 反向操作把池子价格还原,归还闪电贷。

整个过程在一个原子交易内完成,攻击者只承担少量手续费。

防御方法

7. 整数溢出的现代形式:unchecked 误用

Solidity 0.8+ 默认带溢出检查,但 unchecked { } 块会关闭它。unchecked 用于省 Gas,但用错地方会让溢出漏洞回归。

// ❌ 危险:unchecked 里做了不可信的减法
function withdraw(uint256 amount) external {
    unchecked {
        balances[msg.sender] -= amount;  // amount > balance 时下溢成天文数字
    }
}

// ✅ 安全:先检查,确认不会下溢,再 unchecked
function withdraw(uint256 amount) external {
    require(balances[msg.sender] >= amount, "Insufficient");
    unchecked {
        balances[msg.sender] -= amount;  // 已验证,不会下溢
    }
}

原则:unchecked 块里的每一个运算,都必须在进入块之前用 require 证明它不可能溢出。

8. tx.origin 钓鱼攻击

tx.origin 是交易的最初发起者(永远是 EOA),msg.sender 是直接调用者。用 tx.origin 做权限校验会被钓鱼:

// ❌ 危险:用 tx.origin 鉴权
contract Wallet {
    address public owner;
    function transfer(address to, uint256 amount) external {
        require(tx.origin == owner, "Not owner");  // 漏洞
        payable(to).transfer(amount);
    }
}

攻击者诱导 owner 调用一个恶意合约,恶意合约内部再调 Wallet.transfer——此时 tx.origin 仍是 owner,校验通过,资金被转走。

防御:权限校验永远用 msg.sender,不要用 tx.origin

9. DoS 攻击(拒绝服务)

攻击者让某个本应正常的函数永久无法执行。两种常见形式:

① 通过 revert 实现 DoS

// ❌ 危险:一个收款方 revert,整个分发卡死
function distribute(address[] memory winners) external {
    for (uint i = 0; i < winners.length; i++) {
        payable(winners[i]).transfer(prize);  // 某个 winner 是会 revert 的合约
    }
}

只要有一个 winnerreceive 里故意 revert 的合约,整个 distribute 永远失败。

防御:拉取模式(Pull over Push)——不主动给每个人转账,而是记录每个人可领的额度,让他们各自来 withdraw

// ✅ 安全:拉取模式
mapping(address => uint256) public pendingWithdrawals;
function setWinners(address[] memory winners) external {
    for (uint i = 0; i < winners.length; i++) {
        pendingWithdrawals[winners[i]] += prize;
    }
}
function withdraw() external {
    uint256 amount = pendingWithdrawals[msg.sender];
    pendingWithdrawals[msg.sender] = 0;
    (bool ok, ) = msg.sender.call{value: amount}("");
    require(ok, "Failed");
}

② 无界循环 DoS:遍历一个可以被任意人撑大的数组,最终 Gas 超过区块上限,函数永久卡死。防御:避免遍历无界数组,改用映射 + 拉取模式或分页处理。

六、安全防范方法

1. 防止重入攻击

1.1 遵循 CEI 设计模式(推荐)⭐

CEI(Checks-Effects-Interactions)模式是最基本也是最重要的防御方法:

  1. Checks:检查状态(require、assert)
  2. Effects:更新状态变量
  3. Interactions:与外部交互(call、transfer)
function withdraw() external {
    // 1. Checks:检查
    require(balances[msg.sender] > 0, "No balance");

    // 2. Effects:更新状态
    uint256 amount = balances[msg.sender];
    balances[msg.sender] = 0;

    // 3. Interactions:外部交互
    (bool success, ) = msg.sender.call{value: amount}("");
    require(success, "Transfer failed");
}

1.2 使用 OpenZeppelin 的 ReentrancyGuard

import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

contract SafeContract is ReentrancyGuard {
    mapping(address => uint256) public balances;

    function withdraw() external nonReentrant {
        require(balances[msg.sender] > 0, "No balance");

        uint256 amount = balances[msg.sender];
        balances[msg.sender] = 0;

        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "Transfer failed");
    }
}

1.3 手动实现互斥锁

bool internal locked;

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

注意:早期使用 transfer/send(Gas 限制为 2300)来防御重入的方法已不推荐,因为可能导致转账失败。优先使用 CEI 模式 + ReentrancyGuard。

2. 防止委托调用相关风险

2.1 防止存储槽冲突

// ✅ 使用 EIP-1967 标准存储槽
contract SafeProxy {
    bytes32 private constant IMPLEMENTATION_SLOT =
        0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;

    function _setImplementation(address newImplementation) private {
        bytes32 slot = IMPLEMENTATION_SLOT;
        assembly {
            sstore(slot, newImplementation)
        }
    }
}

最佳实践:使用 OpenZeppelin 的代理合约实现;升级逻辑合约时,确保存储布局向后兼容;使用存储布局检查工具验证兼容性。

2.2 安全的初始化函数

使用 OpenZeppelin 的 Initializable 合约,确保初始化函数只能调用一次,使用 initializer 修饰符保护初始化函数。

2.3 避免自毁函数

永远不要在可升级合约中使用 selfdestruct,在代码审查中严格检查是否存在 selfdestruct 调用。

七、总结

  1. 底层调用方法call(常规外部调用,可发送 ETH)、delegatecall(保持调用者上下文,用于代理合约)、staticcall(只读调用)
  2. 安全风险:重入攻击(最常见)、存储槽冲突、初始化函数安全、自毁函数风险、未检查外部调用返回值、Gas Griefing、Call 注入、Front-running/MEV、签名重放、Oracle 价格操纵、unchecked 误用、tx.origin 钓鱼、DoS
  3. 安全防范:CEI 模式、ReentrancyGuard、EIP-1967 标准存储槽、安全的初始化函数、白名单验证、commit-reveal、nonce 防重放、拉取模式

攻击类型速查

攻击核心防御
重入攻击CEI 模式 + ReentrancyGuard
存储槽冲突EIP-1967 标准槽 / ERC-7201 命名空间
Front-runningcommit-reveal / 滑点保护 / 私有内存池
签名重放nonce + chainId + 合约地址 + deadline
Oracle 操纵专业预言机 + 新鲜度校验 + 多源交叉验证
unchecked 误用进入 unchecked 前先 require 证明不溢出
tx.origin 钓鱼权限校验只用 msg.sender
DoS拉取模式(Pull over Push)/ 避免无界循环

安全开发建议

安全永远是智能合约开发的第一要务