您正在查看: 2023年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

无需gas代币和ERC20-Permit还任重而道远

ERC20-Permit(EIP-2612)下,如何避免 使用进行两步交易:授权+ transferFrom!

快进至EIP-2612

从那时起,DAI和Uniswap一直在朝着名为EIP-2612的新标准的方向发展,该标准可以取消 approve + transferFrom,同时还允许无 gas 通证转账。 DAI是第一个为其ERC-20通证添加新的permit功能的公司。它允许用户在链下签署授权的交易,生成任何人都可以使用并提交给区块链的签名。这是解决gas 支付问题的基本的第一步,并且消除了用户不友好的两步过程:发送approve和之后的 transferFrom。

让我们详细研究一下EIP。

原始的错误方法

总体而言,该过程非常简单。用户不在发起授权(approve)交易,而是对approve(spender, amount)签名。签名结果可以被任何人传递到 permit函数,在 permit函数我们只需使用 ecrecover来检索签名者地址,接着用 approve(signer,spender,amount)。

这种方式可用于让其他人为交易支付 gas 费用,也可以删除掉常见的授权(approve)+ transferFrom模式:

之前方法:

  • 用户提交token.approve(myContract.address, amount)交易。
  • 等待交易确认。
  • 用户提交第二个 myContract.doSomething()交易,该交易内部使用 token.transferFrom。

现在:

  • 用户进行授权签名:签名信息signature=(myContract.address,amount)。
  • 用户向 myContract.doSomething(signature)提交签名。
  • myContract使用 token.permit增加配额,并调用 token.transferFrom 获取代币。

之前需要两笔交易,现在只需要一笔!

Permit 细节:防止滥用和重播

我们面临的主要问题是签名可能会多次使用或在原本不打算使用的其他地方使用。为防止这种情况,我们添加了几个参数。在底层,我们使用的是已经存在的,广泛使用的EIP-712标准。

1. EIP-712 域哈希(Domain Hash)

使用EIP-712,我们为ERC-20定义了一个域分隔符

bytes32 eip712DomainHash = keccak256(
    abi.encode(
        keccak256(
            "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
        ),
        keccak256(bytes(name())), // ERC-20 Name
        keccak256(bytes("1")),    // Version
        chainid(),
        address(this)
    )
);

这样可以确保仅在正确的链ID上将签名用于我们给定的通证合约地址。chainID是在以太坊经典分叉之后引入(以太坊经典network id 依旧为 1), 用来精确识别在哪一个网络。 可以在此处查看现有chain ID的列表

2. Permit 哈希结构

现在我们可以创建一个Permit的签名:

bytes32 hashStruct = keccak256(
    abi.encode(
        keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"),
        owner,
        spender,
        amount,
        nonces[owner],
        deadline
    )
);

此hashStruct将确保签名只能用于

  • Permit 函数
  • 从owner授权
  • 授权spender
  • 授权给定的value (金额)
  • 仅在给定的deadline之前有效
  • 仅对给定的 nonce有效
    nonce可确保某人无法重播签名,即在同一合约上多次使用该签名。

3. 最终哈希

现在我们可以用兼容 EIP-191的712哈希构建(以0x1901开头)最终签名:

bytes32 hash = keccak256(
    abi.encodePacked(uint16(0x1901), eip712DomainHash, hashStruct)
);

4. 验证签名

在此哈希上,我们可以使用ecrecover 获得该函数的签名者:

address signer = ecrecover(hash, v, r, s);
require(signer == owner, "ERC20Permit: invalid signature");
require(signer != address(0), "ECDSA: invalid signature");

无效的签名将产生一个空地址,这就是最后一次检查的目的。

5. 增加Nonce 和 授权

现在,最后我们只需要增加所有者的Nonce并调用授权函数即可:

nonces[owner]++;
_approve(owner, spender, amount);

你可以在此处看到完整的实现示例。

已有的ERC20-Permit 实现

DAI ERC20-Permit

DAI是最早引入 permit的通证之一,如此处所述。实现与EIP-2612略有不同:

  1. 没有使用 value,而只使用一个bool allowed,并将allowance 设置为0或MAX_UINT256
  2. deadline参数称为expiry

Uniswap ERC20-Permit

Uniswap实现与当前的EIP-2612保持一致,请参见这里。它允许你调用removeLiquidityWithPermit,从而省去了额外的授权步骤。

如果你想体验一下该过程,请转到https://app.uniswap.org/#/pool 并切换到Kovan网络。不用增加资金的流动性。现在尝试将其删除。单击“Approve”后,你会注意到此MetaMask弹出窗口如下图所示。

这不会提交交易,而只会创建具有给定参数的签名。你可以对其进行签名,并在第二步中使用生成的签名调用removeLiquidityWithPermit。总而言之:只需提交一份交易。

ERC20-Permit 代码库

我已经创建了可以导入的ERC-20-Permit代码库。你可以在https://github.com/soliditylabs/ERC20-Permit 中找到它
其使用:

你可以通过npm安装来简单地使用它:

$ npm install @soliditylabs/erc20-permit --save-dev

像这样将其导入到你的ERC-20合约中:

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

import {ERC20, ERC20Permit} from "@soliditylabs/erc20-permit/contracts/ERC20Permit.sol";

contract ERC20PermitToken is ERC20Permit {
    constructor (uint256 initialSupply) ERC20("ERC20Permit-Token", "EPT") {
        _mint(msg.sender, initialSupply);
    }
}

前端使用

你可以在我的测试中代码在这里看到如何使用eth-permit库创建有效的签名。它会自动获取正确的随机数,并根据当前标准设置参数。它还支持DAI样式的许可证签名创建。完整文档可在https://github.com/dmihal/eth-permit 获得

关于调试的一句话:这可能很痛苦。关闭任何单个参数都将导致revert: Invalid signature。祝你好运找出原因。

在撰写本文时,似乎仍然有一个已知 issue,它可能会或也可能不会影响你,具体取决于你的Web3提供程序。如果确实对你有影响,请使用通过patch-package安装这里的补丁

无需gas代币解决方案

现在回想起我在悉尼的经历,单靠这个标准并不能解决问题,但这是解决该问题的第一个基本模块。现在,你可以为其创建加油站网络,例如Open GSN。部署合约,该网络只需通过permit + transferFrom即可进行通证转账。 GSN内部运行的节点将获取许可签名并提交。

谁支付 gas 费?这将取决于特定的场景。也许Dapp公司支付这些费用作为其客户获取成本(CAC)的一部分。也许用转移的通证支付了GSN节点费用。要弄清所有细节,我们还有很长的路要走。

一如既往的小心使用

请注意,该标准尚未最终确定。当前与Uniswap实现相同,但将来可能会有所变化。如果标准再次更改,我将保持库的更新。我的图书馆代码也未经审核,使用后果自负。

本翻译由 Cell Network 赞助支持。
转载自:https://learnblockchain.cn/article/1790