智能合约安全性研究

本文向大家介绍了常见的合约漏洞,自动化监测合约漏洞的工具以及链上漏洞的应对方案。这些内容不仅仅对大家更好保护资产有帮助而且也有助于更加深入理解智能合约的运作流程。

由于合约和资产的强关联以及合约不可修改的特性,合约漏洞会对项目发起者和参与项目的用户带来财产损失以及信任危机。所以我们要尽可能地避免发生合约被攻击的情况。本文主要分为3个部分:

  1. 介绍常见的合约漏洞,在开发阶段避免一些可能被攻击的写法。
  2. 介绍一些自动化合约漏洞检测工具,在测试阶段更加快捷地发现漏洞。
  3. 介绍合约上链后通过合约暂停,合约监控以及合约升级的方式来避免合约漏洞带来的损失。

合约漏洞

数值溢出

较为典型的是if(a - b > 0)的判断,其实无符号整数溢出在C语言也存在,对于无符号整数而言永远无法通过相减得到小于0的数,(0-1)会获得取值范围内最大的数,就像循环一样。

pragma solidity ^0.6.0;

contract Token {
  mapping(address => uint) balances;
  uint public totalSupply;
  constructor(uint _initialSupply) public {
    balances[msg.sender] = totalSupply = _initialSupply;
  }
  function transfer(address _to, uint _value) public returns (bool) {
    require(balances[msg.sender] - _value >= 0);
    balances[msg.sender] -= _value;
    balances[_to] += _value;
    return true;
  }
  function balanceOf(address _owner) public view returns (uint balance) {
    return balances[_owner];
  }
}

上面的合约大家可以尝试调用transfer函数传入1个比自己余额大的值,是否可以完美通过。

避免此类问题,有2种方案,其原理都是在计算前后进行判定:

  1. 使用openzeppelin提供的SafeMath
import '@openzeppelin/contracts/math/SafeMath.sol';
using SafeMath for uint256;
  1. solidity版本使用8.0以上
pragma solidity ^0.8.0;

私有变量可读取

private作用只是无法直接让其他合约调用,但并不代表通过private存储的值无法被获取,从计算机的基础理论也容易想到,值都是存在内存或磁盘中的,只要我们了解存储规则,private是没有任何作用的。这一点其他编程语言中也是一致的,变量的权限修饰符从来都是为了代码设计,而不是加密。 如同下面的合约

pragma solidity ^0.6.0;
contract Vault {
  bool public locked;
  bytes32 private password;

  constructor(bytes32 _password) public {
    locked = true;
    password = _password;
  }

  function unlock(bytes32 _password) public {
    if (password == _password) {
      locked = false;
    }
  }
}

之前文章智能合约升级详解时介绍过合约存储是按照槽位存储的,我们只需要指定存储槽位即可读取到相应变量的值:

const res = await provider.getStorageAt("合约地址", 1);
 console.log(`res is: ${res}`);

链上随机数不随机

链上没有随机数可言,因为链上代码都是开源的,链上制造随机数的操作,都可以使用另外1个合约执行相同的操作得到。不要希望使用block相关变量得到随机数,模拟者放到同1个区块内执行即可。

权限设置

权限设置更多的是合约设计的考量,我们这里主要介绍一个经典的问题。

使用 msg.sender 而不是 tx.origin 做权限校验

tx.origin是指整个交易链路的首位发起者,而msg.sender才是真正调用合约者,如果是外部账户->合约的简单调用时,其获取的地址是一致的,除此之外这2种方式获取的地址均不相同。 如下合约使用tx.origin进行校验权限

contract TxUserWallet {
    address owner;
    constructor() {
        owner = msg.sender;
    }
    function transferTo(address dest, uint amount) public {
        require(tx.origin == owner);
        dest.transfer(amount);
    }
}

当被攻击合约向攻击合约转账时,攻击合约fallback函数被调用,此时被攻击合约通过tx.origin获得的是整个交易的最初发起者也就是被攻击合约的owner,校验通过。

interface TxUserWallet {
    function transferTo(address dest, uint amount) public;
}

contract TxAttackWallet {
    address payable owner;

    constructor() {
        owner = payable(msg.sender);
    }

    function() public {
        TxUserWallet(msg.sender).transferTo(owner, msg.sender.balance);
    }
}

对于权限控制建议使用openzeppelin contracts包下提供的工具合约。一般使用onlyOwner即可,如果需要较为复杂的角色控制,可以使用AccessControl。

