这篇文章深入探讨了在 DeFi 协议中进行 ERC20 令牌转移时可能遇到的安全问题及其解决方案。文章分析了不同的转移方法(直接转移、批准后转移、回调转移)及其潜在的安全隐患,参考了多个实际漏洞案例,并提出了一系列改进最佳实践,以增强协议的安全性。
DeFi 协议中的 ERC20 代币转账可能会很棘手。尽管这是一个众所周知的过程,但代币转账的漏洞依然存在。为了减轻严重的安全问题,在你开发 DeFi 项目时,有必要考虑与代币转账相关的各种场景。本文旨在突出这些问题,提供实际漏洞的参考,以展示这些问题,并概述在你的协议中实现代币转账时需要考虑的可能选项。
每个 DeFi 协议都需要代币转账。实现这一点有几种方式。以下是一些常见模式:
每种模式都有特定的安全隐患。一个常见的问题是,可能会使用攻击者提供的代币。许多 DeFi 协议允许用户或池/市场创建者提供任意 ERC20 代币,这可能会导致安全风险。恶意构建的代币可能具有“有毒”的 transfer*()、balanceOf() 函数,转移费用,黑名单和重入能力。
通过 transfer() 转移代币时,必须解决几个安全问题。
首先,必须检查转移状态。这个案例在许多地方都有描述,最著名的是 OpenZeppelin 的 safeTransfer() 实现,其核心功能可以在 这里 查找。
ERC20 标准规定必须返回成功/失败值。然而,非标准代币(如 USDT/BNB 等)可能完全不返回值(我们不包括那些“病态”的代币,即在成功转移时返回 false)。为了处理这种情况,"安全"转移的返回值检查可以描述为:“返回值是可选的(在极少数情况下),但如果返回了数据,则它不能是 false”。
在你的协议中正确处理失败的转移至关重要。演示漏洞描述 这里。
协议直接转移意味着协议将代币发送到某个外部地址(用户或合约),因此每个包含 transfer() 的函数都是可能攻击者的“出路”,必须进行最大程度的保护。
这包括非常期望的重入保护。即使在正确使用 safeTransfer() 并遵循检查-效果-交互模式的情况下,在某些情况下,这类函数仍然可能被利用(例如,与“跨合约”或“只读”重入相关的函数)。应特别注意带有 transferAndCall() 回调、接收钩子以及任何其他调用额外代码“内部”转移的 ERC-677 和 ERC-777 代币。一个使用重入的 transfer() 漏洞示例可以在 这里 找到。
此外,考虑以下潜在问题:
此变体是从用户那里接收代币的最广泛使用的方法,因为没有通用的方法可以通知合约接收代币。接收 100 个代币的常见方式是让用户首先批准 100 个代币,然后在协议中调用目标函数(这将执行 transferFrom())。多次从用户那获取代币时,适用相同的机制。此外,此方法也可以通过给予外部合约权限来将代币转账到另一个合约,然后在其中调用目标函数。
这种代币转账有什么危险的场景?
首先是许可额度。用户可以设置一个非常大的许可额度,以允许协议不断使用他们账户中的代币。这意味着,如果协议以某种方式(由于黑客攻击或错误)能够执行 transferFrom(),则可以从用户的地址中提取大量代币。
[注] 它包括不仅对智能合约的攻击,还包括对项目UI的攻击。即使是一个通常在传统 Web2 中不那么危急的 XSS 漏洞,在 Web3 中也可能导致非常严重的安全事件。 一个针对 UI 的攻击的良好示例是 Badger DAO 黑客事件。
正如我们之前提到的,非标准 ERC20 代币可能给协议带来问题,一个良好的示例就是 USDT 代币。USDT 有一个非标准的 approve() 函数,除非首先调用 approve(0),否则无法修改批准金额。这意味着如果协议未花费所有权限,则如果无法将许可降至零,则无法使用 USDT 代币。因此,如果你计划在你的协议中使用 USDT 和权限,请记住这一点。演示此行为的漏洞在 这里。
许可本身并没有很多直接的安全问题,但可以作为攻击的关键部分,作为攻击的“最后一步”。因此,使用 approve() 或 transferFrom() 的函数必须得到良好的保护并经过彻底测试。
这种交互方式非常流行。实际上,它意味着“在你的回调中做你想做的事,但将我的合约余额从 X 改为 Y;我会在你的回调执行后检查”。这种方法要求调用者是合约或将外部回调函数的 calldata 作为参数传递。由于回调可以实现几乎任何代码,因此在此上下文中没有常见的潜在漏洞分类。然而,一些重要的考虑因素必须提及。
代币转账“内部”执行是重入的一种自然形式,允许攻击者在回调仍在执行时调用协议的其他部分。最终转移的代币数量尚未更新,协议“没有知道”代币转账。在某些情况下,这可能导致问题。这样的漏洞示例可以在 这里 找到。
与任何外部调用一样,回调应提供足够的Gas。此外,如果你希望用户能够使用外部交易合约与协议互动,调用回调的函数应该保留足够的Gas以确保成功执行。
在转移代币时,这种类型的漏洞尤其常见。代币数量的计算和使用在一个协议与另一个协议之间可能差异相当大,从而导致广泛的潜在漏洞。许多与代币转账相关的案例涉及传递给这些函数的金额以及在转移前后的使用。让我们讨论它们。
你无法向 transfer() 或 transferFrom() 函数传递一定的金额并确保该金额将被实际转移。例如:
token.transferFrom(from, address(this), amount);
// ...
mint(from, amount); // 非受信任的金额
这种方法将无法与具有“转移费用”机制的代币(例如,USDT,可能随时启用转移费用)或具有复杂转移逻辑的代币起作用。一个明显的漏洞示例可以在 这里 找到。
另一个需要考虑的问题是,相同金额可能会被 transferFrom() 函数(在代币中)与 mint() 函数(在协议中)以不同的方式处理。例如,在 mint() 函数内部存在复杂逻辑的情况下。虽然这与代币转账没有直接相关,但仍需记住,代币金额与协议中使用的金额的精度可能不同,并且在计算中使用方式各异。一个示例说明了在一个函数中以不同方式处理金额如何导致零结果而在另一个函数中产生非零结果,示例在 这里。
在涉及两个由用户控制的地址之间的代币转账时,你应该始终考虑发送者和接收者是相同地址的场景。当结合内存/存储缓存问题时,这可能导致漏洞。示例代码段:
uint256 fromBalance = _balances[from];
uint256 toBalance = _balances[to];
[.. snip ..]
_balances[from] = fromBalance - value;
_balances[to] = toBalance + value; // VULNERABLE!!!
看似安全,但当 from 等于 to 时,会导致 toBalance 的缓存问题。此漏洞在 这里 描述。
操作用于代币转账的值时始终要考虑它们的十进制和标准化。如果你的协议使用多个代币,则在保存和使用为/从代币操作的值时,你需要对十进制保持极为谨慎。示例演示具有不同精度的金额 这里。
我们将在接下来的文章中描述许多计算问题,其中许多问题不仅与代币转账有关,现在让我们进行如何实施的部分。
在你的协议中实施代币转账时,务必仔细研究并考虑以下要点:
现在,让我们看看代码示例,展示(在我们看来)正确的代币转账程序,完全展示上述原则。
第一个示例与直接转移有关,我们将采用 Aave V3 合约,该合约使用多种安全功能进行代币转账。我们将讨论 executeBorrow() 函数,该函数将基础代币发送给借款人(我们将跳过一些部分,完整函数可在 这里 找到)。
function executeBorrow(
// ...
// params.amount 包含目标代币金额
// ...
DataTypes.ExecuteBorrowParams memory params
// ...
) public {
DataTypes.ReserveData storage reserve = reservesData[params.asset];
// ...
ValidationLogic.validateBorrow(
// ...
DataTypes.ValidateBorrowParams({
// ...
asset: params.asset,
userAddress: params.onBehalfOf,
amount: params.amount,
// ...
})
);
// ...
if (params.releaseUnderlying) {
IAToken(reserveCache.aTokenAddress).transferUnderlyingTo(params.user, params.amount);
// transferUnderlyingTo() 函数只是:
// IERC20(_underlyingAsset).safeTransfer(target, amount);
}
}
emit Borrow(
//...
);
}
让我们检查我们上面的检查表:
第一个案例:“有毒”代币:基础 ERC20 代币列表由 Aave DAO 筛选,仅包含“良好”代币,因此该案例不适用。
第二个案例:非标准代币:使用 OpenZeppelin 的 safeTransfer() 函数,该函数正确检查转移状态。尽管黑名单和“收费转移”仍然是诸如 USDT 代币的潜在问题,但市场往往忽略它们 :)。该函数没有来自 ERC-677 和 ERC-777 代币的外部重入保护,但由于代币的 transfer() 是函数中的最后一个操作,且所有状态变化都在之前完成,因此看起来是安全的。
第三个案例:如上所述。
第四、第五个案例:不适用(没有 transferFrom())。
第六、七个案例:不适用(没有多个发送/接收地址和多个代币地址的循环)。
第八、第九个案例:让我们检查与传递给 safeTransfer() 函数的 params.amount 相关的操作。金额(来自用户)由 validateBorrow() 函数验证。它在计算 totalDebt 时使用 这里:
vars.totalDebt =
params.reserveCache.currTotalStableDebt +
vars.totalSupplyVariableDebt +
params.amount;
在这里,我们需要确保计算中使用的所有值与金额一致,避免精度错误和跨不同资产的十进制问题。对 totalDebt 和 params.amount 的检查( 这里 和 这里)应被审查,以确立 params.amount 作为代币转账的受信值。
下一个示例展示了在 Lido 协议中使用 transferFrom() 将 stETH 代币从用户转移到协议。代码(源 这里 ):
function _requestWithdrawal(uint256 _amountOfStETH, address _owner) internal returns (uint256 requestId) {
STETH.transferFrom(msg.sender, address(this), _amountOfStETH);
uint256 amountOfShares = STETH.getSharesByPooledEth(_amountOfStETH);
requestId = _enqueue(uint128(_amountOfStETH), uint128(amountOfShares), _owner);
_emitTransfer(address(0), _owner, requestId);
}
第一、二、三案例:不适用。stETH 代币是合规的、完全符合 ERC20 标准的代币,这消除了“有毒”代币、具有转移钩子和转移费用的代币风险。在转移失败的情况下,它将简单地回退,并且没有来自 stETH 的重入可能性。此外,Lido 中的提款逻辑仅将提款请求放入队列。尽管 transferFrom() 在状态更改之前被调用(通常不推荐这样做以避免重入风险),但在这种情况下是安全的。
第四个案例:由于该函数使用符合 ERC20 标准的 checked transferFrom(),因此不需要使用“安全”实现的 transferFrom。与重入相关的案例已经在上面描述。
第五个案例:Lido 没有使用专门的保险库来存储用户的资金或许可,因此在被攻破时资金并不安全。然而,Lido 具有有限的控制转移的功能,在被攻破的情况下,这种风险与其他风险叠加,这些风险通过多次审计其代码库得到了缓解。
第六和第七个案例:不适用,因为没有多个发送/接收地址和多次检查多个代币的循环。
第八个案例:stETH 使用相同的十进制,所有与 stETH 和 wstETH 的操作都以相同的精度进行。因此,所有代币转账金额计算不会受到精度问题的影响。虽然这并不保证所有数学都是自动正确的,但 Lido 中的协议内部计算并不简单,超出了本文的范围。
第九个案例:不适用,因为这里没有使用转移金额进行多阶段计算。
第十个案例:你可以检查 Lido 测试的功能覆盖,亲自测试代币金额。
下一个例子可能是最著名的 - 它是 Uniswap 的 swap() 函数(V3)。在计算目标金额后,最终的转移在 这里 呈现。代码段:
(amount0, amount1) = zeroForOne == exactInput
? (amountSpecified - state.amountSpecifiedRemaining, state.amountCalculated)
: (state.amountCalculated, amountSpecified - state.amountSpecifiedRemaining);
// 执行转移并收集支付
if (zeroForOne) {
if (amount1 < 0) TransferHelper.safeTransfer(token1, recipient, uint256(-amount1));
uint256 balance0Before = balance0();
IUniswapV3SwapCallback(msg.sender).uniswapV3SwapCallback(amount0, amount1, data);
require(balance0Before.add(uint256(amount0)) <= balance0(), 'IIA');
} else {
if (amount0 < 0) TransferHelper.safeTransfer(token0, recipient, uint256(-amount0));
uint256 balance1Before = balance1();
IUniswapV3SwapCallback(msg.sender).uniswapV3SwapCallback(amount0, amount1, data);
require(balance1Before.add(uint256(amount1)) <= balance1(), 'IIA');
}
在两个分支中,uniswapV3SwapCallback() 函数从合约中调用,启动交换。此回调可以执行任何操作,但其中之一必须是根据计算值改变池的余额。值得注意的是,Uniswap 并不检查转移状态;相反,它完全依赖自己的余额变化。这种方法是处理任意代币的唯一可靠方式,因为它涉及在所有操作后检查最终效果,操作可能包括任何内容。
让我们进行我们的检查表。
第一、二个案例:不适用。Uniswap 是一个无权限协议,所有代币都可以使用。用户负责执行转移,因此所有检查都在调用方的一侧进行。
第三个案例:swap() 函数通过锁定/解锁参与交换的插槽来保护重入。此外,所有池状态的修改在代币转账之前完成。
第四、五、六和七个案例:不适用。
第八个案例:使用金额的精度。Uniswap 将使用的代币储备存储为 uint128 值,并且没有两个储备的组合被用于计算金额。这种情况并不构成关注。
第九个案例:风险在于客户端合约的一侧。
第十个案例:你可以通过自己检查测试来验证测试覆盖率。
正如之前提到的,代币转账可能会很棘手,并且它们在 DeFi 协议中的广泛使用并没有降低它们的挑战性。不安全的代币操作经常出现在审计报告中,尤其是在涉及非标准 ERC20 代币时。为此,在代码中实现 ERC20 转移的地方,需要执行以上所有检查。保持安全!
MixBytes 是一个由专家区块链审计员和安全研究人员组成的团队,专注于为 EVM 兼容及 Substrate 基础项目提供全面的智能合约审计和技术顾问服务。请关注我们的 X,获取最新的行业趋势和见解。
- 原文链接: mixbytes.io/blog/defi-pa...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!