智能合约的安全性问题一直是业界的一个重点话题,由于程序员的某些疏忽造成了思维和逻辑上的漏洞,从而导致黑客有了可乘之机。
原文标题:《智能合约安全⻛险分析与防范》
撰文:Austin Zhang,Jon Li
智能合约的安全性问题一直是业界的一个重点话题,由于程序员的某些疏忽造成了思维和逻辑上的漏洞,从而导致黑客有了可乘之机。我们搜集了目前在 DeFi 领域已经发生了安全事故的智能合约,并根据我们编写的示例代码来实证分析其中的原因,希望能给到同事和同行们一些启示。
(一)重入攻击
主要攻击方式之一:合约调用恶意外部合约结束之前,恶意外部合约函数反向调用原合约函数利用相关漏洞。
(1)示例代码
pragma solidity 0.4.24;
contract SimpleDAO {
mapping (address => uint) public credit;
function donate(address to) payable public{
credit[to] += msg.value;
}
function withdraw(uint amount) public{
if (credit[msg.sender] >= amount) {
require(msg.sender.call.value(amount)());
credit[msg.sender] -= amount;
}
}
}
(2)案例1:2021 年 12 月 22 日 Uniswap V3 流动性管理协议 Visor 被盗 120 ETH
事故原因:deposit 函数没有防重入锁也没有验证 from 地址是否是合法的 Visor 合约地址。攻击者传入攻击合约地址,重复调用 deposit 函数绕过取款金额检查多次取款。
(3)案例2:2021 年 6 月 5 日 BurgerSwap 被盗 700 万美金
事故原因:类似 Uniswap 的原创 dex,分为 Platform 和 Pool 两个合约。Platform 类似 Uniswap 的 Router,Pair 类似 Uniswap 的 Pool,开发者错误的将 K 值校验放在 Platform 计算,攻击者在 Platform 中进行重入攻击,多次以旧的 K 值换取代币,造成流动性提供者损失。
(4)解决方案:调用外部合约前确保所有中间状态变量已更新并使用再入锁(例如 OpenZeppelin’s ReentrancyGuard)。
(二)未检查函数返回值
调用外部合约函数时,有些函数调用失败不会抛出错误回滚交易而是返回 false,如果忘记检查函数返回值会导致误以为调用成功。
(1)示例代码
pragma solidity 0.4.25;
contract ReturnValue {
function callchecked(address callee) public {
require(callee.call());
}
function callnotchecked(address callee) public {
callee.call();
}
}
(2)案例:2021 年 4 月 4 日 ForceDao 到被攻击损失 183 ETH
事故原因:Force 代币的 transferFrom 余额不足时返回 false 而不是直接回滚交易,合约中未做判断导致转账失败时也被认为成功,可以换取到对应代币。
(3)解决方案:使用 call 函数调用外部合约时必须检查调用是否成功。 注:call调用外部合约未匹配到函数时,会调用外部合约 fallback 或者 receive 函数,如果外部合约有定义 receive 函数且 call 函数未携带 calldata 则会调用外部合约 receive 函数,其他情况调用fallback函数。
(三)未正确设置函数可见性
Solidity 中函数默认为 public,可以被外部调用,一旦未将关键函数设置为 Private,就会导致安全风险。
(1)示例代码
pragma solidity ^0.4.24;
contract HashForEther {
function withdrawWinnings() {
require(onlyOwner(msg.sender));
_sendWinnings();
}
function _sendWinnings() {
msg.sender.transfer(this.balance);
}
}
(2)案例1:2022 年 1 月 22 日 Dex Crosswise 被攻击损失 80 万美金
事故原因:Crosswise 虽然实现了权限验证函数 onlyOwner,但忘记设置 setTrustedForwarder 为 private,导致被攻击者利用,将自己设置为池子的 Owner 将代币全部转走。
(3)案例2:2020 年 6 月 18 日 跨链桥 Bancor Network 被攻击损失 14 万美金
事故原因:合约用于转账的函数默认为 public,攻击者可以直接调用转走合约中的代币。
(4)解决方案:提款函数事关合约资产的转移,需谨慎设置权限控制,确保初始化函数只能运行一次。
(四)未验证 Map 中 Key 不存在的情况
Solidity 中的 Mapping 在获取对应 Key 的 Value 时,如果 Key 不存在,会返回对应类型的默认值,而不是报错。例如 Mapping(int → int),如果对应 int 的 Key 不存在,会返回默认值 0。
(1)示例代码
pragma solidity 0.8.7;
contract Mappings {
struct Employee {
string name;
uint8 no;
}
mapping (bytes => Employee) bytesMapping;
function declaring() public returns(string memory) {
bytesMapping["alex"] = Employee("Alex John", 1);
mapping (bytes => Employee) storage ref = bytesMapping;
ref["alex"].name = "Alexanda Jackson";
return bytesMapping["bob"].name;
}
}
(2)案例:2021 年 7 月 11 日 跨链桥 ChainSwap 被攻击损失 400 万美金
事故原因:ChainSwap 依赖其网络中的 validator 进行转账。为了限制 validator 一次转走超过其质押的代币,设置了配额。结果合约中存在漏洞可以绕过配额限制,当地址变量 signatory 不存在时,authQuotes[signatory] 和 lasttimeUpdateQuoteOf[signatory] 会返回 0 ,导致配额计算错误返回预期外的大量配额。
(3)解决方案:使用 map 时必须检查 key 是否存在。
(五)在状态变更前进行转账
转账时有可能被重入,利用未变更的状态进行攻击。
(1)案例:2021 年 8 月 17 日 XSURGE 被攻击损失 500 万美金
事故原因:在转账后才修改 totalSupply,转账时被重入另外一个未加重入锁的函数损失 500 万美金。
(2)解决方案:使用了再入锁也要在所有状态变更之后在转账。
(六)初始化函数未做调用和权限限制
很多合约需要初始化子合约,例如 Uniswap 需要通过 Factory 合约初始化 Pool 合约,这时候如果忘记对子合约的初始化函数做权限和重复初始化限制,可能被攻击者进行恶意初始化。
(1)案例:2021 年 8 月 11 日 Punk Protocol 被攻击损失 400 万美金
事故原因:池子的 initialize 函数未做权限和重复调用限制,攻击者调用该函数将自己设置为 Forge 管理员权限,并调用 withdrawToForge 将池子所有资金都发送到攻击者地址。
(2)解决方案:初始化函数必须设置成只能初始化一次。
(七)未正确检查对应合约函数实现
通常智能合约被调用的函数不存在时会报错,但如果合约实现了 fallback 函数,则会自动调用 fallback 函数。有时 fallback 函数并不会报错,导致调用方误以为调用成功。
(1)案例:2022 年 1 月 18 日跨链桥 Multichain 被攻击损失 450 ETH
事故原因:通常 ERC20 的合约会实现 permit 函数,用于签名检查与授权操作(该函数类似 approve,可以借由预生成的签名由其他合约调用,节省用户的 gas 费)。但 WETH、PERI、OMT、WBNB、MATIC、AVAX 六种代币的合约没有实现 permit 却实现了 fallback,Multichain 在检查这些代币的权限时误以为用户已经授权转账给攻击者,导致代币被盗。
(2)解决方案:不同代币的实现方式不同,引入新代币之前应仔细检查其具体实现。
(八)未正确处理带转账费的代币
有些代币在转账时会销毁一部分转账费用,导致实际收到的代币余额偏少,如果开发者没考虑到这一点,以转账值计算,会导致出现偏差。
(1)案例:2021 年 8 月 19 日 Pinecone 被盗 20 万美金
事故原因:Pinecone 使用其代币 PCT 作为资金池的质押代币,PCT 转账会有手续费的损耗。合约并没有考虑相关损耗导致用户份额和质押的 PCT 总额出现偏差,被攻击者利用领取多余的奖励。
(2)解决方案:谨记不是所有的代币转账费都为 native token。
(九)签名验证漏洞
签名被重复使用,或者利用椭圆曲线签名算法的对称性,根据已有签名构造合法签名。
(1)案例:2021 年 7 月 12 日 AnySwap 被盗 800 万美金
事故原因:对交易签名除了私钥外需要一个随机数 R,但是 Anyswap 部署新合约失误,导致在 BSC 上的 V3 路由器 MPC 帐户下有两个交易具有相同的 R 值签名,攻击者反推到这个 MPC 账户的私钥转走了被盗资金。
(2)解决方案:使用EIP-712标准验证签名,参考OpenZeppelin的实现:https://docs.openzeppelin.com/contracts/3.x/api/drafts。
(十)未考虑合约余额可能产生的变化
矿工挖出块时或者智能合约调用 selfdestruct 函数销毁自己时可以向任意地址强行打币改变其原生代币的余额。当使用余额函数返回值作为判断条件时,余额有可能被强行改变导致风险,极端情况下甚至导致合约拒绝服务(DoS)。
(1)示例代码
pragma solidity ^0.4.24;
contract Donate is Ownable {
// This declares a state variable that would store the contract address
uint256 public donateFund;
uint256 public totalDonate;
/*
constructor function to set token address
*/
constructor() public payable {
donateFund = msg.value;
totalDonate = 0;
}
/*
donate function which take up a array of address, single token amount and eth amount and call the
send eth to these addresses
*/
function donate(address[] _address, uint256 _amount, uint256 _ethAmount) onlyOwner public returns (bool) {
uint256 count = _address.length;
/* strict comparison using balanceOf function */
if (totalDonate + address(this).balance == donateFund){
for (uint256 i = 0; i < count; i++) {
if (this.balance >= _ethAmount) {
totalDrop += _ethAmount;
require(_address[i].send(_ethAmount));
}
}
}
}
}
即使捐赠合约不能接受代币转账,合约余额也可能在部署后被改变,严格检查已空投总量与合约余额之和等于总供应量可能导致捐赠合约拒绝服务(Dos)。
(2)解决方案:在合约中避免对合约余额做严格相等的检查。
(十一)使用 delegatecall 调用外部合约
delegatecall 可以将对应合约的函数代码内嵌到当前上下文中执行,就像调用内置函数一般。如果不小心调用了恶意合约极易导致攻击。
(1)示例代码
pragma solidity ^0.4.24;
contract Proxy {
address private owner;
constructor() public {
owner = msg.sender;
}
function forward(address callee, bytes _data) public {
require(callee.delegatecall(_data));
}
}
contract Attack {
address private owner;
function setOwner() public {
owner = tx.origin;
}
}
当攻击者调用 forward 函数并传入 Attack 合约地址以及函数 setOwner() 作为参数时,Proxy 合约owner 将被修改为攻击者地址。
(2)解决方案:不推荐使用 delegatecall调用外部合约。
(十二)授权 tx.origin
tx.origin 是交易的发起者地址,合约如果使用 tx.origin 做权限检查,当合约的授权用户与恶意合约交互时,恶意合约调用合约即可通过合约权限检查。
(1)示例代码
pragma solidity 0.8.11;
// THIS CONTRACT CONTAINS A BUG - DO NOT USE
contract MyWallet {
address owner;
constructor() {
owner = msg.sender;
}
receive () external payable {
}
function transferTo(address payable dest, uint amount) public {
// THE BUG IS RIGHT HERE, you must use msg.sender instead of tx.origin
require(tx.origin == owner);
(bool success, ) = dest.call{value: amount}(""); // At this point, the caller's code is executed, and can call withdrawBalance again
require(success);
}
}
interface UserWallet {
function transferTo(address payable dest, uint amount) external;
}
contract Attack {
address payable owner;
constructor() {
owner = payable(msg.sender);
}
receive() external payable {
UserWallet(msg.sender).transferTo(owner, msg.sender.balance - msg.value);
}
}
当 MyWallet 合约 owner 使用 transferTo 函数向 Attack 合约转账时,Attack 合约会 重入MyWallet 合约,并调用 transferTo 函数,此时 tx.origin 仍然为 MyWallet owner,require 条件满足,MyWallet 余额将被全部转移至 Attack 合约。
(2)解决方案:不使用 tx.origin 做权限检查。
(十三)交易排序竞争
全节点运行者可以在交易被确认之前获取交易信息,进而根据获取的交易信息,构造高手续费交易,让矿工优先打包自己的交易以执行对自己有利的策略。例如,谜语合约奖励最快找出谜底的用户,恶意用户可以在获悉诚实用户提交的谜底后,构造高手续费交易优先诚实用户提交谜底,从而获取奖励;又如当用户更新授权额度时,被授权用户可以在更新授权额度交易被确认之前转移旧的授权额度,如此,被授权人实际获得的授权额度为两次授权额度之和。
解决方案:针对谜语合约,获得谜底的用户先提交「随机数+自身地址+谜底」的哈希值,谜语合约存储该哈希值后,用户再提交随机信息与答案,合约检查哈希值匹配后再发放奖励;更新授权额度时先置零授权额度。
(十四)使用 block.timestamp 或者 block.number 作为合约时间参考
block.timestamp 与 block.number 都不能获得精确都时间,用作智能合约的时间参考会引入潜在的风险。
解决方案:使用 oracle 获取时间信息。
(十五)Denial-of-Service(DoS) 拒绝服务
调用外部合约可能永久失败导致本合约不能接受新的指令,例如当合约主动对另外一个合约转账,而被转账合约没有接受转账的函数时,转账失败,此时合约可能进入拒绝服务状态。
(1)示例代码
pragma solidity 0.4.24;
contract Refunder {
address[] private refundAddresses;
mapping (address => uint) public refunds;
constructor() {
refundAddresses.push(0x79B483371E87d664cd39491b5F06250165e4b184);
refundAddresses.push(0x79B483371E87d664cd39491b5F06250165e4b185);
}
// 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
}
}
}
当合约向其中一个账号转账失败会导致所有转账全部失败。
(2)解决方案:合约调用外部合约时可能出现的失败,合约需包含处理调用失败情况的代码,防止合约进入拒绝服务状态。
(十六)使用链属性作为随机源
链属性如 block.timestamp, blockhash, bock.difficulty 以及其他属性可被矿工操控,存在风险。
解决方案:考虑使用 RANDAO,oracle 或比特币区块 hash 作为随机源。
(十七)继承顺序错误
多个被继承合约都定义了同一个函数时,继承合约调用该函数的优先级由继承顺序决定,错误的继承顺序将导致函数调用错误。
解决方案:继承顺序说明请参考官方实例:https://solidity-by-example.org/inheritance/。
(十八)Gas不足攻击
多签情况下或者需要其他人帮自己代付 Gas 时,用户准备好签名交易并交给代执行人,代执行人再将用户交易提交给执行合约,代执行人可以提前审查用户代交易,恶意的代执行人或当交易内容不利于代执行人时,可以通过限制Gas的供给,使交易的执行失败,从而阻止交易的执行。
(1)示例代码
pragma solidity ^0.5.0;
contract Relayer {
uint transactionId;
struct Tx {
bytes data;
bool executed;
}
mapping (uint => Tx) transactions;
function relay(Target target, bytes memory _data) public returns(bool) {
// replay protection; do not call the same transaction twice
require(transactions[transactionId].executed == false, 'same transaction twice');
transactions[transactionId].data = _data;
transactions[transactionId].executed = true;
transactionId += 1;
(bool success, ) = address(target).call(abi.encodeWithSignature("execute(bytes)", _data));
return success;
}
}
// Contract called by Relayer
contract Target {
function execute(bytes memory _data) public {
// Execute contract code
}
}
当Relayer调用者通过限制Gas使用导致某个交易失败,那么失败的交易将永远不能再被提交。
(2)解决方案:选择信任的代执行人,或者在执行合约中检查代理人提供的Gas费是否足够。
(十九)函数类型变量跳转
solidity 支持函数类型变量,当函数类型变量使用汇编指令赋值时,函数类型变量有可能被指向恶意构造当函数。
解决方案:如无必要,尽量避免在智能合约中使用汇编指令。
(二十)Gas Limit 服务拒绝攻击(DoS)
区块设置有 Gas 使用上限,如果合约当执行超过了区块Gas使用上限,则合约永远不能被执行成功。
(1)示例代码
pragma solidity ^0.4.25;
contract DosGas {
address[] creditorAddresses;
bool win = false;
function emptyCreditors() public {
if(creditorAddresses.length>1500) {
creditorAddresses = new address[](0);
win = true;
}
}
function addCreditors() public returns (bool) {
for(uint i=0;i<350;i++) {
creditorAddresses.push(msg.sender);
}
return true;
}
function iWin() public view returns (bool) {
return win;
}
function numberCreditors() public view returns (uint) {
return creditorAddresses.length;
}
}
当操作的循环次数过大时,执行合约所需Gas将超过区块上限,导致合约执行失败。
(2)解决方案:在智能合约中谨慎操作大数组,或循环。
(二十一)abi.encodePacked() 哈希碰撞
abi.encodePacked() 采用非填充序列化,当序列化参数包含多个变长数组时,攻击者可以在保持所有元素顺序不变的前提下,改变两个变长数组的元素,如此序列化的结果相同。
(1)示例代码
pragma solidity ^0.5.0;
import "./ECDSA.sol";
contract AccessControl {
using ECDSA for bytes32;
mapping(address => bool) isAdmin;
mapping(address => bool) isRegularUser;
// Add admins and regular users.
function addUsers(
address[] calldata admins,
address[] calldata regularUsers,
bytes calldata signature
)
external
{
if (!isAdmin[msg.sender]) {
// Allow calls to be relayed with an admin's signature.
bytes32 hash = keccak256(abi.encodePacked(admins, regularUsers));
address signer = hash.toEthSignedMessageHash().recover(signature);
require(isAdmin[signer], "Only admins can add users.");
}
for (uint256 i = 0; i < admins.length; i++) {
isAdmin[admins[i]] = true;
}
for (uint256 i = 0; i < regularUsers.length; i++) {
isRegularUser[regularUsers[i]] = true;
}
}
}
通过构造 addUser 的输入,攻击者可以将 regularUsers 的成员加入 admins 成员,但是构造的输入和原输入的签名相同。
(2)解决方案:使用定长数组,或者不让调用者传入 abi.encodePacked() 的参数,或者使用 abi.encode()。
(二十二)transfer() 和 send() 函数 Gas 不足
transfer() 和 send() 函数使用 2300 gas 以防止重入攻击,公链升级后可能导致 gas 不足。
解决方案:推荐使用 call() 函数,但需做好重入攻击防护。
(二十三)链上未加密隐私数据
链上数据完全透明,合约的private关键字不能阻止合约的隐私数据泄漏。
(1)示例代码
pragma solidity ^0.5.0;
contract OddEven {
struct Player {
address addr;
uint number;
}
Player[2] private players;
uint count = 0;
function play(uint number) public payable {
require(msg.value == 1 ether, 'msg.value must be 1 eth');
players[count] = Player(msg.sender, number);
count++;
if (count == 2) selectWinner();
}
function selectWinner() private {
uint n = players[0].number + players[1].number;
(bool success, ) = players[n%2].addr.call.value(address(this).balance)("");
require(success, 'transfer failed');
delete players;
count = 0;
}
}
虽然 players为 private,但攻击者仍然可以通过解析链上数据读取 players。
(2)解决方案:隐私数据需要加密放在链上。
以上是我们分析和总结的二十三种安全事故类型汇总,希望能够给到您些许参考和启示。
原创文章,作者:币圈吴彦祖,如若转载,请注明出处:https://www.kaixuan.pro/news/233522/