智能合约安全 - 常见漏洞(第三篇)
- 原文链接: https://www.rareskills.io/post/smart-contract-security
- 译文出自:登链翻译计划
- 译者:翻译小组 校对:Tiny 熊
- 本文永久链接:learnblockchain.cn/article…
我们在这个系列中,将列出 Solidity 智能合约中一些容易反复出现的问题和漏洞。
如果你只处理受信任的ERC20代币,这些问题大多不适用。然而,当与任意的或部分不受信任的ERC20代币交互时,就有一些需要注意的地方。
当与不信任的代币打交道时,你不应该认为你的余额一定会增加那么多。一个ERC20代币有可能这样实现它的转账函数,如下所示:
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%的税。因此,如果一个智能合约与该代币进行如下交互,我们将得到意想不到的回退或资产被盗。
contract Stake {
mapping(address => uint256) public balancesInContract;
function stake(uint256 amount) public {
token.transferFrom(msg.sender, address(this), amount);
balancesInContract[msg.sender] += amount; // 这是错误的
}
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);
}
}
Rebasing 代币由 Olympus DAO 的sOhm代币 和 Ampleforth 的AMPL代币所推广。Coingecko维护了一个 Rebasing ERC20代币的列表。
当一个代币回溯时,总发行量会发生变化,每个人的余额会根据回溯的方向而增加或减少。
在处理 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];
// 错误, amount 也许会超出转出范围
rebasingToken.transfer(msg.sender, amount);
}
}
许多合约的解决方案是简单地不允许rebase代币。然而,我们可以修改上面的代码,在将账户余额转给接受者之前检查 balanceOf(address(this))。那么,即使余额发生变化,它仍然可以工作。
ERC20,如果按照标准实现,ERC20 代币没有转账钩子(hook),因此 transfer 和 transferFrom 不会有重入问题。
带有转账钩子的代币有应用优势,这就是为什么所有的NFT标准都实现了它们,以及为什么ERC777被最终确定。然而,这已经引起了足够的混乱,以至于Openzeppelin 废止了ERC777库。
如果你只想让你的协议与那些行为像 ERC20 代币但有转账hook的代币兼容,那么这只是一个简单的问题,把transfer和transferFrom函数当作它们会向接收者进行一个函数调用即可。
这种 ERC777 的重入发生在Uniswap身上(如果你好奇,Openzeppelin在这里记录了这个漏洞)。
ERC20规范规定,ERC20代币在转账成功时必须返回true。因为大多数ERC20的实现不可能失败,除非授权不足或转账的金额太多,大多数开发者已经习惯于忽略ERC20代币的返回值,并假设一个失败的trasfer将被回退。
坦率地说,如果你只与一个你知道其行为的受信任的ERC20代币打交道,这并不重要。但在处理任意的ERC20代币时,必须考虑到这种行为上的差异。
在许多合约中都有一个隐含的期望,即失败的转账应该总是回退,而不是返回错误,因为大多数ERC20代币没有返回错误的机制,所以这导致了很多混乱。
使这个问题更加复杂的是,一些ERC20代币并不遵循返回true的协议,特别是Tether。一些代币在转账失败后会回退,这将导致回退的结果冒泡到调用者。因此,一些库包裹了 ERC20 代币的转账调用,以回退恢复并返回一个布尔值。下面是一些实现方法:
参考:Openzeppelin SafeTransfer 及 Solady SafeTransfer (大大地提高了Gas效率)
这不是一个智能合约的漏洞,但为了完整起见,我们在这里提到它。
转账零代币是 ERC20规范所允许的。这可能会导致前端应用程序的混乱,并可能欺骗用户,让他们错误的以为他们最近将代币发送给了某地址。Metamask在这个线程中有更多关于这个问题的内容。
(在web3术语中,"rugged"意味着''跑路", 直译是"从你脚下拉出地毯" 。)
没有什么能阻止有人在ERC20代币上添加函数,让他们随意创建、转账和销毁代币--或自毁或升级。所以从根本上说,ERC20代币的 "无需信任" 程度是有限制的。
当考虑到基于DeFi协议的借贷如何被破坏时,考虑在软件层面传播的bug并影响商业逻辑层面是很有帮助的。形成和完成一个债券合约有很多步骤。这里有一些需要考虑的攻击向量。
如果抵押品从协议中被抽走,那么贷款人和借款人都会损失,因为借款人没有动力去偿还贷款,而借款人则会损失本金。
正如上面所看到的,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方法被调用时,被调用者可以回退,但父函数不会回退。必须检查返回成功的值,并且代码行为必须相应地分支。
在循环中使用msg.value
是很危险的,因为这可能会让发起者 重复使用 msg.value
。
这种情况可能会出现在payable
的multicalls中。Multicalls使用户能够提交一个交易列表,以避免重复支付21,000的Gas交易费。然而,msg.value
在通过函数循环执行时被 "重复使用",有可能使用户双花。
这就是Opyn Hack的根本原因。
私有变量在区块链上仍然是可见的,所以敏感信息不应该被存储在那里。如果它们不能被访问,验证者如何能够处理取决于其值的交易?私有变量不能从外部的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();
委托调用(Delegatecall)不应该被用于不受信任的合约,因为它把所有的控制权都交给了委托接受者。在这个例子中,不受信任的合约偷走了合约中所有的以太币。
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插件和阅读它所保护的问题来避免出错。
作为一个快速的总结,以下是与智能合约升级有关的问题:
本翻译由 DeCert.me 协助支持, DeCert.me 的口号是码一个未来
,支持每一位开发者构建自己的可信履历。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!