拒绝访问攻击

拒绝访问一般出现在合约循环对外转账的场景下,如果对外转账的对象是1个合约账号,其在fallback函数中抛出错误,那么合约循环将无法继续。所以通常情况下要避免循环对外转账,尽量使用满足条件的账户自提的方式,这样可以避免恶意账户埋雷导致其他账户资产受到影响。

重入攻击

重入比较常见的是利用被攻击合约向攻击合约转账时会调用攻击合约的fallback函数,攻击合约一般会在fallback函数中再次调用被攻击合约以达到重新执行函数的目的。

contract Fund {
    mapping(address => uint) shares;
    function withdraw() public {
        if (payable(msg.sender).call.value(shares[msg.sender])())
            shares[msg.sender] = 0;
    }
}

攻击者只需要在合约的fallback函数中继续调用该函数即可耗尽合约资金。

interface Fund {
    function withdraw() external;
}
contract Attack {
    address payable owner;
    constructor() {
        owner = payable(msg.sender);
    }
    function attck(address addr) public {
        Fund f = Fund(addr);
        f.withdraw();
    }
    fallback() external payable{
        Fund f = Fund(msg.sender);
        f.withdraw();
    }
}

为了避免重入,我们需要践行“检查-生效-交互”的设计模式,检查-生效-交互是指,应该先进行条件满足的判断,然后修改合约中的状态,最后再与外部账户进行交互,这样如果外部合约发动重入攻击也是在状态修改之后,重入时会被条件判断拦截。上面的合约应做如下修改:

contract Fund {
    mapping(address => uint) shares;
    function withdraw() public {
        var share = shares[msg.sender];
        if(share > 0) {
          shares[msg.sender] = 0;
          payable(msg.sender).transfer(share);
        }
    }
}

重入锁

此外openzeppelin还为我们提供了重入锁来帮助我们解决重入问题。重入锁的实现如下所示,也是使用了“检查-生效-交互”模式:

pragma solidity ^0.8.0;
abstract contract ReentrancyGuard {
    uint256 private constant _NOT_ENTERED = 1;
    uint256 private constant _ENTERED = 2;
    uint256 private _status;
    constructor() {
        _status = _NOT_ENTERED;
    }

    modifier nonReentrant() {
        //如果状态是初始状态则进入函数
        require(_status != _ENTERED, "ReentrancyGuard: reentrant call");
        //将状态置为访问中
        _status = _ENTERED;
        //执行函数
        _;
        //将状态置为初始状态
        _status = _NOT_ENTERED;
    }
}

条件错误锁死

在一些关键的操作,我们对于条件判断的一定要仔细考虑,编写足够多的测试用例进行测试。

Akutar合约条件错误导致3400万美元资金永久被锁

在这个例子中取回合约中的资金claimProjectFunds函数有3个条件。其中refundProgress >= totalBids条件,大家可以观察refundProgress和totalBids赋值的地方。应该不难发现只要有1次调用_bid函数时amount>1,则refundProgress >= totalBids的结果不可能被满足,这也直接导致该合约内3400万美元被锁死。同时该合约在processRefunds函数存在上文中提到的循环对外转账的操作,非常容易引入拒绝访问攻击。

