欢迎来到Web3.0的世界:Solidity智能合约安全漏洞分析

智能合约概述

智能合约是运行在区块链网络中的一段程序,经由多方机构自动执行预先设定的逻辑,程序执行后,网络上的最终状态将不可改变。智能合约本质上是传统合约的数字版本,由去中心化的计算机网络执行,而不是由政府或银行等中央集权机构执行。智能合约程序可以用Solidity或Vyper等编程语言实现,并存储在区块链上,在公链网络上,任何人都可以访问和执行部署好的智能合约。

智能合约拥有防篡改、透明和自动化等特征,这使其非常适合于金融交易,供应链管理等应用场景,其次,在商业保险,游戏,环保等领域都有所应用。现如今,区块链被视作为一种潜在的革命性技术,可以改变许多行业的协议制定和执行方式。

安全问题分析解决

智能合约既然是一段程序代码,同样会存在着缺陷或者错误导致出现致命的安全漏洞,在执行过程中,存在诸多的风险,并不能保证其完全安全。事实上,大多数的智能合约都和金融资产有所关联,其对应的智能合约漏洞的利用,意味着用户资产的损失,比如代币失窃,执行未经授权的交易,甚至是拖垮整个区块链网络。在这篇文章中,我们将谈论最常见的智能合约安全问题,以及处理这些问题的方法。

不安全的算术运算(Insecure Arithmetic)

这是一类非常经典的漏洞,主要来源于未经检查的算术运算。在Solidity 0.8.x以前,当一个整数变量达到其范围的下限或上限时,它将自动变为一个较低或较高的数字。

漏洞描述
function batchTransfer(address[] _receivers, uint256 _value) public whenNotPaused returns (bool) {
    uint cnt = _receivers.length;
    // 计算应付总金额
    uint256 amount = uint256(cnt) * _value;
    require(cnt > 0 && cnt <= 20);
    require(_value > 0 && balances[msg.sender] >= amount);

    balances[msg.sender] = balances[msg.sender].sub(amount);
    for (uint i = 0; i < cnt; i++) {
        balances[_receivers[i]] = balances[_receivers[i]].add(_value);
        Transfer(msg.sender, _receivers[i], _value);
    }
    return true;
  }

以上的智能合约函数实现了一个批量转账的功能,将合约账户上的资金分别转给多个地址(不超过20个)。主要漏洞在以下这行代码:

uint256 amount = uint256(cnt) * _value;

攻击者可以传入一个比较大的数值,使得计算出来的amount值很小,小于了自己账户里的可用余额,从而通过了可用余额的校验,最终得到了一大笔资金入账。

解决方案
  • 将Solidity编译器升级至0.8.0及其以上的版本,会自动检测数值溢出的异常;
  • 如果不方便升级Solidity编译器的话,可以考虑使用安全的三方库(比如Open Zeppelin),实现安全可信的算术运算;
  • 将以上的有漏洞的代码改为:
function batchTransfer(address[] _receivers, uint256 _value) public whenNotPaused returns (bool) {
    uint cnt = _receivers.length;
    // 计算应付总金额
    uint256 amount = uint256(cnt) * _value;
    require(cnt > 0 && cnt <= 20);
    // 使用除法换算出来的值要等于传入的_value
    require(amout / uint256(cnt) == _value)
    require(_value > 0 && balances[msg.sender] >= amount);
    balances[msg.sender] = balances[msg.sender].sub(amount);
    for (uint i = 0; i < cnt; i++) {
        balances[_receivers[i]] = balances[_receivers[i]].add(_value);
        Transfer(msg.sender, _receivers[i], _value);
    }
    return true;
}

越权攻击(Exceed Authority Access Attack)

通常有两种情况会导致越权攻击:

  • 不恰当的函数可见性设置。如果不显式指定函数可见性,那么默认为public,意味着允许未经授权的用户调用该函数;
  • 没有设置owner,某些关键性的函数不可被任意访问,而是应该指定特定的使用者。
漏洞描述

如以下代码所示,由于_sendWinnings函数没有设置可见性,默认是 public,攻击者可以通过调用此函数直接窃取资金。

