美文网首页区块链故事
区块链安全—合约存储机制安全分析

区块链安全—合约存储机制安全分析

作者: CPinging | 来源:发表于2018-12-18 10:23 被阅读128次

    一、前言

    作为不太成熟的编程语言,Solidity函数由于其运行机制等问题目前能找到很多的安全问题。在之前的分析中,我们针对共识、合约等方向进行过概括性的研究,而最近区块链安全的研究热也激起了研究者对以太坊的深入了解。

    最近的几次CTF比赛中,区块链的题目出现的频率也越来越高,也逐渐进入大家的视野中。今天,我们就针对部分区块链的CTF题目以及生产环境中的实例进行一些相关技术分析,并带领读者一步一步模拟这些漏洞的出现情况。并在以太坊平台上进行相关合约部署,方便研究者更进一步的研究。

    在分析开始之前,我们先对智能合约有一个基础的概念了解。

    智能合约就是运行在区块链网络上的程序,智能合约与合约执行的结果都会储存在区块链上。在区块链的背景下,智能合约不只是一个计算机程序:它自己就是一个参与者,对接收到的信息进行回应并在同时接受和存储相应的价值。除此之外,它也能同外部地址合约进行交互,向外发送信息和价值。智能合约与一般程序的差异主要体现在以下四个方面:

    • 整合资金流程度

    智能合约通过以太坊自带的以太币可以非常容易的整合资金流系统。

    • 部署以及后续费用

    一般程序部署在服务器上,程序部署成功后,除了需要花费一些维护费用外不需要其他的额外花费。智能合约在部署的时候需要一笔费用,这些费用将分给参与交易验证的人。而在合约部署成功后,合约会作为不可更改的区块链的一部分,分散地存储在全球各地的以太坊节点上。因此,智能合约部署后,并不需要定期提供维持费用,同时查询已写入区块链的静态数据时也不需要费用,只有在每次通过智能合约写入数据的时候才需要交易费用。

    例如:


    上述图片为查询owner的信息,而此次查询点击即可获得信息,并不需要支付交易费用。

    然而对于根据智能合约写入信息来说,我们则需要进行手续费的提供。

    image.png
    • 存储成本不同

    一般的应用程序需要将数据存储到服务器上,需要数据时需要从服务器上读取。然而智能合约将数据存储在区块链上,存储数据所需的成本相对比较昂贵,需要根据存储数据的大小支付相当的费用情况。

    • 部署后无法更改

    一般的程序可以通过版本升级的方式进行更改,而智能合约一旦部署到区块链上后,就无法更改这个智能合约。

    二、关键威胁函数分析

    根据知道,在Solidity合约的书写中,跨合约调用是经常出现危险的地方。而我们就要在这里对调用函数进行一些详细的分析。这里我们分别对call()以及delegatecall()函数进行实验分析,之后对某些函数存在的上下文问题进行深入的理论探讨。

    在实验中,我部署了

    pragma solidity ^0.4.23;
    contract subFun {
    
        address public addr;
    
        function subTest() public returns (address a){
            addr = address(this);
            
     
        }
    }
    contract callAndDelegatecall {
        address public b;
        
        address public testaddress;
        constructor(address _address) public {
            testaddress = _address;
        }
        function withcall() public {
            testaddress.call(bytes4(keccak256("subTest()")));
        }
        function withdelegatecall() public {
            testaddress.delegatecall(bytes4(keccak256("subTest()")));
        }
    }
    

    并以此代码进行实验。

    1 call()函数

    image.png

    开始的时候,我们传入地址信息并对此函数进行部署。

    image.png

    由下图,我们通过此函数部署了两个contract。

    image.png

    此时,我们查看两个合约对应的addr参数的值,我们知道初始时的值均为Ox0000000。

    之后我们调用callAndDelegatecall合约中的withcall()函数,将addr的值更改后我们进行查看。

    image.png

    我们发现subFun合约中的地址被修改,而下面的地址仍然是0x00000。

    这就可以很好地说明,调用call()时,上下文环境是被调用的合约的环境。

    2 delegatecall()函数

    二者执行代码的上下文环境的不同,当使用call调用其它合约的函数时,代码是在被调用的合约的环境里执行,对应的,使用delegatecall进行函数调用时代码则是在调用函数的合约的环境里执行。

    对于delegatecall()函数来说,我们同样进行试验。

    image.png

    点击运行函数后,我们发现了不同的现象。

    image.png

    我们下面的地址有了改变。我们根据代码进行分析:

    由于我调用了

    testaddress.delegatecall(bytes4(keccak256("subTest()")));
    

    而这个函数远程调用了子合约中的函数。而我们之严重被改变的地址是父合约的。所以意味着码则是在调用函数的合约的环境里执行。

    所以进行总结,我们得出:

    • call: 最常用的调用方式,调用后内置变量 msg 的值会修改为调用者,执行环境为被调用者的运行环境(合约的 storage)。

    • delegatecall: 调用后内置变量 msg 的值不会修改为调用者,但执行环境为调用者的运行环境。

    三、实例分析

    根据我们上述代码的实验分析,我们知道由于delegatecall函数是在调用者环境中执行代码的,所以我们可以大胆的进行设想:倘若有某个官方系统的合约代码中存在某个接口能够传入参数,并且拥有delegatecall函数的调用可能。那么我们是否可以通过此来进行合约调用?(因为它的上下文环境是在本机)而下面,我们就要针对这个相关的问题进行delegatecall函数的综合利用。并根据EVM的机制漏洞来实验相关不安全代码。

    1 合约实例分析

    pragma solidity ^0.4.23;
    contract Subcontract {
        uint public start;
        uint public calculatedNumber;
    
        function setStart(uint _start) public {
            start = _start;
        }
    
        function setfun(uint n) public {
            calculatedNumber = test(n);
        }
    
        function test(uint n) internal returns (uint) {
    
            return start * n;
    
        }
    }
    
    
    contract Mastercontract {
        address public addr;
        uint public calculatedNumber = 1;
        uint public start = 1;
        uint public withdrawalCounter = 1;
        bytes4 constant fibSig = bytes4(keccak256("setfun(uint)"));
    
        constructor(address _fibonacciLibrary) public {
            fibonacciLibrary = _fibonacciLibrary;
        }
    
        function withdraw() public {
            withdrawalCounter += 1;
            require(addr.delegatecall(fibSig,withdrawalCounter),"something wrong");
            msg.sender.transfer(calculatedNumber * 1 ether);
        }
    
    
        }
    }
    

    分析上述合约,我们来看对应的函数。

    首先例子中存在一个Subcontract()合约,这个为子合约。而自合约中存在test函数,而我们能够看出来test函数中返回的值为传入的n值与start的值的乘积。而在setfun()函数中,我们调用test()函数赋值给变量calculatedNumber

    而我们再看主合约。对于以太币相关的东西,我们最应该关注的地方就是转账函数。而在withdraw函数中,我们存在msg.sender.transfer(calculatedNumber * 1 ether);函数。而在此函数中,合约会向调用者转账calculatedNumber * 1个以太币。所以倘若我们想增加转账数额,那么我们就需要提高calculatedNumber的值。

    而在我们的合约中,我们发现转账参数只有1。所以转账的数额很少。

    我们需要修改calculatedNumber的值,而我们并没有在主函数中发现修改其值的地方。然而,这个代码中却存在着很严重的问题。

    虽然我们不能直接修改calculatedNumber参数的值,但是我们发现了代码中存在函数调用require(addr.delegatecall(fibSig,withdrawalCounter),"something wrong");。那我们能否在这个地方做手脚呢?

    (此处是重点) 在子合约中我们定义了两个uint的变量start 与 calculatedNumber。而在Solidity存储机制中,他们两个被分别存储在slot[0]与slot[1]这两个位置。(代表以太坊虚拟机的两个空间)

    类似的,在Mastercontract合约中,addr 与calculatedNumber也被存储在slot[0]与slot[1]这两个位置。而根据我们上面的测试内容,delegatecall保留了合约的上下文,运行环境其实为本合约。这意味着通过delegatecall的代码将对主调用合约的状态(如存储)产生作用。

    也就是说,我使用delegatecall ()函数后由于是在主合约的上下文中,所以子合约将去寻找start,而在以太坊机制中,我们并不是通过名字来进行值的获取,而且根据位置了寻找。即库合约中的start的存储位置为slot[0],那么当使用delegatecall时,就是在主调用合约的slot[0]位置去找,但是在主调用合约中slot[0]位置的值为addr。也就是说,我们通过远程调用而改变了主函数中变量的值。

    下面我们看具体的代码实验。

    首先我们部署子合约:

    image.png

    之后我们传入子合约地址并部署master合约,之后得到

    image.png

    之后传递aaa的值为55555:

    image.png

    再次点击aaa后,我们查看更新后的值:

    image.png

    发现我们的合约fibonacciLibrary1的值被更改了。

    我们将代码放于此:

    pragma solidity ^0.4.23;
    contract Subcontract {
        
        uint public calculatedNumber;
        // uint public start = 99;
    
        // function setStart(uint _start) public {
        //     start = _start;
        // }
    
        function setfun(uint n) public {
            calculatedNumber =  n;
        }
    
    }
    
    
    contract Mastercontract {
       
        // uint public withdrawalCounter = 20;
        address public fibonacciLibrary1;
        address public fibonacciLibrary;
    
        
        bytes4 constant fibSig = bytes4(keccak256("setfun(uint256)"));
    
        constructor(address _fibonacciLibrary) public {
            fibonacciLibrary = _fibonacciLibrary;
            
        }
        function aaa(uint Counter) public {
           
            fibonacciLibrary.delegatecall(fibSig,Counter);
        }
    
    }
    

    我们发现我们并没有能够更改fibonacciLibrary1参数的入口,但是它确实被更改了。也就意味着我们使用delegatecall函数成功了。

    2 合约CTF题目分析

    下面,我们看一道改编后的ctf题目。

    在测试环境中,我们需要用到三个合约地址:

    image.png

    这三个合约地址分别部署子合约、父合约以及攻击合约。

    而下面我们看一下题目。

    pragma solidity ^0.4.23;
    import "github.com/Arachnid/solidity-stringutils/strings.sol";
    
    contract Ttest {
    
      address public addr1;
      address public addr2;
      address public owner; 
      using strings for *;
    
    
      bytes4 constant setTimeSignature = bytes4(keccak256("set(uint256)"));
    
      
    
      constructor(address _a, address _b) public {
        addr1 = _a; 
        addr2 = _b; 
        owner = msg.sender;
      }
    
    
      function First(uint _timeStamp) public {
        addr1.delegatecall(setTimeSignature, _timeStamp);
      }
    
    
      function Second(uint _timeStamp) public {
        addr2.delegatecall(setTimeSignature, _timeStamp);
      }
    
      function attack(string name) public returns(string){
        require (owner == msg.sender);
        string memory c = "Congratulations  attacker !!";
        
        return c.toSlice().concat(name.toSlice());
      }
    }
    
    
    contract Library {
    
    
      uint first;  
    
      function set(uint _time) public {
        first = _time;
      }
    }
    
    

    主合约中共有三个函数:First Second attack。而前两个函数用于调用子合约中的set函数。我们在attack()函数中看到,在内部需要require,即在执行此函数的过程中需要将我们的owner身份验证为调用者。(也就是说我攻击者需要将owner改成自己的地址才能攻击)

    所以我们根据上面提及的内容,进行分析。我们知道在First Second函数中存在delegatecall (),而我们知道这个函数是在运行函数方的上下文中进行的。所以我们根据上文提及的存储漏洞来进行合约攻击。

    首先部署好合约:

    image.png

    这里分别使用第一个地址与第二个地址部署。

    之后我们使用第三个地址部署攻击合约:

    image.png

    此时我们能够看到目前addr1与addr2变量对应的地址为子合约那个部分的地址。也就是说我现在调用函数会执行自合约部分的set函数。

    image.png

    之后我使用存储漏洞修改掉地址一。此时我们将attack部署在地址三上。然后传入attack合约地址于First函数中。

    image.png

    运行后查看得到:

    image.png

    此时,我们addr1的地址已经变成了部署attack的地方,也就是说此时倘若我运行First()函数,那么我们就会调用attack合约中的set()函数。而我们具体看一下set函数的内容:

      function set (uint _time) public {
          owner = tx.origin;
      }
    

    我们任意的传入参数,之后就会将owner更改为合约所有者——即attacker的地址。

    image.png

    此时我们调用了First函数,之后我们再看owner的变化。

    image.png

    它从0x14.......变成了0x4B......。也就是说它变成了我们攻击者的owner地址。

    image.png

    此时我们就可以调用Ttest合约中的attack()函数(因为已经绕过了owner)。得到:

    image.png

    至此,我们的攻击成功。

    四、参考链接

    本稿为原创稿件,转载请标明出处。谢谢。

    本文首发于先知社区,地址为:https://xz.aliyun.com/t/3606
    

    相关文章

      网友评论

        本文标题:区块链安全—合约存储机制安全分析

        本文链接:https://www.haomeiwen.com/subject/odmbhqtx.html