function claimProjectFunds() external onlyOwner {
        require(block.timestamp > expiresAt, "Auction still in progress");
        require(refundProgress >= totalBids, "Refunds not yet processed");
        require(akuNFTs.airdropProgress() >= totalBids, "Airdrop not complete");

        (bool sent, ) = project.call{value: address(this).balance}("");
        require(sent, "Failed to withdraw");        
  }

  function processRefunds() external {
      require(block.timestamp > expiresAt, "Auction still in progress");
      uint256 _refundProgress = refundProgress;
      uint256 _bidIndex = bidIndex;
      require(_refundProgress < _bidIndex, "Refunds already processed");

      uint256 gasUsed;
      uint256 gasLeft = gasleft();
      uint256 price = getPrice();

      for (uint256 i=_refundProgress; gasUsed < 5000000 && i < _bidIndex; i++) {
          bids memory bidData = allBids[i];
          if (bidData.finalProcess == 0) {
            uint256 refund = (bidData.price - price) * bidData.bidsPlaced;
            uint256 passes = mintPassOwner[bidData.bidder];
            if (passes > 0) {
                refund += mintPassDiscount * (bidData.bidsPlaced < passes ? bidData.bidsPlaced : passes);
            }
            allBids[i].finalProcess = 1;
            if (refund > 0) {
                (bool sent, ) = bidData.bidder.call{value: refund}("");
                require(sent, "Failed to refund bidder");
            }
          }

          gasUsed += gasLeft - gasleft();
          gasLeft = gasleft();
          _refundProgress++;
      }

      refundProgress = _refundProgress;
    }

    function _bid(uint8 amount, uint256 value) internal {
        require(block.timestamp > startAt, "Auction not started yet");
        require(block.timestamp < expiresAt, "Auction expired");
        uint80 price = getPrice();
        uint256 totalPrice = price * amount;
        if (value < totalPrice) {
            revert("Bid not high enough");
        }

        uint256 myBidIndex = personalBids[msg.sender];
        bids memory myBids;
        uint256 refund;

        if (myBidIndex > 0) {
            myBids = allBids[myBidIndex];
            refund = myBids.bidsPlaced * (myBids.price - price);
        }
        uint256 _totalBids = totalBids + amount;
        myBids.bidsPlaced += amount;

        if (myBids.bidsPlaced > maxBids) {
            revert("Bidding limits exceeded");
        }

        if(_totalBids > totalForAuction) {
            revert("Auction Full");
        } else if (_totalBids == totalForAuction) {
            expiresAt = block.timestamp; //Auction filled
        }

        myBids.price = price;

        if (myBidIndex > 0) {
            allBids[myBidIndex] = myBids;
        } else {
            myBids.bidder = msg.sender;
            personalBids[msg.sender] = bidIndex;
            allBids[bidIndex] = myBids;
            bidIndex++;
        }

        totalBids = _totalBids;
        totalBidValue += totalPrice;

        refund += value - totalPrice;
        if (refund > 0) {
            (bool sent, ) = msg.sender.call{value: refund}("");
            require(sent, "Failed to refund bidder");
        }
    }

签名重放

NBAXNFT签名重放白名单被耗尽

在这个例子中NBAXNFT合约本意是想为白名单用户提供mint,但由于verify函数校验白名单时没有校验msg.sender的地址和参数info中的地址是否相同,任何人在传入info时都可以使用已经通过验证的签名信息,合约代码中真正mint发放的时候又使用msg.sender。这里会导致2个问题,第1个问题是非白名单用户也可以通过重放签名进行Mint,第2个问题是白名单用户可以重复mint,因为对于1个地址签名是确定的。

function mint_approved(
        vData memory info,
        uint256 number_of_items_requested,
        uint16 _batchNumber
    ) external {
        require(batchNumber == _batchNumber, "!batch");
        address from = msg.sender;
        require(verify(info), "Unauthorised access secret");
        _discountedClaimedPerWallet[msg.sender] += 1;
        require(
            _discountedClaimedPerWallet[msg.sender] <= 1,
            "Number exceeds max discounted per address"
        );
        presold[from] = 1;
        _mintCards(number_of_items_requested, from);
        emit batchWhitelistMint(_batchNumber, msg.sender);
    }
function verify(vData memory info) public view returns (bool) {
        require(info.from != address(0), "INVALID_SIGNER");
        bytes memory cat =
            abi.encode(
                info.from,
                info.start,
                info.end,
                info.eth_price,
                info.dust_price,
                info.max_mint,
                info.mint_free
            );
        // console.log("data-->");
        // console.logBytes(cat);
        bytes32 hash = keccak256(cat);
        // console.log("hash ->");
        //    console.logBytes32(hash);
        require(info.signature.length == 65, "Invalid signature length");
        bytes32 sigR;
        bytes32 sigS;
        uint8 sigV;
        bytes memory signature = info.signature;
        // ecrecover takes the signature parameters, and the only way to get them
        // currently is to use assembly.
        assembly {
            sigR := mload(add(signature, 0x20))
            sigS := mload(add(signature, 0x40))
            sigV := byte(0, mload(add(signature, 0x60)))
        }

        bytes32 data =
            keccak256(
                abi.encodePacked("\x19Ethereum Signed Message:\n32", hash)
            );
        address recovered = ecrecover(data, sigV, sigR, sigS);
        return signer == recovered;
    }
  • 第一个问题我们可以通过增加msg.sender == info.from来避免非白名单用户mint,但这样会限制委托mint,我们其实只需要保证校验的地址和最终mint的地址一致即可。
  • 第二个问题需要通过引入nonce来解决,在进行签名时加入nonce值, 然后在合约中记录每个地址使用过的nonce,如果已经使用则交易无效,下面是EIP-712避免签名重放攻击的示例
