您正在查看: Surou 发布的文章

智能合约安全审计入门篇 —— 访问私有数据

了解如何访问合约中的私有数据(private 数据)

前置知识

我们先来了解一下 solidity 中的三种数据存储方式:

1. storage(存储)

  • storage 中的数据被永久存储。其以键值对的形式存储在 slot 插槽中。

  • storage 中的数据会被写在区块链中(因此它们会更改状态),这就是为什么使用存储非常昂贵的原因。

  • 占用 256 位插槽的 gas 成本为 20,000 gas。

  • 修改 storage 的值将花费 5,000 gas 。

  • 清理存储插槽时(即将非零字节设置为零),将退还一定量的 gas 。

  • storage 共有 2^256 个插槽,每个插槽 32 个字节数据按声明顺序依次存储,数据将会从每个插槽的右边开始存储,如果相邻变量适合单个 32 字节,然后它们被打包到同一个插槽中否则将会启用新的插槽来存储。

  • storage 中的数组的存储方式就比较独特了,首先,solidity 中的数组分为两种:

    • a.定长数组(长度固定):
      定长数组中的每个元素都会有一个独立的插槽来存储。以一个含有三个 uint64 元素的定长数组为例,下图可以清楚的看出其存储方式:
    • b.变长数组(长度随元素的数量而改变):
      变长数组的存储方式就很奇特,在遇到变长数组时,会先启用一个新的插槽 slotA 用来存储数组的长度,其数据存储在另外的编号为 slotV 的插槽中。slotA 表示变长数组声明的位置,用 length 表示变长数组的长度,用 slotV 表示变长数组数据存储的位置,用 value 表示变长数组某个数据的值,用 index 表示 value 对应的索引下标,则
      length = sload(slotA)
      slotV = keccak256(slotA) + index
      value = sload(slotV)

      变长数组在编译期间无法知道数组的长度,没办法提前预留存储空间,所以 Solidity 就用 slotA 位置存储了变长数组的长度。
      我们写一个简单的例子来验证上面描述的变长数组的存储方式:

      pragma solidity ^0.8.0;
      contract haha{
        uint[] user;
          function addUser(uint a) public returns (bytes memory){
              user.push(a);
              return abi.encode(user);
          }
      }

      部署这个合约后调用 addUser 函数并传入参数 a = 998,debug 后可以看出变长数组的存储方式:

      • 其中第一个插槽为(这里存储的是变长数组的长度):
        0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e563
        这个值等于:
        sha3("0x0000000000000000000000000000000000000000000000000000000000000000")
        key = 0 这是当前插槽的编号
        value = 1 这说明变长数组 user[] 中只有一条数据也就是数组长度为 1 ;
      • 第二个插槽为(这里存储的是变长数组中的数据):
        0x510e4e770828ddbf7f7b00ab00a9f6adaf81c0dc9cc85f1f8249c256942d61d9
        这个值等于:
        sha3("0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e563")
        插槽编号为:
        key=0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e563
        这个值等于:
        sha3("0x0000000000000000000000000000000000000000000000000000000000000000")+0
        插槽中存储的数据为:
        value=0x00000000000000000000000000000000000000000000000000000000000003e6
        也就是 16 进制表示的 998 ,也就是我们传入的 a 的值。
        为了更准确的验证我们再调用一次 addUser 函数并传入 a=999 可以得到下面的结果:

        这里我们可以看到新的插槽为:
        0x6c13d8c1c5df666ea9ca2a428504a3776c8ca01021c3a1524ca7d765f600979a
        这个值等于:
        sha3("0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e564")
        插槽编号为: key=0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e564
        这个值等于:
        sha3("0x0000000000000000000000000000000000000000000000000000000000000000")+1
        插槽中的存储数据为:
        value=0x00000000000000000000000000000000000000000000000000000000000003e7
        这个值就是 16 进制表示的 999 也就是我们刚刚调用 addUser 函数传入的 a 的值。
        通过上面的例子应该可以大致理解变长数组的存储方式了。

2. memory(内存)

  • memory 是一个字节数组,其插槽大小为 256 位(32 个字节)。数据仅在函数执行期间存储,执行完之后,将会被删除。它们不会保存到区块链中。
  • 读或写一个字节(256 位)需要 3 gas 。
  • 为了避免给矿工带来太多工作,在进行 22 次读写操作后,之后的读写成本开始上升。

3. calldata(调用数据)

  • calldata 是一个不可修改的,非持久性的区域,用于存储函数参数,并且其行为基本上类似于 memory。
  • 调用外部函数的参数需要 calldata,也可用于其他变量。
  • 它避免了复制,并确保了数据不能被修改。
  • 带有 calldata 数据位置的数组和结构体也可以从函数中返回,但是不可以为这种类型赋值。

可见性关键字

了解了 solidity 中的三种存储方式后我们再来了解一下合约中的四种可见性关键字:在 solidity 中,有四种可见性关键字:external,public,internal 和 private。默认时函数可见性为 public。对状态变量而言,除了不能用 external 来定义,其它三个都可以来定义变量,状态变量默认的可见性为 internal。

1. external 关键字

external 定义的外部函数可以被其它合约调用。用 external 修饰的外部函数 function() 不能作为内部函数直接调用,也就是说 function() 的调用方式必须用 this.function() 。

2. public 关键字

public 定义的函数可以被内部函数或外部消息调用。对用 public 定义的状态变量,系统会自动生成一个 getter 函数。

3. internal 用关键字

internal 定义的函数和状态变量只能在(当前合约或当前合约派生的合约)内部进行访问。

4. private 关键字

private 定义的函数和状态变量只对定义它的合约可见,该合约派生的合约都不能调用和访问该函数及状态变量。

综上可知,合约中修饰变量存储的关键字仅仅限制了其调用的范围,并没有限制其是否可读。所以我们今天就来带大家了解如何读取合约中的所有数据。

漏洞示例

这次我们的目标合约是部署在 Ropsten 上的一个合约。
合约地址:
0x3505a02BCDFbb225988161a95528bfDb279faD6b
链接:
https://ropsten.etherscan.io/address/0x3505a02BCDFbb225988161a95528bfDb279faD6b#code
这里我也给大家把合约源码展示出来:

contract Vault {
    uint256 public count = 123;
    address public owner = msg.sender;
    bool public isTrue = true;
    uint16 public u16 = 31;
    bytes32 private password;
    uint256 public constant someConst = 123;
    bytes32[3] public data;
    struct User {
        uint256 id;
        bytes32 password;
    }
    User[] private users;
    mapping(uint256 => User) private idToUser;

    constructor(bytes32 _password) {
        password = _password;
    }

    function addUser(bytes32 _password) public {
        User memory user = User({id: users.length, password: _password});
        users.push(user);
        idToUser[user.id] = user;
    }

    function getArrayLocation(
        uint256 slot,
        uint256 index,
        uint256 elementSize
    ) public pure returns (uint256) {
        return
            uint256(keccak256(abi.encodePacked(slot))) + (index * elementSize);
    }

    function getMapLocation(uint256 slot, uint256 key)
        public
        pure
        returns (uint256)
    {
        return uint256(keccak256(abi.encodePacked(key, slot)));
    }
}

漏洞分析

由上面的合约代码我们可以看到,Vault 合约将用户的用户名和密码这样的敏感数据记录在了合约中,由前置知识中我们可以了解到,合约中修饰变量的关键字仅限制其调用范围,这也就间接证明了合约中的数据均是公开的,可任意读取的,将敏感数据记录在合约中是不安全的。

读取数据

下面我们就带大家来读取这个合约中的数据。首先我们先看 slot0 中的数据:
由合约中可以看到 slot0 中只存储了一个 uint 类型的数据,我们读取出来看一下:
我这里使用 Web3.py 取得数据
首先写好程序

运行后得到

我们使用进制转换器转换一下

这里我们就成功的去到了合约中的第一个插槽 slot0 中存储的 uint 类型的变量 count=123 ,下面我们继续:
slot1 中存储三个变量:u16, isTrue, owner


从右往左依次为

owner = f36467c4e023c355026066b8dc51456e7b791d99
isTrue = 01 = true
u16 = 1f = 31

slot2 中就存储着私有变量 password 我们读取看看


slot 3, 4, 5 中存储着定长数组中的三个元素


slot6 中存储着变长数组的长度


我们从合约代码中可以看到用户的 id 和 password 是由键值对的形式存储的,下面我们来读取两个用户的 id 和 password:

  • user1

  • user2

好了,这里我们就成功的将合约中的所有数据读取完成,现在大家应该都能得出一个结论:合约中的私有数据也是可以读取的。

修复建议

  1. 作为开发者
    不要将任何敏感数据存放在合约中,因为合约中的任何数据都可被读取。
  2. 作为审计者
    在审计过程中应当注意合约中是否存在敏感数据,例如:秘钥,游戏通关口令等。

参考文献

本期讲解的知识有点偏底层,可以参考以下文章帮助你更好地理解:

多签钱包的工作原理与使用方式

多签名钱包常被缩写为 “Multisig wallet”,多签钱包最大的特点是需由多个私钥持有者的授权才能进行钱包交易。本文会为你详细介绍什么是多签钱包、多签钱包的应用场景、多签钱包的工作原理及Gnosis Safe 多签钱包的使用流程

什么是多签钱包?

多签名钱包常被缩写为 “Multisig wallet”,与多签钱包对应的是单签钱包,我们要往区块链上发送一笔转账交易,需要去用钱包去做一个签名,我们自己签好名把交易发送出去,交易执行成功转账就成功,这就是典型的单签钱包,也是我们平时使用最多的钱包。

多签钱包,顾名思义,就是需要多个人去签名执行某个操作的钱包。使用多签钱包进行转账,往往需要 >= 1 个人去签名发送交易之后,转账操作才真正完成。使用多签钱包时,我们可以指定 m/n 的签名模式,就是 n 个人里面有 m 个人签名即可完成操作。可以根据自己的需求设置多签规则,例如:

  • 1/2多签模式:两个互相信任的朋友或自己的两个钱包,可以凭各自的私钥独立发起交易(类似于合伙账户)。
  • 2/2多签模式:金库中的资金需要2个管理员均同意才能动用这笔资金(需要两个私钥才能转移资金)。
  • 2/3多签模式:三个合伙人共同管理资金,为了规避私钥丢失的风险,其中两个私钥签名就可以转移资金。

