报告清单
| 项目 | 描述 |
|---|---|
| 客户 | 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 所示。

因此,本报告中测量的严重性分为三个类别:高、中、低。为求完整性,还使用未确定来涵盖无法很好确定的风险情况。
此外,已发现项的状态将属于以下四类之一:
-
未确定 尚未收到回复。
-
已确认 该项已由客户收到,但尚未确认。
-
已确认 该项已被客户识别,但尚未修复。
-
已修复 该项已由客户确认并修复。
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 | 中等 | 预言机计算不当 | 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 |
描述 BountyManager 合约中的三个函数,getLpMfdBounty()、getChefBounty() 和 getAutoCompoundBounty(),是通过函数指针调用的。同时,继承自 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:预言机计算不当
| 项目 | 描述 |
|---|---|
| 严重性 | 中等 |
| 状态 | 版本 11 中已修复 |
| 引入者 | 版本 1 和版本 4 |
描述 ComboOracle 合约中的 consult() 函数用于计算多个来源的平均价格。在版本 1 的实现中,它使用算术平均值来计算最终价格,这可以通过影响一个源预言机来操纵。
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 时,将返回最低价格。 但是,如果一个源预言机的返回值异常低,价格仍然可能被操纵。
/**
* @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 返回的价格可能被操纵,允许攻击者从中获利。
建议 我们建议使用中值而不是平均值。如果有两个源预言机,并且出现相当大的差异,当平均价格远大于最低价格时,回滚交易更为合理。
反馈 将只有两个源预言机。如果出现相当大的差异,我们将使用 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 的限制,攻击者可以通过多次调用 executeBounty() 来耗尽 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() 实现。
_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 太小,预言机可能会返回意外的值。
建议 在 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 合约中用户的状态。然而,如果发送方等于接收方,senderBalance 将不会被更新,这是不正确的。
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 在开始时未设置,攻击者可以将 leverager 设置为任何地址,从而获得对 depositWithAutoDLP() 函数逻辑的控制权。
建议 在 initialize() 函数中设置 leverager 或为 setLeverager() 函数添加访问控制。
3.2.12 潜在问题 13:addLiquidityWETHOnly() 中没有滑点检查
| 项目 | 描述 |
|---|---|
| 严重性 | 中等 |
| 状态 | 已确认 |
| 引入者 | 版本 1 |
描述 用户可以使用借入的 WETH 代币(或他/她的自有 ETH 代币)或 MFD 合约中的vesting 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 无法被移除。
建议 实现一个特权函数来修改 minters。
反馈 因为 BountyManager、ChefIncentivesController 和 MultiFeeDistribution 将是可升级的,所以 minters 始终保持相同的代理地址。
3.3 额外建议
3.3.1 潜在问题 18:Gas 优化 (MFD 中的 zapVestingToLp())
| 项目 | 描述 |
|---|---|
| 状态 | 版本 10 中已修复 |
| 引入者 | 版本 1 |
描述 zapVestingToLp() 函数只能由 LockZap 合约调用,以转出用户锁定的收益。它从索引 0 开始迭代用户的 earnings 数组,并检查 unlockTime 是否大于当前时间戳。如果是,此 earnings 将从数组中移除并转出。然而,由于数组中的 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 | 检测器 | 描述 | 影响 | 发现 | FP | 结果 |
|---|---|---|---|---|---|---|
| 1 | arbitrary-send-erc20 | 使用任意地址调用 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 | 将 ETH 发送到任意目的地的函数 | 高 | 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 | 重入漏洞(盗窃 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 | 锁定 ETH 的合约 | 中等 | 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 | 重入漏洞(不盗窃 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:针对 Lending 相关逻辑测试的属性
| ID | 属性 | 结果 |
|---|---|---|
| 1 | 调用 deposit 从不会导致 onBehalfOf 的 RToken 数量减少 | 通过 |
| 2 | 调用 withdraw 从不会导致 msg.sender 的 RToken 数量增加 | 通过 |
| 3 | 以稳定利率模式调用 borrow 从不会导致 onBehalfOf 的 StableDebtToken 减少 | 通过 |
| 4 | 以可变利率模式调用 borrow 从不会导致 onBehalfOf 的 VariableDebtToken 减少 | 通过 |
| 5 | 以 onBehalfOf 不等于 msg.sender 的模式调用 borrow 从不会导致 msg.sender 的借款额度增加。 | 通过 |
| 6 | 以稳定利率模式调用 repay 从不会导致 onBehalfOf 的 StableDebtToken 增加 | 通过 |
| 7 | 以可变利率模式调用 repay 从不会导致 onBehalfOf 的 VariableDebtToken 增加 | 通过 |
| 8 | liquidityIndex 永远不会减少。 | 通过 |
| 9 | liquidityIndex 在同一个区块内将保持不变。 | 通过 |
| 10 | variableBorrowIndex 永远不会减少。 | 通过 |
| 11 | variableBorrowIndex 在同一个区块内将保持不变。 | 通过 |
| 12 | 抵押品数量减少永远不会导致健康因子小于 1。 | 通过 |
| 13 | 借款数量增加永远不会导致健康因子小于 1。 | 通过 |
表 4.3:针对 Staking 相关逻辑测试的属性
| ID | 属性 | 结果 |
|---|---|---|
| 1 | 用户总余额始终等于锁定余额、未锁定余额和应得余额的总和。 | 通过 |
| 2 | 用户锁定余额始终等于 userLocks 金额的总和 | 通过 |
| 3 | 用户锁定带乘数余额始终等于 userLocks 金额乘以 userLocks 乘数 | 通过 |
| 4 | lockedSupply 始终等于用户余额的总和 | 通过 |
| 5 | lockedSupplyWithMultiplier 始终等于用户带乘数余额的总和 | 通过 |
| 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 免责声明
本报告不构成投资建议或个人推荐。它不考虑,也不应被解释为考虑或影响代币、代币销售或任何其他产品、服务或资产的潜在经济效益。任何实体不应以任何方式依赖本报告,包括用于购买或出售任何代币、产品、服务或资产的决定。
本报告并非对任何特定项目或团队的认可,报告不保证任何特定项目的安全性。此次安全测试并不保证能发现智能合约的所有安全问题,即评估结果不保证不存在任何进一步的安全问题。由于安全测试不能被视为全面的,我们始终建议进行独立审计和公开赏金计划,以确保智能合约的安全性。
本次安全测试的范围仅限于第 1.2 节中提及的代码。 除非另有明确说明,否则语言本身的安全性(例如,Solidity 语言)、底层编译工具链和计算基础设施均不在范围内。
5.2 审计程序
我们按照以下程序进行审计。
-
漏洞检测我们首先使用自动代码分析器扫描智能合约,然后手动验证(拒绝或确认)它们报告的问题。
-
语义分析我们研究智能合约的业务逻辑,并使用自动模糊测试工具(由我们的研究团队开发)对潜在漏洞进行进一步调查。我们还通过独立审计师手动分析可能的攻击场景,以交叉检查结果。
-
建议我们从良好的编程实践的角度为开发人员提供一些有用的建议,包括 Gas 优化、代码风格等。
我们在以下内容中展示主要具体的检查点。
5.2.1 软件安全
- 重入
- DoS(拒绝服务)
- 访问控制
- 数据处理和数据流
- 异常处理
- 不受信任的外部调用和控制流
- 初始化一致性
- 事件操作
- 易出错的随机性
- 代理系统使用不当
5.2.2 DeFi 安全
- 语义一致性
- 功能一致性
- 权限管理
- 业务逻辑
- 代币操作
- 紧急机制
- 预言机安全
- 白名单和黑名单
- 经济影响
- 批量转账
5.2.3 NFT 安全
- 重复物品
- 代币接收者验证
- 链下元数据安全
5.2.4 额外建议
- Gas 优化
- 代码质量和风格
注意:之前的检查点是主要的。在审计过程中,我们可能会根据项目的具体功能使用更多的检查点。



