
智能合约安全⻛险分析与防范
作者:Austin Zhang & Jon Li ;排版:David Xiong
智能合约的安全性问题一直是业界的一个重点话题,由于程序员的某些疏忽造成了思维和逻辑上的漏洞,从而导致黑客有了可乘之机。我们搜集了目前在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) 案例:2021 年 7 月 11 日 跨链桥 ChainSwap 被攻击损失 400 万美金

(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 在检查这些代币的权限时误以为用户已经授权转账给攻击者,导致代币被盗。
