区块链安全

230 阅读57分钟

区块链隐私保护 9
区块链安全基础概念 1 *
漏洞挖掘原理 1 *
访问控制权限 5
浮点与精度控制 1 *
智能合约存储布局 5 *
溢出漏洞 13  *
拒绝服务攻击 16 *
未初始化的storage变量(变量覆盖) 12 *
call/delegatecall使用安全 12*
重入漏洞 14 *
随机数安全 1 *
中心化应用引入的安全隐患 2 * 去中心化金融应用基本概念与设计原理 13*
三明治攻击 5*
闪电贷攻击 1*

区块链隐私保护

密码学的目标:机密性、完整性、身份认证和不可否认性。
常见的对称密码算法:

image.png 三种常见的非对称密码算法密钥长度:

image.png

同态加密
对经过同态加密的数据进行处理得到一个输出,将这一输出进行解密,其结果与用同一方法处理未加密的原始数据得到的输出结果是一样的。

  • 同态加密是一种公钥加密算法,加密使用公钥pk,解密使用私钥sk。
  • 密文具有计算功能,无需解密就能对加密数据进行处理,处理过程不会泄露任何原始内容,拥有密钥的用户解密后可以得到处理后的结果。
  • f()满足加法运算,则称为加法同态;f()满足乘法运算,则称为乘法同态;f()同时满足加法和乘法运算,则成为全同态。

零知识证明
证明者能够在不向验证者提供任何有用的信息的情况下,使验证者相信某个论断是正确的。

目前ZCASH(大零币)使用零知识机制来证明交易有效,在ZCASH中,摒弃了之前的UTXO方式,而是使用了一种基于UTXO,被称为NOTE(支票)的新方式代替。NOTE代表了当前账户对资产的支配权,与UTXO不同,账户余额的存储方式不再是“未消费的交易输出”,而是“未被作废的支票(NOTE)”;一个NOTE是由所有者公钥PK、所拥有金额V、和唯一区分支票的序列号r组成,表示为NOTE=(PK, v, r)。

ZCash交易分为两类:透明地址交易和隐藏地址交易。透明地址交易的输入、输出直接是可见的NOTE信息; 隐藏地址交易,输入和/或输出的地址和金额是隐藏的

在ZCASH的隐藏地址交易中,交易的输入输出不再是明文的NOTE,而是NOTE的签发和废弃通知

image.png

每次进行转账,就会把转账方的NOTE放到作废列表里,代表此NOTE已经作废,同时为收款方创建一张等额的NOTE。都是记录的NOTE的哈希值,因此并不知道被废弃的和新签发的NOTE的内容,这样就做到了隐藏交易双方及交易细节。

环签名技术 签名者利用自己的私钥和集合中其他成员的公钥就能独立的进行签名,集合中的其他成员可能不知道自己被包含在了其中。这种方案的优势除了能够对签名者进行无条件的匿名外,环中的其他成员也不能伪造真实签名者签名。

隐私威胁

1、用户身份隐私
用户身份隐私主要是指交易双方的地址信息,其本质是双方公钥的哈希值。最常用的解决方案主要是混币机制,也就是将多笔交易混合在一起,切断加密货币中交易方与接收方的联系,提高加密货币的隐私性和匿名性。
混币过程的执行可以由可信第三方或者是某种协议执行,根据这种特性,混币机制也可分为基于中心化结点的混币机制和去中心化的混币机制。
基于中心化的混币机制:其本质是单纯地将一笔资金在多个地址中多次转移,实现简单、易于操作在现有的各类数字货币系统中具有极高的适用性。
去中心化的混币机制:整个过程由混币协议实现,不需要第三方节点的参与,能有效避免中心化混币机制出现的问题,可去中心化混币机制的计算成本要更高,服务效率也更加较为低下。
2、用户交易隐私
基于以上的问题,有人又提出了基于双重加密的交易隐私保护方法设计,在保证隐私数据安全性的同时,优化了加密方案,使性能得到了很好的提升。除此之外还有同态加密、零知识证明、数据隔离、属性加密等解决方案。
3 网络隐私
网络隐私主要分为节点隐私以及通信隐私。节点隐私主要内容有服务器地理位置、节点的物理信息、系统版本、节点 IP 等。
通信隐私主要内容包括数据流量、节点间数据明文及密文等。
4、应用隐私
应用隐私一般分为用户端隐私与服务端隐私,隐私主要内容有支付流敏感信息、浏览器 Cookie、密钥存放位置等。这一类隐私信息的泄露威胁其实并不来源于区块链技术本身,其主要问题还是在用户和服务商身上。
对于这类隐私威胁,常用的解决方案有发布官方插件、身份认证、增强用户安全意识等。

区块链安全基础概念

智能合约安全主要关注智能合约及其与区块链内部要素交互过程中的安全性,如智能合约架构设计安全、代码安全、运行安全等。

狭义来说区块链安全是指针对区块链系统进行的安全保障措施,包括保障网络通信安全、身份验证、加密算法等技术和措施。区块链安全的目的是确保区块链系统的数据和资产的完整性、可靠性和不可篡改性。

  • Solidity 级别的漏洞主要涵盖调用不明确(call to unkown)、没有足够的 Gas 发送(gasless send)、异常障碍(exception disorder)、类型转换(type cast)、重入攻击(reentrancy)和保密(keeping secret);
  • EVM级别的漏洞主要包括传输过程中网络数据丢失(ether lost in transfer)和堆栈容量限制(stack size limit)等
  • Blockchain级别的漏洞主要涉及不可预测的状态(unpredictable state)、产生随机性(generating randomness)和时间限制(time constraint)。

智能合约的典型安全属性主要包含以下几个类别:

  • 调用完整性(call integrity) 智能合约调用完整性可以防止重入攻击和调用不明确等安全问题。

  • 原子性(atomicity) 当智能合约不满足原子性时,将出现不可预知异常(mishandled exceptions)等类型的安全问题。

  • 可变账户状态的独立性(independence of mutable account state) 当智能合约不满足可变账户状态的独立性时,将出现交易顺序依赖(transaction order dependency)和不可预测状态等类型的安全问题

  • 交易环境独立性(independence of transaction environment) 当智能合约不满足交易环境独立性时,将出现时间戳依赖(timestamp dependency)、时间限制(time constraints)和产生随机性等类型的安全问题

开发与设计模式安全

基于开发设计模式安全,为了解决Ethereum和Solidity相关的安全性问题,社区和开发人员提出了检查效果交互(CEI, checks-effect-interaction)、紧急停止(emergency stop)、减速带(speed bump)、速率限制(rate limit)、互斥(mutex)和余额限制(balance limit) 6 种设计模式用于处理开发智能合约过程中的典型安全问题和漏洞。
说明:

  • 检查效果交互模式通过一定的代码顺序,在最后一步调用外部智能合约,阻止外部智能合约发动重复调用攻击,解决恶意代码劫持控制流的漏洞。
  • 紧急停止模式将紧急停止功能集成到智能合约代码中,由认证方触发以禁用某些敏感功能。
  • 减速带模式通过延长执行敏感任务的智能合约的完成时间,解决短时间内某项任务请求执行频率过高的问题。
  • 速率限制模式通过降低智能合约一段时间内的执行速率,缓解任务请求繁忙状况,实现智能合约正常运行。
  • 互斥模式利用互斥死锁阻止外部调用重新输入调用方函数,防止重入攻击。
  • 余额限制模式通过限制智能合约中风险资金的最高金额,降低智能合约受到攻击后造成的金融风险。

漏洞挖掘原理

漏洞挖掘 是指查找目标系统中可能存在的漏洞,在这个过程中,需要运用多种计算机技术和工具。根据挖掘对象的不同,漏洞挖掘一般可以分为两大类,即基于源代码的漏洞挖掘和基于目标代码的漏洞挖掘。

对于基于源代码的漏洞挖掘来说,首先要获取系统或软件的源代码程序,采取静态分析或动态调试的方式查找其中可能存在的安全隐患。但大多数商业软件的源代码很难获得,一般只有一些开源系统能为挖掘者提供源码,如LINUX系统,所以目前基于源代码的挖掘一般都是LINUX系统及其开源软件。对于不能提供源码的系统或软件而言,只能采用基于目标代码的漏洞挖掘方法,该方法一般涉及程序编译器、计算机硬件指令系统、可执行文件格式等方面的分析技术,实现难度较大。

漏洞挖掘的一般流程:

以挖掘SRC为例:

  • 阅读规则确定测试范围
  • 读赏金标准
  • 信息搜集
  • 信息搜集--子域名收集
  • 子域名收集--泛解析问题
  • 信息搜集--C段资产收集
  • 信息搜集--移动端资产收集
  • 信息搜集--同备案号资产收集

访问控制权限

可见性说明符

  • Solidity中的函数都需要有可见性说明符
  • Solidity的函数和状态变量有四种可见性:public、external、internal、private。