当然,还有1/3多签、3/6多签、5/8多签不同规则的多签方案,规则是按需的。多签钱包最大的特点是需由多个私钥持有者的授权才能进行钱包交易。我们讲了这么多签名规则,那多签钱包的应用场景是什么呢?

多签钱包的应用场景

多签钱包最常见的应用场景是需求强安全性的个人,以及管理公共资产的投资机构、交易所以及项目方。

1. 资金安全

资金的安全也可以理解为私钥的安全,有一些常见的方案如使用硬件钱包来防止私钥泄露,使用助记词密盒来防止私钥遗忘等等,但依然存在“单点故障”的问题。

在单签钱包中,加密资产的所有权和管理员是在单人手中,一但私钥泄露或遗忘就意味着失去了对钱包的控制权,与之关联的加密资产将完全丢失。而多签钱包的存在,就很大程度上降低了资产损失的风险。以2/3多签模式为例,在全部的3个私钥中,只要有2个私钥完成签名授权就能完成加密资产的转移。

对于个人而言,可以通过一个多签钱包,关联多个钱包地址,分布在多处(类似异地多活、同城多机房),一个放在MetaMask浏览器扩展、一个安装在手机钱包App、一个在冷钱包,需要转移加密资产时只需要用其中的两个钱包共同签名即可。当然为了方便的话,可以使用1/3多签模式,这就类似于把同一个私钥记在三个助记词卡上放在多处一样,但这种方式仅仅是降低了密钥丢失的风险。

2. 资金共管

很多DeFi 协议/DAO 组织/区块链团队其实都有自己的金库,金库里的资产是不能由任何一个人直接动用的,每次动用都要经过多数人的同意或社区投票。这时使用多签钱包来保存金库资产是再合适不过了。

3. 多签操作

在目前这个发展阶段,很多去中心化协议其实都是有个管理员权限的,这个管理员权限往往可以更改协议的某些关键参数。行业普遍做法是把这个管理员权限交给一个多签钱包或时间锁,当需要更改参数时,需要多个人共同签署相关操作。

多签钱包的工作原理

上文中提到的n/m多签方式,多个私钥对应一个多签钱包,这个多签钱包是如何实现的呢?

我们常说的多签主要针对的是比特币和以太坊ERC-20标准代币。在比特币中有2种类型的地址,1开头的是P2PKH表示个人地址,3开头的是P2SH一般表示一个多签地址。普通的比特币地址是由公钥做哈希后得到的,而多重签名地址基于脚本哈希,所以能够实现复杂的交易逻辑。所以在原生上比特币就支持多签。而以太坊原生并不支持多签地址,通常需要依靠智能合约来实现这一机制。因此,比特币多签钱包技术上要更容易实现,也更常见。
在以太坊中,多签钱包往往是一个智能合约。我们以 Gnosis 的一个多签钱包地址的合约为例进行简要阐述,图中截取了核心流程的主要代码,详细可查看:0xcafE1A77e84698c83CA8931F54A755176eF75f2C (如果非开发者可以略过本章节继续往下看)

1. 构造多签合约的调用者权限

图中的 constructor 构造方法是合约创建时触发调用的,通过传入 onwers 参数传入授权的多个钱包地址,以及 required 参数表示最少签名人数。
即以M/N多签模式为例,N表示 owners.length ,N表示 required

2. 提交多签钱包交易申请

图中 submitTransaction 方法的作用是多签名人任一一方提交交易申请,返回一个交易号(transactionId 后面会用到)。参数 destination 是接受人的钱包地址,value 为转出的 ether 数量(以 wei 为单位),data 是该交易的数据。

前两个参数比较好理解,向某地址转出多少资产,data 参数可以传入任意数组来实现任意功能,比如如果转出ETH那么此参数是[] (空),如果转出ERC20代码(如USDT),则此参数是ERC20 transfer 方法的哈希和参数 ([0]:xxxxx [1]:xxxxx)。

3. 其余签名人对交易确认

图中的 confirmTransaction 方法的作用是其他参与签名的人发起确认以表示对某个交易执行的认可。参数就是 submitTransaction 流程里提交交易申请时产生的交易号。当然参与者也可以拒绝认可,还有一个 revokeConfirmation 方法来提供拒绝的行为在图中没有体现,可以去合约代码里查看。

4. 正式执行交易操作

当确认的人数达到最低(required)要求,executeTransaction 的内部逻辑将被触发,从而执行第一步用户所提交的逻辑。当 executeTransaction 内部逻辑被触发,即完成了多签合约的真正调用,如上图所述,value 和 data 可以控制多签执行任意逻辑(转移 ether 或 ERC20 代币等)。

常用的多签钱包有哪些?

这一章节并非做多签钱包的推荐,我只罗列出我用过的两个多签钱包,并通过使用流程的介绍来辅助理解合约代码中的逻辑。

Gnosis Safe 是一款为钱包提供多签功能的智能合约。使用不同加密钱包的用户可以在Gnosis Safe 网页端创建一个多签账户,将需要共管的资产存入这一多签账户并进行相应的多签交易,Gnosis Safe本身并不掌握任何私钥。目前,Gnosis Safe支持以太坊网络、币安智能链网络以及Polygon网络等12个网络的多签,支持币种包括ETH、ERC20标准代币以及ERC721标准代币等。

