智能合约场景下的模糊测试——智能合约基本介绍
Posted DeamLake
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了智能合约场景下的模糊测试——智能合约基本介绍相关的知识,希望对你有一定的参考价值。
智能合约场景下的模糊测试——智能合约基本介绍
前言
模糊测试和区块链的相关概念在此不再赘述,网络上有很多成熟的文档可以自行查阅。本人是在模糊测试领域有一年研究基础的CS研究生,应老板项目要求将模糊测试应用于区块链的智能合约场景并做一些新的尝试。奈何本人在区块链方向也是个小白,所以借此文对智能合约的相关概念作一个大致的了解并在此做一个总结,留作之后回顾。
本篇文章总结自注脚1中的综述,如若感兴趣可自行阅读原文。
如果内容有问题或者有歧义欢迎大家留言建议和交流。
一 基本概念
1.1 智能合约
智能合约是一种基于区块链平台运行,为缔约的多方提供安全可信赖能力的去中心化应用程序1。类似于一份不需要可信第三方监督或者参与的自动化合同,由技术手段来强制保证,在满足条件后能够自动履行承诺。
并且智能合约在部署到区块链平台之后是不能修改的,这也是把模糊测试应用到这个场景的主要动机,合约上线之前需要进行充分的测试,而模糊测试是一种非常有效的测试手段。
1.2 图灵完全
2当一个数据操作规则(一门编程语言、或者一个指令集)能够实现图灵机模型里的全部功能时,就称它具有图灵完备性。其中被称为区块链2.0的以太坊,其最大的特点就是支持运行图灵完全的智能合约运行。
二 智能合约特性
2.1 运行环境
以太坊虚拟机(Ethereum Virtual Machine, EVM)
EVM是一个无寄存器,基于栈式运行的虚拟机。其为智能合约提供了三种不同的存储空间,分别为栈(Stack)、临时内存(Memory)和永久存储(Storage)。前两者是临时的,仅在智能合约被调用时使用,Storage的存储结果则是永久生效的;Stack用于保存各种临时数据,以32字节为访问粒度;Momory用于保存数组字符串等较大的临时数据,以单字节为访问粒度。
2.2 生命周期
1)开发
智能合约的开发语言包括Solidity、Vyper、idris等。其中Solidity是使用人数最多也是最活跃的。
2)编译
所有语言开发的智能合约代码都需要被编译成统一的智能合约字节码(bytecode),才能在EVM上运行。被编译后还会生成相应的合约调用接口(Application Binary Interface, ABI).
3)部署
合约的部署由一笔合约部署交易来完成,其中交易的数据(data),字段被设置为合约部署字节码,而交易的接收方被设置为空。矿工在进行交易打包时,将会按照交易发送者的地址(address)和交易序列号(nonce)信息来生成一个新的地址,并把合约的字节码部署到该地址。这个地址就是合约地址,也是合约的唯一标识。
4)调用
区块链上的用户可以通过合约地址对合约进行调用。有两种调用方式:
一种是由普通地址发起一笔合约调用交易,这被成为交易调用(Transaction Call),会在区块数据中留下调用信息;
另一种是由某个合约发起的对另一个合约中函数的调用,称为消息调用(Message Call),不会留下调用参数信息。
5)销毁
以太坊允许合约进行自我销毁,但是需要开发者在合约编写时加入这个功能。“销毁”只是意味着合约在当前的区块状态(state)中被标记为删除,且不能被后续调用,但其合约代码和Storage存储还是可以被恢复和查看。
2.3 程序特性
1)Gas机制
对合约的任何操作都需要申请固定额度的Gas,如果合约程序的执行开销超过了这个阈值,以太坊虚拟机就被抛出Out-Of-Gas异常来停止合约执行。Gas机制保障了合约程序的可终止性,但也会被利用以发起Dos攻击。
2)异常传递机制
智能合约中的函数调用分为两种:
对本合约或父合约中函数的内部函数调用
对指定地址的外部合约函数的外部函数调用
内部调用只需要指令跳转,外部调用需要使用CALL命令向外部合约发送消息。后者如果在执行中发生错误,则异常不会延调用栈进行传递,而是直接用bool类型的返回值来标识调用是否完成。这也引发了很多安全问题。
3)委托调用
DELEGATECALL会改变函数调用者的上下文信息。所以一旦调用的目标地址被攻击者可控,攻击者就能在当前合约上执行任意代码…
4)合约代码无法修改
见1.1节
三 智能合约安全威胁
智能合约由很多区别于普通程序的特性,也因此带来了新的安全威胁。我按照注脚1综述中的顺序对智能合约中高级语言、虚拟机和区块链三个层面的安全漏洞作一个大体的概括。
3.1 高级语言层面
1)变量覆盖
solidity中没有特殊声明的变量应为Memory类型,但在某些版本的solidity中默认声明的数组或结构体会被误用为Storage类型的变量,而Storage中的变量一般为重要信息或者管理员信息。攻击者可以利用这个漏洞对关键信息进行恶意纂改。
2)整数溢出
各种计算机语言中常见的一种错误,需要加安全性检测。
3)未校验返回值
本文2.3.2的异常传递机制中说过,对外部合约调用的返回值只是简单的bool,因此开发者需要对这些返回值进行提前的预校验而不是交给合约的使用者自己处理,这容易造成合约内部控制流状态混乱。
4)任意地址写入
合约中包含用户可控的对任意Storage地址写入数据的漏洞。
5)拒绝服务
不安全的代码编写规范导致合约易受Dos攻击。
6)资产冻结
智能合约的一个重要作用是管理区块链平台上的数字资产。开发者如果在开发合约时只有接受ETH的功能而没有任何允许ETH转出的操作,则合约接受到的ETH资产将被永久冻结= =,一定程度上也因为合约部署之后无法被修改。
7)未初始化变量
没有被初始化的Storage变量可能会指向位置的Storage存储内容,对这些变量的读取会导致未知事件。
8)影子变量
各种情况下的全局变量和局部变量重名引起的逻辑问题。
3.2 虚拟机层面
特指EVM
9)重入
理应是原子性事务的“修改Storage变量并转账”这个操作采用了先转账再修改Storage变量的顺序,转账操作被恶意利用反复递归执行,从而破坏操作的原子性,进而重复获得转账收益。
10)代码注入
智能合约中的DELEGATECALL会使用调用目标的上下文信息,如果外部合约的地址是由攻击者可以控制的,攻击者就可以任意修改这个地址在当前合约中执行任何想要执行的代码。
11)短地址攻击
攻击者通过构造末尾为零的地址进行合约调用,并在调用参数中故意将地址末尾的零社区,从而利用虚拟机对于数据的自动补全机制将第二个参数进行移位放大。如果合约没有对用户输入长度进行校验,就会因为这个漏洞使得实际转账金额被扩大若干倍。
12)不一致性攻击
指智能合约因虚拟机实现不一致从而导致的智能合约状态混乱。
3.3 区块链层面
13)时间戳依赖
指智能合约在代码中使用严格的时间戳来进行重要的控制流决策,从而引入的安全漏洞。区块时间戳看似具有偶然性,但却是可以被矿工在一定的取值范围内操控的。
14)条件竞争
指智能合约中仅通过交易顺序来作为决策条件的程序逻辑所引起的漏洞。原因在于交易在由用户发起后,便可以被网络中的部分节点观测到,但此时离交易被打包尚有一段时间,且矿工通常先打包手续费更高的交易,因此攻击者可以快速发起同样的交易并通过提高手续费的方式让自己的交易被优先打包。条件竞争漏洞的根源在于区块链的交易打包和手续费机制。
15)随机性不足
指智能合约中误用了很多与区块链有关的变量作为随机源,但这样的做法将导致随机数可被预测。
Solidity智能合约单元测试介绍
Solidity智能合约单元测试介绍
当前在各种区块链中,生态最全的要属兼容EVM的区块链,在该类区块链上的智能合约绝大部分使用Solidity编写。因此,对Solidity编写的智能合约进行单元测试便成为一项经常性的工作。本文简要的介绍一下怎样使用hardhat进行Solidity智能合约单元测试。
一、什么是Hardhat
我们来看其官方文档的描述:
Hardhat is a development environment to compile, deploy, test, and debug your Ethereum software.
意思为 Hardhat
是一个编译,部署,测试和调试以太坊程序的开发环境,在这里本文只涉及到其测试功能。
在Hardhat
之前,我们使用truffle
做为开发、部署和测试环境。作为后来者,Hardhat
的功能更强大,因此现在我们一般使用Hardhat
来作为智能合约开发和测试工具。
官方文档介绍了两种测试方式:ethers.js + Waffle
和Web3.js + Truffle
。在这里我们使用ethers.js + Waffle
模式。
二、测试内容
我们进行单元测试,经常性的测试内容有:
- 状态检查,例如合约部署后检查初始状态是否正确,函数调用后检查状态是否改变。一般状态检查为读取view函数。
- 事件触发。基本上,合约中的关键操作都应该触发事件进行相应追踪。在单元测试中了可以测试事件是否触发,抛出的参数是否正确。
- 交易重置。在测试一些非预期条件时,交易应当重置并给出相应的原因。使用单元测试可以检测是否重置及错误原因是否相同。
- 函数计算。例如要计算不同条件下某函数的返回值(例如奖励值),我们需要循环调用 某个函数并输入不同的参数,看是否结果相符。
- 完全功能测试。例如我们合约中涉及到了区块高度或者 区块时间,比如质押一年后才能提取。此时我们一般需要加速区块时间或者区块高度来进行测试。幸运的是,
hardhat
提供了接口可以方便的进行此项测试。 - 测试覆盖率。包含代码覆盖率,函数覆盖率和分支覆盖率。一般情况下,应该追求 100%完全覆盖。比如你写了一个
modifier
,但是忘记加到函数上去了,而单元测试也漏掉了,此时代码覆盖就会显示该代码未测试,这样可以发现一些简单的BUG。特殊情况下或者确定有代码不会执行的情况下,不追求100%覆盖率。
接下来我们来详细介绍每项内容的测试方法。
三、示例合约
我们按照官方介绍新建一个示例工程Greeting
。在工作目录下运行下列命令:
mkdir Greeting
cd Greeting
npm install --save-dev hardhat
npx hardhat
此时选择第二项,创建一个高级示例项目(当然也可以选第3项使用typescrit),等待依赖库安装完毕。
运行code .
使用vocode打开当前目录。
我们可以看到项目的contracts
目录下已经生成了一个示例合约Greeter.sol
,内容如下:
//SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.0;
import "hardhat/console.sol";
contract Greeter
string private greeting;
constructor(string memory _greeting)
console.log("Deploying a Greeter with greeting:", _greeting);
greeting = _greeting;
function greet() public view returns (string memory)
return greeting;
function setGreeting(string memory _greeting) public
console.log("Changing greeting from '%s' to '%s'", greeting, _greeting);
greeting = _greeting;
代码比较简单,需要注意的是它使用了一个hardhat/console.sol
插件,该插件可以在hardhat netwrok
环境中打印出相应的值,方便开发时调试。可以看到,它支持占位符模式。
进一步查看其文档,它实现了类似Node.js
的console.log
格式,其底层调用是util.format
。这里我们看到它只使用了%s
这一种占位符。
四、示例测试
打开项目根目录下的test
目录,我们可以看到有一个sample-test.js
的文件,其内容如下:
const expect = require("chai");
const ethers = require("hardhat");
describe("Greeter", function ()
it("Should return the new greeting once it's changed", async function ()
const Greeter = await ethers.getContractFactory("Greeter");
const greeter = await Greeter.deploy("Hello, world!");
await greeter.deployed();
expect(await greeter.greet()).to.equal("Hello, world!");
const setGreetingTx = await greeter.setGreeting("Hola, mundo!");
// wait until the transaction is mined
await setGreetingTx.wait();
expect(await greeter.greet()).to.equal("Hola, mundo!");
);
);
这里的测试也比较简单,一般使用describe
来代表测试某个项目或者功能,使用it
来代表具体某项测试。注意,describe
和it
是函数,在javascript中,一切都是函数。因此,我们可以在describe
中再次嵌套describe
,这样最外层的describe代表整个项目,内部的describe代表某项目功能。
在该测试文件中,先进行了合约的部署,然后验证合约的状态变量greeting
是否为部署时提供的Hello, world!
。然后运行setGreeting
函数改变问候语为Hola, mundo!
,并再次验证更改后的greeting
。
五、运行测试
我们运行npx hardhat test ./test/sample-test.js
,结果如下:
Compiled 2 Solidity files successfully
Greeter
Deploying a Greeter with greeting: Hello, world!
Changing greeting from 'Hello, world!' to 'Hola, mundo!'
✔ Should return the new greeting once it's changed (946ms)
1 passing (949ms)
这里可以看到,我们打印出来了两个日志,刚好是我们合约中的console.log
语句。
六、测试console
这里,console.log支持的数据类型有限,它仅支持4种数据类型:
- uint
- string
- bool
- address
但是它又提供了额外的API来支持其它类型,如console.logBytes(bytes memory b)
等。详情见https://hardhat.org/hardhat-network/reference/#console-log 。
我们来简单测试一下,在Greeter.sol中添加如下函数:
function testConsole() public view returns(bool)
console.log("Caller is '%s'", msg.sender);
console.log("Caller is '%d'", msg.sender);
console.log("Caller is ", msg.sender);
console.log("Number is '%s'", 0xff);
console.log("Number is '%d'", 0xff);
console.logBytes1(bytes1(0xff));
console.logBytes(abi.encode(msg.sender));
console.log("Reslut is ", true);
return true;
在sample-test.js
中添加一行代码expect(await greeter.testConsole()).to.be.equal(true);
,再次运行npx hardhat test ./test/sample-test.js
,结果如下:
Compiled 1 Solidity file successfully
Greeter
Deploying a Greeter with greeting: Hello, world!
Changing greeting from 'Hello, world!' to 'Hola, mundo!'
Caller is '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266'
Caller is '1.3908492957860717e+48'
Caller is 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266
Number is '255'
Number is '255'
0xff
0x000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266
Reslut is true
✔ Should return the new greeting once it's changed (707ms)
1 passing (709ms)
可以看到,当我们把地址类型当成整数打印时,它打印了对应的整数值。通常情况下,对于console.log
支持的四种类型,我们可以不使用通配符或者全部使用%s
作为字符串输出,特殊类型的数据使用相应的API进行打印。
七、事件测试
我们知道,合约中重要的操作基本上都会触发事件,因此,捕获抛出的事件并检查事件中的参数也是一项经常性的工作。在合约中添加如下代码。
function eventTest() public
emit CallerEmit(msg.sender, 500);
我们这次修改我们的测试文件,将各功能均写一个describe来进行,代码如下:
const expect, util, assert = require("chai");
const ethers = require("hardhat");
describe("Greeter", function ()
let greeter;
let owner, user1, users;
beforeEach(async () =>
[owner, user1, ...users] = await ethers.getSigners();
const Greeter = await ethers.getContractFactory("Greeter");
greeter = await Greeter.deploy("Hello, world!");
await greeter.deployed();
);
describe("State check test", function ()
it("Should return the new greeting once it's changed", async function ()
expect(await greeter.greet()).to.equal("Hello, world!");
const setGreetingTx = await greeter.setGreeting("Hola, mundo!");
// wait until the transaction is mined
await setGreetingTx.wait();
expect(await greeter.greet()).to.equal("Hola, mundo!");
);
);
describe("Console test", function ()
it("Console.log should be successful", async function ()
expect(await greeter.testConsole()).to.be.equal(true);
);
);
describe("Event test", function ()
it("owner emit test", async () =>
await expect(greeter.eventTest())
.to.be.emit(greeter, "CallerEmit")
.withArgs(owner.address, 500);
);
it("user1 emit test", async () =>
await expect(greeter.connect(user1).eventTest())
.to.be.emit(greeter, "CallerEmit")
.withArgs(user1.address, 500);
);
it("Get emit params test", async () =>
const tx = await greeter.connect(users[0]).eventTest();
await tx.wait();
const receipt = await ethers.provider.getTransactionReceipt(tx.hash);
const hash = ethers.utils.solidityKeccak256(
["string"],
["CallerEmit(address,uint256)"]
);
const infos = receipt.logs[0];
assert.equal(infos.topics[0], hash);
const sender = ethers.utils.getAddress(
"0x" + infos.topics[1].substring(26)
);
assert.equal(sender, users[0].address);
const value = ethers.BigNumber.from(infos.data);
expect(value).to.be.equal(500);
);
);
);
可以看到,我们测试事件时进行了三项测试,分别为:
- 正常测试,主要是检查事件是否触发,参数是否正确。
- 同上,主要是切换合约调用者为
user1
。 - 这里是解析事件来获取事件参数,此场景应用于某些事件参数无法提前获取等,比如一个伪随机数。
八、重置测试
我们来测试条件不满足时的交易重置,在合约中添加如下代码:
function revertTest(uint a, uint b) public
require(a > 10, "a <= 10");
if(b > 10)
revert("b > 10 ");
else
revert();
注意:这里会有编译警告,提示我们最后一个revert缺少提示字符串,我们是故意这样的,请忽略它。
在测试文件中添加如下describe
:
describe("Revert test", function ()
it("a < 10 should be failed", async () =>
await expect(greeter.revertTest(5, 5)).to.be.revertedWith("a <= 10");
);
it("b > 10 should be failed", async () =>
await expect(greeter.revertTest(15, 55)).to.be.revertedWith("b > 10");
);
it("b < 10 should be failed", async () =>
await expect(greeter.revertTest(15, 5)).to.be.reverted;
);
);
然后我们运行测试通过。
九、区块测试
当我们合约中的内容涉及到区块时,一般需要进行相应区块高度或者区块时间的条件测试。先在测试合约中添加如下内容:
function blockNumberTest() public
require(block.number >= 10000,"not matched block number");
console.log("block number: %d", block.number);
function blockTimeTest() public
require(block.timestamp >= 1750631915,"not matched block time");
console.log("block timestamp: %d", block.timestamp);
编译时会提示上面两个函数为view
函数,但是如果我们把它标记为view
函数,那么测试时便不会mine
一个新区块。为了模拟真实场景,我们不把它标记为view
函数,从而在调用时产生一个新的区块。
然后在测试文件中增加如下describe
:
describe("Block test", () =>
let block;
let timestamp;
// 用来去除16进制的左边自动补零
function convertNum(num)
let big = ethers.BigNumber.from("" + num)
let str = big.toHexString()
let index = 0
for(let i=2;i<str.length;i++)
if(str[i] !== "0")
index = i;
break;
if(index === 0)
return str;
else
return str.substring(0,2) + str.substring(index)
beforeEach(async () =>
block = await ethers.provider.getBlockNumber();
timestamp = (await ethers.provider.getBlock(block)).timestamp;
);
// 注意,这里hardhat network 默认是一秒一个区块
it("Call before timestamp 1651631915 should be failed", async () =>
assert.ok(timestamp < 1651631915);
await expect(greeter.blockTimeTest()).to.be.revertedWith(
"not matched block time"
);
);
it("Call at timestamp 1651631915 should be successfult", async () =>
await ethers.provider.send("evm_mine", [1651631915 - 1]);
await greeter.blockTimeTest();
);
it("Call before block 10000 should be failed", async () =>
assert.ok(block < 10000);
await expect(greeter.blockNumberTest()).to.be.revertedWith(
"not matched block number"
);
);
it("Call at block 10000 should be successful", async () =>
let value = 10000 - block - 1;
//快速推进到100000区块前一个
await ethers.provider.send("hardhat_mine", [convertNum(value]);
await greeter.blockNumberTest();
);
);
注意,在上面的子describe
中又使用了beforeEach
函数。这里讲一下beforeEach
和before
的区别,beforeEach
顾名思义,在每项it
测试前都会执行一次;而before
,在一个describe
中只会执行一次。
这里it
函数要使用的describe
函数内的变量都放在describe
中定义,通常我们测试时会使用一个全新的状态,所以一般使用beforeEach
而不是before
。但特殊场景会有时会使用before
,比如后面的测试依赖于前面的测试结果的。
执行测试后输出的结果显示,我们确定是在block == 10000
和timestamp == 5555555555
调用了相应的函数。
这里,我们采用的是hardhat自动出块策略。此时,每笔交易不管成功还是失败,都会出一个块,并且每个区块内就只有一个交易。但是如果我们想一个区块内包含多个交易怎么办?hardhat也提供了相应的rpc接口,例如evm_setAutomine
和evm_setIntervalMining
来模拟真实的出块场景。
我们在测试合约中增加如下代码:
uint public curBlock;
uint public counter;
modifier oneBlock()
if(curBlock != 0)
require(block.number == curBlock,"not in one block");
_;
if(curBlock == 0)
curBlock = block.number;
function addCounter() external oneBlock
counter ++;
这里增加了一个函数addCounter
用来在一个区块内改变记数器。如果不在一个区块内,则会revert。
相应的测试文件增加如下代码,在Block test
里增加:
it("addCounter test", async () =>
expect(await greeter.counter()).to.be.equal(0);
expect(await greeter.curBlock())以上是关于智能合约场景下的模糊测试——智能合约基本介绍的主要内容,如果未能解决你的问题,请参考以下文章