public: 其修饰的函数对所有智能合约可见,可以被外部调用也可以被内部调用;
external: 其修饰的函数智能被外部合约调用,不允许内部调用;
internal: 其修饰的函数只允许被本合约和派生合约内部调用;
private:其修饰的函数只允许被当前合约调用,其派生合约不可见。

函数修饰器

修饰器是合约的可继承属性,可以被派生合约覆盖,但前提是它们被标记为virtual(早期版本没有virtual修饰符不需要声明为virtual)。

浮点与精度控制

目前 Solidity 还没有完全支持定长浮点型,可以声明定长浮点型的变量,但不能给它们赋值或把它们赋值给其他变量。定长浮点型的关键字是 fixed/ufixed,表示各种大小的有符号和无符号的定长浮点型

在关键字 ufixedMxN 和 fixedMxN 中,“M” 表示该类型占用的位数,“N” 表示可用的小数位数。“M” 必须能整除8,即8位到256位。“N” 则可以是从0到80之间的任意数。ufixed 和 fixed 分别是 ufixed128x18 和 fixed128x18 的别名。

image.png

在 Solidity 中支持浮点运算

通过整型来实现自己的浮点运算。但如果使用不当,会导致意想不到的漏洞。
看看下面这段代码:

pragma solidity 0.8.7;

contract FunWithNumbers {

    uint  public tknPerEther = 10;
    uint  public weiPerEther = 1e18;
    
    mapping(address => uint) public balance;

    function buyToken() external payable {
        // converting wei to eth, then multiplying by the token rate
        uint token = msg.value/weiPerEther*tknPerEther;
        balance[msg.sender] += token;
    }
    function sellTokens(uint tokens) public {
        require(balance[msg.sender] >= tokens);
        uint eth = tokens/tknPerEther;
        balance[msg.sender] -= tokens;
        payable (msg.sender).transfer(eth*weiPerEther);
    }
}

这是一个简单的买卖token的合约。仔细观察就会发现,虽然买卖的数学计算是正确的,但如果没有使用浮点数,就出现了问题。

  • 在buyToken函数中,如果msg.value小于 1 个以太币,无论它多么接近 1 个以太币,在除以weiPerEther后都会得到 0。这将对代码产生相当大的影响。将最终计算(乘法)保留为 0 的效果。
  • 在sellTokens函数中,任何小于10的tokens的数值都将导致eth为0。如果你将一个整型当做浮点处理,它会进行四舍五入,但可能不是以你期望的方式。

更安全的使用浮点运算

  • 正确的理解精度与运算顺序等细节。
  • 优先考虑使用成熟的开源库,而不是自己开发。但需要注意成熟的开源库并不能保证绝对的安全。

具体的预防措施:

  1. 注意操作顺序的区别,如果除法无法避免,乘法一定要在除法前,以避免精度丢失。在上面的示例中,可以修改为 msg.value*tknPerEth/weiPerEther 结果会更加精确。
  2. 最好在执行任何必要的数学运算之前将值转换为更高的精度,最终转换回输出所需的精度。
  3. 最好在 Solidity 中保持所有变量的高精度,并在第三方应用程序中将它们转换回较低的精度。

成熟的开源的浮点运算库:

image.png

智能合约存储布局

以太坊合约是经过 EVM 执行后,直接从 KV 数据库中读写。以太坊中,访问数据是需要从 KV 数据中实时读取,因此在访问前必须知道某个数据确切的存储位置。

Storage 在 EVM 中以 key-value 对的形式存储持久化的状态,key、value 都被约定为固定的 32 字节大小,整存整取。Solidity 利用其独特的编码方式,将 32 字节的 Storage 转变为复杂的数据类型。

了解存储布局的用处:

  1. 在状态变量未设置为 public 时,仍能获取到其数据
  2. 如何优化存储以减少 gas 开销
  3. 使用内联汇编直接操作 storage
  4. 理解溢出,变量覆盖的原理

Solidity 合约数据存储采用的是为合约每项数据指定一个可计算的存储位置,数据存在容量为2^256的超级数组中,数组中每项数据的初始值为 0。 你不用担心存储会占用太多空间,实际上存储是稀疏的。在存储到 KV 数据库中时只有非零(空值)数据才会被写入。

基本规则:

  • 存储槽的第一项以低位对齐(lower-order aligned)的方式存储
  • 值类型只使用存储它们必要的字节数
  • 如果存储槽的剩余部分无法放入该值类型,就把它存储到下一个存储槽中
  • 结构体和数组数据总是以新的存储槽开始,并且它们严格按照这些规则打包
  • 紧跟结构体或数组后面的项总是以新的存储槽开始

常量不是状态变量,无法对其使用 .slot.offset

对于使用继承的合约,状态变量的顺序由没有任何其他合约依赖的合约开始的 C3 线性顺序(C3-linearized order)决定。如果上述规则允许的话,不同合约的状态变量共享同一个存储槽。

静态数据布局:

在 Solidity 语言中,一部分的值类型所需要占用的存储是确定的。
布尔类型,只需要占用一字节
uint16 只需要占用 2 字节

因为以太坊虚拟机每次读取数据都是 32 字节,当你的数据小于 32 字节时需要更多的指令操作才能将所需值取出。  当然这种开销,相对于更多的存储占用要便宜得多。

// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.0;
library Storage {
    function getStorageAt(uint slot) public view returns (bytes32 ret) {
        assembly {
            ret := sload(slot)
        }
    }
    function setStorageAt(uint slot, uint data) public returns (bytes32 ret) {
        assembly {
            sstore(slot, data)
            ret := sload(slot)
        }
    }
}
contract UseStorage {
    uint public a = 1; // slot 0
    uint public b = 2; // slot 1
    uint immutable public c; // not stored at storage
    uint constant public d = 4; // not stored at storage
    string public e = "chengduxinxigongchengdaxue"; // slot 2(占用存储大小取决于数据长度)
struct Info {        
        uint128 f;
        uint128 g;
        uint256[2] h;
    }
    
    Info public i;
    constructor() {
        c = 3;
        i = Info({
            f: 5,       // slot 3
            g: 6,       // slot 3
            h: [uint(7), uint(8)]       // slot 4-5
        });
    }
    function getStorageAt(uint slot) public view returns (bytes32 ret) {
        return Storage.getStorageAt(slot);
    }
    function setStorageAt(uint slot, uint data) public returns (bytes32 ret) {
        return Storage.setStorageAt(slot, data);
    }
}

合约继承:

// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.0;
import './UseStorage.sol';
contract A {
    uint a = 1; // slot 0 for contract D. slot 2 for contract E.
    uint128 b = 2; // slot 1 for contract D slot 3 for contract E.
    uint128 c = 3;
}
contract B {
    uint d = 4; // slot 2 for contract D. slot 0 for contract E.
    uint e = 5; // slot 3 for contract D. slot 1 for contract E.
}
contract C {
    function getStorageAt(uint slot) public view returns (bytes32 ret) {
        return Storage.getStorageAt(slot);
    }
    function setStorageAt(uint slot, uint data) public returns (bytes32 ret) {
        return Storage.setStorageAt(slot, data);
    }
}
contract D is A, B, C {
    uint f = 6; // slot 4
}
contract E is B, A , C{
    uint f = 6; // slot 4
}

映射和动态数组

假设,在使用存储布局规则后,映射或动态数组的存储位置最终是存储槽 p。对于动态数组,这个存储槽存储数组中的元素个数(byte 数组和字符串除外)。对于映射,这个存储槽一直是空的,但它仍然需要确保,即便有两个相邻的映射,它们的内容最终在不同的存储槽位置。

动态数组
数组数据在 keccak256(p) 存储槽开始,并且以与定长数组相同的方式排列:一个元素挨一个元素,如果元素长度不超过 16 字节,可能共享同一个存储槽。

元素 x[i] 的位置(x 的类型为 uint256[]),计算方法如下(假定 x 本身存储在槽 p):

Slot = keccak256(p)
the data of elemt = keccak256(p) + i

contract DynArray {
    uint public a = 1; // slot 0
    uint public b = 2; // slot 1
    uint[] c;
    constructor () {
        c.push(3);
        c.push(4);
    }
    function calSlot(uint slot) public pure returns(uint256 ret){
        return uint256(keccak256(abi.encodePacked(slot)));
    }
    function getStorageAt(uint slot) public view returns (bytes32 ret) {
        return Storage.getStorageAt(slot);
    }
    function setStorageAt(uint slot, uint data) public returns (bytes32 ret) {
        return Storage.setStorageAt(slot, data);
    }
    function getSlotNum() public returns(uint256 slota, uint256 slotb, uint256 slotc0,uint256 slotc1 ) {
        assembly{
            slota := a.slot
            slotb := b.slot
        }
    }}

映射 映射的 key k 对应存储的数据的位置计算方式: keccak256(h(k) . p),其中 . 是连接符,h 是一个函数,它根据 k 的类型采用不同的手段:

  • 对于值类型,h ,与在内存中存储值的方式相同,把值扩展为 32 字节。
  • 对于字符串和字节数组,h 计算未扩展数据的 keccake256 hash。

如果映射值是一个非值类型,计算槽位置标志着数据的开始位置。例如,如果值是结构类型,你必须添加一个与结构成员相对应的偏移量才能到达该成员。

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.0 <0.9.0;
contract C {
    struct S { uint16 a; uint16 b; uint256 c; }
    uint x;
    mapping(uint => mapping(uint => S)) data;
}

计算一下 data[4][9].c 的存储位置
映射本身的位置是 1( 前面有32字节变量 x )。 因此 data[4] 存储在 keccak256(uint256(4) . uint256(1))
data[4] 的类型又是一个映射, data[4][9] 的数据开始于槽位 keccak256(uint256(9). keccak256(uint256(4). uint256(1))
在结构 S 的成员 c 中的槽位偏移是 1,因为 ab被装在一个槽位中。
最后 data[4][9].c 的插槽位置是 keccak256(uint256(9) . keccak256(uint256(4) . uint256(1)) + 1. 该值的类型是 uint256,所以它使用一个槽。

溢出漏洞

整型溢出

由于计算机底层是二进制,任何十进制数字都会被编码到二进制。溢出会丢弃最高位,导致数值不正确。

如:八位无符号整数类型的最大值是 255,翻译到二进制是 1111 1111;当再加一时,当前所有的 1 都会变成 0,并向上进位。但由于该整数类型所能容纳的位置已经全部是 1 了,再向上进位,最高位会被丢弃,于是二进制就变成了 0000 0000

在Solidity语言中,变量支持的整数类型步长以8递增,支持从uint8到uint256,以及int8到int256。,一个 uint8类型 ,只能存储在范围 0到2^8-1,也就是[0,255] 的数字,一个 uint256类型 ,只能存储在范围 0到2^256-1的数字。
如果试图存储256这个数字到一个 uint8类型中,这个256数字最终将变成 0

整型也可以分为以下几类:加法溢出、减法溢出、乘法溢出
在低版本合约中,通常使用SafeMath库来避免整型溢出。

function batchTransfer(address[] _receivers, uint256 _value) public whenNotPaused returns (bool) {
    uint cnt = _receivers.length;
    uint256 amount = uint256(cnt) * _value; //溢出点,这里存在整数溢出
    require(cnt > 0 && cnt <= 20);
    require(_value > 0 && balances[msg.sender] >= amount);
    balances[msg.sender] = balances[msg.sender].sub(amount);
    for (uint i = 0; i < cnt; i++) {
        balances[_receivers[i]] = balances[_receivers[i]].add(_value);
        Transfer(msg.sender, _receivers[i], _value);
    }
    return true;

}

当时的合约版本是 ^0.4.16,小于 0.8 版本,也没有使用 SafeMath 库。我们将_value传入了一个极大的值(这里为2**255),通过乘法向上溢出,使得 amount(要转的总币数)溢出后变为一个很小的数字或者0(这里变成0),从而绕过balances[msg.sender] >= amount的检查代码,使得巨大 _value 数额的恶意转账得以成功。

数组溢出

对storage存储的数组来说,元素类型可以是任意的,类型可以是数组,映射类型,结构体等。但对于memory的数组来说。如果作为public函数的参数,它不能是映射类型的数组,只能是支持ABI的类型。

mapping(uint256 => uint256)[] public arr;

数组数据位于起始位置keccak256(p),其布局方式与静态大小的数组数据相同:一个元素接一个元素,如果元素不超过 16 字节,则可能共享存储槽。动态数组的动态数组递归地应用此规则。

contract ArrayStorage {
   uint16[] public a =  [1,2,3,4,5];
   uint256[] public b =  [6,7,8,9,10];
}

如何获取a[2],b[2]的值? 首先我们可以计算出a、b的初始存储位置: keccak256(0)=0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e563 keccak256(1)=0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6 然后我们再根据数据类型计算偏移量即可。

image.png

例子:

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

import '../helpers/Ownable-05.sol';

contract AlienCodex is Ownable {

  bool public contact;
  bytes32[] public codex;

  modifier contacted() {
    assert(contact);
    _;
  }
  
  function make_contact() public {
    contact = true;
  }

  function record(bytes32 _content) contacted public {
    codex.push(_content);
  }

  function retract() contacted public { // 使数组长度溢出为极大值,这样我们就可以操控合约所有存储
    codex.length--;
  }

  function revise(uint i, bytes32 _content) contacted public {
    codex[i] = _content;
  }
}

这种length--的操作已在0.6.0以后的版本被编译器禁止。

类型混淆导致的溢出

类型转换

Solidity允许类型之间进行相互转换,转换时必须符合一定条件,不能导致信息丢失

uint8可以转换为uint16,但是int8不可以转换为uint256,因为int8可以包含uint256中不允许的负值

注意:
(1) 转换成更小的类型,会丢失高位。

uint32 public a = 165536; uint16 public b = uint16(a); // b = 34464

(2) 赋值时,不能超过对应类型的范围

uint8 a = 23;
uint32 b = 233;
uint16 c = 233333; // TypeError: Type int_const 233333 is not implicitly convertible to expected type uint16. Literal is too large to fit in uint16.

运算和指数运算

在0.7.0版本之前,将对常量使用移位或指数运算是,会使用非常量的类型(例如:250 << x,或者250 ** x中,其结果是x的类型);而在0.7.0版本之后,将使用常量的类型来操作

0.7.0之前

pragma solidity ^0.4.23;
contract DemoContract {
		uint8 x = 2;
		uint public shift = 250 << x; // result: 232.
		uint public exp = 250 ** x; // result: 36.

}

0.7.0之后

pragma solidity ^0.7.0;

contract DemoContract {
		uint8 x = 2;
		uint public shift = 250 << x; // result: 1000.
		uint public exp = 250 ** x; // result: 62500.

}

此外,在solidity中位运算的最大值为256,超过256的位运算将返回0,且即使在0.8.0以上的版本都不会报错

pragma solidity ^0.8.0;

contract DemoContract {
		uint x = 200;
		uint public shift1 = 250 << x; // result: 401734511064747568885490523085290650630550748445698208825344000.
		uint y = 350;
		uint public shift2 = 250 << y; // result: 0.
}

拒绝服务攻击

拒绝服务(DoS,Denial of Service)

攻击者试图通过暂时或无限期地中断连接到网络的主机的服务,使其目标用户无法使用机器或网络资源。 系统无法处理用户需要的正常服务请求。例如,当计算机系统崩溃或带宽耗尽或硬盘已满而无法提供正常服务时,就构成了DoS。

引起 DoS 的攻击称为 DoS 攻击。目的是使计算机或网络无法提供正常服务

在互联网中,DoS 攻击大致可以分为三类:

  • 利用软件自身缺陷
  • 利用协议中的漏洞
  • 利用资源压制

在以太坊智能合约中,DoS攻击大致可以分为两类:

  • 利用非预期的回滚的 DoS(DoS with (Unexpected) revert)
    依赖外部调用状态
    权限操作
  • 利用区块 Gas Limit 的 DoS(DoS with Block Gas Limit)
    不受控制的操作在合约层进行的 Gas Limit DoS(Gas Limit DoS on a Contract via Unbounded Operations)
    通过区块填充在网络层进行的Gas Limit DoS(Gas Limit DoS on the Network via Block Stuffing)

在以太坊智能合约中,DoS 漏洞可以简单理解为“不可恢复的恶意操纵或不受控制的无限资源消耗” 即对以太坊合约进行 DoS 攻击,可能导致大量消耗 Ether 和 Gas,甚至导致异常的合约逻辑。

利用非预期的回滚的DoS

依赖外部调用状态

contract Auction {
    address currentLeader;
    uint highestBid;

    function bid() payable {
        require(msg.value > highestBid);

        require(currentLeader.send(highestBid)); // Refund the old leader, if it fails then revert

        currentLeader = msg.sender;
        highestBid = msg.value;
    }
}

如果攻击者使用具有 fallback 函数的智能合约出价,则攻击者可以在任何拍卖中获胜。当合约尝试向旧领导者退款时,如果退款失败,它会回滚。这意味着恶意出价者可以成为领导者,同时确保向其地址的任何退款都将始终失败(比如在 fallback 函数中简单的通过 revert 函数回滚)
通过这种方式,他们可以防止其他任何人调用该 bid() 函数,并永远保持领导者的身份

另一个例子是当一个合约通过一个数组遍历来向用户支付
如果其中任何一个支付失败,将导致整个支付回滚,这个循环永远不会遍历完。这就成为了攻击者的一个攻击点,强制让其中一次支付失败,这样,就没有任何人能够从中得到应有的付款。

address[] private refundAddresses;
mapping (address => uint) public refunds;

// bad
function refundAll() public {
    for(uint x; x < refundAddresses.length; x++) { // arbitrary length iteration based on how many addresses participated
        require(refundAddresses[x].send(refunds[refundAddresses[x]])) // doubly bad, now a single failure on send will hold up all funds
    }
}

权限操作

在智能合约中,有一些特权的地址是很常见的,比如 owner 地址,它负责管理合约的参数调整、紧急关停等敏感操作。如果 owner 地址丢失或无法正常工作,则会导致整个合约无法运行,从而导致非主观的 DoS 攻击。

bool public isFinalized = false;
address public owner; // Contract owner
 
function finalize() public {
    require(msg.sender == owner);
    isFinalized == true;
}
 
// ... Some extra ICO features
 
// Rewrite the transfer function to check isFinalized
function transfer(address _to, uint _value) returns (bool) {
    require(isFinalized);
    super.transfer(_to,_value)
}

在 ICO 结束后,如果 owner 地址的私钥丢失,则无法调用 Finalize() 函数启动 transfer 功能,用户也就无法进行 token 转移,合同也就无法如期的正常工作了。

利用区块 Gas Limit 的 DoS

每个区块都有可以消耗的 gas 上限(Gas Limit),这是Block Gas Limit。如果消耗的 gas 超过此限制,交易将失败

不受控制的操作在合约层进行的 Gas Limit DoS

一次性向所有人进行支付,可能会遇到区块 gas 上限的限制,导致整个交易无法完成。

如果该数组的大小不受控制,那么即便没有恶意攻击,也可能导致这样的问题。考虑下攻击者会如何做?

攻击者可能会添加大量的地址到 refundAddresses 中,每个地址只支付很少的以太(比如添加地址的条件是转入的 Ether 大于0,那么最低可以每个地址只花 1 Wei 的成本)。那么最终,进行退款时由于需要给大量地址进行退款,因此它的 gas 成本很可能超过了Block Gas Limit,从而阻止交易的执行。

如果你必须要遍历一个不受控制大小的数组的话,那么你应该需要注意到将对它的遍历分到多个区块进行执行,因此你需要一个变量来跟踪当前的进度,并从该点继续:

struct Payee {
    address addr;
    uint256 value;
}

Payee[] payees;
uint256 nextPayeeIndex;

function payOut() {
    uint256 i = nextPayeeIndex;
    while (i < payees.length && gasleft() > 200000) {
      payees[i].addr.send(payees[i].value);
      i++;
    }
    nextPayeeIndex = i;
}

通过区块填充在网络层进行的Gas Limit DoS

即使你的合约中不包含无限循环,攻击者也可以通过以足够高的 Gas Price 广播计算密集型交易来阻止其他交易被包含在区块链中几个区块。
为此,攻击者可以发出多个交易,这些交易将消耗整个 Block Gas Limit,并在下一个区块被打包时立即给出足够高的 Gas Price。当然,没有一个固定的 Gas Price 值能够保证你的交易被包含在区块中,但 Gas Price 越大,机会就越大。

如果攻击成功的话,则该区块无法包含除攻击者的交易以外的其他交易。一般来说,这用于在特定时间之前阻止对特定合约的交易。

它只有在预期回报超过其成本时才有利可图

如何预防 DoS 攻击

pull payment system

为了最大限度地减少此类故障造成的损害,通常最好将每个外部调用隔离到它自己的交易中,该交易可以由调用的接收者启动。
尤其是在支付相关的场景,最好让用户自己提取资金而不是自动将资金支付给他们(这也减少了gas limit 出现问题的可能性)。避免在单个交易中合并多个转账操作。

// bad
contract auction {
    address highestBidder;
    uint highestBid;

    function bid() payable {
        require(msg.value >= highestBid);

        if (highestBidder != address(0)) {
            (bool success, ) = highestBidder.call.value(highestBid)("");
            require(success); // if this call consistently fails, no one else can bid
        }

       highestBidder = msg.sender;
       highestBid = msg.value;
    }
}

// good
contract auction {
    address highestBidder;
    uint highestBid;
  mapping(address => uint) refunds;

  function bid() payable external {
        require(msg.value >= highestBid);

        if (highestBidder != address(0)) {
            refunds[highestBidder] += highestBid; // record the refund that this user can claim
        }

        highestBidder = msg.sender;
        highestBid = msg.value;
    }

    function withdrawRefund() external {
        uint refund = refunds[msg.sender];
        refunds[msg.sender] = 0;
        (bool success, ) = msg.sender.call.value(refund)("");
        require(success);
    }
}

避免单点故障

对于权限操作引起的 DoS 攻击来说,最主要的就是它出现了单点故障,从而导致整个系统无法正常工作。一般来说解决思路有两个:

  • 特权地址不要单纯使用外部拥有地址(EOA),而使用多签钱包地址或 DAO 地址来代替。
  • 预留备用方案,来避免单点故障。

未初始化的storage变量

代码中的数据存储是实现一些代码功能不可或缺的流程,智能合约也不例外,但是如果存储数据时不细心,就可能会造成未初始化的存储指针漏洞,该漏洞产生的主要原因是没有对存储变量进行初始化,造成之前位置的数据被意外覆盖。

代码中的数据存储是实现一些代码功能不可或缺的流程,智能合约也不例外,但是如果存储数据时不细心,就可能会造成未初始化的存储指针漏洞,该漏洞产生的主要原因是没有对存储变量进行初始化,造成之前位置的数据被意外覆盖。

Solidity 允许定义一个指向外部存储 storage 的指针(引用),这个引用在未初始化的情况下等于 0,而在 storage 地址为 0 的位置存放着有意义的数据因为Solidity对于状态变量的存储次序一般是按照出现的先后顺序依次排列的。如果此时直接对「未初始化的 storage 引用」进行赋值,那么就会错误覆盖合约存储在 storage 上面的状态变量。

开发人员失误导致的案例

 struct BorrowAgreement {
    address lender;
    address borrower;
    uint256 tokenAmount;
    uint256 collateralAmount;
    uint32 collateralRatio;  // Extra collateral, in integer percent.
    uint expiration;
  }

  IERC20Token constant public bancorToken =
      IERC20Token(0x1F573D6Fb3F13d689FF844B4cE37794d79a7FF1C);
  BancorChanger constant public bancorChanger =
      BancorChanger(0xb72A0Fa1E537c956DFca72711c468EfD81270468);
  BorrowAgreement[] public agreements;

 function isCollateralWithinMargin(
      uint256 tokenAmount, uint256 collateralAmount, uint32 collateralRatio)
  returns(bool) {
    IERC20Token etherToken = bancorChanger.getQuickBuyEtherToken();
    uint256 collateralInTokens =
        bancorChanger.getPurchaseReturn(etherToken, collateralAmount);
    uint256 minCollateral = tokenAmount * (100 + collateralRatio) / 100;
    return (collateralInTokens > minCollateral);
  }

  function offerToLend(
      uint256 _amount, uint256 _collataral_ratio, uint _expiration) {
    assert(bancorToken.transferFrom(msg.sender, this, _amount));
    BorrowAgreement agreement;
    agreement.lender = msg.sender;
    agreement.borrower = 0;
    agreement.tokenAmount = _amount;
    agreement.expiration = _expiration;
    agreements.push(agreement);
  }

slot 0x00 被未初始化的 agreement storage 指针所指向,因此,问题代码中offerToLend函数中的赋值操作则会分别更新 storage slot 0x00 ~ 0x03 上的值。

蜜罐

蜜罐合约是开发者故意利用各种技巧使代码部分特殊用途不易被参与者发现,利用当中的信息不对称,使参与者产生错误判断,从而被骗取本金。未初始化的 storage 指针正是“蜜罐合约”部署者最常用的一种技巧。

这是一个竞猜合约,参与者调用 guess() 接口,传入 _number 数字进行竞猜,如果猜的数字等于合约中的 luckyNum,则竞猜成功,参与者可获取两倍回报。

contract Honeypot {
	uint256 luckyNum = 42;
	uint256 public last;
	struct Game {
		address player;
		uint256 number;
	}
	Game[] public gameHistory;
	address owner = msg.sender;
	
	function guess(uint256 _number) public payable {
		Game game;
		game.player = msg.sender;
		game.number = _number;
		gameHistory.push(game);
		if (_number == luckyNum) {
		   msg.sender.transfer(msg.value * 2);
		}
		last = now;
	}
}

函数 guess() 先把参与者的地址和竞猜数字放入 gameHistory 数组中保存。而数组 gameHistory 由 Game 结构体(Struct)构成。函数开始先通过 Game game 声明了一个结构体变量 game,再分别对成员变量进行赋值,最后将变量 game 塞到 gameHistory 数组中。
对于未初始化的 storage 指针,Solidity 默认其指向 storage 的起始地址(slot 0x00)。在蜜罐合约中,luckyNum 变量正是这个合约中第一个被定义的状态变量,占据了 storage 的开始位置(slot 0x00)。

image.png

靶场

下面一个案例是Solidity Wargame中的NoRefunds合约,参与者需要取出合约的代币余额(10000)并将其发送到自己的地址上。

contract NoRefunds {
  uint public withdraw_amount = 0;
  address public withdraw_beneficiary = address(0);
  address public owner;
  
  struct RefundReq {
    uint request_amount;
    address request_beneficiary;
    bool approved;
  }

  mapping (address => RefundReq) public pending_refunds;
  mapping (address => uint) public balances;
  ...
  function withdrawBalance() public {
    require(withdraw_beneficiary != address(0));
    giveTokens(withdraw_beneficiary, withdraw_amount);
  }
function refund(bool _activate_refund, uint _amount, address _beneficiary) public {
    if (_activate_refund) {
      RefundReq storage req = pending_refunds[msg.sender];
      require(req.approved);
      require(req.request_amount <= _amount);
      require(req.request_beneficiary == _beneficiary);
      req.approved = false;
      giveTokens(_beneficiary, _amount);
    } else {
      	req.request_amount = _amount;
      	req.request_beneficiary = _beneficiary;
      	req.approved = false;
    }
  }

  function giveTokens(address _to, uint _amount) private {
    require(balances[address(this)] >= _amount);
    balances[address(this)] -= _amount;
    balances[_to] += _amount;
  }
}

在NoRefunds合约的 refund函数中,看似req 进行了初始化赋值,但是初始化赋值只在 if 分支中有效,在 else 分支中,req却并没有进行初始化赋值,而是直接进行了使用。此时 else 分支中的 req 指向了初始位置 slot 0x00。

首先,调用 refund 函数,使其执行 else 分支,将 withdraw_amount 修改为合约中的余额,将 withdraw_beneficiary 修改为自身地址;然后再调用 withdrawBalance 函数便可以“盗取”合约中的所有余额。

call调用

call 与 static call 是 EVM 中合约内调用其他合约的两个方式,其对应的底层操作码是 CALL 和 STATICCALL 。
CALL 是在被调用者的上下文中执行,只能修改被调用者的状态。 STATICCALL 与 CALL 类似,但它不会修改被调用者的。

未检查的调用返回值

通过一些简单的代码示例来看看 send 、 transfer 、 call 这几个底层消息调用的特点:

bytes memory payload = abi.encodeWithSignature("register(string)", "MyName");
(bool success, bytes memory returnData) = address(nameReg).call(payload);
require(success);
pragma solidity 0.4.25;

contract ReturnValue {

  function callchecked(address callee) public {
    require(callee.call());
  }

  function callnotchecked(address callee) public {
    callee.call();
    // do something
  }
}

如果没有检查返回值,将导致代码会继续执行,即便被调用的 callee 合约中发生了 revert 。如果该调用意外的失败或者攻击者强制本次调用失败,将可能导致接下来代码执行的意外行为。

该情况同样适用于 send 、 transfer 、 call 等底层消息调用,接下来以 send 和 transfer 进行代码演示

pragma solidity 0.8.18;

contract Receiver1 {
    receive() external payable {}

    function balance() public view returns(uint) { return address(this).balance; }
}

contract Receiver2 {
    receive() external payable { revert(); }

    function balance() public view returns(uint) { return address(this).balance; }

    function test() public { revert(); }
}

contract EtherTransfer {
    address r1;
    address r2;

    constructor() {
        r1 = address(new Receiver1());
        r2 = address(new Receiver2());
    }

    function bal1() public view returns(uint) {
        return r1.balance;
    }

    function bal2() public view returns(uint) {
        return r2.balance;
    }

    modifier ethersRequired() {
        require(msg.value > 0, "ethers required");
        _;
    }

    function testSuccessOnTransfer() public payable ethersRequired {
        payable(r1).transfer(msg.value);
    }

    // The function will stop with an exception and revert to initial state.
    function testFailureOnTransfer() public payable ethersRequired {
        payable(r2).transfer(msg.value);
    }

    function testSuccessOnSend() public payable ethersRequired {
        payable(r1).send(msg.value);
    }

    // The function will finish without an exception, but
    // the execution of `send` failed.
    function testFailureOnSend() public payable ethersRequired {
        payable(r2).send(msg.value);
    }

    function testFailureOnSendWithRecommendedPattern() public payable ethersRequired {
        bool sucess = payable(r2).send(msg.value);
        if (!sucess) { revert("send failed"); }
    }
}

transfer 与 send 之间的区别: send 在执行失败后,不会抛出异常,但会以 false 返回值标识执行失败。

testSuccessOnTransfer 方法会顺利的成功执行; testFailureOnTransfer 方法会停止执行并抛出异常; testSuccessOnSend 方法将执行成功,即便里面的 send 调用失败了; testFailureOnSendWithRecommendedPattern 方法在执行 send 调用时判断了其返回值,因此该交易会抛出 send failed 的异常。

调用深度限制

消息调用的深度被限制在1024,这意味着对于更复杂的操作,循环应该优先于递归调用。

调用深度攻击

if (gameHasEnded && !( prizePaidOut ) ) {
  winner.send(1000); // send a prize to the winner
  prizePaidOut = True;
}

这里的问题是 send 方法可能会失败(注意没有检测返回值)。如果失败,则获胜者不会得到钱,而且 prizePaidOut可能会赋值为 True。

实际上有两种不同的情况 winner.send() 可能会失败:
第一种情况是如果 winer 地址是一个合约(而不是用户帐户),并且该合约的代码抛出异常(例如,如果它使用了太多的 gas)。如果是这种情况,那么这种担忧可能就没有意义了,因为无论如何这都是“赢家”自己的错。
第二种情况不太明显。EVM 有一个称为“调用栈”的有限资源,这个资源在触发外部调用时会被使用(导致调用栈深度+1)。
如果在我们到达 send 方法前调用深度已经达到1024,那么无论获胜者如何,它都会失败。

EIP150

在EIP150中,对消息调用时使用的 gas 做了更新:在消息调用中只能转发当前可用 gas 的 63/64 用于子调用(在 EIP150 提出)。实际上最大调用栈深度限制在 ~340(低于 ~1024),但1024的调用栈深度限制仍然存在。

它的提出主要解决了两个问题:

  • 调用深度攻击
  • 减轻依赖调用的任何进一步潜在的 DoS 攻击所造成的危害

可以通过减少最大调用栈大小来强制合约在 send() 或 call() 上静默失败。因此 1/64 是对已部署合约的保护,以防止调用深度攻击。

Call方法注入漏洞

Call方法注入漏洞,顾名思义就是外界可以直接控制合约中的call方法调用的参数,按照注入位置可以分为以下三个场景:

  1. 参数列表可控 .call(bytes4 selection, arg1, arg2, ...)
  2. 函数选择器可控 .call(bytes4selection, arg1, arg2, ...)
  3. Bytes可控 .call(bytesdata) .call(msg.data)
       pragma solidity 0.4.24;

contract ContractA {
   uint public a;
   function doSomething(bytes memory data) public {
       address(this).call(data);
   }

   function doSomething2(string memory func_name) public {
       address(this).call(bytes4(keccak256(func_name)), 888);
   }

   function sensitiveOperation(uint _a) public {
       require(msg.sender == address(this));
       a = _a;
   }

   function attack() public returns(bytes memory) {
       bytes memory payload = abi.encodeWithSignature("sensitiveOperation(uint256)", 666);
       doSomething(payload);
   }

   function attack2() public returns(bytes memory) {
       doSomething2("sensitiveOperation(uint256)");
   }
}

sensitiveOperation 方法由于是敏感操作,因此必须合约自身调用才能执行。然而这里的 doSomething 方法中有个 call 的调用,并且外界可以直接控制 call 调用的字节数组,因此如果外界精心构造一个 data,这个 data 的函数选择器指定为 sensitiveOperation 方法,那么外部用户就可以以合约身份调用到这个 sensitiveOperation 方法,这样就会造成一定的风险。 比如这里封装了 attack 方法帮助演示这一过程,调用后最终其状态变量 a 被修改为 666。

ERC223 标准是为了解决 ERC20 中对智能合约账户进行转币场景缺失的问题,可以看作是 ERC20 标准的升级版。但是在很多ERC223标准的实现代码中就带入了call注入的问题:

image.png

很多合约在判断权限的时候会将合约自身的地址也纳入到白名单中:

image.png

在编写代码的时候,尽量不要引入这样的 call 调用,如果非要使用 call,需要注意以下两点:

  • 可以指定函数选择器字符串,避免直接使用 bytes 进行底层的 call 调用。
  • 对于包含特权地址判断的敏感操作,不要轻易将合约自身的地址作为可信地址

delegatecall

什么是delegatecall

委托调用(delegatecall)是一个低级函数,其功能与call类似,区别在于delegatecall是使用指定地址的代码,而其他信息(存储数据)则是使用当前合约。

image.png

delegatecall与可升级合约

代理合约的简单实现:

contract Flag {
    bool public flag;
    address public sender;
    function setFlag() public {
        flag = true;
        sender = msg.sender;
        }
}

contract Delegate {
    bool public flag;
    address public sender;
	
    function setFlagByDelegatecall (address addr) public {
        (bool success, bytes memory data) = addr.delegatetcall(abi.encodeWithDignature("setFlag()"));
    }
}

可升级合约的简单实现:

image.png

image.png

delegatacall用于代理模式的注意点

返回值处理问题

在工程实践中,大多数时候我们使用代理的入口是在fallback函数,我们看下面的代码片段,直接调用delegatecall,其返回值data其实是无法返回给外部调用者的,这在我们实际操作中,就会带来很多不便。

image.png

因此,通常我们使用的解决方法是利用汇编重写一个_delegate函数:


    /**
     * @dev Delegates the current call to `implementation`.
     *
     * This function does not return to its internal call site, it will return directly to the external caller.
     */
    function _delegate(address implementation) internal virtual {
        assembly {
            // Copy msg.data. We take full control of memory in this inline assembly
            // block because it will not return to Solidity code. We overwrite the
            // Solidity scratch pad at memory position 0.
            calldatacopy(0, 0, calldatasize())

            // Call the implementation.
            // out and outsize are 0 because we don't know the size yet.
            let result := delegatecall(gas(), implementation, 0, calldatasize(), 0, 0)

            // Copy the returned data.
            returndatacopy(0, 0, returndatasize())

            switch result
            // delegatecall returns 0 on error.
            case 0 {
                revert(0, returndatasize())
            }
            default {
                return(0, returndatasize())
            }
        }
    }

合约初始化问题

在代理模式中,由于逻辑合约并不具备构造函数的功能,所以实现函数一般都含有initialize函数用于初始化基本参数,并且保证该函数只能被调用一次。如下图所示则是一个错误的的合约实现,那么攻击者在合约部署后仍是可以再次调用initialize函数更新合约的关键参数。

        function initialize(
        string memory name_,
        string memory symbol_,
        address _stOKT,
        address _agent,
        address _stakeOracle
    ) external  {
        __Ownable_init();
        __ERC721_init(name_, symbol_);
        __ERC721Pausable_init();
        pauser = msg.sender;
        stakeOracle = _stakeOracle;
        version = "1.0";
    }

因此,目前比较常见的三方库实现了initializer修饰器来限制此函数,保证该函数只能被调用一次。但即使这样也是可能出现问题的,如下图为一般initializer修饰器的实现:

image.png

可以发现,initializer修饰器是否通过检查的关键在于关键bool型变量的值,但如果代理合约对应bool型变量的存储位被其他变量所覆盖,那么可能导致initializer修饰器检查失效。

存储冲突

image.png

函数选择器冲突

通常的代理模式中,调用代理合约函数时,会率先根据调用数据的函数选择器进行查询,如果是代理合约接口,那么就直接执行call调用;但如果是对应的函数选择器在代理合约并未找到,那么将进行fallback函数中,执行delegatacall操作。因此,如果逻辑合约中函数选择器和代理合约一致,那么将始终调用代理合约,而不能进入逻辑合约中(如:transferOwnership)。

自毁

由于delegatecall在调用合约时,如果目标合约地址不存在,也会顺利调用返回。 如果逻辑合约被通过selfdestruct指令清除,在代理合约层面delegatecall会继续顺利调用,但是合约已经被销毁,逻辑合约应该禁止使用自毁函数。

另外如果用于管理升级的逻辑位于逻辑合约中而不位于代理合约中(如使用了UUPS代理模式),则实际上会导致再也无法使用代理。

重入攻击

计算机程序或子程序在执行过程中中断,然后在它前一次调用完成执行之前再次进行调用,那么该程序或子程序称为重入程序。

在以太坊智能合约中,合约能够调用和利用其他外部合约的代码。合约在正常执行期间可以通过执行函数调用或者简单地转移以太币来执行对其他合约的调用。这些外部调用可以被攻击者劫持,从而强制合约执行下一步代码(即通过fallback函数),包括回调自身。在这种情况下,我们可以说合约被重入。

重入攻击的类型可以分为:单函数重入、跨函数重入、跨合约重入

单函数重入

当一个易受攻击的函数是攻击者试图递归调用的同一个函数时,就会发生单函数重入攻击。

mapping(address => uint256) public balance;

function withdraw(uint256 _value) external {
	require(balance[msg.sender] >= _value);
	msg.sender.call{value: _value}(""); // send eth to user
	balance[msg.sender] -= _value;
}

在此示例中,合约向调用者退还其存入的以太后再将账户余额状态进行修改。这时候,会让黑客可以在状态修改之前利用fallback()函数多次调用该函数,直至取走合约账户内的全部余额。

跨函数重入

当一个易受攻击的函数与一个可被攻击者利用的函数共享状态时,就会发生跨函数重入

image.png

由于unstake和vote都使用了weight,通过unstake触发重入调用vote将其weight减为0,此时回到标记1处继续执行,虽然unstake前面做了weight[msg.sender] >= _value检查,但明显此时该值已通过vote进行了修改。此时继续执行weight[msg.sender] -= _value;将导致溢出,使得攻击者权重变成一个极大值,严重影响了投票的公平。

跨合约重入

跨函数重入和单函数重入在同一个合约中,也有不在同一个合约,重入可以发生在跨多个合约,便是多个合约共享同一个状态
当一个合约中的一个状态在另一个合约中使用,但在被调用之前未完全更新时,可能会发生跨合约重入

  • 一个合约中的状态在另一个合约中共享或者使用
  • 攻击者可以通过利用执行流来操纵合约的状态

预防措施

  • 对于单函数重入、跨函数重入,可以在合约中实现互斥锁,用来防止重复调用同一个合约中的函数,从而防止重入。实现锁的一种广泛使用的方法是继承OpenZeppelin的ReentrancyGuard并使用nonReentrant修饰符。

  • 在调用外部合约或所谓的“检查-生效-交互”模式之前检查并尝试更新所有状态。这样,即使重入,也不会产生任何影响,因为所有状态都已完成更新

  • gas限制可以防止重入攻击,但这不应该被视为一种安全策略,因为gas成本取决于以太坊的操作码,这些操作码可能会发生变化。 关于重入,send和transfer都有2300个单位的gas限制。使用这些函数应该可以防止发生重入攻击,因为这不足以递归回调源函数来利用资金

随机数安全

什么是随机数

真伪随机在统计学上实际上是无法区分的,但是由于区块链环境的特殊性,我们对于序列不仅需要做到根据先前的数据无法预测之后的数据,还要做到在算法铭文的情况下无法对结果进行预测。
传统应用中硬件真随机数发生器,通常使用芯片实现;而软件真随机数发生器,则使用系统自带的非确定现象,譬如硬盘寻道时间,RAM内容,进程调度随机性或者用户输入。Linux中的/dev/random就是一种真随机数发生器,通过采集机器运行中的硬件噪音数据获得足够的数据来源。

获取随机数的方式

目前以太坊不存在产生随机数的方法,虽然没有random方法,但在一些应用业务场景下,比如彩票,盲盒都等等都需要用到随机数,那智能合约应当如何获得随机数呢?

一般情况下智能合约获取随机数有两种方式:通过链上信息生成或者通过链下喂养

链上生成

链上生成随机数的核心是在交易被打包到区块之前尽可能的选取不可预测的种子,那么通过链上可以获取的熵源有哪些:

  • block.coinbase 当前区块的矿工地址
  • block.difficulty 当前区块的挖掘难度(其值由上一个区块产生的时间,上一个区块的难度以及当前区块产生的时间、当前区块的高度决定)
  • block.gaslimit 区块内交易的最大限制燃气消耗量
  • block.number 当前区块高度
  • block.basefee gas的基础费用(basefee是动态变化的,其值由和之前区块的gas实际消耗以及gas target值(gasLimit / ELASTICITY_MULTIPLIER 乘数)决定)
  • block.timestamp 当前区块挖掘时间
  • block.blockhash(block.number) :当前区块的区块哈希[在EVM中本值为0,区块上链后才会赋值]
 // SPDX-License-Identifier: GPL-3.0

pragma solidity >=0.7.0 <0.9.0;

contract game {

    bytes32 constant MASK = bytes32("chengdu xinxi gongcheng daxue");

    function throwCoin() private view returns (uint256){
        
        return uint256(keccak256(abi.encodePacked(
                                           block.timestamp, 
                                           block.difficulty,
                                           block.coinbase,
                                           MASK
                                           ))) % 2;

    }


    function guess(uint256 g) payable public {
        require(msg.value == 0.1 ether);
        if (g == throwCoin()) {
            payable(msg.sender).transfer( 0.2 ether);
        }

    }

任何人都可以调用guess向合约发送0.1个以太猜测硬币投掷的正反面,我们使用了众多看似不可预测的随机数熵源,但是真的不可预测吗?需要注意的是,这些区块变量在同一区块上是相同的。

我们通过攻击合约将计算随机数和猜测硬币的正反面放在同一笔交易里,将计算出来的答案作为'猜测',必然是百发百中。

矿工作恶(时间戳依赖)
假设我们在以上投掷硬币的方法实现中加入msg.sender参数,再加入不允许合约调用的修饰器,这时候不同调用者调用随机数生成器生成的随机数都不一样了,也杜绝了通过部署攻击合约计算答案,使其变得看似"不可预测"。

function isContract(address addr) public view returns (bool) {
    uint size;
    assembly { size := extcodesize(addr) }
    return size > 0;
}

modifier isNotContract() {
    require(!isContract(msg.sender), "msg.sender can not be a contract");
     _;
}

function throwCoin() isNotContract private view returns (uint256){
        
        return uint256(keccak256(abi.encodePacked(
                                           block.timestamp, 
                                           block.difficulty,
                                           block.coinbase,
                                           uint256(block.number),
                                           msg.sender
                                           MASK
                                           ))) % 2;

    }

矿工在打包区块的时候,由于区块依赖的数据对挖出新块的矿工来说均为确定性数据,并且timestamp在一定程度上可以由矿工控制,这也就意味着矿工可以通过操纵timestamp的值控制投掷硬币的结果。
因此这种方式在某些与主业务无关的场景中可以使用。但是,涉及到资产以及主业务的模块仍然不可取

blockhash(block.number)的不恰当使用

function rand() public view returns(uint256) {

        uint256 random = uint256(keccak256(blockhash(block.number)));

        return  random%10;

    }

通过 block.number 变量可以获取当前区块区块高度。但是在执行时,当前区块属于未来区块,它的blockhash是不可知的,即只有当矿工打包包含此合约调用的交易时,这个未来区块才变为当前区块,这时候我们获取到的blcokhash是0x0000...0000。
也就是说uint256 random = uint256(keccak256(block.blockhash(block.number)));实际上等价于uint256 random = uint256(keccak256(0));那么这个rand也就是一个定值。

function throwCoin() isNotContract private view returns (uint256){
        
        return uint256(keccak256(abi.encodePacked(
                                           // block.timestamp, 								// 屏蔽时间戳依赖风险
                                           block.difficulty,
                                           block.coinbase,
                                           uint256(block.number),
                                           blockhash(block.number - 1),    // 加入新熵源
                                           msg.sender,
                                           MASK
                                           ))) % 2;

    }

这样的方式,虽然理论上可以获得随机数,但这个随机数是不安全的。因为攻击者可以将交易做一个预执行,每出一个新块就将这个新块作为上一个区块执行并获得结果,再选择性广播那些可以符合攻击者期望的交易,这样就可以操纵交易的执行结果。

中心化应用引入的安全隐患

短地址攻击与假充值就是其中最著名的两个中心化应用引入的漏洞。

短地址攻击

在实际开发过程中,有些漏洞并不一定是合约本身的漏洞,但由于对与智能合约交互方面不够熟悉,很可能在实际对接智能合约时出现纰漏。短地址攻击本质上就是由于对智能合约 ABI 的编码规则不够熟悉导致的漏洞。

以下是 ERC20 的 transfer 接口定义:
function transfer(address to, uint amount) public returns(bool success);

由于 EVM 的特性,实际进行合约调用的时候,合约是根据交易中的 data 字段进行解析,从而执行相应的操作。它是一个字节数组,其前4字节是函数选择器,其后是参数的 ABI 编码,在这个例子中,是对 address、uint256 两个类型的值的编码。address 占用 20 字节,uint 占用完整的32字节。

假设我们的数据是:

to = 0xdeaddeaddeaddeaddeaddeaddeaddeaddeaddead
amount = 123

ABI 规范要求将参数填充为 32 字节的倍数,因此, 不足 32 字节的部分会被填充。其 ABI 编码结果为(为了可读性,我们每32字节做了一次换行):

0x000000000000000000000000deaddeaddeaddeaddeaddeaddeaddeaddeaddead 000000000000000000000000000000000000000000000000000000000000007b

让我们再加上函数选择器(其中函数选择器单独一行):

0xa9059cbb 000000000000000000000000deaddeaddeaddeaddeaddeaddeaddeaddeaddead 000000000000000000000000000000000000000000000000000000000000007b

对于合约来说,就是将我们刚刚分步骤编码的过程逆向一次,就解码得到了对应的 toamount 的具体数值。 我们考虑让地址变短一点,减少一个字节,看看会发生什么,我们首先得到少一个字节的地址值,其他保持不变:

to = 0xdeaddeaddeaddeaddeaddeaddeaddeaddeadde

如果说客户端发起合约调用时,没有对参数做校验,简单的将两个值拼接成字节数组的话,就会构造成如下数据:

0xa9059cbb 000000000000000000000000deaddeaddeaddeaddeaddeaddeaddeaddeadde00 0000000000000000000000000000000000000000000000000000000000007b

可以很明确看到少了一个字节,同样由于 ABI 的规范,再解码时,依然读取完整的32字节,因此第二个参数的第一个字节被补到了第一个参数末尾。而第二个参数少掉的一个字节被自然的填充成了32字节,于是最终在合约解码时的数据看起来是这样的:

0xa9059cbb 000000000000000000000000deaddeaddeaddeaddeaddeaddeaddeaddeadde00 0000000000000000000000000000000000000000000000000000000000007b00

最终数据被解码为:

to = 0xdeaddeaddeaddeaddeaddeaddeaddeaddeadde00 amount = 31488 // 也就是十六进制的 0x7b00

因此,如果这样的一个短地址由攻击者控制,而转账人的余额也充足的话,原本在转账时,转账人实际只想转 123 个 token 的,平白无故多转了很多出去。

要避免也很简单,在做开发时对用户输入数据的校验做好即可。

假充值

当提到“假充值”攻击时,我们通常谈的是攻击者利用公链的某些特性,绕过交易所的充值入账程序,进行虚假充值,并真实入账。

这样的问题同样发生在 ERC20 的 transfer 接口中,由于接口定义中有一个bool返回值用于返回转账是否成功。因此很多实现在转账失败时,并没有抛出异常,而是通过简单的返回 false 替代。

function transfer(address _to, uint256 _value) returns (bool success) {
	if (_balances[msg.sender] >= _value && _value > 0) {
		_balances[msg.sender] -= _value;
		_balances[_to] += _value;
		emit Transfer(msg.sender, _to, _value);
		return true;
	} else{
		return false;
	}
}

这样的设计在合约层面看起来是合理的,但却很容易与以太坊本身的交易执行结果的状态混淆。

在 Tx Receipt 中的 Status 字段由于标识该交易是否抛出了异常(比如使用了 require/assert/revert/throw 等机制),如果未抛出异常,则为0x01(true),否则为0x00(false)。如果中心化交易所在实现充值时,仅判断 Tx Receipt 的状态来区分是否充值成功的话,就会出现调用 transfer 失败返回 false,但 Tx Receipt 的状态却显示 true 的情况,此时中心化交易所认为充值成功(然而实际失败),将款打到了攻击者的账上。

因此,针对这个问题,我们严格的使用 require/assert 的方式限定条件,当不符合条件时,中止合约继续执行:

function transfer(address _to, uint256 _value) returns (bool success) {
	require(_to != address(0), "_to address can't be zore");
	require(_balances[msg.sender] >= _value);
	_balances[msg.sender] -= _value;
	_balances[_to] += _value;
	emit Transfer(msg.sender, _to, _value);
	return true;
} 

而对于接口使用方来说,除了判断 Tx Receipt 的状态之外,还应该判断收款地址的余额是否增加。一种方式是通过直接检查收款地址的余额来实现;另一种方式是监听相应的 Transfer 事件来实现,但需要注意合约开发者可能作恶,因此在使用第三方合约接口时,需要严格对代码进行审查。

去中心化金融应用基本概念与设计原理

去中心化交易所

订单簿与流动性池

在传统的交易所中,交易者通过标价出售或投标买入来进行交易,也就是提交卖单或是买单。
交易所维护一份订单簿,记录当前所有的买单和卖单,并使用算法来匹配合适的买卖单,促成交易。

比如在ETH/USDT交易对中,交易者提交买单,本质是希望使用USDT去换ETH,而价格则是使用USDT兑换ETH的比例,当碰到有交易者提交更低价格的卖单时,交易完成,买卖双方完成USDT和ETH的交换。

在传统的订单簿交易模型中,交易者与交易对手(counterparty)进行币种的交换,而在AMM算法中,对于每个交易对,合约维护了一个装有两个币种的池子,叫做流动性池(Liquidity Pool),比如在ETH/USDT交易对中,就维护了一个装有ETH和USDT的池子,交易者可以往池子里放入USDT,并按照一定比例换取ETH,反之亦然。

而流动性池中的币则由流动性供应商提供,算法会从每一次交易中抽取手续费给流动性供应商,以此来激励供应商提供流动性。与订单簿不同的是,订单簿中交易者可以限定价格等待成交,而在AMM中则需要按照当前池子计算出的价格进行交易。

自动做市商(AMM)

做市商(Market Maker)是负责在交易所提供价格的实体,否则没有交易活动就会缺乏流动性。做市商从自己的账户买入和卖出资产,最终目的是为了获利。他们的交易活动为其他交易者创造了流动性,降低了交易的滑点。

自动做市商(AMM)使用算法 "Money Robots "来模拟DeFi等市场内的价格行为。虽然不同的去中心化交易所设计不同,但基于AMM的DEX一直以来都拥有最大的流动性和最高的日均交易量。

恒定函数做市商(CFMM) 是最受欢迎的一类AMM,专门为实现数字资产的去中心化交易而设计的。这些AMM交易所基于恒定函数,交易对的综合资产储备必须保持不变。在非托管式AMM中,各个交易对的用户保证金被集中在一个智能合约内,任何交易者都可以利用该合约进行代币兑换。因此,用户是与智能合约(集合资产)进行交易,而不是像在订单簿交易所那样直接与对手方进行交易。

恒定乘积做市商(CPMM)

CPMM基于函数x*y=k,该函数根据每个代币的可用数量(流动性)确定了两个代币的价格范围。当X的供应量增加时,Y的供应量必须减少,反之亦然,以保持k的乘积不变。当绘制出曲线,结果是一个双曲线,其中流动性总是可用的,但当价格越来越高,两端将接近无穷。

简化公式:(TokenA余额 + 你出售的TokenA)*(TokenB余额 - 你获得的TokenB) = 常数K

恒定总和做市商(CSMM)恒定平均值做市商(CMMM)

恒定总和做市商(CSMM)非常适合零滑点交易,但不能提供无限的流动性。CSMM遵循公式x + y = k,在绘制时会创建一条直线。
不幸的是,如果代币之间的链下参考价格不是1:1,则这种设计允许套利者耗尽其中的一项储备。这种情况将破坏流动资金池的一侧,迫使流动资金提供者承担损失,而交易者则没有更多的流动资金。因此,CSMM是AMM的罕见模型。

恒定平均值做市商(CMMM)可以创建具有两个以上代币的AMM,恒定平均值做市商由恒定乘积做市商这一概念推广而来,可用于两种以上资产,权重比例不仅限于 50/50 。恒定平均值做市商由 Balancer 率先引入。

相关功能和概念

  • (addLiquidity)添加/移除流动性

  • 使用一种代币兑换成另外一种代币

  • (Liquidity provider)流动性凭证LP
    流动性提供者(简写为LP, 即Liquidity Providers)代币(LP Token)是向在自动做市商(AMM)协议上运行的去中心化交易所(DEX)的流动性提供者发行的代币。lp代币作为凭证代币,一般来说用户可随时使用其向协议赎回所提供的流动性,协议通过lp所占比例分发。lp token作为一种ERC20标准代币自然也可以转移,流通。但是协议只认凭证不认“人”,转移lp token的时候需要谨慎。

  • 滑点
    交易滑点是指在实际交易过程中,由于各种原因,实际成交价格与预期成交价格存在一定的差距​。滑点的大小取决于市场的流动性、交易量和市场波动等因素。

甲在 Uniswap-v2 上提供了 1000 个 TokenA 和 100 个 TokenB 作为流动池,TokenA 和 TokenB 的标记比价为 10:1,即10个 TokenA 可以兑换 1 个 TokenB。然而在乙使用 1000 个 TokenA 在 Uniswap-v2 上兑换了 50 个 TokenB之后,其实际的交易比价约为 1000:50,实际上使用 10 个 TokenA 只能兑换 0.5 个 TokenB。这就是在 Uniswap-v2 上交易产生了滑点,滑点高达 50% 。

  • 无常损失
    无常损失是指因市场波动而导致的投资价值下降。这种情况通常是由于市场情绪的变化,投资者对加密货币的预期发生了变化,导致价格波动。

1、甲在 Uniswap-v2 上提供了 1000 个 TokenA 和 100 个 usdt 作为流动池,乙在 Uniswap-v2 上使用 1000 个 TokenA 兑换了 50 个 usdt,池子里剩下 2000 个 TokenA 和 50 个 usdt。

2、原来甲在 Uniswap-v2 上提供的总资产价值为 1000 个 TokenA 和 100 个 usdt,TokenA 对 usdt 的兑换比率为 1000 :100,可以换算为 200 个 usdt。乙兑换之后,甲在 Uniswap-v2 上提供的流动性资产价值为:2000 个 TokenA 和 50 个 usdt,TokenA 对 usdt 的兑换比率为 2000:50 ,总计为 100 个 usdt。

3、而如果甲本来就不提供流动性,TokenA 对 usdt 的兑换比率跌为2000:50 ,则其资产为 1000 个 TokenA 和 100 个 usdt = 125 usdt。可以看到,甲因为提供流动性多损失了25usdt,这就是资产下跌造成的无常损失。 4、乙改为在 Uniswap-v2 上使用 100 个 usdt 兑换了 500 个 TokenA。乙兑换之后,甲在 Uniswap-v2 上提供的流动性资产价值为:500 个 TokenA 和 200 个 usdt,TokenA 对 usdt 的兑换比率为 500:200,总计为 400 个 usdt。

5、而如果甲本来就不提供流动性,TokenA 对 usdt 的兑换比率涨到500:200,则其资产为 1000 个 TokenA 和 100 个 usdt = 500 usdt。可以看到,甲因为提供流动性少赚了100usdt,这就是资产上涨造成的无常损失。

无常损失就是流动性提供者,在资产上涨过程中自动卖出,在资产下跌过程中自动买入所造成的损失

  • TWAP(时间加权平均价格)
    时间加权平均价格(time-weighted average price,首字母缩略字:TWAP)在金融业中是指特定时间内证券的平均价格。TWAP模型设计的目的是使交易对市场影响减小的同时提供一个较低的平均成交价格,从而达到减小交易成本的目的。

image.png

三明治攻击

三明治攻击(sandwich attacks)是DeFi里流行的抢先交易技术的一种。为了形成一个“三明治”交易,攻击者(或者我们叫他掠夺性交易员)会找到一个待处理的受害者交易,然后试图通过前后的交易夹击该受害者。这种策略来源于买卖资产从而操作资产价格的方法。

所有区块链交易都可在内存池(mempool)中查到。一旦掠夺性交易者注意到潜在受害者的待定资产X交易被用于资产Y,他们就会在受害者之前购买资产Y。掠夺性交易者知道受害者的交易将提高资产的价格,从而计划以较低的价格购买Y资产,让受害者以较高的价格购买,最后再以较高的价格出售资产。

现在假设用户A发送了一笔使用代币X兑换代币Y的交易:

闪电贷攻击

传统借贷与闪电贷

传统借贷:
传统信贷机构放贷时面临两种风险:
第一种是违约风险:如果贷款人携款潜逃,信贷机构会吞下苦果。
第二种是流动性风险:如果一家信贷机构在错误的时间放出太多贷款,或者借款人未及时还款,信贷机构可能意外遭遇流动性紧张,无法履行自己的义务。

闪电贷: 闪电贷顾名思义,就是在极短的时间内(一个区块时间或者一笔交易内)完成贷款与还款的操作。在DeFi领域,闪电贷为金融业务提供了免抵押借款服务,其概念最早是由Marble协议提出来的,并由Aave、dYdX、Uniswap等协议进行了普及,第一笔闪电贷操作来自于Aave协议。

闪电贷(Flash Loan)必须以你借入的同一资产偿还:如果借入Dai,就需要偿还Dai。我们以生息协议Aave为例,该协议对闪电贷收取0.09%的费用。它至少需要进行三个操作:

  • 在Aave上借款
  • 在一个DEX上进行兑换
  • 在另一个DEX上进行套利交易以实现利润,最后偿还借款以及闪电贷费用

闪电兑(Flash Swap) 允许交易者在交易后期支付(或返还)资产之前,先接收资产并在其他地方利用该资产。如果我们使用闪电兑换拿ETH买入Dai,那么用Dai或ETH偿还都可以,这使我们可以执行更复杂的操作。

闪电贷是利用智能合约的原子性,来完成零风险贷款的业务,因此闪电贷业务只能由智能合约来实现。该原子事务是不可分割的,在事务执行完毕之前,不会被任何其他操作所中断。而该事务中的所有操作,要么全部被成功执行,要么全部执行失败,不会出现一部分成功而另一部分失败的情况。

uniswap 闪电兑(闪电贷)

Uniswap: Uniswap的pair合约中的swap函数提供了闪电贷服务,当传入的data不为空时,就会调用to地址的uniswapV2Call函数,因为uniswap的swap函数模式是先转账再进行k值校验,所以可以先将需要的代币借出后调用自己合约中的uniswapV2Call函数进行其它操作,完成后再将借的代币归还。

Aave 闪电贷

Aave的LendingPool合约中的flashloan函数提供了闪电贷功能,通过调用flashloan函数,传入借贷的金额和代币类型借贷地址等参数。执行过程中将代币转给传入的接收地址后会调用传入地址的executeOperation函数,这时可以在executeOperation函数完成其它操作,需要注意的,Aave闪电贷的还款过程需要借贷者向LendingPool合约授权,在完成闪电贷过程最后将借用的钱和手续费从借款地址中转回。

闪电贷攻击

  1. 利用闪电贷大额的资金量操纵价格,一些项目中价格获取逻辑存在问题。

  2. 一些项目在抵押或其它过程中会产生瞬时奖励,利用闪电贷获得大额奖励。

  3. 项目中存在其它的逻辑漏洞,利用闪电贷大的资金量放大套利空间。