一、前言
作为不太成熟的编程语言,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()")));
}
}
并以此代码进行实验。
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的机制漏洞来实验相关不安全代码。
合约实例分析
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)。得到:
至此,我们的攻击成功。
四、参考链接
https://my.oschina.net/u/3794778/blog/1800631
https://paper.seebug.org/633/#0x03-call
https://blog.riskivy.com/%E6%99%BA%E8%83%BD%E5%90%88%E7%BA%A6ctf%EF%BC%9Aethernaut-writeup-part-4/