本文可作为智能合约安全的微型课程,并提供了在 Solidity 智能合约中经常反复出现的问题和漏洞的详尽列表。
Solidity 中的安全问题归根结底是智能合约未能按照其预期的方式运行。这大致可分为四大类:
- 资金被盗
- 资金被锁定或冻结在合约内
- 人们获得的奖励少于预期(奖励被延迟或减少)
- 人们获得的奖励多于预期(导致通货膨胀和贬值)
我们不可能列出所有可能出错的情况的详尽清单。然而,正如传统软件工程具有诸如 SQL 注入、缓冲区溢出和跨站脚本等常见的漏洞主题一样,智能合约也有可以被记录的、反复出现的反模式。
智能合约黑客攻击与漏洞
可以将本指南更多地视为一份参考资料。如果不把这篇文章写成一本书,就不可能详细讨论每一个概念(友情提示:这篇文章超过了1万字,所以请随意收藏并分块阅读)。然而,它提供了一个关于需要注意什么和学习什么的列表。如果某个主题让你感到陌生,这就表明值得花时间去练习识别该类漏洞。
先决条件
本文假设你已具备 Solidity 的基础熟练度。如果你是 Solidity 的新手,请参阅我们的免费的 Solidity 教程。
重入 (Reentrancy)
我们已经就智能合约重入撰写了大量文章,因此在此不再赘述。以下是一个简短的总结:
每当智能合约调用另一个智能合约的函数、向其发送 Ether 或向其转移代币时,就有可能发生重入。
- 当转移 Ether 时,会调用接收合约的
fallback或receive函数。这就将控制权交给了接收方。 - 某些代币协议会通过调用预定的函数来提醒接收方的智能合约他们已经收到了代币。这会将控制流移交给该函数。
- 当攻击合约获得控制权时,它不必调用移交控制权的同一个函数。它可以调用受害者智能合约中的另一个函数(跨函数重入),甚至是不同的合约(跨合约重入)。
- 当在合约处于中间状态时访问
view函数,就会发生只读重入(Read-only reentrancy)。
尽管重入可能是最为人熟知的智能合约漏洞,但它仅占现实世界中发生的黑客攻击的一小部分。安全研究员 Pascal Caversaccio (pcaveraccio) 维护着一个不断更新的 GitHub 重入攻击列表。截至 2023 年 4 月,该仓库中记录了 46 起重入攻击。
访问控制 (Access Control)
这看起来像是一个简单的错误,但忘记对谁可以调用敏感函数(例如提取 ether 或更改所有权)设置限制的情况发生得惊人地频繁。
即使设置了 modifier,也出现过 modifier 实现不正确的情况,比如下面的例子中缺失了 require 语句。
// DO NOT USE!
modifier onlyMinter {
minters[msg.sender] == true
_;
}
上面的代码是来自这次审计的一个真实案例:https://code4rena.com/reports/2023-01-rabbithole/#h-01-bad-implementation-in-minter-access-control-for-rabbitholereceipt-and-rabbitholetickets-contracts
这是访问控制出错的另一种方式:
function claimAirdrop(bytes32 calldata proof[]) {
bool verified = MerkleProof.verifyCalldata(proof, merkleRoot, keccak256(abi.encode(msg.sender)));
require(verified, "not verified");
require(!alreadyClaimed[msg.sender], "already claimed");
_transfer(msg.sender, AIRDROP_AMOUNT);
}
在这种情况下,alreadyClaimed 永远没有被设置为 true,因此认领人可以多次调用该函数。
现实生活中的例子:交易机器人被利用
一个相当近期的访问控制不足的例子是,一个交易机器人(名为 0xbad,因为其地址以该序列开头)用于接收闪电贷的函数未受保护。它累积了超过一百万美元的利润,直到有一天攻击者注意到任何地址都可以调用闪电贷接收函数,而不仅仅是闪电贷提供商。
正如交易机器人通常的情况一样,用于执行交易的智能合约代码并未开源验证,但攻击者还是发现了这个弱点。更多信息请见 rekt news 的报道。
不正确的输入验证 (Improper Input Validation)
如果说访问控制是关于控制谁调用函数的,那么输入验证就是关于控制他们使用什么参数调用合约的。
这通常归咎于忘记设置适当的 require 语句。这里有一个基本的例子:
contract UnsafeBank {
mapping(address => uint256) public balances;
// allow depositing on other's behalf
function deposit(address for) public payable {
balances += msg.value;
}
function withdraw(address from, uint256 amount) public {
require(balances[from] <= amount, "insufficient balance");
balances[from] -= amount;
msg.sender.call{value: amount}("");
}
}
上面的合约确实检查了你提取的金额没有超过你账户中的余额,但它并没有阻止你从任意账户中提取资金。
现实生活中的例子:Sushiswap
Sushiswap 由于一个外部函数的一个参数未被净化,遭遇了此类黑客攻击。

https://twitter.com/peckshield/status/1644907207530774530
不正确的访问控制与不正确的输入验证有何区别?
不正确的访问控制意味着 msg.sender 没有充分的限制。不正确的输入验证意味着函数的参数没有被充分净化。这种反模式也有其反面:对函数调用施加了过多的限制。
函数限制过度 (Excessive function restriction)
过度验证可能意味着资金不会被盗,但也可能意味着资金被锁定在合约中。设置过多的保护措施也不是一件好事。
现实生活中的例子:Akutars NFT
最引人注目的事件之一是 Akutars NFT,最终导致价值 3400 万美元的 Eth 被困在智能合约中无法提取。
该合约有一个善意的机制,以防止合约所有者在完成所有支付高于荷兰式拍卖价格的退款之前提取资金。但由于下面链接的 Twitter 主题帖中记录的错误,所有者无法提取这笔资金。