contract HashForEther {
    function withdrawWinnings() {
        // 钱包地址十六进制的后8位全是0
        require(uint32(msg.sender) == 0);
        _sendWinnings();
     }
     function _sendWinnings() {	
         msg.sender.transfer(this.balance);
     }
}
解决方案
  1. _sendWinnings函数的可见性设置为private
  2. _sendWinnings函数限制调用者,通常是管理员或者合约部署者
contract HashForEther {
	address private _owner;
    constructor(address owner) {
        _owner = owner;
    }
    modifier ownerable() {
        require(_owner == msg.sender);
        _;
    }
    function withdrawWinnings() public {
        // 钱包地址十六进制的后8位全是0
        require(uint32(msg.sender) == 0);
        _sendWinnings();
    }
    function _sendWinnings() public ownerable {
        msg.sender.transfer(this.balance);
    }
}

重入攻击(Reentrancy attack)

重入攻击是存在以太坊上最常见的智能合约安全漏洞。在以太坊中,对其他智能合约函数的调用并非异步进行的,也就是意味着自身的智能合约继续执行之前,会等待外部方法的执行结束,这将非常有可能导致被调用的合约的中间状态被不合理的利用。

漏洞描述
pragma solidity 0.8.17;
contract EtherStore {
	// 存储链上地址与对应的可用余额
    mapping(address => uint) public balances;
    function deposit() public payable {
    	// 消息调用者在该合约中的存款加上账户当余额
        balances[msg.sender] += msg.value;
    }

    function withdraw() public {
        uint bal = balances[msg.sender];
        // 判断是否有可用余额
        require(bal > 0);
		// 提取全部的金额
        (bool sent, ) = msg.sender.call{value: bal}("");
        require(sent, "Failed to send Ether");
		// 将地址对应的修改为0
        balances[msg.sender] = 0;
    }
}

以上是一个简单的存款/提款的智能合约,漏洞主要出现在以下的一行代码:

(bool sent, ) = msg.sender.call{value: bal}("");

能使得以上漏洞被成功利用,是具备了三个条件:

  • call函数的调用没有交易手续费(Gas)限制,默认会使用所有剩余的Gas,这是用于执行智能合约的以太坊虚拟机的特性;
  • msg.sender是来自另外一个恶意智能合约的地址,当收到交易转账后,会触发fallback函数;
  • 发起攻击的智能合约实现fallback函数,主要是再一次触发被攻击的智能合约的提款函数。
    欢迎来到Web3.0的世界:Solidity智能合约安全漏洞分析_第1张图片

实际上,两个智能合约之间的调用已经进入了“递归黑洞”,攻击者只需要向被攻击的智能合约中存入少量的资金,通过不断调用提款函数,可以提取超额的回报。

解决方案
  • 使用send()或者transfer()函数,因为有Gas限制,最多消耗2300Gwei;

  • 慎用外部函数,检查每一个直接或者间接调用外部函数的地方,确保状态变更完成之后,再调用;

    function withdraw() external {
        uint bal = balances[msg.sender];
        require(bal > 0);
        // 先更新余额变化,再发送资金
        // 重入攻击的时候,balances[msg.sender]已经被更新为0了,不能通过上面的检查。
        balanceOf[msg.sender] = 0;
        (bool success, ) = msg.sender.call{value: bal}("");
        require(success, "Failed to send Ether");
    }
    
  • 为每一个账户地址增加重入标识,操作执行完成之前,不允许重复执行相同的逻辑。

    uint private _status; // 重入锁
    // 重入锁
    modifier nonReentrant() {
        // 在第一次调用 nonReentrant 时,_status 将是 0
        require(_status == 0, "ReentrancyGuard: reentrant call");
        // 在此之后对 nonReentrant 的任何调用都将失败
        _status = 1;
        _;
        // 调用结束,将 _status 恢复为0
        _status = 0;
    }
    // 只需要用nonReentrant重入锁修饰withdraw()函数,就可以预防重入攻击了。
    function withdraw() external nonReentrant {
        uint bal = balances[msg.sender];
        // 判断是否有可用余额
        require(bal > 0);
        // 提取全部的金额
        (bool sent, ) = msg.sender.call{value: bal}("");
        require(sent, "Failed to send Ether");
        // 将地址对应的修改为0
        balances[msg.sender] = 0;
    }
    

拒绝服务攻击(DoS Attack)

正常情况下,一个智能合约对外提供稳定的服务是基于一个大前提:在耗尽交易手续费(Gas)之前,智能合约程序可以正常执行结束。攻击者正是破坏了这一个大前提,使得智能合约不能正常提供服务。