function transferWithSignature(
    address recipient, 
    uint256 amount, 
    address sender, 
    bytes32 nonce, 
    bytes memory signature
  ) public returns (bool) {

    // Hash of structured data as defined according to the TYPEHASH
    bytes32 hashStruct = keccak256(abi.encode(
      TRANSFER_TYPEHASH,
      sender,
      recipient,
      amount,
      nonce
    ));

    // Signed hash as defined by EIP-712
    bytes32 hashed = keccak256(abi.encodePacked(
      "\x19\x01",
      domainSeparator,
      hashStruct
    ));

    address recovered = ECDSA.recover(hashed, signature);
    require(recovered == sender, "Signature mismatch");

    require(!nonces[sender][nonce], "Repeated nonce");
    nonces[sender][nonce] = true;

    _transfer(sender, recipient, amount);
  }

合约漏洞自动化检测工具

编写完合约代码后我们需要经过严格的测试,如果较大的项目需要进行专业的合约审计。下面介绍2款自动化漏洞检测工具能够对我们的人工测试进行有效补充。

slither

slither是Solidity代码的静态分析工具,可以在不执行代码的情况下对代码进行静态分析。 分析命令:

slither 合约路径

官方链接: https://github.com/crytic/slither

echidna

echidna为我们提供状态分析,我们在聊一聊智能合约介绍过可以将智能合约执行的过程理解为状态机状态变化的过程。echidna帮助我们分析状态的可达性。如果某些状态无论如何执行都符合我们预期则测试通过,如果有些执行路径不满足预期,则会输出不满足预期情况下的调用情况。

echidna要求我们编写状态检测函数

function echidna_check_balance() public returns (bool) {
    return(balance >= 20);
}

执行合约检查

chidna-test 合约地址

官方链接: https://github.com/crytic/echidna

合约监控修复方案

合约可暂停&链上合约监控

合约可暂停

当我们发现线上合约存在漏洞时,我们需要第一时间暂停合约的正常运行来避免合约被攻击即只保留个别函数可以被正常调用,其余函数均无法被调用,openzeppelin为我们提供了Pausable工具合约来帮助我们有效实现这一目标。如下所示,当发现合约存在问题时我们调用_pause将合约暂停

pragma solidity ^0.8.0;

import "../utils/Context.sol";
abstract contract Pausable is Context {
    event Paused(address account);
    event Unpaused(address account);
    bool private _paused;
    constructor() {
        _paused = false;
    }
    function paused() public view virtual returns (bool) {
        return _paused;
    }
    modifier whenNotPaused() {
        require(!paused(), "Pausable: paused");
        _;
    }
    modifier whenPaused() {
        require(paused(), "Pausable: not paused");
        _;
    }
    function _pause() internal virtual whenNotPaused {
        _paused = true;
        emit Paused(_msgSender());
    }
    function _unpause() internal virtual whenPaused {
        _paused = false;
        emit Unpaused(_msgSender());
    }
}

链上合约监控

上面我们介绍了如何在发现漏洞时暂停合约,如何及时发现漏洞也有较多方案,我们在之前使用TheGraph实现Dapp的万倍速度优化介绍了TheGraph的使用。我们对链上一些异常的监控也可以使用TheGraph,在发现链上数据有异常时及时报警。

合约可升级

当合约暂停后,我们期望最好的结果肯定是进行修复后合约能够继续正常运转。合约虽然上链之后代码不可更改,但我们可以通过代理合约的方式实现合约的动态升级,在之前智能合约升级详解中已经进行了详细介绍。通过合约升级可以将我们发现存在风险的链上合约进行升级修复。但这里再次强调一次,使用合约升级时需要配合升级治理,否则项目参与者对合约的信任度会降低。

结尾

本文向大家介绍了常见的合约漏洞,自动化监测合约漏洞的工具以及链上漏洞的应对方案。这些内容不仅仅对大家更好保护资产有帮助而且也有助于更加深入理解智能合约的运作流程。合约漏洞是web3安全的一个重要组成,但不是唯一因素。此外还包含链下钓鱼、跨链安全(跨链桥合约的安全)等等。希望大家能写出更加安全的合约代码。

相关链接:

点赞 3
收藏 5
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
web3探索者
web3探索者
0x3167...f450
元宇宙新著民致力研究web3 会定期分享web3技术 公众号:web3探索者