https://twitter.com/0xInuarashi/status/1517674505975394304
把握好平衡
Sushiswap 赋予了不可信用户过多的权力,而 Akutars NFT 则赋予管理员过少的权力。在设计智能合约时,必须主观判断每一类用户应享有多少自由度,而且这种决定不能留给自动化测试和工具来完成。这需要在去中心化、安全性和用户体验 (UX) 之间做出必须考量的重大权衡。
对于智能合约程序员来说,明确写出用户应该和不应该用特定函数做什么,是开发过程的重要组成部分。
我们稍后将重新讨论权力过大的管理员这一主题。
安全通常归结为管理资金流出合约的方式
正如引言中所述,智能合约遭到黑客攻击的主要方式有四种:
- 资金被盗
- 资金被冻结
- 奖励不足
- 奖励过多
这里的“资金”指的是任何有价值的东西,例如代币,而不仅仅是加密货币。在编写或审计智能合约时,开发者必须认真考虑价值流入和流出合约的预期方式。上述列出的问题是智能合约被黑客攻击的主要方式,但还有许多其他的根本原因会引发严重的连锁问题,下文记录了这些内容。
双重投票或 msg.sender 欺骗
使用原生的 ERC20 代币或 NFT 作为加权投票的票据是不安全的,因为攻击者可以用一个地址投票,将代币转移到另一个地址,然后再次用该地址投票。
这里是一个最简化的示例:
// A malicious voter can simply transfer their tokens to
// another address and vote again.
contract UnsafeBallot {
uint256 public proposal1VoteCount;
uint256 public proposal2VoteCount;
IERC20 immutable private governanceToken;
constructor(IERC20 _governanceToken) {
governanceToken = _governanceToken;
}
function voteFor1() external notAlreadyVoted {
proposal1VoteCount += governanceToken.balanceOf(msg.sender);
}
function voteFor2() external notAlreadyVoted {
proposal2VoteCount += governanceToken.balanceOf(msg.sender);
}
// prevent the same address from voting twice,
// however the attacker can simply
// transfer to a new address
modifier notAlreadyVoted {
require(!alreadyVoted[msg.sender], "already voted");
_;
alreadyVoted[msg.sender] = true;
}
}
为了防止这种攻击,应使用 ERC20 Snapshot 或 ERC20 Votes。通过对过去某一时间点进行快照,当前的代币余额就无法被操纵以获取非法的投票权。
闪电贷治理攻击
然而,如果有人可以通过借入闪电贷暂时增加余额,然后在同一笔交易中对他们的余额进行快照,那么使用具有快照或投票功能的 ERC20 代币并不能完全解决问题。如果该快照被用于投票,他们将掌握不合理的大量选票。
闪电贷会将大量的 Ether 或代币借给一个地址,但如果资金未在同一笔交易中偿还,则会 revert。
contract SimpleFlashloan {
function borrowERC20Tokens() public {
uint256 before = token.balanceOf(address(this));
// send tokens to the borrower
token.transfer(msg.sender, amount);
// hand control back to the borrower to
// let them do something
IBorrower(msg.sender).onFlashLoan();
// require that the tokens got returned
require(token.balanceOf(address(this) >= before);
}
}
攻击者可以利用闪电贷突然获得大量选票,以使提案朝有利于自己的方向发展,和/或做一些恶意的事情。
闪电贷价格攻击
这可以说是 DeFi 中最常见(或至少是最受瞩目)的攻击,导致了数亿美元的损失。这里是一份备受瞩目的案例列表。
区块链上资产的价格通常计算为资产之间的当前汇率。例如,如果一个合约目前正在以 1 USDC 交换 100 k9coin 进行交易,那么你可以说 k9coin 的价格为 0.01 USDC。然而,价格通常会随着买卖压力而波动,而闪电贷可以制造出巨大的买卖压力。
当向另一个智能合约查询资产价格时,开发人员需要非常小心,因为他们是在假设被调用的智能合约能免疫闪电贷操纵。
绕过合约检查
你可以通过查看地址的字节码大小来“检查”某个地址是否是智能合约。外部拥有账户(普通钱包)没有任何字节码。这里是几种检查方法:
import "@openzeppelin/contracts/utils/Address.sol"
contract CheckIfContract {
using Address for address;
function addressIsContractV1(address _a) {
return _a.code.length != 0;
}
function addressIsContractV2(address _a) {
// use the openzeppelin libraryreturn _a.isContract();
}
}
然而,这有一些局限性:
- 如果合约从
constructor发起外部调用,那么它的表面字节码大小将为零,因为智能合约部署代码尚未返回运行时代码。 - 该空间现在可能是空的,但攻击者可能知道他们将来可以使用
create2在那里部署一个智能合约。
通常,检查地址是否为合约通常(但不总是)是一种反模式。多签钱包本身也是智能合约,做任何可能会破坏多签钱包兼容性的事都会破坏可组合性。
例外情况是在调用转账钩子之前检查目标是否是智能合约。稍后会详细说明。
tx.origin
使用 tx.origin 很少有正当的理由。如果使用 tx.origin 来识别发送者,那么就有可能遭受中间人攻击。如果用户被诱骗调用了恶意的智能合约,那么智能合约就可以利用 tx.origin 所拥有的所有权限大肆破坏。
考虑以下练习,以及代码上方的注释。
contract Phish {
function phishingFunction() public {
// this fails, because this contract does not have approval/allowance
token.transferFrom(msg.sender, address(this), token.balanceOf(msg.sender));
// this also fails, because this creates approval for the contract,
// not the wallet calling this phishing function
token.approve(address(this), type(uint256).max);
}
}
这并不意味着调用任意的智能合约就是安全的。但是,大多数协议都内置了一层安全机制,如果使用 tx.origin 进行身份验证,这层机制将被绕过。
有时候,你可能会看到看起来像这样的代码:
require(msg.sender == tx.origin, "no contracts");
当智能合约调用另一个智能合约时,msg.sender 将是那个智能合约,而 tx.origin 将是用户的钱包,从而提供了一个可靠的指标表明进来的调用是来自智能合约。即使调用发生于 constructor 中也是如此。
多数情况下,这种设计模式并不是个好主意。多签钱包和来自 EIP 4337 的钱包将无法与具有这种代码的函数进行交互。这种模式在 NFT 铸造中经常出现,因为期望大多数用户使用的是传统钱包是合理的。但随着账户抽象变得越来越流行,这种模式将弊大于利。
Gas 恶意干扰 (Gas Griefing) 或拒绝服务 (Denial of Service)
恶意干扰攻击 (griefing attack) 意味着黑客试图给他人“制造麻烦”,即使他们这样做在经济上没有任何收益。
智能合约可以通过进入无限循环来恶意耗尽转发给它的所有 gas。考虑以下例子:
contract Mal {
fallback() external payable {
// infinite loop uses up all the gas
while (true) {
}
}
}
如果另一个合约按照以下方式将 ether 分发给一组地址:
contract Distribute {
funtion distribute(uint256 total) public nonReentrant {
for (uint i; i < addresses.length; ) {
(bool ok, ) addresses.call{value: total / addresses.length}("");
// ignore ok, if it reverts we move on
// traditional gas saving trick for for loops
unchecked {
++i;
}
}
}
}
那么当它向 Mal 发送 ether 时,函数将会 revert。上面代码中的调用转发了可用 gas 的 63/64(在关于 EIP 150 的文章中了解更多关于此规则的信息),因此在只剩下 1/64 的 gas 时,可能没有足够的 gas 来完成操作。
智能合约还可以返回消耗大量 gas 的大型内存数组。考虑以下例子:
function largeReturn() public {
// result might be extremely long!
(book ok, bytes memory result) =
otherContract.call(abi.encodeWithSignature("foo()"));
require(ok, "call failed");
}
内存数组在 724 字节之后会消耗二次方数量的 gas,因此仔细选择返回数据的大小可以对调用者造成恶意干扰。
即使变量 result 没被使用,它仍然会被复制到内存中。如果你想把返回大小限制在某个特定数量内,你可以使用汇编:
function largeReturn() public {
assembly {
let ok := call(gas(), destinationAddress, value, dataOffset, dataSize, 0x00, 0x00);
// nothing is copied to memory until you
// use returndatacopy()
}
}
删除他人可以追加元素的数组也是一种拒绝服务攻击向量
虽然擦除存储是一个在 gas 上高效的操作,但它仍有净成本。如果数组变得太长,它将变得无法删除。这里有一个极简示例:
contract VulnerableArray {
address[] public stuff;
function addSomething(address something) public {
stuff.push(something);
}
// if stuff is too long, this will become undeletable due to
// the gas cost
function deleteEverything() public onlyOwner {
delete stuff;
}
}
ERC777、ERC721 和 ERC1155 也可能成为恶意干扰攻击的向量
如果智能合约转移具有转账钩子的代币,攻击者可以设置一个不接受该代币的合约(它要么没有 onReceive 函数,要么将该函数编程为 revert)。这将使代币无法转移,并导致整个交易 revert。
在使用 safeTransfer 或 transfer 之前,要考虑到接收方可能会强制交易 revert 的情况。
contract Mal is IERC721Receiver, IERC1155Receiver, IERC777Receiver {
// this will intercept any transfer hook
fallback() external payable {
// infinite loop uses up all the gas
while (true) {
}
}
// we could also selectively deny transactions
function onERC721Received(address operator,
address from,
uint256 tokenId,
bytes calldata data
) external returns (bytes4) {
if (wakeUpChooseViolence()) {
revert();
}
else {
return IERC721Receiver.onERC721Received.selector;
}
}
}
不安全的随机数
目前在区块链上不可能通过单笔交易安全地生成随机数。区块链必须完全是确定性的,否则分布式节点将无法就状态达成共识。由于它们是完全确定性的,任何“随机”数都可以被预测。以下掷骰子函数可被利用。
contract UnsafeDice {
function randomness() internal returns (uint256) {
return keccak256(abi.encode(msg.sender, tx.origin, block.timestamp, tx.gasprice, blockhash(block.number - 1);
}
// our dice can land on one of {0,1,2,3,4,5}
function rollDice() public payable {
require(msg.value == 1 ether);
if (randomness() % 6) == 5) {
msg.sender.call{value: 2 ether}("");
}
}
}
contract ExploitDice {
function randomness() internal returns (uint256) {
return keccak256(abi.encode(msg.sender, tx.origin, block.timestamp, tx.gasprice, blockhash(block.number - 1);
}
function betSafely(IUnsafeDice game) public payable {
if (randomness % 6) == 5)) {
game.betSafely{value: 1 ether}()
}
// else don't do anything
}
}
不管你如何生成随机数都不重要,因为攻击者完全可以重现它。引入更多“熵”源(如 msg.sender、时间戳等)不会产生任何效果,因为智能合约也能获取它。
错误使用 Chainlink 随机数预言机
Chainlink 是获取安全随机数的流行解决方案。它分两步完成。首先,智能合约向预言机发送随机数请求,然后在随后的几个区块之后,预言机以随机数进行响应。
由于攻击者无法预测未来,因此他们无法预测随机数。
除非智能合约错用了预言机。
- 请求随机数的智能合约在随机数返回之前不得做任何事。否则,攻击者可以监视内存池 (mempool) 中预言机返回的随机数,并在知道随机数结果的情况下对预言机进行抢跑。
- 随机数预言机本身可能会试图操纵你的应用程序。如果没有其他节点的共识,它们不能随意挑选随机数,但如果你的应用程序同时请求多个随机数,它们可以保留和重新排序随机数。
- 在 Ethereum 或大多数其他 EVM 链上,最终确定性并非瞬间完成。仅仅因为某个区块是最新的,并不意味着它必定会保持这种状态。这被称为“链重组 (chain re-org)”。事实上,链可以改变的不止是最后一个区块。这被称为“重组深度 (re-org depth)”。Etherscan 报告了各条链的重组情况,例如以太坊重组和 Polygon 重组。Polygon 上的重组深度可能深达 30 个区块甚至更多,因此等待较少的区块会使应用面临漏洞风险(当 zk-evm 成为 Polygon 上的标准共识时,这种情况可能会改变,因为其最终确定性将匹配以太坊,但这是对未来的预测,而不是目前的现实)。
- 这些是使用 Chainlink 随机数的其他安全考虑因素。
从价格预言机获取陈旧数据
Chainlink 没有 SLA(服务等级协议)保证在一定时间范围内保持其价格预言机处于最新状态。当链严重拥堵时(例如当 Yuga Labs Otherside 铸造使以太坊不堪重负到没有任何交易能通过的程度时),价格更新可能会被延迟。
使用价格预言机的智能合约必须明确检查数据没有过时,也就是说,在一定的阈值内最近被更新过。否则,它不能做出与价格相关的可靠决定。
还有一个额外的复杂情况,如果价格变化没有超过偏差阈值,预言机为了节省 gas 可能不更新价格,这可能会影响什么样的时长阈值被认为是“过时的”。
必须了解智能合约所依赖的预言机的 SLA。
仅依赖单一预言机
无论一个预言机看起来多么安全,未来都有可能发现攻击方式。抵御这种情况的唯一防线是使用多个独立的预言机。
预言机通常很难不出错
区块链可以相当安全,但一开始将数据放到链上就需要某种链下操作,这就放弃了区块链提供的所有安全保障。即使预言机保持诚实,它们的数据源也可能被操纵。例如,预言机可以可靠地报告来自中心化交易所的价格,但这些价格可以被巨大的买单和卖单所操纵。同样地,依赖于传感器数据或某些 Web2 API 的预言机也易受传统黑客攻击向量的影响。
好的智能合约架构应尽可能完全避免使用预言机。
混合记账 (Mixed accounting)
考虑以下合约:
contract MixedAccounting {
uint256 myBalance;
function deposit() public payable {
myBalance = myBalance + msg.value;
}
function myBalanceIntrospect() public view returns (uint256) {
return address(this).balance;
}
function myBalanceVariable() public view returns (uint256) {
return myBalance;
}
function notAlwaysTrue() public view returns (bool) {
return myBalanceIntrospect() == myBalanceVariable();
}
}
上面的合约没有 receive 或 fallback 函数,所以直接向其转移 Ether 会 revert。但是,合约可以通过 selfdestruct 强制向其发送 Ether。在那一种情况下,myBalanceIntrospect() 将大于 myBalanceVariable()。任一 Ether 记账方法都行,但如果你两者混用,那么合约可能会出现行为不一致的情况。
这也适用于 ERC20 代币。
contract MixedAccountingERC20 {
IERC20 token;
uint256 myTokenBalance;
function deposit(uint256 amount) public {
token.transferFrom(msg.sender, address(this), amount);
myTokenBalance = myTokenBalance + amount;
}
function myBalanceIntrospect() public view returns (uint256) {
return token.balanceOf(address(this));
}
function myBalanceVariable() public view returns (uint256) {
return myTokenBalance;
}
function notAlwaysTrue() public view returns (bool) {
return myBalanceIntrospect() == myBalanceVariable();
}
}
我们同样不能假设 myBalanceIntrospect() 和 myBalanceVariable() 总是会返回相同的值。是有可能直接将 ERC20 代币转移给 MixedAccountingERC20,从而绕过存款函数并不更新 myTokenBalance 变量的。
当利用内部检查(Introspection)核对余额时,应避免使用严格的相等性检查,因为余额可以被外部人士随意更改。
将密码学证明视为密码
这不是 Solidity 的特殊问题,更像是开发者中关于如何使用密码学赋予地址特殊权限的普遍误解。以下代码是不安全的:
contract InsecureMerkleRoot {
bytes32 merkleRoot;
function airdrop(bytes[] calldata proof, bytes32 leaf) external {
require(MerkleProof.verifyCalldata(proof, merkleRoot, leaf), "not verified");
require(!alreadyClaimed[leaf], "already claimed airdrop");
alreadyClaimed[leaf] = true;
mint(msg.sender, AIRDROP_AMOUNT);
}
}
这段代码不安全有三个原因:
- 任何知道被选中参与空投的地址的人都可以重建 Merkle 树并创建一个有效的证明。
leaf没有被哈希。攻击者可以提交一个等于 Merkle 根的leaf,并绕过require语句。- 即使上述两个问题被修复,一旦有人提交了一个有效的证明,他们也可能被抢跑。
密码学证明(Merkle 树、签名等)需要与 msg.sender 绑定在一起,在没有获得私钥的情况下,攻击者是无法操纵的。
Solidity 不会向上转换到最终的 uint 大小
function limitedMultiply(uint8 a, uint8 b) public pure returns (uint256 product) {
product = a * b;
}
尽管 product 是一个 uint256 变量,但乘法的结果不能大于 255,否则代码将 revert。
这个问题可以通过分别向上转换 (upcast) 每个变量来缓解。
function unlimitedMultiply(uint8 a, uint8 b) public pure returns (uint256 product) {
product = uint256(a) * uint256(b);
}
如果在 struct 中对打包的整数相乘,就会发生这种情况。在对打包在 struct 中的小数值进行相乘时,你应注意这一点:
struct Packed {
uint8 time;
uint16 rewardRate
}
//...
Packed p;
p.time * p.rewardRate; // this might revert!
Solidity 会暗中将某些字面量设为 uint8
以下代码将会 revert,因为这里的三元运算符返回的是 uint8。
function result(bool inp) external pure returns (uint256) {
return uint256(255) + (inp ? 1 : 0);
}
要使其不 revert,请执行以下操作:
function result(bool inp) external pure returns (uint256) {
return uint256(255) + (inp ? uint256(1) : uint256(0));
}
你可以通过这个 Twitter 主题帖了解更多关于这种现象的信息。
Solidity 向下转换溢出时不会 revert
Solidity 不会检查将整数转换为更小的整数是否安全。除非某些业务逻辑能确保向下转换 (downcast) 是安全的,否则应使用像 SafeCast 这样的库。
function test(int256 value) public pure returns (int8) {
return int8(value + 1); // overflows and does not revert
}
写入 storage 指针不会保存新数据
代码看起来像是将 myArray[1] 中的数据复制到了 myArray[0],但实际上并没有。如果你注释掉函数中的最后一行,编译器会说该函数应该被改成 view 函数。对 foo 的写入操作并没有真正写入底层的 storage。
contract DoesNotWrite {
struct Foo {
uint256 bar;
}
Foo[] public myArray;
function moveToSlot0() external {
Foo storage foo = myArray[0];
foo = myArray[1]; // myArray[0] is unchanged
// we do this to make the function a state
// changing operation
// and silence the compiler warning
myArray[1] = Foo({bar: 100});
}
}
因此,请不要向 storage 指针写入。
删除包含动态数据类型的 struct 不会删除动态数据
如果一个 struct 内部有一个 mapping(或动态数组),并且该 struct 被删除,其中的 mapping 或数组将不会被删除。
除了删除数组的情况,delete 关键字只能删除一个存储槽。如果存储槽包含了对其他存储槽的引用,那些都不会被删除。
contract NestedDelete {
mapping(uint256 => Foo) buzz;
struct Foo {
mapping(uint256 => uint256) bar;
}
Foo foo;
function addToFoo(uint256 i) external {
buzz[i].bar[5] = 6;
}
function getFromFoo(uint256 i) external view returns (uint256) {
return buzz[i].bar[5];
}
function deleteFoo(uint256 i) external {
// internal map still holds the data in the
// mapping and array
delete buzz[i];
}
}
现在让我们执行以下交易序列:
addToFoo(1)getFromFoo(1)返回 6deleteFoo(1)getFromFoo(1)仍然返回 6!
记住,在 Solidity 中,mapping 永远不是“空”的。因此,如果有人访问已被删除的元素,交易不会 revert,而是会返回该数据类型的零值。
ERC20 代币问题
如果你只处理受信任的 ERC20 代币,大多数这些问题都不适用。然而,在与任意的或部分不受信任的 ERC20 代币进行交互时,需要注意以下几点。
ERC20: 转账扣费 (Fee on transfer)
在处理不受信任的代币时,你不应假设你的余额一定会增加该数额。ERC20 代币完全可以像这样实现它的 transfer 函数:
contract ERC20 {
// internally called by transfer() and transferFrom()
// balance and approval checks happen in the caller
function _transfer(address from, address to, uint256 amount) internal returns (bool) {
fee = amount * 100 / 99;
balanceOf[from] -= to;
balanceOf[to] += (amount - fee);
balanceOf[TREASURY] += fee;
emit Transfer(msg.sender, to, (amount - fee));
return true;
}
}
该代币对每笔交易征收 1% 的税。所以,如果一个智能合约像下面这样与代币交互,我们将要么得到意外的 revert,要么钱被“偷”走。
contract Stake {
mapping(address => uint256) public balancesInContract;
function stake(uint256 amount) public {
token.transferFrom(msg.sender, address(this), amount);
balancesInContract[msg.sender] += amount; // THIS IS WRONG!
}
function unstake() public {
uint256 toSend = balancesInContract[msg.sender];
delete balancesInContract[msg.sender];
// this could revert because toSend is 1% greater than
// the amount in the contract. Otherwise, 1% will be "stolen"
// from other depositors.
token.transfer(msg.sender, toSend);
}
}
ERC20: Rebase 代币
Rebase 代币因 Olympus DAO 的 sOhm 代币和 Ampleforth 的 AMPL 代币而流行。Coingecko 维护了一份 Rebase ERC20 代币的列表。
当一个代币发生 rebase 时,总供应量会改变,而每个人的余额会根据 rebase 的方向增加或减少。
在处理 Rebase 代币时,以下代码很可能会崩溃:
contract WillBreak {
mapping(address => uint256) public balanceHeld;
IERC20 private rebasingToken
function deposit(uint256 amount) external {
balanceHeld[msg.sender] = amount;
rebasingToken.transferFrom(msg.sender, address(this), amount);
}
function withdraw() external {
amount = balanceHeld[msg.sender];
delete balanceHeld[msg.sender];
// ERROR, amount might exceed the amount
// actually held by the contract
rebasingToken.transfer(msg.sender, amount);
}
}
许多合约的解决方案是干脆不允许支持 Rebase 代币。然而,也可以修改上面的代码以在将账户余额发送给发送者之前检查 balanceOf(address(this))。这样一来,即使余额发生改变,它依然可以工作。
ERC20: 披着 ERC20 外衣的 ERC777
ERC20 代币如果按照标准实现,是不包含转账钩子 (transfer hooks) 的,因此 transfer 和 transferFrom 不存在重入问题。
带有转账钩子的代币有其显著优势,这也是为什么所有 NFT 标准都实现了它们,以及为什么最终确立了 ERC777 的原因。然而,它造成了足够的混乱,以至于 OpenZeppelin 已经弃用了 ERC777 库。
如果你希望你的协议能与行为类似 ERC20 代币但带有转账钩子的代币兼容,那只需将 transfer 和 transferFrom 函数视同它们会对接收者发出函数调用即可。
这种 ERC777 重入攻击发生在 Uniswap 身上(如果你感到好奇,OpenZeppelin 在这里记录了这个漏洞利用过程)。
ERC20: 并非所有 ERC20 代币都返回 true
ERC20 规范规定,ERC20 代币必须在转账成功时返回 true。因为大多数 ERC20 实现除非授权额度不足或转账数额过大外通常不会失败,所以大多数开发者已经习惯了忽略 ERC20 代币的返回值,并假设失败的转账会自动 revert。
坦率地说,如果你仅在和你清楚其行为的且可信的 ERC20 代币打交道,那无关紧要。但当与任意的 ERC20 代币交互时,必须考虑这种行为上的差异。
在许多合约中都有一个隐式期望,即失败的转账应始终 revert,而不是返回 false,因为大多数 ERC20 代币没有机制来返回 false,这引起了很多混乱。
使这个问题进一步复杂化的是,一些 ERC20 代币并未遵循返回 true 的协议,尤其是 Tether。一些代币在转账失败时会 revert,这将使 revert 向上冒泡传递给调用者。因此,一些库包装了 ERC20 代币的转移调用,以拦截 revert 并返回一个布尔值。以下是一些实现:
OpenZeppelin SafeTransfer
Solady SafeTransfer (在 gas 消耗上要高效得多)
ERC20: 地址投毒 (Address Poisoning)
这不是智能合约漏洞,但为了完整性,我们在此提及。
规范允许转移零个 ERC20 代币。这可能会导致前端应用产生混乱,并可能在最近发送过代币的对象上欺骗用户。Metamask 在此 Twitter 主题帖中有更多相关说明。
ERC20: 直接跑路 (Rug pull)
(在 Web3 术语中,“rugged”指的是“被人抽走地毯”,即跑路。)
没有什么能阻止别人在一个 ERC20 代币中加入能让他们随意铸造、转移和销毁代币的函数——或者加入 selfdestruct 甚至是升级。所以从根本上说,一个 ERC20 代币可以“不受信任”的程度是有限的。
借贷协议中的逻辑错误
在思考基于借贷机制的 DeFi 协议能如何崩溃时,从软件层面错误如何传播并影响到业务逻辑层面去考虑是很有帮助的。形成和结清贷款合约有很多步骤。以下是一些需要考虑的攻击向量。
贷款方受损的方式
- 使到期本金能够减少(可能减至零)且无需进行任何还款的漏洞。
- 当贷款未偿还或抵押品跌破阈值时,无法清算借款人的抵押品。
- 如果协议有转移债务所有权的机制,这可能成为从贷款方窃取债券的攻击向量。
- 贷款本金或还款的到期日被不适当地推迟。
借款方受损的方式
- 偿还本金并未导致本金缩减的漏洞。
- 漏洞或恶意干扰攻击阻止用户进行还款。
- 本金或利率被非法提高。
- 预言机操纵导致抵押品贬值。
- 贷款本金或还款的到期日被不适当地提前。
如果协议中的抵押品被耗尽,那么贷款方和借款方都会受损,因为借款方没有还款的动机,而借款方损失了本金。
如上所见,导致一个 DeFi 协议被“黑”(通常见诸报端的那些巨额资金流失事件)涉及的层面,远比单纯的一堆钱从协议中被吸走要复杂得多。
质押协议中的漏洞
上新闻的那类黑客攻击通常是质押协议被盗走数百万美元,但这并不是需要寻找的唯一问题。
- 奖励的发放能否被延迟,或是被过早提取?
- 奖励能否被不当地减少或增加?在最坏的情况下,是否可以阻止用户获得任何奖励?
- 人们是否可以认领不属于他们的本金或奖励,从而在最坏的情况下耗尽整个协议?
- 存入的资产是否会卡在协议中(部分或全部)或是在提取时被不当延迟?
- 反之,如果质押要求有时间承诺,用户能提前提取吗?
- 如果是用不同资产或货币进行支付,在这个智能合约范围内,它的价值能被操纵吗?如果协议铸造自己的代币来奖励流动性提供者或质押者,这就会息息相关。
- 如果有一种预期的并已披露的质押本金损失风险,那种风险是否会被不当操纵?
- 协议的关键参数是否存在管理员风险、中心化风险或治理风险?
寻找的关键区域是那些涉及代码中“资金流出”部分的代码区域。
此外还要寻找“资金流入”漏洞。
- 有权参与协议中资产质押的用户会被不适当地阻止吗?
用户获得的奖励隐含着其特有的风险收益特征,以及资金的时间价值预期方面。明确这些假设是什么,以及协议如何可能偏离这些预期是很有帮助的。
未经检查的返回值
调用外部智能合约有两种方法:1) 使用接口定义调用函数;2) 使用 .call 方法。具体如下所示:
contract A {
uint256 public x;
function setx(uint256 _x) external {
require(_x > 10, "x must be bigger than 10");
x = _x;
}
}
interface IA {
function setx(uint256 _x) external;
}
contract B {
function setXV1(IA a, uint256 _x) external {
a.setx(_x);
}
function setXV2(address a, uint256 _x) external {
(bool success, ) =
a.call(abi.encodeWithSignature("setx(uint256)", _x));
// success is not checked!
}
}
在合约 B 中,如果 _x 小于 10,setXV2 可能会默默失败。当通过 .call 方法调用函数时,被调用者可能会 revert,但父级调用者不会 revert。必须检查 success 的值并且相应的代码行为必须有相应的分支处理。
循环中的 msg.value
在循环内使用 msg.value 是危险的,因为这可能允许发送者“重复使用” msg.value。
这可能会在 payable 的多重调用 (multicalls) 中出现。多重调用使用户能够提交一份交易列表以避免一再支付 21,000 gas 的交易费用。然而,在循环执行函数时,msg.value 会被“重复使用”,有可能使得用户产生双花行为。
这是 Opyn Hack 的根本原因。
私有变量 (Private Variables)
私有变量在区块链上仍然是可见的,因此切勿在其中存储敏感信息。如果它们不可访问,验证者将如何处理依赖它们值的交易呢?私有变量无法从外部 Solidity 合约中读取,但可以使用以太坊客户端在链下读取。
要读取一个变量,你需要知道它的存储槽。在下面的示例中,myPrivateVar 的存储槽是 0。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract PrivateVarExample {
uint256 private myPrivateVar;
constructor(uint256 _initialValue) {
myPrivateVar = _initialValue;
}
}
以下是读取已部署智能合约私有变量的 Javascript 代码:
const Web3 = require("web3");
const PRIVATE_VAR_EXAMPLE_ADDRESS = "0x123..."; // Replace with your contract address
async function readPrivateVar() {
const web3 = new Web3("http://localhost:8545"); // Replace with your provider's URL
// Read storage slot 0 (where 'myPrivateVar' is stored)
const storageSlot = 0;
const privateVarValue = await web3.eth.getStorageAt(
PRIVATE_VAR_EXAMPLE_ADDRESS,
storageSlot
);
console.log("Value of private variable 'myPrivateVar':",
web3.utils.hexToNumberString(privateVarValue));
}
readPrivateVar();
Try Catch 很难正确使用
虽然低级调用 (low-level call) 将仅根据成功与否返回 true 或 false,但 try catch 却能区分 panic 和 revert,并且如果返回数据无法正确解析可能会默默失败。最好避免使用它,只需使用低级调用。
不安全的 Delegate Call
切勿与不受信任的合约一起使用 Delegatecall,因为它会将所有控制权移交给被 delegatecall 调用的合约。在这个例子中,不受信任的合约偷走了合约中所有的 ether。
contract UntrustedDelegateCall {
constructor() payable {
require(msg.value == 1 ether);
}
function doDelegateCall(address _delegate, bytes calldata data) public {
(bool ok, ) = _delegate.delegatecall(data);
require(ok, "delegatecall failed");
}
}
contract StealEther {
function steal() public {
// you could also selfdestruct here
// if you really wanted to be mean
(bool ok,) = tx.origin.call{value: address(this).balance}("");
require(ok);
}
function attack(address victim) public {
UntrustedDelegateCall(victim).doDelegateCall(
address(this),
abi.encodeWithSignature("steal()"));
}
}
与代理模式相关的升级漏洞
我们无法在短短一节中详尽讨论这个主题。一般可以通过使用 OpenZeppelin 的 hardhat 插件并阅读关于它所防范问题的文档来避免大多数升级漏洞。(https://docs.openzeppelin.com/upgrades-plugins/1.x/)
简而言之,以下是有关智能合约升级的问题总结:
- 不应在实现合约 (implementation contracts) 中使用
selfdestruct和delegatecall。 - 必须小心确保升级期间
storage变量绝不能互相覆盖。 - 应避免在实现合约中调用外部库,因为无法预测它们将如何影响存储访问。
- 部署者绝不能忽略调用初始化函数。
- 在基础合约中未包含
gap变量会导致在向基础合约添加新变量时发生存储碰撞(这可以由 hardhat 插件自动处理)。 - 在升级之间,
immutable变量的值无法被保留。 - 极不鼓励在
constructor中执行任何操作,因为未来的升级将不得不执行相同的构造函数逻辑以保持兼容性。
权力过大的管理员
即便合约拥有所有者或管理员,这并不意味着他们的权力就应当是无限的。以 NFT 为例。仅允许所有者提取 NFT 销售的收入是合理的,但如果所有者的私钥被泄露,允许暂停合约(阻止转账)可能会造成严重破坏。通常,管理员权限应尽可能降至最低,以最小化不必要的风险。
说到合约所有权…
使用 Ownable2Step 代替 Ownable
这在技术上不算是一个漏洞,但是 OpenZeppelin ownable 可能会在所有权转移到一个不存在的地址时导致合约所有权的丢失。Ownable2Step 要求接收方确认所有权。这就防止了意外地将所有权发送给一个输入错误的地址。
舍入误差 (Rounding Errors)
Solidity 没有浮点数,因此舍入误差是不可避免的。设计者必须意识到:正确做法是向上取整还是向下取整?这种取整对谁有利?
除法应当总是留在最后进行。下面的代码错误地在具有不同小数位数的稳定币之间进行了转换。以下交换机制允许用户在兑换 dai(具有 18 位小数)时免费拿走少量的 USDC(具有 6 位小数)。变量 daiToTake 将向下取整为零,使用户用非零的 usdcAmount 换取时什么都不用支付。
contract Exchange {
uint256 private constant CONVERSION = 1e12;
function swapDAIForUSDC(uint256 usdcAmount) external pure returns (uint256 a) {
uint256 daiToTake = usdcAmount / CONVERSION;
conductSwap(daiToTake, usdcAmount);
}
}
抢跑 (Frontrunning)
在以太坊(及类似链)的背景下,抢跑意味着通过支付更高的 gas 价格,在观察到某个待处理的交易后,抢先执行另一笔交易。也就是说,攻击者“跑”在了这笔交易的前面。如果该交易是一笔有利可图的交易,那么除了支付更高的 gas 价格外,完全复制该交易是有意义的。这种现象有时被称为 MEV,即矿工可提取价值 (miner extractable value),但在其他语境中有时也指最大可提取价值。区块生产者拥有无限的权力重新排序交易并插入自己的交易;在以太坊转向权益证明之前,区块生产者就是矿工,这也就是此名称的由来。
抢跑:未受保护的提取
从智能合约中提取 Ether 可被视为一笔“有利可图的交易”。你执行了一笔零成本的交易(除了 gas 之外),最终获得的加密货币比你开始时要多。
contract UnprotectedWithdraw {
constructor() payable {
require(msg.value == 1 ether, "must create with 1 eth");
}
function unsafeWithdraw() external {
(bool ok, ) = msg.sender.call{value: address(this).value}("");
require(ok, "transfer failed").
}
}
如果你部署这个合约并尝试提取,抢跑机器人会在内存池中注意到你调用的 “unsafeWithdraw”,并复制它以抢先获得这些 Ether。
抢跑:ERC4626 通胀攻击,抢跑与舍入误差的结合
我们已经在我们的 ERC4626 教程 中深入撰写了关于 ERC-4626 通胀攻击的内容。但这其中的核心是,ERC4626 合约会根据交易者贡献的“资产”百分比来分配“份额”代币。大致过程如下:
function getShares(...) external {
// code
shares_received = assets_contributed / total_assets;
// more code
}
当然,没有人会只贡献资产而得不到任何份额,但如果有人可以抢跑交易以获得这些份额,他们是无法预料到这种情况的。
例如,当池子里有 20 资产时,他们贡献了 200 资产,他们期望能获得 100 份额。但如果有人抢跑这笔交易并存入 200 资产,那么计算公式将变为 200 / 220,这将向下取整为零,从而导致受害者损失资产却拿不回任何份额。
抢跑:ERC20 授权 (approval)
说明这一点的最好办法是用一个实际例子而不是抽象地描述。
- 假设 Alice 向 Eve 授权了 100 个代币。(Eve 总是代表反派人物,而不是 Bob,所以我们将保持这个惯例)。
- Alice 改变主意了,于是发送了一笔交易试图把 Eve 的授权额度改为 50。
- 在将授权额度改为 50 的交易被包含在区块中之前,它会处于内存池中,Eve 能够看到它。
- Eve 发送一笔认领她 100 个代币的交易,从而抢跑那笔改额度为 50 的授权交易。
- 改额度为 50 的授权交易随后成功执行。
- Eve 又拿走了那 50 个代币。
现在 Eve 拿到了 150 个代币而不是 100 或 50 个。针对处理不受信任授权的解决方案是,在增加或减少授权之前,应先将授权额度设置为零。
抢跑:三明治攻击 (Sandwich attacks)
资产价格会随着买卖压力而变动。如果内存池中有一笔大额订单,交易者就有动机复制这笔订单,只不过设定更高的 gas 价格。这样,他们就买下了该资产,任由大订单将价格推高,然后他们立即卖出。卖出订单有时被称为“尾随交易 (backrunning)”。这种卖单可以通过以较低的 gas 价格下达来实现,从而使这个序列看起来像这样:
- 抢跑买入
- 大额买入
- 卖出
防御此类攻击的主要方法是提供一个“滑点 (slippage)”参数。如果“抢跑买入”本身将价格推过了某个特定阈值,“大额买入”订单就会 revert,从而导致抢跑者在交易中失败。请参考这份资源以了解更多与滑点相关的漏洞和攻击。
它之所以被称为三明治,是因为这笔大额买单被前面的抢跑买入和后面的尾随卖出给夹在了中间。这种攻击同样适用于大额卖单,只是方向相反而已。
了解更多有关抢跑的知识
抢跑是一个庞大的课题。Flashbots 对这个主题进行了深入的研究,并发布了多种工具和研究文章,以帮助最小化其负面外部性。能否通过适当的区块链架构彻底在“设计层面消除”抢跑,目前仍是一个尚未定论的争论主题。以下两篇文章是该领域的永恒经典:
以太坊是一片黑暗森林 (Ethereum is a dark forest)
逃离黑暗森林 (Escaping the dark forest)
签名相关
在智能合约中,数字签名有两个用途:
- 允许地址在不发出实际交易的情况下在区块链上授权某些交易。
- 向智能合约证明,根据预定地址的规定,发送者拥有执行某项操作的特定权限。
这是一个关于如何安全使用数字签名来赋予用户铸造 NFT 权限的示例:
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
contract NFT is ERC721("name", "symbol") {
function mint(bytes calldata signature) external {
address recovered = keccak256(abi.encode(msg.sender)).toEthSignedMessageHash().recover(signature);
require(recovered == authorizer, "signature does not match");
}
}
一个经典的例子是 ERC20 中的 Approve 功能。要授权某个地址从我们的账户提取一定数量的代币,我们必须发起一次实际的以太坊交易,这需要消耗 gas。
有时更高效的做法是在链下向接收方传递数字签名,然后由接收方向智能合约提供该签名,以此证明他们被授权执行该交易。
ERC20Permit 支持通过数字签名进行授权。该函数的描述如下:
function permit(address owner,
address spender,
uint256 amount,
uint256 deadline,
uint8 v,
bytes32 r,
bytes32 s
) public
所有者无需发送真正的授权交易,只需为支出者(并附带一个截止日期)“签名”这项授权即可。随后,被授权的支出者可以用提供的参数来调用 permit 函数。
签名的解剖结构
你会经常看到变量 v、r 和 s。它们在 Solidity 中分别被表示为数据类型 uint8、bytes32 和 bytes32。有时,签名会被表示为一个 65 字节的数组,即把所有这些值连接在一起:abi.encodePacked(r, s, v);
签名的另外两个基本组成部分是消息哈希(32 字节)和签名地址。其流程如下:
- 使用私钥 (
privKey) 生成一个公钥地址 (ethAddress) - 智能合约预先存储
ethAddress - 一个链下用户对消息进行哈希并签署该哈希值。这生成了一对
msgHash和signature (r, s, v) - 智能合约接收到消息后,对其进行哈希处理以生成
msgHash,随后将它与(r, s, v)结合来推导会得出什么地址。 - 如果得出的地址与
ethAddress匹配,那么说明此签名有效(这是建立在特定假设前提下的,稍后我们会详细讨论!)
智能合约会在步骤 4 中使用预编译合约 ecrecover 执行我们刚才提到的结合操作,并将推导地址返回。
在这个过程的很多步骤中,事情都有可能出岔子。
签名:当地址无效时 ecrecover 返回 address(0) 并且不会 revert
如果将一个未初始化的变量与 ecrecover 的输出进行比较,这可能会导致漏洞。
此代码易受攻击:
contract InsecureContract {
address signer;
// defaults to address(0)
// who lets us give the beneficiary the airdrop without them
// spending gas
function airdrop(address who, uint256 amount, uint8 v, bytes32 r, bytes32 s) external {
// ecrecover returns address(0) if the signature is invalid
require(signer == ecrecover(keccak256(abi.encode(who, amount)), v, r, s), "invalid signature");
mint(msg.sender, AIRDROP_AMOUNT);
}
}
签名重放 (Signature replay)
签名重放通常发生在合约未对签名之前是否已被使用进行追踪时。在以下代码中,我们修复了前面的问题,但它依然不安全。
contract InsecureContract {
address signer;
function airdrop(address who, uint256 amount, uint8 v, bytes32 r, bytes32 s) external {
address recovered == ecrecover(keccak256(abi.encode(who, amount)), v, r, s);
require(recovered != address(0), "invalid signature");
require(recovered == signer, "recovered signature not equal signer");
mint(msg.sender, amount);
}
}
人们可以无限次地领取这笔空投!
我们可以加上这几行:
bytes memory signature = abi.encodePacked(v, r, s);
require(!used[signature], "signature already used");
// mapping(bytes => bool);
used[signature] = true;
哎,这代码还是不安全!
签名延展性 (Signature malleability)
如果给定一个有效的签名,攻击者可以通过一些快速运算推导出另一个不同的签名。然后攻击者可以“重放”这个修改过的签名。但首先,让我们提供一些代码,来演示我们可以拿一个有效的签名,修改它,并证明新签名仍然通过验证。
contract Malleable {
// v = 28
// r = 0xf8479d94c011613baeffe9239e4ff65e2adbac744c34217ca7d51378e72c5204
// s = 0x57af17590a914b759c45aaeabaf513d5ef72d7da1bdd19d9f2e1bc371ece5b86
// m = 0x0000000000000000000000000000000000000000000000000000000000000003
function foo(bytes calldata msg, uint8 v, bytes32 r, bytes32 s) public pure returns (address, address){
bytes32 h = keccak256(msg);
address a = ecrecover(h, v, r, s);
// The following is math magic to invert the
// signature and create a valid one
// flip s
bytes32 s2 = bytes32(uint256(0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141) - uint256(s));
// invert v
uint8 v2;
require(v == 27 || v == 28, "invalid v");
v2 = v == 27 ? 28 : 27;
address b = ecrecover(h, v2, r, s2);
assert(a == b);
// different signatures, same address!;
return (a, b);
}
}
所以,我们上面的例子仍有漏洞。一旦有人提供了一个有效签名,它的镜像签名也能被生成并绕过使用过的签名检查。
contract InsecureContract {
address signer;
function airdrop(address who, uint256 amount, uint8 v, bytes32 r, bytes32 s) external {
address recovered == ecrecover(keccak256(abi.encode(who, amount)), v, r, s);
require(recovered != address(0), "invalid signature");
require(recovered == signer, "recovered signature not equal signer");
bytes memory signature = abi.encodePacked(v, r, s);
require(!used[signature], "signature already used"); // this can be bypassed
used[signature] = true;
mint(msg.sender, amount);
}
}
安全的签名
你现在大概想看一点真正安全的签名代码了吧?我们建议你参考我们关于在 Solidity 中创建签名并在 Foundry 中进行测试的教程。这里是核对清单:
- 使用 OpenZeppelin 的库来防止延展性攻击以及推导至零地址的问题。
- 不要把签名当密码使用。消息需要包含攻击者无法轻易重用的信息(例如
msg.sender)。 - 需在链上对被签名的内容进行哈希。
- 使用
nonce防止重放攻击。更好的做法是遵循 EIP712,这样用户就能知道他们签名的是什么,并且你也能防止签名在不同合约和不同链之间被重用。
缺乏适当防护的情况下,签名可能被伪造或被精心构造
如果不直接在链上执行哈希操作,上述的攻击还能被进一步泛化。在上面的例子中,由于哈希是在智能合约内部完成的,所以这些示例不容易受到下文利用方式的攻击。
让我们看看恢复签名的代码:
// this code is vulnerable!
function recoverSigner(bytes32 hash, uint8 v, bytes32 r, bytes32 s) public returns (address signer) {
require(signer == ecrecover(hash, v, r, s), "signer does not match");
// more actions
}
用户同时提供了哈希值和签名。如果攻击者已经见识过签名者发出的某个有效签名,他们就能轻而易举地重用另一条消息的哈希值和签名。
这就是为什么必须在智能合约内部计算消息哈希,而不是在链下的原因。
欲在实际场景中见证此漏洞,请查看我们在 Twitter 上发布的 CTF 挑战。
原挑战题目:
Part 1: https://twitter.com/RareSkills_io/status/1650869999266037760
Part 2: https://twitter.com/RareSkills_io/status/1650897671543197701
答案解析:
https://twitter.com/RareSkills_io/status/1651527648676573185 https://twitter.com/RareSkills_io/status/1651224817465540611
签名作为标识符
不应使用签名作为识别用户的手段。因为受延展性影响,不能假设它们是唯一的。msg.sender 在唯一性保证上强硬得多。
某些 Solidity 编译器版本存在漏洞
点击此处查看我们在 Twitter 上组织的一次安全演练。在审计代码库时,请将 Solidity 的版本与 Solidity 官网上的发布公告进行对比,看看是否会包含相关漏洞。
假设智能合约是不可变的
智能合约可以通过代理模式(或是更罕见的变形模式)进行升级。智能合约不应依赖某个任意智能合约的功能会保持不变这一假设。
遇到多签钱包时,transfer() 和 send() 可能会失败
不应该使用 Solidity 函数 transfer 和 send。它们有意地将这笔交易所转发的 gas 上限设定为了 2,300,这会导致大多数操作耗尽 gas 而失败。
常用的 Gnosis Safe 多签钱包支持在其 fallback 函数中将调用转发到另一个地址。如果有人使用 transfer 或 send 将 Ether 发送到这个多签钱包,它的 fallback 函数就可能耗尽 gas,从而导致转账失败。下面提供了 Gnosis Safe 的 fallback 函数的截图。读者可以清楚地看到:其中的操作远远足够消耗掉 2300 gas。
// @notice Forwards all calls to the fallback handler if set. Returns 0 if no handler is set.
// @dev Appends the non-padded caller address to the calldata to be optionally used in the handler
// The handler can make use of 'HandlerContext.sol' to extract the address.
// This is done because in the next call frame the `msg.sender` will be FallbackManager's address
// and having the original caller address may enable additional verification scenarios.
// @solhint-disable-next-line payable-fallback, no-complex-fallback
fallback() external {
bytes32 slot = FALLBACK_HANDLER_STORAGE_SLOT;
// @solhint-disable-next-line no-inline-assembly
assembly {
let handler := sload(slot)
if iszero(handler) {
return(0, 0)
}
calldatacopy(0, 0, calldatasize())
// The msg.sender address is shifted to the left by 12 bytes to remove the padding
// Then the address without padding is stored right after the calldata
mstore(calldatasize(), shl(96, caller()))
// Add 20 bytes for the address appended add the end
let success := call(gas(), handler, 0, 0, add(calldatasize(), 20), 0, 0)
returndatacopy(0, 0, returndatasize())
if iszero(success) {
revert(0, returndatasize())
}
return(0, returndatasize())
}
}
Gnosis Safe Fallback 函数
如果你需要与使用了 transfer 或 send 的合约交互,请参阅我们关于以太坊访问列表交易的文章,这有助于你减少由于存储和合约访问操作造成的 gas 开销。
算术溢出还有可能发生吗?
Solidity 0.8.0 已经内置了上溢和下溢保护机制。所以,除非使用了 unchecked 块或是低级的 Yul 代码,一般是不会存在溢出危险的。因此,不应该再使用 SafeMath 库,因为那纯粹是在额外的检查上浪费 gas。
block.timestamp 又如何呢?
一些文献记载道,block.timestamp 是一个漏洞向量,因为矿工可以操纵它。这通常适用于将时间戳用作随机数源的场景,正如前文所记录的那样,这种情况无论如何都不应发生。合并后的以太坊以精确的 12 秒(或 12 秒的倍数)间隔更新时间戳。然而,在秒级粒度上测量时间是一种反模式。在以分钟计的尺度上,如果有验证者错过了他们的区块槽从而导致区块生产出现 24 秒的断层,这就存在相当大的误差空间了。
边缘情况 (Corner Cases, Edge Cases) 和差一错误 (Off By One Errors)
边缘情况没法被轻易定义,但如果你见得足够多,慢慢就会对它们形成一种直觉。一个典型的边缘情况例子如:有人试图认领奖励,但他根本没有进行任何质押。这种逻辑上是可接受的,只要给他发零奖励就行。类似地,我们通常想要将奖励均分,但如果实际上只有一个接收者,严格来说这时候本来不应发生任何除法分配。怎么处理?
边缘情况:示例 1
该示例取自 Akshay Srivastav 的 Twitter 主题帖并有所修改。考虑这样一种情况:如果一组特权地址为某个操作提供了签名,那么任何人都能够执行这个特权操作。
contract VulnerableMultisigAuthorization {
struct Authorization {
bytes signature;
address authorizer;
bytes32 hashOfAction;
// more fields
}
// more code
function takeAction(Authorization[] calldata auths, bytes calldata action) public {
// logic for avoiding replay attacks
for (uint256 i; i < auths.length; ++i) {
require(validateSignature(auths[i].signature, auths[i].authorizer), "invalid signature");
require(authorizers[auths[i].authorizer], "address is not an authorizer");
}
doTheAction(action)
}
}
如果有任何签名无效,或者签名跟一个有效地址对不上,revert 就会发生。可是如果这个数组为空呢?在这个例子中,程序会直接跳到 doTheAction 且根本不需要任何签名。
差一错误:示例 2
contract ProportionalRewards {
mapping(address => uint256) originalId;
address[] stakers;
function stake(uint256 id) public {
nft.transferFrom(msg.sender, address(this), id);
stakers.append(msg.sender);
}
function unstake(uint256 id) public {
require(originalId[id] == msg.sender, "not the owner");
removeFromArray(msg.sender, stakers);
sendRewards(msg.sender, totalRewardsSinceLastclaim() / stakers.length());
nft.transferFrom(address(this), msg.sender, id);
}
}
尽管上面的代码没有展示出所有的函数实现细节,但就算这些函数表现得如同它们的名字一样,还是存在一个错误。你能找出来吗?这是张图,给你留点思考空间,免得你一往下划就看到答案。

停止向下滚动
removeFromArray 和 sendRewards 这两个函数的调用顺序错了。如果在 stakers 数组中只剩下一个用户了,这时候就会产生一个除以零的错误,从而导致用户拿不回他自己的 NFT。此外,分配奖励的方式可能完全违背了作者的本意。如果最初一共有四个质押者,而其中一个人提取出来时,他将能分到三分之一的奖励(因为提现那一刻的数组长度为 3)。
边缘情况示例 3:Compound Finance 奖励计算错误
让我们拿一个据估计造成过超过 1 亿美元损失的真实案例来说。就算你不完全懂 Compound 协议也没关系,我们只会聚焦在相关的部分。(另外,Compound 协议是 DeFi 史上最重要、最具影响力的协议之一,我们在 DeFi 训练营 中也会教授它,所以如果这是你第一次了解该协议,请不要对它产生偏见)。
无论如何,Compound 的核心在于鼓励用户把他们闲置的加密货币借给可能有用处的其他交易员。借款人不仅会得到利息,还会获得 COMP 代币(借方也是可以获得 COMP 代币奖励的,但我们现在不讲那些)。
Compound 的 Comptroller 是一种代理合约,能够将调用委托给可由 Compound 治理机制设置的实现合约。
在 2021 年 9 月 30 日的第 62 号提案中,该实现合约被设置为一个存在漏洞的实现合约。就在该提案上线的同一天,有人观察到即使某些交易根本没有质押任何代币,它们也能照样申领 COMP 奖励。
易受攻击的函数:distributeSupplierComp()
原始代码如下:
/**
* @notice Calculate COMP accrued by a supplier and possibly transfer it to them
* @param cToken The market in which the supplier is interacting
* @param supplier The address of the supplier to distribute COMP to
*/
function distributeSupplierComp(address cToken, address supplier) internal {
// TODO: Don't distribute supplier COMP if the user is not in the supplier market.
// This check should be as gas efficient as possible as distributeSupplierComp is called in many places.
// - We really don't want to call an external contract as that's quite expensive.
CompMarketState storage supplyState = compSupplyState[cToken];
uint supplyIndex = supplyState.index;
uint supplierIndex = compSupplierIndex[cToken][supplier];
// Update supplier's index to the current index since we are distributing accrued COMP
compSupplierIndex[cToken][supplier] = supplyIndex;
if (supplierIndex == 0 && supplyIndex > compInitialIndex) {
// Covers the case where users supplied tokens before the market's supply state index was set.
// Rewards the user with COMP accrued from the start of when supplier rewards were first
// set for the market.
supplierIndex = compInitialIndex;
}
// Calculate change in the cumulative sum of the COMP per cToken accrued
Double memory deltaIndex = Double({mantissa: sub_(supplyIndex, supplierIndex)});
uint supplierTokens = CToken(cToken).balanceOf(supplier);
// Calculate COMP accrued: cTokenAmount * accruedPerCToken
uint supplierDelta = mul_(supplierTokens, deltaIndex);
uint supplierAccrued = add_(compAccrued[supplier], supplierDelta);
compAccrued[supplier] = supplierAccrued;
emit DistributedSupplierComp(CToken(cToken), supplier, supplierDelta, supplyIndex);
}
很讽刺的是,这个 Bug 竟然正好出在那个 TODO 注释里:“如果用户不在供应商市场中,请不要发放供应商 COMP 奖励 (Don’t distribute supplier COMP if the user is not in the supplier market)”。可是代码里根本就没有关于这一点的检查。只要用户的钱包里持有了质押用的代币(CToken(cToken).balanceOf(supplier);),那么
在 2021 年 10 月 9 日的第 64 号提案 中,该漏洞被修复了。
尽管这有可能会被强行说成是个输入验证类 Bug,但用户其实没有提交任何恶意的东西。如果有人在未质押任何东西的情况下去认领奖励,计算结果本来就应该是零。可以说,这应该更算是一个业务逻辑或者说边缘情况引起的 Bug。
现实世界黑客攻击案例
现实世界中发生的 DeFi 黑客攻击,有很多其实并不属于上述的那些条条框框之中。
Parity 钱包冻结事件 (2017年11月)
Parity 钱包一开始并不是为了让用户直接去使用的。它是一个作为智能合约克隆的参考实现而存在的。实现方式允许这些克隆(clones)在需要的时候可以销毁自己,但前提必须是要所有的钱包所有者都同意才行。
// throw unless the contract is not yet initialized.
modifier only_uninitialized { if (m_numOwners > 0) throw; _; }
function initWallet(address[] _owners, uint _required, uint _daylimit) only_uninitialized {
initDaylimit(_daylimit);
initMultiowned(_owners, _required);
}
钱包的所有者是这样声明的:
// kills the contract sending everything to `_to`.
function kill(address _to) onlymanyowners(sha3(msg.data)) external {
suicide(_to);
}
某些文章在提到此事时会说这是一次“不受保护的自毁事件”,将其归为访问控制上的疏忽,但这个说法不太准确。真正的问题是实现合约上根本没有调用过 initWallet,使得某些人自己抢先调用了 initWallet 函数并将自己变为了所有者。这样一来,他们就有了调用 kill 函数的权限。造成这一切的罪魁祸首是实现合约没有被初始化。由此可见,这个 Bug 不是糟糕的 Solidity 代码带来的,而是由于漏洞百出的部署流程造成的。
Badger DAO 黑客事件 (2021年12月)
本次黑客事件中并没有针对什么 Solidity 代码的漏洞进行利用。取而代之的是,攻击者获取了 Cloudflare 的 API 密钥,并将脚本注入到了网站的前端页面中去,将用户的交易统统导向了由攻击者控制的地址处提现。更多详情请阅读此篇文章。
针对钱包的攻击向量
私钥缺乏足够的随机性
发掘包含大量前导零地址的动机在于使用它们来发送交易会更节省 gas 费用。因为如果是零字节,以太坊交易只对其收取 4 gas 费用,若是非零字节则要收取 16 gas 费用。所以,
Wintermute 正是因为使用了名为 profanity 的靓号生成器而被攻破的(解析文章)。此为 1inch 所撰写的文章,阐释了 profanity 靓号地址生成器是如何被攻陷的。
Trust Wallet 也存在过类似记录在这篇文章中的漏洞(https://blog.ledger.com/Funds-of-every-wallet-created-with-the-Trust-Wallet-browser-extension-could-have-been-stolen/)
请注意,这套说辞并不适用于通过在 create2 中改变 salt 来发现带有前导零的智能合约,因为智能合约没有所谓的私钥。
重复使用的 nonce 或随机性不足的 nonce。
椭圆曲线签名(Elliptic Curve signature)中的 “r” 和 “s” 点生成如下:
r = k * G (mod N)
s = k^-1 * (h + r * privateKey) (mod N)
G、r、s、h 以及 N 统统都是公开已知的。如果 “k” 也遭公开,那 “privateKey” 将是仅剩的那个未知变量,而它就会被解出来。因此,钱包需极为完美地生成完全随机的 k 并确保永远不再重用它。如果随机性并非百分之百完美,那 k 也能被推演出来。2013 年,Java 库内不安全的随机数生成,便使众多 Android 比特币钱包暴露在受窃之危下。(比特币使用的是与以太坊相同的签名算法。)(https://arstechnica.com/information-technology/2013/08/all-android-created-bitcoin-wallets-vulnerable-to-theft/)。
大多数漏洞都具有应用特定性
通过训练让你能一眼看穿这份清单中的反模式,可以使你成为一个效率更高的智能合约开发者,但是,后果重大的智能合约 Bug 绝大多数都是由于预期的业务逻辑与实际代码之间存在的鸿沟所造成的。
可能引发 Bug 的其它区域:
- 糟糕的代币经济学激励机制
- 差一错误
- 拼写错误
- 管理员或用户的私钥被盗
本可依靠单元测试逮出的大把漏洞
对智能合约进行单元测试可以说是对智能合约最为基础的保护了。但有大量的智能合约甚至连这个基础都没有,抑或者是根本没达到令人放心的测试覆盖率。
不过单元测试通常仅会测试合约的“理想路径 (happy path)”(预期的/所设计的行为)。为了对付更出人意料的情况,还要采用额外的测试方法。
在将一份智能合约交付审计之前,应当首先搞定以下这些事情:
- 使用诸如 Slither 之类的工具进行静态分析,以免漏掉那些最基础的错误
- 通过单元测试达到 100% 的代码行与分支覆盖率
- 开展变异测试以确认你的单元测试里都有着坚如磐石的
assert断言语句 - 模糊测试,这对于算术运算特别有用
- 针对有状态属性开展的不变量测试
- 视具体需要展开形式化验证
如果对于这里所罗列的那些方法你并不怎么熟悉,Cyfrin Audits 的 Patrick Collins 在他的视频中做了一场非常幽默的有状态和无状态模糊测试简介。如今用来做这些任务的工具,正变得愈发触手可得且更为易用了。
更多资源
以下仓库中整理了过往发生的各种 DeFi 黑客攻击合集,供你查阅:
- https://github.com/coinspect/learn-evm-attacks
- https://github.com/SunWeb3Sec/DeFiHackLabs
- https://rekt.news/
Secureum 曾被大范围用于安全方向的学习和实操,但要注意该仓库其实已经差不多 2 年未作过实质性的内容更新了:
你可以用我们所写的这套Solidity 谜题 (Solidity Riddles) 来试着演练利用各种 Solidity 漏洞。
DamnVulnerableDeFi 是一套极为经典的挑战赛,值得每一位开发者亲手演练一番:
Capture The Ether 以及 Ethernaut 都是经典之作,但你要心里有数,其中的有些问题简单得有些脱离现实,或者是在教授早已过时的 Solidity 概念:
诸如 Code4rena 等享誉盛名的众包型安全众测公司,会留存下一套可以拿来慢慢揣摩的往期审计清单。
成为一名智能合约审计员
如果不精通 Solidity,那么你就绝对没有能力去审计以太坊智能合约。如果你才刚刚开始起步,请参阅我们的免费的 Solidity 教程。
成为一名智能合约审计员没有任何行业内所公认的资格证书可考。任何人都可以随便搞个网站以及社交媒体简介便宣称自己是名 Solidity 审计员然后就开始兜售服务了,这种做法的人大有人在。因此,在聘用一位审计员之前必须极为慎重,且要获得其圈内口碑引荐才行。
想要成为一名智能合约审计员,你在捉拿 Bug 这个手艺活上,必须要是超出一般普通 Solidity 开发者水准之上的一大截才行。所以说,所谓成为审计员的“捷径”,无非也就是日复一日、月复一月地投身到各种刻意的魔鬼式集训操练当中,直到你捉拿各种 Bug 的身手要强过绝大多数人为止。
如果在揪出各种漏洞这方面,你没有那股要全面超越身边同行的死磕决心的话,那么你大概率是不可能比那些训练有素且动机十足的犯罪分子抢先一步找到致命漏洞的。
成为智能合约安全审计员,你需要明白的残酷成功概率真相
智能合约审计员近期一直被大家给视作为是个颇令人向往的差事,皆因人们误觉得它是个钱多速来的美差事。不可否认的是,有些漏洞赏金支出的数额的确超过了 100 万美元之巨,但这可是极少数人身上才会有的极其罕见例外罢了。
Code4rena 将参加审计赛事的各路选手的累计奖金总额排成了一张公开的排行榜,透过这些数据我们是可以对成功率有个清晰概念的。
排行榜上共有 1171 个名字,然而
- 终生赚取的赏金能够超过 $100,000 的仅有区区 29 位选手(2.4%)
- 只有 57 位选手能终生赚过超过 $50,000 的赏金(4.9%)
- 终生所赚超 $10,000 以上的,统共不过 170 位而已(14.5%)
不妨再细想想这些:当 OpenZeppelin 之前开放了关于其安全研究项目的报名通道(请注意,那只是一个为了准备正式工作前的岗前筛选与职前培训项目,尚不属于聘用工种)时,只挑选寥寥不到 10 名的候选人,他们却一下子收到了多达三百多份的简历报名申请,而且就算有幸拿到了这个培训名额,最后能留在原厂干上一份全职工的更是屈指可数。

https://twitter.com/David_Bessin/status/1625167906328944640
这个录取率甚至低过要被哈佛大学录取呢。
智能合约审计可是一个充满着残酷竞争意味的零和博弈游戏。只有那么点项目可供审计、只有那么点点安全预算可供花费,以及只有那么几个少得可怜的 Bug 等你去找出它。倘若你打算从此刻开始入门研习安全了,那么早就有一帮几十个人组成的极具高度动机的散兵游勇以及各类机构早就把你甩在其后好一段身位了。通常的大部分项目都是愿意斥点重金来买个有口皆碑的成名审计师的心安服务,而不是让哪个初出茅庐的无名审计界小白拿自己的身家性命练手试刀的。
在这篇文章中,我们已列出了至少 20 余种不同类别的漏洞。即便是你每周攻克并精通掉一样漏洞(虽然说实话这都有点过于理想化了),你也只能说是刚刚摸到了对于资深审计员而言属于最起码必知的那个常识领域的边界罢了。此外更要命的是,我们在本文压根儿都没去探讨过关于 gas 优化或者代币经济学的问题,对于一个合格审计员而言那同样也是个务必需要摸透的核心要害呢。自己盘盘算算吧,这绝对不是什么随便走走就能应付得来的坦途捷径。
尽管如此,社区整体上的氛围其实还是对各路初学者充满友善且能够经常提供援助的各种小窍门以及技巧。只是如果此时读着这篇文章的某些人心中的确是盘算着以此来当作安家立命的全职工作大干一番事业,那么这里必须坦诚向你指明:拿到极其丰厚的真金白银的回报这一胜算,可并不站在你那一侧的。成功这玩意儿绝非是你可以随手唾得的一个默认终点。
当然这种事儿你也能做得到。毕竟也有那么一拨人确实从一行 Solidity 代码都不懂,成功转型到了如今干着赚得盆满钵满的审计行当去的。其实真要论起在两年光景里弄上一份智能合约审计师的铁饭碗,相较于说你得想方设法去挤破头读进去个靠谱点儿的法学院并且还必须要考取司法牌照这类苦差事,应该反倒还要来得更为容易一点吧。再说这相比其他的大片海量择业之路上,确实是有过之而无不及的上升天花板在那儿摆着。
但既然话已至此了,接下来就需要你那拿出无比艰苦卓绝的韧性,去吃透跟前那座正在时刻急速演变之中的知识大山了,且还需要日复一日地不断去刻意打磨出属于自己找寻各种 Bug 漏洞的手感与直觉。
以上并非是要在此奉劝各位去学打住别再去深究这门关于智能合约安全的手艺活了。毕竟掌握这门技术绝对是件非常有价值且意义深远的好差事。但如果你只不过是因为那满眼珠子打转的美元符号,才想要跑去趟这个行当浑水的话,请千万把你的预期给摆低一点。
结论
知晓那堆尽人皆知的反模式无疑是极其重要的。只是这现实当中的那些绝大多数的各式 Bug,都是极具着应用程序各自的独特专一属性而存在的。不管是需要你去认出或者是想要去挑出这两个维度中哪一类的薄弱死角所在,都势必需要你日复一日持续性地去投入到各种艰苦卓绝的刻意磨炼当中去。
要想研习这套关于智能合约安全、以及许许多多与之息息相关的在以太坊生态开发环节中的进阶之道的话,你完全可以通过关注我们那行业领先级的 Solidity 培训体系去展开全面了解。
原发布于 2023年5月5日