Gnosis Safe的优点是多签参与者不用再额外注册统一的多签钱包,使用现有的加密钱包就可以完成多签步骤;缺点是该智能合约直接部署在区块链上,每一次交互都是链上的一次交易,即创建钱包、多签过程中的每一次签名授权都要支付一笔Gas费用。每次支付的费用都会根据当时的网络情况、参与人数、交易复杂程度发生变化。

接下来我以 Gnosis Safe 钱包来演示一下多签钱包的使用流程,作为有多签钱包需求读者的入门教程,使用过多签的读者可以略过。

1. 创建多签钱包

创建多签钱包的过程很简单,输入钱包名称和参与签名的钱包地址即可,我在 Polygon 网络(以太坊侧链,Gas比较便宜)演示 2/2 签名模式,即多签钱包对应2个签名者,且两个签名者均同意才能转出资产。

对于新的用户需要额外说明的是,在我们创建普通钱包(或叫外部账户,以太坊的账户分为外部账户和合约账户)时,只是在钱包的客户端通过一定的加密算法在客户端本地生成的钱包地址(没有上链,只有产生了交易才会在链上有了关联),所以普通钱包是不需要支持Gas费的,而多签钱包本质上是一个部署在链上的智能合约,而部署合约就像发起转账一样会产生一笔交易,所以需要支出Gas费用来奖励旷工确认这笔交易。

创建多签合约时的交易:https://polygonscan.com/tx/0x78dee97d40ea5e45c4b2d08d878694d075be76bd34dfb01508afae9b9bf34f73

注意:从创建钱包,到付款和收款,均重点关注选择的网络(本示例为 Polygon 网络),一定注意!!



2. 通过多签钱包收款

这一步不做过多说明,多签钱包的合约和普通钱包一样具有收款的能力,只是在转出机制不同:

  • 普通钱包地址是通过在钱包客户端本地对交易进行签名然后广播上链
  • 合约地址是需要触发合约公开的方法通过合约执行交易行为



为了演示用多签钱包付款,这里先往里转入小额的 $MATIC:
https://polygonscan.com/tx/0xff65a58854d42610dc531b9a0f0efff22ca7e97def6e49f9eccd1011fa0c569b

3. 通过多签钱包付款

这是比较重要的步骤,感兴趣的读者可以结合上面的代码示例来理解

i. 任意签名人发起一笔转账申请

这一步对应到合约里的 submitTransaction 方法,发起一笔交易申请,但资产没有真正开始转移,需要其他的参与者进行确认这笔交易申请。
这一步的操作是 签名人A 在浏览器通过 MetaMask 钱包登录 Gnosis Safe,选择对应的网络,发起付款操作(填入转出的钱包地址和金额)


ii. 其他参与者对这笔转账申请进行确认

这一步会演示两个流程:参与确认、执行转账,对应到合约代码里的就是 confirmTransaction 和 executeTransaction 方法。

以这个 2/2 多签模式,上一步签名人A发起了一笔申请, 此时签名人B 在另外一个浏览器(模拟两个不同的参与人)同样通过 MetaMask 钱包登录 Gnosis Safe,会在交易中看到签名人A发起的待确认的交易,然后执行确认交易。此时因为已经两个人参与,达到了最少参与人的要求,所以参与人B在确认时就会触发真正的转账行为。

此时的确认操作即调用了合约发起转账(tx.destination.call.value(tx.value)(tx.data)),执行转账的方法(调用合约的写方法)会产生Gas的消耗,即最后的确认者需要支付本次交易的手续费(是不是有点冤)。

对应的链上交易:https://polygonscan.com/tx/0x484f32a722dec98bd8ca9ac508bc8c846a663a1ac5500fbbde38d53a13d1f71d



以上,就是多签钱包的介绍、使用场景、工作原理、操作流程的全部内容,感谢阅读。如果有问题交流,可以关注并私信我:微信(jingwentian)、Twitter(@0xDaotian)、微信公众号(北极之野)、Substack邮件订阅(文叔白话WEB3)。
转载:https://learnblockchain.cn/article/4077

解析 Solidity 0.6 新引入的 try/catch 特性

在以太坊中对智能合约进行编程与常规开发人员所用的编程有很大不同,并且缺乏基本处理错误工具一直是一个问题,经常导致智能合约逻辑“破裂”。

当EVM中的交易执行触发revert时,所有状态更改都将回滚并中止执行。 因此,来自现代编程语言的开发人员在编写Solidity时,都可能最终会搜索“如何在Solidity中try/catch”来处理这些回滚。

Solidity 0.6的新功能里最酷的功能之一是使用try/catch语句。try/catch 的文档可以点击这里查看

为什么需要 try / catch

try/catch 结构可以用于许多场景:

  • 如果一个调用回滚(revert)了,我们不想终止交易的执行。
  • 我们想在同一个交易中重试调用、存储错误状态、对失败的调用做出处理等等。

在 Solidity 0.6 之前,模拟 try/catch 仅有的方式是使用低级的调用如: call, delegatecall 和 staticcall.
这是一个简单的示例,说明如何内部调用同一合约的另一个函数中实现某种try/catch:

pragma solidity <0.6.0;