漏洞描述

以下是一个拍卖的智能合约,主要的功能是价高者胜出,未中标的买家将会被立即退还竞拍保证金。

contract Auction {
    address currentLeader;
    uint highestBid;
    constructor () {
        currentLeader = msg.sender;
        highestBid = 1;
    }
    function bid() payable {
        require(msg.value > highestBid);
        (bool success, ) = currentLeader.call{value: highestBid}("");
        require(success, "Refund failed");
        currentLeader = msg.sender;
        highestBid = msg.value;
    }
}

将会产生漏洞的代码是:

(bool success, ) = currentLeader.call{value: highestBid}("");

攻击者可以制造一个恶意的智能合约,实现了fallback回调函数,在fallback函数内回滚交易。这个智能合约持续向拍卖合约发起攻击,一旦自己成为了最高价者,在试图退还竞拍保证金的时候,由于恶意智能合约的fallback函数,返回的success的值是false,导致退还失败,在这之后的赋值新的竞拍者的代码逻辑将永远不会执行到,其他竞拍者也就没有机会获得成功。

解决方案

解决以上漏洞,最主要是分开竞拍和退款两个操作。若竞拍失败,先记录退款地址,再单独提供退款的操作,由用户自行提取竞拍保证金。

contract Auction {
    address highestBidder;
    uint highestBid;
    mapping(address => uint) refunds;
	constructor () {
        currentLeader = msg.sender;
        highestBid = 1;
    }
    function bid() payable external {
        require(msg.value >= highestBid);
        if (highestBidder != address(0)) {
        	// 记录要退款的金额
            refunds[highestBidder] += highestBid;
        }
        highestBidder = msg.sender;
        highestBid = msg.value;
    }
	// 单独提供退款操作
    function withdrawRefund() external {
        uint refund = refunds[msg.sender];
        refunds[msg.sender] = 0;
        (bool success, ) = msg.sender.call.value(refund)("");
        require(success);
    }
}

值得注意的是,这里不建议开启一个循环自动处理退款,有两个原因:

  1. 退款地址可能是一个恶意攻击的合约地址;
  2. 退款地址数量很大,Gas耗费巨大,不能保证全部的退款能到账。

蜜罐攻击(Honeypot Attack)

一些智能合约会故意暴露显而易见的“漏洞”,通常情况下,用户会发送资金,以期获得超额的回报,最终却被该智能合约“反咬一口”,不但没有获得预期的回报,反而损失了本金。

漏洞描述
contract CryptoRoulette {
    uint256 private secretNumber;
    uint256 public lastPlayed;
    uint256 public betPrice = 0.001 ether;
    address public ownerAddr;
    struct Game {
        address player;
        uint256 number;
    }
    Game[] public gamesPlayed;
    constructor() public {
        ownerAddr = msg.sender;
        shuffle();
    }
    function shuffle() internal {
        // 中奖号码设置为一个固定的数字6
        secretNumber = 6;
    }
    function play(uint256 number) payable public {
        require(msg.value >= betPrice && number <= 10);
        Game game;
        game.player = msg.sender;
        game.number = number;
        gamesPlayed.push(game);
        if (number == secretNumber) {
            // 如果传入的数字正好是中奖号码,则可以赢取奖金
            msg.sender.transfer(this.balance);
        }
        //shuffle();
        lastPlayed = now;
    }
    function kill() public {
        if (msg.sender == ownerAddr && now > lastPlayed + 6 hours) {
            suicide(msg.sender);
        }
    }
    function() public payable { }
}

如上述代码所示,很容易被注意到,初始化的中奖号码是6,但是实际调用play(6)之后,并不会如期赢取奖金。其原因,主要是Game变量未实例化,EVM的存储机制决定了secretNumber最终的值已不再是6了,而是智能合约的调用者的地址,所以参与者始终都不会得到奖金。
欢迎来到Web3.0的世界:Solidity智能合约安全漏洞分析_第2张图片
如上图所示,EVM的存储结构是由 2^256 个插槽 (Slot)组成,每个插糟有 32byte,等同于256bit,正好是可以存放一个uint256类型的变量,合约中的状态变量会根据其具体类型分别顺序保存到这些插槽中。

