如果你只处理受信任的 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 插件和阅读它所保护的问题来避免出错。
作为一个快速的总结,以下是与智能合约升级有关的问题: