Back to Blog

Radiant V2 安全测试报告

Code Auditing
March 23, 2023
33 min read

报告清单

描述
客户 Radiant Capital
目标 Radiant V2

版本历史

版本 日期 描述
1.0 2023年3月15日 初始版本
2.0 2023年3月21日 第二版

1. 引言

1.1 关于安全测试

Radiant Capital 邀请我们(作为红队)对 Radiant V2 的智能合约进行安全测试,以识别潜在风险。 Radiant Capital 作为一支负责任的团队,高度重视安全。 因此,尽管合约已由多家安全公司审计[^1],但团队仍决定投入更多精力来确保这些智能合约的安全性。

请注意,安全测试在目标和要求方面均与安全审计不同。 具体来说,安全测试旨在通过模拟攻击者来破解程序/协议,从而发现额外的/不寻常的易受攻击,而安全审计旨在通过列举可能的攻击来提供相对全面的安全检查。 因此,由于时间和资源有限,安全测试可能无法涵盖安全审计可以识别的一些复杂逻辑错误。

1.2 关于目标合约

信息 描述
类型 智能合约
语言 Solidity
方法 静态分析,动态分析,半自动和手动验证

目标仓库是 Radiant_v2.1.1。 本次安全测试期间的提交 SHA 值如下所示。 我们的报告仅对初始版本(即版本 1)以及用于修复报告中问题的代码(在后续版本中)负责。

请注意,本报告仅涵盖此仓库的 radiant_v2.1.1/contracts 文件夹下的智能合约,包括:

  • bounties
  • deployments
  • flashloan
  • leverage
  • lock
  • oracles
  • staking
  • zap
  • eligibility
  • misc
  • oft
  • protocol
  • stargate

在版本 8 更新后,本次安全测试涵盖的文件包括:

  • lending/AaveOracle.sol
  • lending/AaveProtocolDataProvider.sol
  • lending/ATokensAndRatesHelper.sol
  • lending/StableAndVariableTokensHelper.sol
  • lending/UiPoolDataProviderV2V3.sol
  • lending/UiPoolDataProvider.sol
  • lending/WETHGateway.sol
  • lending/WalletBalanceProvider.sol
  • lending/configuration
  • lending/flashloan
  • lending/lendingpool
  • lending/tokenization
  • radiant/accessories
  • radiant/eligibility
  • radiant/oracles
  • radiant/staking
  • radiant/token
  • radiant/zap

1.3 安全模型

为评估风险,我们遵循业界和学术界广泛采用的标准或建议,包括 OWASP 风险评级方法[^2]和常见弱点枚举[^3]。 风险的总体严重性可能性影响决定。 具体来说,可能性用于估计漏洞被攻击者发现和利用的可能性,而影响用于衡量成功利用的后果。

在本报告中,可能性和影响均分为两级,即,它们的组合如表 1.1 所示。

表 1.1:漏洞严重性分类
表 1.1:漏洞严重性分类

因此,本报告中衡量的严重性分为三类:。 为求完整性,还使用了未确定来涵盖风险无法很好确定的情况。

此外,已发现项目的状态将分为以下四类之一:

  • 未确定 尚未收到回复。

  • 已确认 该项目已收到客户的接收,但尚未确认。

  • 已确认 该项目已得到客户的认可,但尚未修复。

  • 已修复 该项目已得到客户的确认和修复。

2. 自动化安全测试

2.1 自动化静态安全测试

我们使用基于 Slither 的内部静态分析工具来检查漏洞的存在。 在手动检查结果后,未发现任何问题。 详细的测试结果可在附录的表 4.1 中找到。

2.2 自动化动态安全测试

我们利用模糊测试技术来测试目标合约的健壮性、可靠性和精确性。 具体来说,模糊测试过程的初始种子是根据函数语义和合约测试脚本确定的。 为了模拟链上环境,我们还维护了一组与合约 LendingPool 和 MultiFeeDistribution 交互过的地址。

我们的模糊器在交易序列生成过程中也考虑了函数语义。 例如,MultiFeeDistribution 合约中的 stake 函数和 LendingPool 合约中的 deposit 函数很可能在序列中先调用。 对函数参数和序列的变异受到合约代码覆盖率的指导。 如果某个参数或序列达到更高的代码覆盖率,它将在下一轮模糊测试中优先被变异。 为了探索一些受魔术数字限制的路径,我们收集了运行时从存储中读取的值(即 SLOAD 指令),并使用它们在变异过程中生成函数参数。

总共,我们生成了 100,000 个测试用例,并使用了 31 个预言机,用于检测是否发生故障。 每个测试用例包含 30 个具有指定顺序的交易。 最后,我们发现了一个严重问题(即第 3.2.6 节),这在我们的手动安全测试过程中也发现了。 详细的测试结果可在附录的表 4.2、4.3 和 4.4 中找到。

3. 手动安全测试

我们投入人力来理解整体设计和模块之间的交互,然后根据我们从过往研究和经验中获得的潜在攻击面的知识进行安全测试。

总共,我们发现了十七个潜在问题。 此外,我们还有个建议和一个注意事项如下:

  • 高风险:2

  • 中风险:8

  • 低风险:7

  • 建议:3

  • 注意事项:1

ID 严重性 描述 类别 状态
1 中等 没有用于重置函数指针的保留接口 软件安全 已修复
2 中等 Oracle 计算不当 DeFi 安全 已修复
3 通过 BaseBounty 潜在的资金耗尽 DeFi 安全 已修复
4 潜在的无效排放计划 DeFi 安全 已修复
5 可跳过的排放计划 DeFi 安全 已确认
6 中等 迁移期间可变的汇率 DeFi 安全 已修复
7 _transfer() 实现不当 (I) DeFi 安全 已修复
8 UniV2TwapOracle 中缺少周期检查 DeFi 安全 已修复
9 中等 不可退还的零碎代币 DeFi 安全 已修复
10 中等 _transfer() 实现不当 (II) DeFi 安全 已修复
11 中等 可操纵的复合奖励 DeFi 安全 已修复
12 中等 setLeverager() 中缺少访问控制 DeFi 安全 已修复
13 中等 addLiquidityWETHOnly() 中没有滑点检查 DeFi 安全 已确认
14 loopETH() 中缺少 borrowRatio 检查 DeFi 安全 已修复
15 setPoolIDs() 中缺少 assets 和 poolIDs 长度检查 DeFi 安全 已修复
16 addBountyContract() 中缺少 mint 权限撤销 DeFi 安全 已确认
17 Minters 只能分配一次 DeFi 安全 已确认
18 - Gas 优化 (MFD 中的 zapVestingToLp()) 建议 已修复
19 - BountyManager 中非空的 Bounty 储备 建议 已修复
20 - requiredUsdValue() 中命名不一致 建议 已确认
21 - 弃用的 MFDPlus 注意事项 注意事项 已确认

详情见以下章节。

3.1 软件安全

3.1.1 潜在问题 1:没有用于重置函数指针的保留接口

描述
严重性 中等
状态 版本 7 中已修复
由...引入 版本 1

描述 三个函数,getLpMfdBounty()、getChefBounty() 和 getAutoCompoundBounty(),通过 BountyManager 合约中的函数指针调用。 同时,继承自 Ownable-Upgradable 表明该合约将是代理的实现。 这表明实现合约将来可以升级,这带来了与函数指针相关的问题。

function initialize(
        address _rdnt,
        address _weth,
        address _lpMfd,
        address _mfd,
        address _chef,
        address _priceProvider,
        address _eligibilityDataProvider,
        uint256 _hunterShare,
        uint256 _baseBountyUsdTarget,
        uint256 _maxBaseBounty,
        uint256 _bountyBooster
    ) external initializer {
        require(_rdnt != address(0));
        require(_weth != address(0));
        require(_lpMfd != address(0));
        require(_mfd != address(0));
        require(_chef != address(0));
        require(_priceProvider != address(0));
        require(_eligibilityDataProvider != address(0));
        require(_hunterShare <= 10000);
        require(_baseBountyUsdTarget != 0);
        require(_maxBaseBounty != 0);
 
        rdnt = _rdnt;
        weth = _weth;
        lpMfd = _lpMfd;
        mfd = _mfd;
        chef = _chef;
        priceProvider = _priceProvider;
        eligibilityDataProvider = _eligibilityDataProvider;
 
        HUNTER_SHARE = _hunterShare;
        baseBountyUsdTarget = _baseBountyUsdTarget;
        bountyBooster = _bountyBooster;
        maxBaseBounty = _maxBaseBounty;
 
        bounties[1] = getLpMfdBounty;
        bounties[2] = getChefBounty;
        bounties[3] = getAutoCompoundBounty;
        bountyCount = 3;
 
        slippageLimit = 10;
        minDLPBalance = uint256(5).mul(10 ** 18);
 
 
        __Ownable_init();
        __Pausable_init();
    } 

列表 3.1:BountyManager.sol

影响 当上述三个函数的偏移量发生变化时,函数指针将无法按预期工作,整个合约的逻辑可能会发生变化。

建议 合约应提供用于重置函数指针的接口。

3.2 DeFi 安全

3.2.1 潜在问题 2:Oracle 计算不当

描述
严重性 中等
状态 版本 11 中已修复
由...引入 版本 1 和版本 4

描述 ComboOracle 合约中的 consult() 函数用于计算多个来源的平均价格。 在版本 1 的实现中,它使用算术平均值来计算最终价格,这可以通过影响一个来源的 Oracle 来操纵。

function consult() public view override returns (uint256 price) {
        require(sources.length != 0);

        uint256 sum;
        for (uint256 i = 0; i < sources.length; i++) {
            uint256 price = sources[i].consult();
            require(price != 0, "source consult failure");
            sum = sum.add(price);
        }
        price = sum.div(sources.length);
    }

列表 3.2:ComboOracle.sol

在版本 4 的实现中,当平均价格大于最低价格×1.025 时,将返回最低价格。 然而,如果来自一个源 Oracle 的返回值异常低,仍然可以操纵返回值。

/**
    * @notice Calculated price
    * @return price Average price of several sources.
    */
   function consult() public view override returns (uint256 price) {
       require(sources.length != 0);

       uint256 sum;
       uint256 lowestPrice;
       for (uint256 i = 0; i < sources.length; i++) {
           uint256 price = sources[i].consult();
           require(price != 0, "source consult failure");
           if (lowestPrice == 0) {
               lowestPrice = price;
           } else {
               lowestPrice = lowestPrice > price ? price : lowestPrice;
           }
           sum = sum.add(price);
       }
       price = sum.div(sources.length);
       price = price > ((lowestPrice * 1025) / 1000) ? lowestPrice : price;
   }

列表 3.3:ComboOracle.sol

影响 ComboOracle 返回的价格可能会被操纵,从而允许攻击者从中获利。

建议 我们建议使用中位数而不是平均值。 如果只有一个源 Oracle 且出现相当大的差异,则在平均价格远大于最低价格时回滚交易更为合理。

反馈 将只有一个源 Oracle。 如果出现相当大的差异,我们将使用 OZ Defender Sentinel 来暂停相关合约。

注意 合约 ComboOracle 已删除,不再使用。

3.2.2 潜在问题 3:通过 BaseBounty 潜在的资金耗尽

描述
严重性
状态 版本 4 中已修复
由...引入 版本 1

描述 用户可以锁定代币(即 RDNT)固定一段时间以赚取奖励。 当锁定到期时,其他用户可以通过调用 executeBounty() 函数为启用自动重置的该用户重新锁定代币以赚取 BaseBounty。 在重新锁定的过程中,内部函数 _cleanWithdrawableLocks() 会清除并重新质押过期的锁定。 然而,有一个变量 maxLockWithdrawPerTxn 限制了可以清除的最大锁定数量。 在这种情况下,即使在 executeBounty() 函数执行后,未清除的过期锁定仍可能存在。 这可以进一步绕过 MFDPlus 合约中 claimBounty() 函数第 106 行的检查。 issueBaseBounty 将被设置为 true 并返回。

**
    * @notice Withdraw all lockings tokens where the unlock time has passed
    */
   function _cleanWithdrawableLocks(
       address user,
       uint256 totalLock,
       uint256 totalLockWithMultiplier
   ) internal returns (uint256 lockAmount, uint256 lockAmountWithMultiplier) {
       LockedBalance[] storage locks = userLocks[user];

       if (locks.length != 0) {
           uint256 length = locks.length <= maxLockWithdrawPerTxn ? locks.length : maxLockWithdrawPerTxn;
           for (uint256 i = 0; i < length; ) {
               if (locks[i].unlockTime <= block.timestamp) {
                   lockAmount = lockAmount.add(locks[i].amount);
                   lockAmountWithMultiplier = lockAmountWithMultiplier.add(
                       locks[i].amount.mul(locks[i].multiplier)
                   );
                   locks[i] = locks[locks.length - 1];
                   locks.pop();
                   length = length - 1;
               } else {
                   i = i + 1;
               }
           }
           if (locks.length == 0) {
               lockAmount = totalLock;
               lockAmountWithMultiplier = totalLockWithMultiplier;
               delete userLocks[user];

               userlist.removeFromList(user);
           }
       }
   }

列表 3.4:MultiFeeDistribution.sol

具体来说,攻击者可以多次质押具有相同到期时间的 1 wei 代币,这远大于 maxLockWithdrawPerTxn。 之后,攻击者可以将操作设置为 getLpMfdBounty 并反复调用 executeBounty()。 由于已清除锁定的数量受 maxLockWithdrawPerTxn 的限制,因此攻击者可以耗尽 BountyManager 合约中的 BaseBounty。

影响 攻击者可以在一次交易中耗尽 BountyManager 合约中的所有资金,从而导致设计的赏金机制中断。

建议 确保 _cleanWithdrawableLocks() 函数能够清除所有过期的锁定,并在 _stake() 函数中设置最小质押金额。

3.2.3 潜在问题 4:潜在的无效排放计划

描述
严重性
状态 版本 10 中已修复
由...引入 版本 1

描述 在 ChefIncentivesController 合约中,owner 调用 setEmissionSchedule() 函数来为不同的奖励率设置计划。 在这种情况下,每个计划的开始时间 (_startTimeOffsets[i] + startTime) 应验证是否大于当前时间戳。 然而,它只检查 _startTimeOffsets 中的第一个元素,这还不够。 此外,_startTimeOffsets[i] 在加到 emissionSchedule 时从 uint256 转换为 uint128,如果原始输入过大,可能会被截断。

function setEmissionSchedule(
        uint256[] calldata _startTimeOffsets,
        uint256[] calldata _rewardsPerSecond
    ) external onlyOwner {
        uint256 length = _startTimeOffsets.length;
        require(length > 0 && length == _rewardsPerSecond.length, "empty or mismatch params");
        if (startTime > 0) {
            require(_startTimeOffsets[0] > block.timestamp.sub(startTime), "invalid start time");
        }
 
        for (uint256 i = 0; i < length; i++) {
            emissionSchedule.push(
                EmissionPoint({
                    startTimeOffset: uint128(_startTimeOffsets[i]),
                    rewardsPerSecond: uint128(_rewardsPerSecond[i])
                })
            );
        }
        emit EmissionScheduleAppended(_startTimeOffsets, _rewardsPerSecond);
    } 

列表 3.5:ChefIncentivesController.sol

影响 如果 _startTimeOffsets 不是升序排列的,则承诺的一些奖励将不会分配给用户。 如果 _startTimeOffsets[i] 超出了 uint128 的范围,将添加一个无效的排放计划。

建议 确保 _startTimeOffsets 是升序排列的,并且所有元素都在 uint128 范围内。

3.2.4 潜在问题 5:可跳过的排放计划

描述
严重性
状态 已确认
由...引入 版本 1

描述 在 ChefIncentivesController 合约中,setScheduleRewardsPerSecond() 函数会迭代 emissionSchedule 以查找已开始的具有最大索引的目标计划,并相应地更新奖励率。 然而,在这种情况下,一些排放计划可能会被跳过。

function setScheduledRewardsPerSecond() internal {
		if (!persistRewardsPerSecond) {
			uint256 length = emissionSchedule.length;
			uint256 i = emissionScheduleIndex;
			uint128 offset = uint128(block.timestamp.sub(startTime));
			for (; i < length && offset >= emissionSchedule[i].startTimeOffset; i++) {}
			if (i > emissionScheduleIndex) {
				emissionScheduleIndex = i;
				_massUpdatePools();
				rewardsPerSecond = uint256(emissionSchedule[i - 1].rewardsPerSecond);
			}
		}
	}

列表 3.6:ChefIncentivesController.sol

影响 如果 setScheduledRewardsPerSecond() 函数长时间未被调用,一些承诺的奖励可能不会分配给用户。

建议 setScheduledRewardsPerSecond() 函数在 claim() 和 _handleActionAfterForToken() 函数内部调用,因此唯一可能跳过排放计划的情况是,在排放时期没有人与协议进行交互。

3.2.5 潜在问题 6:迁移期间可变的汇率

描述
严重性 中等
状态 版本 5 中已修复
由...引入 版本 1

描述 Migration 合约用于用户以指定的 exchangeRate 从 tokenV1 兑换为 tokenV2。 然而,在迁移过程中,owner 仍然可以通过 setExchangeRate() 函数调整此 exchangeRate。

/**
    * @notice Migrate from V1 to V2
    * @param amount of V1 token
    */
   function exchange(uint256 amount) external whenNotPaused {
       uint256 v1Decimals = tokenV1.decimals();
       uint256 v2Decimals = tokenV2.decimals();

       uint256 outAmount = amount.mul(1e4).div(exchangeRate).mul(10**v2Decimals).div(10**v1Decimals);
       tokenV1.safeTransferFrom(_msgSender(), address(this), amount);
       tokenV2.safeTransfer(_msgSender(), outAmount);

       emit Migrate(_msgSender(), amount, outAmount);
   }

列表 3.7:Migration.sol

影响 如果在迁移过程中更改 exchangeRate,则对其他用户不公平。

建议 迁移开始后,exchangeRate 应固定。

3.2.6 潜在问题 7:_transfer() 实现不当 (I)

描述
严重性
状态 版本 7 中已修复
由...引入 版本 1

描述 在 IncentivizedERC20 合约中,_transfer() 函数没有考虑到发送方和接收方是同一账户(所谓的自转账)的情况。 具体来说,如果发送方等于接收方,发送方的余额将在更新接收方的余额时被覆盖。 在这种情况下,黑客可以通过反复转账给自己来无限增加自己的余额。

function _transfer(
        address sender,
        address recipient,
        uint256 amount
      ) internal virtual {
        require(sender != address(0), 'ERC20: transfer from the zero address');
        require(recipient != address(0), 'ERC20: transfer to the zero address');
    
        _beforeTokenTransfer(sender, recipient, amount);
    
        uint256 senderBalance = _balances[sender].sub(amount, 'ERC20: transfer amount exceeds balance');
        uint256 recipientBalance = _balances[recipient].add(amount);
    
        if (address(_getIncentivesController()) != address(0)) {
          // uint256 currentTotalSupply = _totalSupply;
          _getIncentivesController().handleActionBefore(sender);
          if (sender != recipient) {
            _getIncentivesController().handleActionBefore(recipient);
          }
        }
    
        _balances[sender] = senderBalance;
        _balances[recipient] = recipientBalance;
    
        if (address(_getIncentivesController()) != address(0)) {
          uint256 currentTotalSupply = _totalSupply;
          _getIncentivesController().handleActionAfter(sender, senderBalance, currentTotalSupply);
          if (sender != recipient) {
            _getIncentivesController().handleActionAfter(recipient, recipientBalance, currentTotalSupply);
          }
        }
      }

列表 3.8:IncentivizedERC20.sol

影响 代币可以被无限铸造。

建议 正确实现 _transfer() 函数。 例如,OpenZeppelin 的标准 _transfer() 实现 ERC20。

_balances[sender] = _balances[sender].sub(amount, 'ERC20: transfer amount exceeds balance');
_balances[recipient] = _balances[recipient].add(amount);

列表 3.9:OpenZeppelin 中的 ERC20.sol

3.2.7 潜在问题 8:UniV2TwapOracle 中缺少周期检查

描述
严重性
状态 版本 9 中已修复
由...引入 版本 1

描述 在 UniV2TwapOracle 合约中,_period 属性未在 initialize() 和 setPeriod() 函数中进行验证。

function initialize(
        address _pair,
        address _rdnt,
        address _ethChainlinkFeed,
        uint _period,
        uint _consultLeniency,
        bool _allowStaleConsults
    ) external initializer {
        __Ownable_init();

        pair = IUniswapV2Pair(_pair);
        token0 = pair.token0();
        token1 = pair.token1();
        price0CumulativeLast = pair.price0CumulativeLast(); // Fetch the current accumulated price value (1 / 0)
        price1CumulativeLast = pair.price1CumulativeLast(); // Fetch the current accumulated price value (0 / 1)
        uint112 reserve0;
        uint112 reserve1;
        (reserve0, reserve1, blockTimestampLast) = pair.getReserves();
        require(reserve0 != 0 && reserve1 != 0, 'UniswapPairOracle: NO_RESERVES'); // Ensure that there's liquidity in the pair

        PERIOD = _period;
        CONSULT_LENIENCY = _consultLeniency;
        ALLOW_STALE_CONSULTS = _allowStaleConsults;

        baseInitialize(_rdnt, _ethChainlinkFeed);
    }

    function setPeriod(uint _period) external onlyOwner {
        PERIOD = _period;
    }

列表 3.10:UniV2TwapOracle.sol

影响 在这种情况下,如果 _period 太小,Oracle 可能会返回意外的值。

建议 在 initialize 和 setPeriod 函数中设置 _period 的最小限制。

3.2.8 潜在问题 9:不可退还的零碎代币

描述
严重性 中等
状态 版本 5 中已修复
由...引入 版本 1

描述 在 UniswapPoolHelper 合约中,zapWETH() 函数旨在帮助用户将 WETH 代币转换为 LP 代币。 它将调用 addLiquidityWETHOnly() 函数在 LP 代币的池中添加流动性。 在此过程中,可能会存在零碎代币,这些代币应退还给用户。 然而,UniswapPoolHelper 没有实现此类功能来处理这些零碎代币。

function zapWETH(uint256 amount)
    public
    returns (uint256 liquidity)
{
    IWETH WETH = IWETH(wethAddr);
    WETH.transferFrom(msg.sender, address(liquidityZap), amount);
    liquidity = liquidityZap.addLiquidityWETHOnly(amount, address(this));
    IERC20 lp = IERC20(lpTokenAddr);
    
    liquidity = lp.balanceOf(address(this));
    lp.safeTransfer(msg.sender, liquidity);
}

列表 3.11:UniswapPoolHelper.sol

影响 零碎代币将保留在合约中,可以通过 zapTokens(0,0) 函数被其他人提取。

建议 在添加流动性后实现返回零碎代币的函数。

3.2.9 潜在问题 10:_transfer() 实现不当 (II)

描述
严重性 中等
状态 版本 9 中已修复
由...引入 版本 7

描述 在 IncentivizedERC20 合约中,_transfer() 函数将调用 handle_ActionAfter() 函数以相应地更新 ChefIncentivesController 合约中用户的状态。 然而,如果发送方等于接收方,发送方余额将不会被更新,这是不正确的。

function _transfer(
        address sender,
        address recipient,
        uint256 amount
      ) internal virtual {
        require(sender != address(0), 'ERC20: transfer from the zero address');
        require(recipient != address(0), 'ERC20: transfer to the zero address');
    
        _beforeTokenTransfer(sender, recipient, amount);
    
        uint256 senderBalance = _balances[sender].sub(amount, 'ERC20: transfer amount exceeds balance');
    
        if (address(_getIncentivesController()) != address(0)) {
          // uint256 currentTotalSupply = _totalSupply;
          _getIncentivesController().handleActionBefore(sender);
          if (sender != recipient) {
            _getIncentivesController().handleActionBefore(recipient);
          }
        }
    
        _balances[sender] = senderBalance;
        uint256 recipientBalance = _balances[recipient].add(amount);
        _balances[recipient] = recipientBalance;
    
        if (address(_getIncentivesController()) != address(0)) {
          uint256 currentTotalSupply = _totalSupply;
          _getIncentivesController().handleActionAfter(sender, senderBalance, currentTotalSupply);
          if (sender != recipient) {
            _getIncentivesController().handleActionAfter(recipient, recipientBalance, currentTotalSupply);
          }
        }
      }

列表 3.12:IncentivizedERC20.sol

影响 当用户转账给自己时,他们在 ChefIncentivesController 合约中的状态不会得到正确更新,这将导致奖励方面出现进一步问题。

建议 在 handleActionAfter() 函数中更正 senderBalance。

3.2.10 潜在问题 11:可操纵的复合奖励

描述
严重性 中等
状态 版本 10 中已修复
由...引入 版本 5

描述 在 MFDPlus 合约中,_convertPendingRewardsToWeth() 函数通过 Uniswap 路由器将用户的奖励兑换成 WETH 进行重新锁定。 然而,在交换后没有滑点检查。

IERC20(underlying).safeApprove(uniRouter, removedAmount);
    uint256[] memory amounts = IUniswapV2Router02(uniRouter)
    .swapExactTokensForTokens(
         removedAmount,
         0, // slippage handled after this function
         mfdHelper.getRewardToBaseRoute(underlying),
         address(this),
         block.timestamp + 10
     );

列表 3.13:MFDPlus.sol

影响 攻击者可以抢跑交易以操纵价格并获利。

建议 在 claimCompound() 函数中添加滑点检查。

3.2.11 潜在问题 12:setLeverager() 中缺少访问控制

描述
严重性 中等
状态 版本 9 中已修复
由...引入 版本 1

描述 LendingPool 合约中的 setLeverager() 函数没有访问控制。

uint256[] memory amounts = IUniswapV2Router02(uniRouter)
    .swapExactTokensForTokens(
         removedAmount,
         0, // slippage handled after this function
         mfdHelper.getRewardToBaseRoute(underlying),
         address(this),
         block.timestamp + 10
     );

列表 3.14:LendingPool.sol

影响 如果在开始时未设置 leverager,攻击者可以将其设置为任何地址,从而获得对 depositWithAutoDLP() 函数逻辑的控制权。

建议 在 initialize() 函数中设置 leverager 或为 setLeverager() 函数添加访问控制。

3.2.12 潜在问题 13:addLiquidityWETHOnly() 中没有滑点检查

描述
严重性 中等
状态 已确认
由...引入 版本 1

描述 用户可以使用借入的 WETH 代币(或自己的 ETH 代币)或 MFD 合约中的归属 RDNT 代币来获取 LP 代币(即 WETH-RDNT)。

然而,在向池中添加流动性时,所需代币的计算基于池中储备的数量,这可能会被操纵。 在这种情况下,如果用户只有 WETH 代币,addLiquidityWETHOnly() 函数将被调用,以在不检查滑点的情况下在不平衡的池中将一半 WETH 代币兑换为 RDNT 代币。

function addLiquidityWETHOnly(uint256 _amount, address payable to)
    public
    returns (uint256 liquidity)
{
    require(to != address(0), "LiquidityZAP: Invalid address");
    uint256 buyAmount = _amount.div(2);
    require(buyAmount > 0, "LiquidityZAP: Insufficient ETH amount");

    (uint256 reserveWeth, uint256 reserveTokens) = getPairReserves();
    uint256 outTokens = UniswapV2Library.getAmountOut(
        buyAmount,
        reserveWeth,
        reserveTokens
    );

    _WETH.transfer(_tokenWETHPair, buyAmount);

    (address token0, address token1) = UniswapV2Library.sortTokens(
        address(_WETH),
        _token
    );
    IUniswapV2Pair(_tokenWETHPair).swap(
        _token == token0 ? outTokens : 0,
        _token == token1 ? outTokens : 0,
        address(this),
        ""
    );

    return _addLiquidity(outTokens, buyAmount, to);
}

列表 3.15:LiquidityZap.sol

function getAmountOut(uint amountIn, uint reserveIn, uint reserveOut) internal pure returns (uint amountOut) {
       require(amountIn > 0, 'UniswapV2Library: INSUFFICIENT_INPUT_AMOUNT');
       require(reserveIn > 0 && reserveOut > 0, 'UniswapV2Library: INSUFFICIENT_LIQUIDITY');
       uint amountInWithFee = amountIn.mul(997);
       uint numerator = amountInWithFee.mul(reserveOut);
       uint denominator = reserveIn.mul(1000).add(amountInWithFee);
       amountOut = numerator / denominator;
   }  

列表 3.16:UniswapV2Library.sol

影响 攻击者可以抢跑交易以操纵价格并获利。

建议 在 addLiquidityWETHOnly() 函数中检查滑点,或确保只能由 UniswapPoolHelper 调用。

3.2.13 潜在问题 14:loopETH() 中缺少 borrowRatio 检查

描述
严重性
状态 版本 10 中已修复
由...引入 版本 1

描述 loopETH() 函数用于杠杆借贷,并接收一个 borrowRatio 参数来指定借贷比例。 然而,在循环开始前没有检查 borrowRatio。

function loopETH(
        uint256 interestRateMode,
        uint256 borrowRatio,
        uint256 loopCount
    ) external payable {
        uint16 referralCode = 0;
        uint256 amount = msg.value;
        if (IERC20(address(weth)).allowance(address(this), address(lendingPool)) == 0) {
            IERC20(address(weth)).safeApprove(address(lendingPool), type(uint256).max);
        }
        if (IERC20(address(weth)).allowance(address(this), address(treasury)) == 0) {
            IERC20(address(weth)).safeApprove(treasury, type(uint256).max);
        }

        uint256 fee = amount.mul(feePercent).div(RATIO_DIVISOR);
        _safeTransferETH(treasury, fee);
        
        amount = amount.sub(fee);

        weth.deposit{value: amount}();
        lendingPool.deposit(address(weth), amount, msg.sender, referralCode);

        for (uint256 i = 0; i < loopCount; i += 1) {
            amount = amount.mul(borrowRatio).div(RATIO_DIVISOR);
            lendingPool.borrow(address(weth), amount, interestRateMode, referralCode, msg.sender);
            weth.withdraw(amount);

            fee = amount.mul(feePercent).div(RATIO_DIVISOR);
            _safeTransferETH(treasury, fee);

            weth.deposit{value: amount.sub(fee)}();
            lendingPool.deposit(address(weth), amount.sub(fee), msg.sender, referralCode);
        }

        zapWETHWithBorrow(wethToZap(msg.sender), msg.sender);
    }

列表 3.17:Leverager.sol

影响 borrowRatio 可能高于 RATIO_DIVISOR,这与原始设计不符。

建议 确保 borrowRatio 小于或等于 RATIO_DIVISOR。

3.2.14 潜在问题 15:setPoolIDs() 中缺少 assets 和 poolIDs 长度检查

描述
严重性
状态 版本 10 中已修复
由...引入 版本 1

描述 setPoolIDs() 函数允许 owner 为不同的资产设置不同的 poolIDs。 然而,没有检查这两个数组的长度是否相等。

// Set pool ids of assets
    function setPoolIDs(address[] memory assets, uint256[] memory poolIDs) external onlyOwner {
        for (uint256 i = 0; i < assets.length; i += 1) {
            poolIdPerChain[assets[i]] = poolIDs[i];
        }
        emit PoolIDsUpdated(assets, poolIDs);
    } 

列表 3.18:StarBorrow.sol

影响 资产将不会分配给正确的 poolIDs。

建议 确保 assets 和 poolIDs 的长度相等。

3.2.15 潜在问题 16:addBountyContract() 中缺少 mint 权限撤销

描述
严重性
状态 已确认
由...引入 版本 1

描述 addBountyContract() 函数用于设置新的 BountyManager。 然而,原始赏金合约仍然持有 mint 权限,这与原始设计相悖。

function addBountyContract(address _bounty) external onlyOwner {
       BountyManager = _bounty;
       minters[_bounty] = true;
   }

列表 3.19:Leverager.sol

影响 已弃用的 BountyManager 仍具有 mint 权限。

建议 撤销原始 BountyManager 合约的 mint 权限。

反馈 addBountyContract 函数将只调用一次以初始化 BountyManager。

3.2.16 潜在问题 17:Minters 只能分配一次

描述
严重性
状态 已确认
由...引入 版本 1

描述 minters 用于记录那些有权访问 mint() 和 addReward() 函数的人。 然而,当其中一个 minters(例如,ChefIncentivesController 合约)被更新时,无法删除过时的 minters。

function setMinters(address[] memory _minters) external onlyOwner {
        require(!mintersAreSet);
        for (uint256 i; i < _minters.length; i++) {
            minters[_minters[i]] = true;
        }
        mintersAreSet = true;
    }

列表 3.20:MultiFeeDistribution.sol

影响 当过时的 minters 被升级时,无法删除它们。

建议 实现一个特权函数来修改 minters。

反馈 由于 BountyManager、ChefIncentivesController 和 MultiFeeDistribution 可升级,因此 minters 始终保持相同的代理地址。

3.3 额外建议

3.3.1 潜在问题 18:Gas 优化 (MFD 中的 zapVestingToLp())

描述
状态 版本 10 中已修复
由...引入 版本 1

描述 zapVestingToLp() 函数只能由 LockZap 合约调用,以转出用户的锁定收益。 它从索引 0 开始迭代用户的收益数组,并检查 unlockTime 是否大于当前时间戳。 如果是,则该收益将从数组中移除并转出。 然而,由于数组中的 unlockTime 随着索引的增加而增加,因此从数组末尾到开头进行迭代会更有效。 如果 unlockTime 小于当前时间戳,则可以中断循环。

function zapVestingToLp(address _user)
        external
        override
        returns (uint256 zapped)
    {
        require(msg.sender == lockZap);

        LockedBalance[] storage earnings = userEarnings[_user];
        uint256 length = earnings.length;

        for (uint256 i = 0; i < length; ) {
            // only vesting, so only look at currently locked items
            if (earnings[i].unlockTime > block.timestamp) {
                zapped = zapped.add(earnings[i].amount);
                // remove + shift array size
                earnings[i] = earnings[earnings.length - 1];
                earnings.pop();
                length = length.sub(1);
            } else {
                i = i.add(1);
            }
        }

        rdntToken.safeTransfer(lockZap, zapped);

        Balances storage bal = balances[_user];
        bal.earned = bal.earned.sub(zapped);
        bal.total = bal.total.sub(zapped);

        return zapped;
    }

列表 3.21:MultiFeeDistribution.sol

建议 从 earnings 的末尾到开头开始迭代。 如果 unlockTime 小于当前时间戳,则可以中断循环。

3.3.2 潜在问题 19:BountyManager 中非空的 Bounty 储备

描述
状态 版本 10 中已修复
由...引入 版本 1

描述 在 _sendBounty() 函数中,如果 BountyManager 合约中的 RDNT 代币不足以进行转账,将发出 BountyReseveEmpty() 事件,并且合约将暂停。 然而,仍然可能有一些 RDNT 代币剩余,这与发出的事件不符。

function _sendBounty(address _to, uint256 _amount)
		internal
		returns (uint256)
	{
		if (_amount == 0) {
			return 0;
		}

		uint256 bountyReserve = IERC20(rdnt).balanceOf(address(this));
		if(_amount > bountyReserve) {
			emit BountyReserveEmpty(bountyReserve);
			_pause();
		} else {
			IERC20(rdnt).safeTransfer(address(mfd), _amount);
			IMFDPlus(mfd).mint(_to, _amount, true);
			return _amount;
		}
	}

列表 3.22:BountyManager.sol

建议 即使不够,也要将剩余的 RDNT 代币转出。

3.3.3 潜在问题 20:requiredUsdValue() 中命名不一致

描述
状态 已确认
由...引入 版本 1

描述 requiredUsdValue() 函数用于检查希望获得奖励资格的用户所需的锁定价值(通过持有 RTokens)。 计算基于用户的抵押品价值,该价值由 getUserAccountData() 函数返回。 然而,返回的值被命名为 totalCollateralETH,这与 requiredUsdValue() 函数中的 totalCollateralUSD 不一致。

