29738 - [SC - Low] Missing Chainlink circuit breaker check allows ...
Submitted on Apr 1st 2024 at 06:09:20 UTC by @marchev for Boost | Immunefi Arbitration
Report ID: #29738
Report type: Smart Contract
Report severity: Low
Target: https://github.com/immunefi-team/vaults/blob/main/src/RewardTimelock.sol
Impacts:
Theft of unclaimed royalties
Description
Brief/Intro
Chainlink aggregators feature a built-in circuit breaker that activates during significant price fluctuations, keeping the asset price within a pre-defined range. In scenarios like the LUNA crash, the oracle reports a pre-set minimum value (minAnswer
) rather than the real market price. This mechanism could enable malicious projects to payout rewards that are below market value by leveraging the inflated price from the Chainlink oracle.
Such a vulnerability has resulted in a $11M exploit on Venus Protocol during the LUNA crash: https://therecord.media/collapse-of-luna-cryptocurrency-leads-to-11-million-exploit-on-venus-protocol
Vulnerability Details
The RewardTimelock
contract depends on Chainlink price oracles to align reward payouts with the actual market value. It employs _checkRewardDollarValue
, a function that fetches the asset's current market price with PriceConsumer#tryGetSaneUsdPrice18Decimals()
, implementing various checks to ensure the price's accuracy and relevance.
Nonetheless, this function lacks verification for the activation of Chainlink's circuit breaker. In extreme price movements, when the asset price falls below minAnswer
or rises above maxAnswer
, the Chainlink feed still reports these thresholds instead of the actual market price of the asset. Missing this critical check means a project could exploit the situation and pay whitehats a reduced award under these circumstances.
Let's take the following example:
Example scenario:
A significant price drop triggers the Chainlink circuit breaker for a reward token, causing the feed to report
minAnswer
.A project initiates a whitehat reward payout via
RewardTimelock
.After the cooldown period, the project executes the payout at the reported, inflated price.
Impact Details
This vulnerability means a whitehat could receive a reward less valuable than the market-equivalent dollar amount expected. While such an exploit relies on rare, dramatic market events (akin to the LUNA crash), the potential impact is significant, allowing projects to issue substantially undervalued rewards. Thus, this vulnerability is classified with Low severity to reflect its low likeliehood.
Solution
The proposed fix involves integrating checks for the activation of Chainlink's circuit breaker. The following code modifications illustrate the necessary adjustments:
diff --git a/src/oracles/IFeedRegistryMinimal.sol b/src/oracles/IFeedRegistryMinimal.sol
index 1a569d3..79be2f7 100644
--- a/src/oracles/IFeedRegistryMinimal.sol
+++ b/src/oracles/IFeedRegistryMinimal.sol
@@ -12,4 +12,6 @@ interface IFeedRegistryMinimal {
external
view
returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound);
+
+ function getFeed(address base, address quote) external view returns (address aggregator);
}
diff --git a/src/oracles/FeedRegistryL2.sol b/src/oracles/FeedRegistryL2.sol
index 076f806..4697d90 100644
--- a/src/oracles/FeedRegistryL2.sol
+++ b/src/oracles/FeedRegistryL2.sol
@@ -106,4 +106,8 @@ contract FeedRegistryL2 is IFeedRegistryMinimal, Ownable2Step {
uint256 timeSinceUp = block.timestamp - startedAt;
require(timeSinceUp > GRACE_PERIOD_TIME, "FeedRegistryL2: Grace period not over");
}
+
+ function getFeed(address base, address quote) external view returns (address aggregator) {
+ //TODO: Add actual implementation
+ }
}
diff --git a/src/oracles/PriceConsumer.sol b/src/oracles/PriceConsumer.sol
index a024bd3..2025e4a 100644
--- a/src/oracles/PriceConsumer.sol
+++ b/src/oracles/PriceConsumer.sol
@@ -4,6 +4,7 @@ pragma solidity 0.8.18;
import { Ownable2Step } from "openzeppelin-contracts/access/Ownable2Step.sol";
import { IFeedRegistryMinimal } from "./IFeedRegistryMinimal.sol";
+import { IOffchainAggregatorMinimal } from "./IOffchainAggregatorMinimal.sol";
import { Denominations } from "./chainlink/Denominations.sol";
import { IPriceFeed } from "./IPriceFeed.sol";
import { IPriceConsumerEvents } from "./IPriceConsumerEvents.sol";
@@ -53,6 +54,12 @@ contract PriceConsumer is Ownable2Step, IPriceConsumerEvents {
require(response.updatedAt <= block.timestamp, "PriceConsumer: Feed updatedAt is in the future");
require(block.timestamp - response.updatedAt <= _getFeedTimeout(base), "PriceConsumer: Feed is stale");
+ IOffchainAggregatorMinimal baseAggregator = IOffchainAggregatorMinimal(registry.getFeed(base, Denominations.USD));
+ int256 minAnswer18Decimals = _convertTo18Decimals(baseAggregator.minAnswer(), registry.decimals(base, Denominations.USD));
+ int256 maxAnswer18Decimals = _convertTo18Decimals(baseAggregator.maxAnswer(), registry.decimals(base, Denominations.USD));
+ require(response.answer > minAnswer18Decimals, "PriceConsumer: Min feed price circuit breaker");
+ require(response.answer < maxAnswer18Decimals, "PriceConsumer: Max feed price circuit breaker");
+
return uint256(response.answer);
}
Furthermore, a new interface, src/oracles/IOffchainAggregatorMinimal.sol
, is required to fetch the minAnswer
and maxAnswer
directly from the aggregator:
// SPDX-License-Identifier: Immuni Software PTE Ltd General Source License
// https://github.com/immunefi-team/vaults/blob/main/LICENSE.md
pragma solidity 0.8.18;
interface IOffchainAggregatorMinimal {
function minAnswer() external view returns (int192);
function maxAnswer() external view returns (int192);
}
Proof of Concept
The following coded PoC demonstrates how a payout could be performed even if the Chainlink price oracle's circuit breaker is activated.
Add the following import in test/foundry/RewardTimelock.t.sol
:
import { ERC20PresetMinterPauser } from "openzeppelin-contracts/token/ERC20/presets/ERC20PresetMinterPauser.sol";
Then add the following test case to it as well:
function testNoCircuitBreakerForMinMaxPriceWhenExecutingRewardTx() public {
// Token in which the award would be paid
ERC20PresetMinterPauser token = new ERC20PresetMinterPauser("Token", "TOK");
console.log("TOK/USD (Market price after flash crash) = 1e8");
console.log("TOK aggregator minAnswer = 10e8");
token.mint(address(vault), 10_000 ether);
uint256 dollarAmount = 50_000; // Award dollar amount
uint256 tokenAmount = 5_000 ether; // Award token amount (5,000 TOK x 10 = 50,000 USD)
// *** MOCKS SETUP - START ***
// Mock arbitration.vaultIsInArbitration
vm.mockCall(
address(arbitration),
abi.encodeCall(arbitration.vaultIsInArbitration, (address(vault))),
abi.encode(true)
);
address tokenAggregator = makeAddr("tokenAggregator");
// Mock feedRegistry.getFeed()
vm.mockCall(
address(feedRegistry),
abi.encodeWithSignature("getFeed(address,address)", token, Denominations.USD),
abi.encode(tokenAggregator)
);
// Mock tokenAggregator.minAnswer() & tokenAggregator.maxAnswer()
vm.mockCall(
address(tokenAggregator),
abi.encodeWithSignature("minAnswer()"),
abi.encode(int192(10e8))
);
vm.mockCall(
address(tokenAggregator),
abi.encodeWithSignature("maxAnswer()"),
abi.encode(int192(1_000_000_000e8))
);
// Mock feedRegistr.latestRoundData()
vm.mockCall(
address(feedRegistry),
abi.encodeWithSignature("latestRoundData(address,address)", address(token), Denominations.USD),
abi.encode(
31337, // roundId
int256(10e8), // answer is equal to minAnswer
0, // startedAt (ignored)
block.timestamp + rewardTimelock.txCooldown() - 1 hours, // updatedAt
0 // answeredInRound (ignored)
)
);
// Mock feedRegistry.decimals()
vm.mockCall(
address(feedRegistry),
abi.encodeWithSignature("decimals(address,address)", address(token), Denominations.USD),
abi.encode(8)
);
// *** MOCKS SETUP - END ***
// Set right permissions on moduleGuard
vm.startPrank(protocolOwner);
moduleGuard.setTargetAllowed(address(vaultDelegate), true);
moduleGuard.setAllowedFunction(address(vaultDelegate), vaultDelegate.sendReward.selector, true);
moduleGuard.setDelegateCallAllowedOnTarget(address(vaultDelegate), true);
vm.stopPrank();
uint256 nonce = rewardTimelock.vaultTxNonce(address(vault));
bytes32 txHash = rewardTimelock.getQueueTransactionHash(address(this), dollarAmount, address(vault), nonce);
_sendTxToVault(
address(rewardTimelock),
0,
abi.encodeCall(rewardTimelock.queueRewardTransaction, (address(this), dollarAmount)),
Enum.Operation.Call
);
assertEq(rewardTimelock.vaultTxNonce(address(vault)), nonce + 1);
console.log("Transaction reward for %s USD queued", dollarAmount);
vm.warp(block.timestamp + rewardTimelock.txCooldown());
assertTrue(rewardTimelock.canExecuteTransaction(txHash));
Rewards.ERC20Reward[] memory erc20Rewards = new Rewards.ERC20Reward[](1);
erc20Rewards[0] = Rewards.ERC20Reward({ token: address(token), amount: tokenAmount });
console.log("Expected behavior: executeRewardTransaction fails since Chainlink price feed circuit breaker is hit (answer == minAnswer)");
_sendTxToVault(
address(rewardTimelock),
0,
abi.encodeCall(rewardTimelock.executeRewardTransaction, (txHash, 0, erc20Rewards, 0, 50_000)),
Enum.Operation.Call,
true
);
}
Run the PoC via forge test --mt "testNoCircuitBreakerForMinMaxPriceWhenExecutingRewardTx" -vvvvv
The expected behavior is that the reward payout fails but the actual behavior is that it gets executed successfully.
Last updated
Was this helpful?