contract OldTryCatch {

    function execute(uint256 amount) external {

        // 如果执行失败,低级的call会返回 `false` 
        (bool success, bytes memory returnData) = address(this).call(
            abi.encodeWithSignature(
                "onlyEven(uint256)",
                amount
            )
        );

        if (success) {
            // handle success            
        } else {
            // handle exception
        }
    }

    function onlyEven(uint256 a) public {
        // Code that can revert
        require(a % 2 == 0, "Ups! Reverting");
        // ...
    }
}

当调用 execute(uint256 amount), 输入的参数 amount 会通过低级的call调用传给 onlyEven(uint256) 函数, call 调用会返回 bool 值作为第一个参数来指示调用的成功与否,而不会让整个交易失败。参考文档: Solidity中文 文档-地址类型的成员

请注意,在对 onlyEven(uint256) 的低级call调用返回了false的情况下,它会(必需)还原在低级调用执行中所做的状态更改,但是在调用之前和/或之后的更改不会被还原应用。
这种 try/catch的定制实现虽然有点脆弱,但它既可以用于从同一合约(如刚刚的例子)中调用函数,也可以(更多的)用于外部合约的调用。
这是控制外部调用的错误的一种有用方法,但我们应始终记住,由于执行我们不能信任的外部代码可能会出现安全性问题,因此不建议使用低级调用。
这是为什么 try/catch 特性用于外部调用引入 ,在最新的编译器中,可以这样写:

pragma solidity <0.7.0;

contract CalledContract {    
    function someFunction() external {
        // Code that reverts
        revert();
    }
}


contract TryCatcher {

    event CatchEvent();
    event SuccessEvent();

    CalledContract public externalContract;

    constructor() public {
        externalContract = new CalledContract();
    }

    function execute() external {

        try externalContract.someFunction() {
            // Do something if the call succeeds
            emit SuccessEvent();
        } catch {
            // Do something in any other case
            emit CatchEvent();
        }
    }
}

try/catch 概述

就像前面提到的,新功能 try/catch 仅适用于外部调用。参考文档: Solidity中文 文档-外部调用
如果我们想在合同中的内部调用中使用try/catch模式(如第一个示例),我们仍然可以使用前面介绍的低级调用的方法,或者可以使用全局变量this来调用内部函数,就像外部调用一样。

如上面的例子:

try this.onlyEven(3) {
    ...
} catch {
    ...
}

如果尝试使用新语法进行低级调用,则编译器将提示错误:

每当我们尝试使用try/catch语法进行低级调用时,编译器都会返回TypeError错误提示。
如果仔细阅读了编译器错误信息,则TypeError提示会指出,try/catch甚至可以用于创建合约,让我们来尝试一下:

pragma solidity <0.7.0;

contract CalledContract {

    constructor() public {
        // Code that reverts
        revert();
    }

    // ...
}

contract TryCatcher {

    // ...

    function execute() public {

        try new CalledContract() {
            emit SuccessEvent();
        } catch {
            emit CatchEvent();
        }
    }
}

要注意,在try代码块内的任何内容仍然可以停止执行, try仅针对于call。 例如,在try 成功后,依旧可以 revert 交易,例如下面的例子:

function execute() public {

    try externalContract.someFunction() {
        // 尽管外部调用成功了, 依旧可以回退交易。
        revert();
    } catch {
       ...
    }

因此,请注意:try 代码块内的 revert 不会被catch本身捕获。

返回值和作用域内变量
Try / catch 允许使用从外部调用返回值和作用域内变量。

构造调用的例子:

contract TryCatcher {

    // ...

    function execute() public {

        try new CalledContract() returns(CalledContract returnedInstance) {
            // returnedInstance  是新部署合约的地址
            emit SuccessEvent();
        } catch {
            emit CatchEvent();
        }
    }
}

外部调用:

contract CalledContract {    
    function getTwo() public returns (uint256) {
        return 2;
    }
}

contract TryCatcher {

    CalledContract public externalContract;

    // ...

    function execute() public returns (uint256, bool) {

        try externalContract.getTwo() returns (uint256 v) {
            uint256 newValue = v + 2;
            return (newValue, true);
        } catch {
            emit CatchEvent();
        }

        // ...
    }
}

注意本地变量newValue 和返回值只在 try 代码块内有效。这同样适用于在catch块内声明的任何变量。
要在catch语句中使用返回值,我们可以使用以下语法:

contract TryCatcher {

    event ReturnDataEvent(bytes someData);

    // ...

    function execute() public returns (uint256, bool) {

        try externalContract.someFunction() {
            // ...
        } catch (bytes memory returnData) {            
            emit ReturnDataEvent(returnData);
        }
    }
}

外部调用返回的数据将转换为bytes ,并可以在catch 块内进行访问。 注意,该catch 中考虑了各种可能的 revert 原因,并且如果由于某种原因解码返回数据失败,则将在调用合约的上下文中产生该失败-因此执行try/catch的交易也会失败。

指定 catch 条件子句

Solidity 的 try/catch也可以包括特定的catch条件子句。 已经可以使用的第一个特定的catch条件子句是:

contract TryCatcher {

    event ReturnDataEvent(bytes someData);
    event CatchStringEvent(string someString);
    event SuccessEvent();

    // ...

    function execute() public {

        try externalContract.someFunction() {
            emit SuccessEvent();
        } catch Error(string memory revertReason) {
            emit CatchStringEvent(revertReason);
        } catch (bytes memory returnData) {
            emit ReturnDataEvent(returnData);
        }
    }
}

在这里,如果还原是由require(condition,"reason string")或revert("reason string")引起的,则错误签名与catch Error(string memory revertReason)子句匹配,然后与之匹配块被执行。 在任何其他情况下,(例如, assert失败)都会执行更通用的 catch (bytes memory returnData) 子句。

注意,catch Error(string memory revertReason)不能捕获除上述两种情况以外的任何错误。 如果我们仅使用它(不使用其他子句),最终将丢失一些错误。 通常,必须将catch或catch(bytes memory returnData)与catch Error(string memory revertReason)一起使用,以确保我们涵盖了所有可能的revert原因。

在一些特定的情况下,如果catch Error(string memory revertReason) 解码返回的字符串失败,catch(bytes memory returnData)(如果存在)将能够捕获它。

计划在将来的Solidity版本中使用更多条件的catch子句。

Gas 失败

如果交易没有足够的gas执行,则out of gas error 是不能捕获到的。

在某些情况下,我们可能需要为外部调用指定gas,因此,即使交易中有足够的gas,如果外部调用的执行需要的gas比我们设置的多,内部out of gas 错误可能会被低级的catch子句捕获。

pragma solidity <0.7.0;

contract CalledContract {

    function someFunction() public returns (uint256) {
        require(true, "This time not reverting");
    }
}

contract TryCatcher {

    event ReturnDataEvent(bytes someData);
    event SuccessEvent();

    CalledContract public externalContract;

    constructor() public {
        externalContract = new CalledContract();
    }

    function execute() public {
 // Setting gas to 20
        try externalContract.someFunction.gas(20)() {
            // ...
        } catch Error(string memory revertReason) {
            // ...
        } catch (bytes memory returnData) {
            emit ReturnDataEvent(returnData);
        }
    }
}

当gas设置为20时,try调用的执行将用掉所有的 gas,最后一个catch语句将捕获异常:catch (bytes memory returnData)。 相反,将gas设置为更大的量(例如:2000)将执行try块会成功。

结论

  • 总结一下,这里是使用Solidity新添加的try/catch时要记住的事项:
  • 这是仅仅提供给外部调用的特性,如上所述。部署新合约也被视为外部调用。
  • 该功能能够捕获仅在调用内部产生的异常。调用后的 try 代码块是在成功之后执行。不会捕获try 代码块中的任何异常。
  • 如果函数调用返回一些变量,则可以在以下执行块中使用它们(如以上示例中所述)。
    • 如果执行了 try 成功代码块,则必须声明与函数调用实际返回值相同类型的变量。
    • 如果执行了低级的catch块,则返回值是类型为bytes的变量。任何特定条件的catch子句都有其自己的返回值类型。
  • 请记住,低级catch (bytes memory returnData) 子句能够捕获所有异常,而特定条件的catch子句只捕获对应的错误。处理各种异常时,请考虑同时使用两者。
  • 在为 try 外部调用设置特定的gas使用量时,低级的catch子句会捕获最终的out of gas错误。 但如果交易本身没有足够的 gas执行代码,则out of gas是没法捕获的。

本文翻译自 openzeppelin 论坛,原文
转载:https://learnblockchain.cn/article/868

Solidity进阶之静态分析

静态分析是相对容易掌握的工具,对开发复杂的Defi应用非常有帮助。

简介

合约的安全性自动化检测有静态分析、动态分析和形式化验证。静态分析不执行合约代码,通过对合约代码做模式匹配或者语义分析来检测漏洞。动态分析需要执行合约,通过大量的模糊测试来观察合约的状态是否会出现问题。形式化验证是将合约的业务逻辑用数学表达式来描述,只要证明数学表达式是正确的,则合约的业务逻辑也是正确的(不代表合约的实现没有问题)。

静态分析的优点是使用简单,速度快,但只能检测已知的安全漏洞。动态分析能检测出未知的安全问题,但是成本高、速度慢。形式化验证的使用范围窄,比较适用于一些公共库合约。

开发者对合约做静态分析是最基本的要求,使用静态分析工具可以快速检测是否存在一些常见的漏洞,比如:

  • 权限缺失,比如Oracle的更新没有设置权限
  • 重入,这个出的问题最多
  • 整数溢出
  • DDOS,攻击或者缺陷会导致合约无法执行正常的业务逻辑
  • 价格操纵

但静态分析工具不能检测出跟业务逻辑特定相关的问题,还需要开发人员通过自检去做人工静态分析。

Solhint

Solhint能提供一些代码规范和安全检查,一些推荐的代码规范比如:

  • 对字符串使用双引号
  • 使用驼峰命名规则
  • 明确指定状态变量的可见性
  • 避免使用call,delegatecall等底层操作码
  • 避免使用tx.origin
  • 同一个方法中避免多次使用msg.value
  • 避免使用block.number和block.timestamp

Solhint的能力较弱,只能做到语法层面的一些检查,但对规范代码比较有用。

Semgrep

Semgrep是一个通用型的静态分析工具,支持多种语言,对solidity的支持目前还较弱。Semgrep跟Solhint一样也是采用模式匹配来进行检测,Solhint的规则是内置的,Semgrep能自定义规则。比如下面这个规则compound-sweeptoken-not-restricted

rules:
 -
    id: compound-sweeptoken-not-restricted
    message: function sweepToken is allowed to be called by anyone
    metadata:
        references:
        - https://medium.com/chainsecurity/trueusd-compound-vulnerability-bc5b696d29e2
        - https://chainsecurity.com/security-audit/compound-ctoken/
        - https://blog.openzeppelin.com/compound-comprehensive-protocol-audit/
        - https://etherscan.io/address/0xa035b9e130f2b1aedc733eefb1c67ba4c503491f # Compound
        category: access-control
        tags:
        - compound
        - tusd
    patterns:
    - pattern-inside: |
        function sweepToken(...) {
        ...
        }
    - pattern: token.transfer(...);
    - pattern-not-inside: |
        require(msg.sender == admin, "...");
        ...
    languages: 
    - solidity
    severity: WARNING

这个规则专门针对的Compound曾经出现过的TUSD漏洞,由于有很多其它链的项目fork了Compound,因此这个规则可以快速检测出这些项目是否有类似的问题。

Slither

Slither的功能包括:

  • 漏洞自动检测
  • 提供代码优化建议
  • 展现代码的拓扑结构
  • 通过API能自定义漏洞检测规则

Slither的原理是将Solidty抽象语法树(AST)作为输入:

  • 第一步,先解析出合约间的继承图、控制循环图(CFG)和表达式。
  • 第二步,将合约代码转换成SlitherIR(一种内部表达码)。
  • 第三步,对SlitherIR执行一系列单一静态分析(SSA)来完成漏洞检测。

Solhint和Semgrep都是在语法级别进行规则匹配,相比而言Slither能在语义级别进行分析。Slither也可以通过插件来实现自定义的漏洞检测规则,实现上要比Semgrep这种配置文件的方式复杂点。

自检

相对于自动检测工具而言,开发者的自检能完成更复杂的静态分析。比如

address[] public minters;
function setMinter() external {
    minters.push(msg.sender);
}

静态分析工具没法知道修改minters这个状态变量需要什么权限,因为这属于业务逻辑的范围。再比如

if (a > 100) {
    b++;
}

如果开发者误将a >= 100写成了a > 100,这种业务逻辑错误静态分析工具也没法处理。

合约的业务逻辑都是主要在接口中实现的,因此接口检查就很重要:

  • 参数是否有校验,尤其是需要注意是否有任意输入。
  • 接口必须是external或者public吗?如果把一个internal或者private接口暴露出去会非常危险。
  • 需要加payable吗?不加的话没法接收eth,但是若无必要则一定不要加。
  • 接口会修改状态变量吗?修改这些变量需要权限吗?这一点往往是静态分析工具无法检测到的漏洞。
  • 通过call或者delegatecall的调用对象是可信的吗?
  • 外部调用需要设置gasLimit吗?外表调用的返回结果需要处理吗?
  • 在有外部调用的代码前后遵守了Checks-Effects-Interactions规范吗?
  • 外部调用能重入到合约中的其它接口然后通过旁路回到本接口吗?

如果接口涉及到Token的转移,则需要的检查有:

  • 如果Token转移过程中内部扣费会影响业务逻辑吗?
  • Token转移过程中会有钩子函数回调发送者或者接收者吗?
  • Token如果是可升级合约,对业务逻辑有影响吗?
  • 有使用地址的eth余额参与控制逻辑吗?eth的余额是可以通过挖矿或者selfdestruct强制增加的。

对于借贷相关的合约,一般需要使用价格,则需要的检查有:

  • Offchain oracle是可靠的吗?
  • Onchain oracle的价格容易被操纵吗?
  • LP token的价格计算算法是正确的吗?

总之,所有的检查都围绕几个核心:

  • 敏感的权限是否能被转移到任意地址
  • 资产是否有可能被较小的代价转走
  • 资产是否有可能无法取出

结束语

静态分析是相对容易掌握的工具,对开发复杂的Defi应用非常有帮助。不同的静态分析工具可以结合使用,可以先使用Solhint来规范代码,然后使用Semgrep来识别已知的漏洞,接着使用Slither来识别一些语义级别的问题。

开发者更需要自己检查代码,最好是邀请同行互审。最后还是需要审计机构审计代码,不过也不要迷信审计机构,尤其是当Defi的业务逻辑比较复杂的时候,审计机构不一定能精确地理解每一个业务逻辑。

转载自:https://learnblockchain.cn/article/4270

Solidity智能合约安全:防止重入攻击的4种方法

使用检查、影响和交互模式(简称CEI:Checks, Effects, and Interactions)、互斥锁、Pull 支付方式以及gas限制都是防止可重入攻击的有效技术。

Solidity智能合约安全:防止重入攻击的4种方法

重入是一种编程技术,在这种技术中,一个函数(“A”)的执行被一个外部函数调用打断,在外部函数调用的逻辑中,能够递归地调用原函数(“A”)。在某些情况下,重复地重新进入一个函数来执行外部逻辑可能是可取的,不一定是错误。然而,这种技术不建议用于智能合约,因为它将控制流执行释放给不受信任的合约,而器可能用于盗取资金 。因此,在执行对外部合约的调用时,应使用反重入模式和防护措施来防止这种类型的攻击发生。

有三种主要技术来防止重入:

  • 使用 检查、影响、交互(CEI)
  • 使用 防重入互斥锁
  • 使用Pull 支付方式

此外,还有一种技术可能是有效的,但不推荐使用,他是使用 Gas Limit 限制。

检查、影响、交互

CEI模式是一种简单而有效的防止重入的方法。检查指的是判断是否符合条件(验证真实性)。影响指的是由交互产生的状态修改。最后,交互指的是函数或合约之间的调用。

下面是一个错误的示范(因为交互在影响之前)

// contract_A: holds user's funds

function withdraw() external {
  uint userBalance = userBalances[msg.sender];

  require(userBalance > 0);

  (bool success,) = msg.sender.call{ value: userBalance }("");
  require(success,);

  userBalances[msg.sender] = 0;
}

这里是攻击者的receive函数

// contract_B: reentrancy attack

receive() external payable {
  if (address(contract_A).balance >= msg.value) {
    contract_A.withdraw();
  }
}

攻击者的receive函数收到提款后,本应该只返回success,但却检查contract_A是否包含更多的资金。如果是,contract_B会再次调用提款函数,递归直到contract_A所有资金用完。

下面是一个使用CEI模式的提款函数的例子:

function withdraw() external {
  uint userBalance = userBalances[msg.sender];

  require(userBalance > 0);
  userBalances[msg.sender] = 0;

  (bool success,) = msg.sender.call{ value: userBalance }("");
  require(success,);
}

通过在向contract_A转移资金之前将用户在contract_B的账户余额清零,当contract_B发起重入攻击时,提取函数中的条件将为假,执行将被回退。正如这个案例所强调的那样,一行代码的位置可能引起有重大漏洞与重入性安全之间的巨大区别。

重入保护互斥锁

重入防护互斥锁(mutex)可以被构造成一个函数或函数修改器,但其逻辑很简单:一个布尔锁被放置在易受重入影响的函数调用周围。locked的初始状态为假(unlocked),在易受攻击的函数执行开始前,它被立即设置为真(locked),然后在其终止后被设置回假(unlocked)。

下面是一个使用上面的提款函数的例子:

bool internal locked = false;

function withdraw() external {
  require(!locked);
  locked = true;

  uint userBalance = userBalances[msg.sender];
  require(userBalance > 0);
  (bool success,) = msg.sender.call{ value: userBalance }("");
  require(success,);
  userBalances[msg.sender] = 0;

  locked = false;
}

虽然这个提款函数没有遵循CEI模式,但简单的布尔 locked 变量可以防止重入,因此同样可以防止重入攻击。
因为在重入时,第一个require语句条件将为false,会回退交易。

Pull(拉) 方式支付

最后这种技术被Open Zeppelin推荐为最佳实践。然而,它在自动化方面有一个小小的折衷。Pull方式支付是通过中间托管账号发送资金来避免直接和潜在的危险合约交互来实现安全。

在这里,合约资金被发送到一个中间托管账号。

function sendPayment(address user, address escrow) external {
  require(msg.sender == authorized);

  uint userBalance = userBalances[user];

  require(userBalance > 0);

  userBalances[user] = 0;

  (bool success,) = escrow.call{ value: userBalance }("");
  require(success,);
}

而在托管账户的资金,则由接收方来提取:

function pullPayment() external {
  require(msg.sender == receiver);

  uint payment = account(this).balance;

  (bool success,) = msg.sender.call{ value: payment }("");
  require(success,);
}

通过中间托管账号发送资金,合约资金受到保护,不会受到重入攻击。如果托管人持有多个账户的资金,可能会受到重入攻击,所以在适用的情况下,应该实现CEI模式和(或)重入保护互斥锁。

Gas Limit限制

最后,通过Gaslimit 限制也可以防止重入攻击,但这不应该被视为一种安全策略,因为Gas成本取决于以太坊的操作码,而操作码gas是可以改变的。另一方面,智能合约代码是不可改变的。不过, send, transfer, 和 call 这些函数之间的区别是值得了解的。

send和 transfer函数本质上是相同的,但如果交易失败,transfer会回退,而send则不会,而是会返回 false。

// transfer will revert if the transaction fails
address(receiver).transfer(amount);

// send will not revert if the transaction fails
address(receiver).send(amount);

关于重入问题, send和 transfer都有2300个单位的Gas限制。使用这些函数应该可以防止重入性攻击的发生,因为他们没有足够的Gas来递归调用到原函数来利用资金。

与 send和 transfer不同,call没有Gas限制,为了执行复杂的多合约交易(当然,也包括重入攻击),会转发其所有剩余Gas。

结论

一次成功的重入攻击后果可能是毁灭性的,可能会耗尽受害者合约中的所有资金,因此,意识到潜在的漏洞并实现有效的保障措施是很重要的。

无论是否存在漏洞,CEI模式都应该被默认实现,这只是一种良好的做法。额外的安全可以通过使用重入锁和(或)Pull支付方式来完成。最后,Gas限制可以防止重入,但不应该被视为一种安全策略。

本翻译由 Duet Protocol 赞助支持。
转载:https://learnblockchain.cn/article/4162