建议 标准化函数命名约定,使用正确的代币名称。 例如,将 requiredUsdValue() 重命名为 requiredEthValue()。

反馈 我们更倾向于让 AAVE 合约尽可能相似,因此我们没有更新名称。

3.4 注意事项

3.4.1 潜在问题 21:弃用的 MFDPlus

描述
状态 已确认
由...引入 版本 10

描述 MFDPlus 合约不再使用。 复合逻辑已移至 AutoCompounder 合约,其他逻辑已移至 MiddleFeeDistribution 合约。

4. 附录

4.1 自动化静态安全测试结果

表 4.1:自动化静态安全测试结果。 Found 指示工具报告的问题数量。 FP 是手动验证后的误报数量。

ID 检测器 描述 影响 发现 误报 结果
1 arbitrary-send-erc20 使用任意 from 地址调用 transferFrom 1 1 通过
2 array-by-reference 按值修改存储数组 0 0 通过
3 incorrect-shift shift 指令中参数顺序错误 0 0 通过
4 multiple-constructors 多个构造函数方案 0 0 通过
5 name-reused 重用合约名称 0 0 通过
6 protected-vars 未经访问控制直接修改变量 0 0 通过
7 rtlo 使用从右到左覆盖控制字符 0 0 通过
8 shadowing-state 状态变量遮蔽 1 1 通过
9 suicidal 允许任何人销毁合约的函数 0 0 通过
10 uninitialized-state 未初始化的状态变量 3 3 通过
11 uninitialized-storage 未初始化的存储变量 0 0 通过
12 unprotected-upgrade 未受保护的可升级合约 1 1 通过
13 arbitrary-send-erc20-permit transferFrom 使用带 permit 的任意 from 0 0 通过
14 arbitrary-send-eth 将 Ether 发送到任意目标的函数 0 0 通过
15 controlled-array-length 受控的数组长度赋值 0 0 通过
16 controlled-delegatecall 受控的 delegatecall 目标 0 0 通过
17 delegatecall-loop 在循环中使用 payable 函数的 delegatecall 0 0 通过
18 msg-value-loop 在循环中使用 msg.value 0 0 通过
19 reentrancy-eth 重入漏洞(以太币盗窃) 5 5 通过
20 storage-array 已签名的存储整数数组编译器错误 0 0 通过
21 unchecked-transfer 未经检查的代币转账 12 12 通过
22 weak-prng 弱伪随机数生成器 0 0 通过
23 domain-separator-collision 检测到 ERC20 代币具有与 EIP-2612 的 DOMAIN_SEPARATOR() 签名冲突的函数 中等 0 0 通过
24 enum-conversion 检测到危险的 enum 转换 中等 0 0 通过
25 erc20-interface 不正确的 ERC20 接口 中等 0 0 通过
26 erc721-interface 不正确的 ERC721 接口 中等 0 0 通过
27 incorrect-equality 危险的严格相等 中等 23 23 通过
28 locked-ether 锁定以太币的合约 中等 1 1 通过
29 mapping-deletion 删除包含结构的映射 中等 0 0 通过
30 shadowing-abstract 抽象合约的状态变量遮蔽 中等 0 0 通过
31 tautology 重言式或矛盾 中等 0 0 通过
32 write-after-write 未使用的写入 中等 3 3 通过
33 boolean-cst 布尔常量误用 中等 0 0 通过
34 constant-function-asm 使用汇编代码的常量函数 中等 0 0 通过
35 constant-function-state 更改状态的常量函数 中等 0 0 通过
36 divide-before-multiply 不精确的算术运算顺序 中等 20 20 通过
37 reentrancy-no-eth 重入漏洞(无以太币盗窃) 中等 12 12 通过
38 reused-constructor 重用的基类构造函数 中等 0 0 通过
39 tx-origin tx.origin 的危险使用 中等 1 1 通过
40 unchecked-lowlevel 未经检查的低级调用 中等 0 0 通过
41 unchecked-send 未经检查的发送 中等 0 0 通过
42 uninitialized-local 未初始化的局部变量 中等 33 33 通过
43 unused-return 未使用的返回值 中等 19 19 通过

4.2 自动化动态安全测试结果

表 4.2:针对借贷相关逻辑的测试属性

ID 属性 结果
1 调用 deposit 永不会导致 onBehalfOf 的 RToken 数量减少 通过
2 调用 withdraw 永不会导致 msg.sender 的 RToken 数量增加 通过
3 使用稳定利率模式调用 borrow 永不会导致 onBehalfOf 的 StableDebtToken 数量减少。 通过
4 使用可变利率模式调用 borrow 永不会导致 onBehalfOf 的 VariableDebtToken 数量减少。 通过
5 调用 borrow 时 onBehalfOf 不等于 msg.sender 永不会导致 msg.sender 的借款限额增加。 通过
6 使用稳定利率模式调用 repay 永不会导致 onBehalfOf 的 StableDebtToken 数量增加。 通过
7 使用可变利率模式调用 repay 永不会导致 onBehalfOf 的 VariableDebtToken 数量增加。 通过
8 liquidityIndex 永不会减少。 通过
9 liquidityIndex 在同一区块内将保持不变。 通过
10 variableBorrowIndex 永不会减少。 通过
11 variableBorrowIndex 在同一区块内将保持不变。 通过
12 降低抵押品数量永不会导致健康因子小于 1。 通过
13 增加借款金额永不会导致健康因子小于 1。 通过

表 4.3:针对质押相关逻辑的测试属性

ID 属性 结果
1 用户的总余额始终等于已锁定余额、未锁定余额和已赚取余额的总和。 通过
2 用户的已锁定余额始终等于 userLocks 金额的总和 通过
3 用户的 lockedWithMultiplier 余额始终等于 userLocks 金额乘以 userLocks multiplier 的总和 通过
4 lockedSupply 始终等于用户锁定余额的总和 通过
5 lockedSupplyWithMultiplier 始终等于用户 lockedWithMultiplier 余额的总和 通过
6 rewardPerTokenStored 永不会减少。 通过
7 rewardPerTokenStored 在同一区块内将保持不变。 通过
8 totalSupply 始终等于用户金额的总和 通过
9 accRewardPerShare 永不会减少。 通过
10 accRewardPerShare 在同一区块内将保持不变。 通过

表 4.4:针对其他功能的测试属性

ID 属性 结果
1 LockedZap 合约的 WETH 和 RDNT 余额将始终为零。 通过
2 LiquidityZap 合约的 WETH 和 RDNT 余额将始终为零。 通过
3 BalancerPoolHelper 合约的 WETH 和 RDNT 余额将始终为零。 通过
4 UniswapPoolHelper 合约的 WETH 和 RDNT 余额将始终为零。 通过
5 调用 loop 始终导致用户有资格获得奖励 通过
6 调用 loopETH 始终导致用户有资格获得奖励 通过
7 调用 executeBounty 且 _execute 为 false 时,永不会导致存储更改。 通过
8 调用 transfer 时发送方等于接收方时,余额永远不会改变。 版本 1 失败。 版本 7 通过

5. 通知和评论

5.1 免责声明

本报告不构成投资建议或个人推荐。 它不考虑,也不应被解释为考虑或对代币、代币销售或任何其他产品、服务或其他资产的潜在经济学产生任何影响。 任何实体均不应以任何方式依赖本报告,包括用于购买或出售任何代币、产品、服务或其他资产的决策。

本报告不认可任何特定项目或团队,并且本报告不保证任何特定项目的安全性。 本安全测试并未就发现所有安全问题做出任何保证,即评估结果不保证不存在任何进一步的安全问题。 由于安全测试不能被认为是全面的,我们始终建议进行独立的审计和公开的 Bug Bounty 计划,以确保智能合约的安全性。

本安全测试的范围仅限于第 1.2 节中提到的代码。 除非另有明确说明,语言本身(例如,solidity 语言)、底层编译工具链和计算基础设施的安全性均不在本报告的范围内。

5.2 审计程序

我们按照以下程序进行审计。

  • 漏洞检测我们首先使用自动代码分析器扫描智能合约,然后手动验证(拒绝或确认)它们报告的问题。

  • 语义分析我们研究智能合约的业务逻辑,并使用自动模糊测试工具(由我们的研究团队开发)进一步调查可能的漏洞。 我们还通过独立的审计员手动分析可能的攻击场景,以交叉检查结果。

  • 建议我们从良好的编程实践的角度为开发人员提供一些有用的建议,包括 Gas 优化、代码风格等。

我们在以下列出主要的具体检查点。

5.2.1 软件安全

  • 重入性

  • 拒绝服务 (DoS)

  • 访问控制

  • 数据处理和数据流

  • 异常处理

  • 不受信任的外部调用和控制流

  • 初始化一致性

  • 事件操作

  • 易出错的随机性

  • 代理系统使用不当

5.2.2 DeFi 安全

  • 语义一致性

  • 功能一致性

  • 权限管理

  • 业务逻辑

  • 代币操作

  • 紧急机制

  • Oracle 安全

  • 白名单和黑名单

  • 经济影响

  • 批量转账

5.2.3 NFT 安全

  • 重复项

  • 代币接收者验证

  • 链下元数据安全

5.2.4 额外建议

  • Gas 优化

  • 代码质量和风格

注意:先前的检查点是主要的。 根据项目的具体功能,我们可能会在审计过程中使用更多检查点。

[^1]:https://twitter.com/RDNTCapital/status/1625579906502475776

[^2]:https://owasp.org/www-community/OWASP_Risk_Rating_Methodology

[^3]:https://cwe.mitre.org/

Sign up for the latest updates
Newsletter - April 2026
Security Insights

Newsletter - April 2026

In April 2026, the DeFi ecosystem experienced three major security incidents. KelpDAO lost ~$290M due to an insecure 1-of-1 DVN bridge configuration exploited via RPC infrastructure compromise, Drift Protocol suffered ~$285M from a multisig governance takeover leveraging Solana's durable nonce mechanism, and Rhea Finance incurred ~$18.4M following a business logic flaw in its margin-trading module that allowed circular swap path manipulatio

~$7.04M Lost: GiddyDefi, Volo Vault & More | BlockSec Weekly
Security Insights

~$7.04M Lost: GiddyDefi, Volo Vault & More | BlockSec Weekly

This BlockSec weekly security report covers eight attack incidents detected between April 20 and April 26, 2026, across Ethereum, Avalanche, Sui, Base, HyperLiquid, and MegaETH, with total estimated losses of approximately $7.04M. The highlighted incident is the $1.3M GiddyDefi exploit, where the attacker did not break any cryptography or use a flash loan but simply replayed an existing on-chain EIP-712 signature with the unsigned `aggregator` and `fromToken` fields swapped out for a malicious contract, demonstrating how partial signature coverage turns any historical signature into a generic permit. Other incidents include a $3.5M Volo Vault operator key compromise on Sui, a $1.5M Purrlend privileged-role takeover, a $413K SingularityFinance oracle misconfiguration, a $142.7K Scallop cross-pool index injection, a $72.35K Kipseli Router decimal mismatch, a $50.7K REVLoans (Juicebox) accounting pollution, and a $64K Custom Rebalancer arbitrary-call exploit.

Weekly Web3 Security Incident Roundup | Apr 13 – Apr 19, 2026
Security Insights

Weekly Web3 Security Incident Roundup | Apr 13 – Apr 19, 2026

This BlockSec weekly security report covers four attack incidents detected between April 13 and April 19, 2026, across multiple chains such as Ethereum, Unichain, Arbitrum, and NEAR, with total estimated losses of approximately $310M. The highlighted incident is the $290M KelpDAO rsETH bridge exploit, where an attacker poisoned the RPC infrastructure of the sole LayerZero DVN to fabricate a cross-chain message, triggering a cascading WETH freeze across five chains and an Arbitrum Security Council forced state transition that raises questions about the actual trust boundaries of decentralized systems. Other incidents include a $242K MMR proof forgery on Hyperbridge, a $1.5M signed integer abuse on Dango, and an $18.4M circular swap path exploit on Rhea Finance's Burrowland protocol.

Best Security Auditor for Web3

Validate design, code, and business logic before launch. Aligned with the highest industry security standards.

BlockSec Audit