The CoreChainlinkFeed contract utilizes a BTC/USD Chainlink oracle to determine the price of WBTC. However, this approach can lead to potential issues if WBTC were to depeg from BTC. In such a scenario, WBTC would no longer maintain an equivalent value to BTC. This can result in significant problems, including borrowing against a devalued asset and the accumulation of bad debt. Given that one of the three protocol's feeds continues to value WBTC based on BTC/USD, the issuance of bad USDS loans could persist, exacerbating the overall level of bad debt.
Vulnerability Details
Note: there is no WBTC/USD price feed on Ethereum mainnet, so it's not just a wrong feed address issue. It will require requesting both BTC/USD and WBTC/BTC prices. The only available WBTC feed is WBTC/BTC.
I. Let's say WBTC suddenly depegged to 1 USD
a) CoreChainlinkFeed feed returns 40_000 (BTC/USD price)
b) CoreUniswapFeed (TWAP) feed slowly changes the price from 40_000 to 1 (WBTC/USDC price) for 30 minutes
c) CoreSaltyFeed returns 1 (WBTC/USDS price)
It will lead to DoS for at least 30 minutes, until CoreUniswapFeed price == CoreSaltyFeed price (see PoC):
1) PriceAggregator::_aggregatePrices returns 0
`liquidateUser` also
calls [`CollateralAndLiquidity::underlyingTokenValueInUSD`](https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/stable/CollateralAndLiquidity.sol#L163)
calls [`PriceAggregator::getPriceBTC`](https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/stable/CollateralAndLiquidity.sol#L198)
will revert
5) withdrawCollateralAndClaim calls maxWithdrawableCollateral
calls userCollateralValueInUSD
calls PriceAggregator::getPriceBTC
will revert
Note: DoS will probably affect USDS price, it can depeg and lead to different prices in CoreSaltyFeed and CoreUniswapFeed even after 30 minutes because USDS != USDC => WBTC/USDC != WBTC/USDS. Which will lead to a permanent DoS.
II. In case the depeg is slow, PriceAggregator will return a higher price:
a) CoreChainlinkFeed feed returns 40_000 (BTC/USD price)
b) CoreUniswapFeed (TWAP) feed slowly changes the price from 40_000 to a lower price (WBTC/USDC price)
c) CoreSaltyFeed returns a lower price immediately
At first, while _absoluteDifference < PriceAggregator::maximumPriceFeedPercentDifferenceTimes1000 (default 3%) it will report higher than the spot price, average from CoreChainlinkFeed and CoreUniswapFeed, which will allow to borrowUSDS without appropriate collateral.
Note that it will lead to a drop in USDS price. Attackers/bots would use the opportunity to mint a lot of USDS backed by WBTC (that dropped in price but Salty.io still values it almost as BTC), sell it, buy more WBTC, and repeat. Because of that CoreSaltyFeed will return a value different from CoreUniswapFeed
Then we will have DoS as described in "Let's say WBTC suddenly depegged to 1 USD".
Impact
DoS => no liquidations => bad debt => loss of collateral
Uncontrollable growth of the supply of USDS => significant depeg of USDS => loss to USDS holders
Uniswap TWAP behavior can be checked using a Geometric Mean Calculator (e.g., this one). We need 150 values because 30*60=1800 seconds TWAP / 12 seconds per ETH block = 150. You can use JavaScript '40000 '.repeat(149) + '1' to print it, then paste it into the calculator.
You can place this file in src/stable/tests/M1.t.sol and run COVERAGE="yes" forge test -f wss://ethereum-sepolia.publicnode.com -vvv --mt testOne to see DoS proof in case all 3 prices are significantly different.
contract TestPriceAggregator is TestCollateral {
// Does not matter for this example
uint constant ETH_PRICE = 3000 ether;
function testOne() external {
/* Based on `testLiquidatePosition` */
uint256 depositedWBTC = ( 1000 ether *10**8) / priceAggregator.getPriceBTC();
uint256 depositedWETH = ( 1000 ether *10**18) / priceAggregator.getPriceETH();
vm.startPrank(alice);
collateralAndLiquidity.depositCollateralAndIncreaseShare(
depositedWBTC, depositedWETH, 0, block.timestamp, false
);
vm.warp( block.timestamp + 1 hours );
collateralAndLiquidity.depositCollateralAndIncreaseShare(
depositedWBTC, depositedWETH, 0, block.timestamp, false
);
// Account for both deposits
depositedWBTC = depositedWBTC * 2;
depositedWETH = depositedWETH * 2;
vm.stopPrank();
// Deposit extra so alice can withdraw all liquidity without having to worry about the DUST reserve limit
vm.prank(DEPLOYER);
collateralAndLiquidity.depositCollateralAndIncreaseShare(1 * 10**8, 1 ether, 0, block.timestamp, false);
vm.warp(block.timestamp + 1 hours);
vm.startPrank(alice);
uint maxUSDS = collateralAndLiquidity.maxBorrowableUSDS(alice);
collateralAndLiquidity.borrowUSDS(maxUSDS);
vm.stopPrank();
/* Reverts with Invalid BTC price */
ForcedPriceFeed feed1 = new ForcedPriceFeed(40_000 ether, ETH_PRICE);
ForcedPriceFeed feed2 = new ForcedPriceFeed(20_000 ether, ETH_PRICE);
ForcedPriceFeed feed3 = new ForcedPriceFeed(1 ether, ETH_PRICE);
vm.startPrank(PriceAggregator(address(priceAggregator)).owner());
priceAggregator.setPriceFeed(1, IPriceFeed(address(feed1)));
vm.warp(block.timestamp + priceAggregator.priceFeedModificationCooldown());
priceAggregator.setPriceFeed(2, IPriceFeed(address(feed2)));
vm.warp(block.timestamp + priceAggregator.priceFeedModificationCooldown());
priceAggregator.setPriceFeed(3, IPriceFeed(address(feed3)));
vm.stopPrank();
vm.expectRevert("Invalid BTC price");
vm.prank(bob);
collateralAndLiquidity.liquidateUser(alice);
}
}
## Tools Used
Manual review
## Recommended Mitigation Steps
Rewrite to use WBTC/USD price, which will require reading two feeds, BTC/USD and WBTC/BTC.
## Assessed type
Oracle
Lines of code
https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/price_feed/CoreChainlinkFeed.sol#L15
Vulnerability details
Summary
The
CoreChainlinkFeed
contract utilizes a BTC/USD Chainlink oracle to determine the price of WBTC. However, this approach can lead to potential issues if WBTC were to depeg from BTC. In such a scenario, WBTC would no longer maintain an equivalent value to BTC. This can result in significant problems, including borrowing against a devalued asset and the accumulation of bad debt. Given that one of the three protocol's feeds continues to value WBTC based on BTC/USD, the issuance of bad USDS loans could persist, exacerbating the overall level of bad debt.Vulnerability Details
The vulnerability lies in the reliance on a BTC/USD Chainlink oracle to obtain the price of WBTC. If the bridge connecting WBTC to BTC becomes compromised and WBTC depegs, WBTC may depeg from BTC. Consequently, WBTC's value would no longer be equivalent to BTC, potentially rendering it worthless. https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/price_feed/CoreChainlinkFeed.sol
I. Let's say WBTC suddenly depegged to 1 USD a)
CoreChainlinkFeed
feed returns 40_000 (BTC/USD price) b)CoreUniswapFeed
(TWAP) feed slowly changes the price from 40_000 to 1 (WBTC/USDC price) for 30 minutes c)CoreSaltyFeed
returns 1 (WBTC/USDS price)It will lead to DoS for at least 30 minutes, until
CoreUniswapFeed
price ==CoreSaltyFeed
price (see PoC): 1)PriceAggregator::_aggregatePrices
returns 02)
PriceAggregator::getPriceBTC
reverts3)
CollateralAndLiquidity::borrowUSDS
callsCollateralAndLiquidity::maxBorrowableUSDS
callsCollateralAndLiquidity::userCollateralValueInUSD
callsCollateralAndLiquidity::underlyingTokenValueInUSD
calls
PriceAggregator::getPriceBTC
will revert 4)CollateralAndLiquidity::liquidateUser
callscanUserBeLiquidated
callsCollateralAndLiquidity::userCollateralValueInUSD
callsCollateralAndLiquidity::underlyingTokenValueInUSD
calls
PriceAggregator::getPriceBTC
will revert5)
withdrawCollateralAndClaim
callsmaxWithdrawableCollateral
callsuserCollateralValueInUSD
callsPriceAggregator::getPriceBTC
will revert
Note: DoS will probably affect USDS price, it can depeg and lead to different prices in
CoreSaltyFeed
andCoreUniswapFeed
even after 30 minutes because USDS != USDC => WBTC/USDC != WBTC/USDS. Which will lead to a permanent DoS.II. In case the depeg is slow,
PriceAggregator
will return a higher price: a)CoreChainlinkFeed
feed returns 40_000 (BTC/USD price) b)CoreUniswapFeed
(TWAP) feed slowly changes the price from 40_000 to a lower price (WBTC/USDC price) c)CoreSaltyFeed
returns a lower price immediately At first, while_absoluteDifference
<PriceAggregator::maximumPriceFeedPercentDifferenceTimes1000
(default 3%) it will report higher than the spot price, average fromCoreChainlinkFeed
andCoreUniswapFeed
, which will allow toborrowUSDS
without appropriate collateral.Note that it will lead to a drop in USDS price. Attackers/bots would use the opportunity to mint a lot of USDS backed by WBTC (that dropped in price but Salty.io still values it almost as BTC), sell it, buy more WBTC, and repeat. Because of that
CoreSaltyFeed
will return a value different fromCoreUniswapFeed
Then we will have DoS as described in "Let's say WBTC suddenly depegged to 1 USD".
Impact
Proof of Concept
PriceAggregator
behavior is already described inTestPriceAggregator::testAggregatePrices
.30*60=1800
seconds TWAP / 12 seconds per ETH block = 150. You can use JavaScript'40000 '.repeat(149) + '1'
to print it, then paste it into the calculator.src/stable/tests/M1.t.sol
and runCOVERAGE="yes" forge test -f wss://ethereum-sepolia.publicnode.com -vvv --mt testOne
to see DoS proof in case all 3 prices are significantly different.import "./CollateralAndLiquidity.t.sol"; import "../../price_feed/tests/ForcedPriceFeed.sol";
contract TestPriceAggregator is TestCollateral { // Does not matter for this example uint constant ETH_PRICE = 3000 ether;
}