play函数中,因为Game并没有初始化,对game.playergame.number的赋值,实际上是分别对Slot0Slot1进行了赋值,按照变量定义的顺序,其分别是secretNumberlastPlayed。如果用户传入的number是6的话,与实际的secretNumber的值是不相等的,非但不能获得奖金,而且还损失了本金。

解决方案

从用户视角来看,作为合约的调用方/使用者,需要甄别对方的智能合约的实现是否合理,除了使用未经实例化的局部变量,还有诸如Solidity版本过低,使用了未知的代理合约,引用了恶意的代码库等等。

除此之外,应该多关注业界发生的安全事件,及其相关的资讯文章,比如 mirror、DL News,也可以借助一些工具和平台,辅助交易,比如 BlockSec,Flashbots。

智能合约升级

智能合约与传统应用程序有一个不同的地方在于智能合约一经发布于区块链上就无法篡改,即使智能合约中有漏洞需要修复,或者需要对业务逻辑进行变更,它也不能在原有的合约上直接修改再重新发布,因此在设计之初就需要结合业务场景考虑合理的升级机制。

按照程序升级的通常意义来理解,升级后的程序首先是要满足用户的正常使用,用户的信息和资产没有丢失,其次是最好能做到兼容和适配以往的版本。

实现原理

如果要编写可升级的智能合约,通常的做法是使用代理模式来实现。用户请求的是代理合约(Proxy Contract),再通过代理合约进行委托调用实际的逻辑合约(Logic Contract)。因为是通过delegatecall函数调用逻辑合约,实际上是由代理合约来存储状态变量,即它是存储层。这就像你只是执行了逻辑合约的程序,并在代理合约所在的上下文中存储状态变量。代理合约通常有两种实现方式:透明代理,UUPS。这两种方式最核心的区别在于智能合约升级的逻辑在哪里实现,透明代理模式把升级的逻辑放在了代理合约里,而UUPS则放在了逻辑合约里。
欢迎来到Web3.0的世界:Solidity智能合约安全漏洞分析_第3张图片

示例代码

在代理合约中,完成对实际的逻辑合约重定向的功能(setLogicAddress),以及通过委托调用,对主要函数的实现(setNumber)。

contract proxy {
    uint256 private number;
    address private logicAddress;
    address private owner;
    constructor(address _logicAddress) {
        logicAddress = _logicAddress;
        owner = msg.sender;
    }
    modifier ownerable() {
        require(owner == msg.sender);
        _;
    }
    function setLogicAddress(address _logicAddress) ownerable public {
        logicAddress = _logicAddress;
    }
    function setNumber(uint256 _number) public returns(bool) {
        (bool success,) = logicAddress.delegatecall(abi.encodeWithSignature("setNumber(uint256)", _number));
        return success;
    }
}

在第一个版本的逻辑合约中,我们实现的功能是对number进行+1操作,部署logic1合约,调用proxy合约中的setLogicAddress方法,传入logic1合约的地址。

contract logic1 {

    uint256 private number;

    function setNumber(_number) public {
        number = _number + 1;
    }
    function getNumber() public view returns(uint256) {
        return number;
    }
}

随后需要升级,改为对number进行×2操作,部署logic2合约,调用proxy合约中的setLogicAddress方法,传入logic2合约的地址,即可完成升级。

contract logic2 {

    uint256 private number;

    function setNumber(_number) public {
        number = _number * 2;
    }
    function getNumber() public view returns(uint256) {
        return number;
    }
}

总结

智能合约的开发技术相对较新,暂未形成工业级的标准规范,开发者缺乏明确的指导,不能保证所开发的代码的安全性。另外,既然都是由人创造的,就会受限于主观意识,一些人为因素也将会导致事故的发生。对于智能合约的安全验证,暂未出现正式的并且广泛使用的技术规范。

智能合约的安全性是区块链技术的一个重要方面,也正是其复杂之处。智能合约在带来诸多好处的同时,也容易受到各种潜在安全风险和漏洞的影响。在开发基于区块链的应用程序时,智能合约的安全性是一个值得考虑的重要因素,必须采取积极主动的方法来识别和减少漏洞,以确保合约及其所管理资产的完整性和安全性。

转载申明:未经作者本人同意,本篇文章不可转载或者作为文摘、资料刊登。

你可能感兴趣的:(Web3.0,web3,智能合约,安全威胁分析,区块链)