Platform: Code4rena
Start Date: 04/03/2024
Pot Size: $88,500 USDC
Total HM: 31
Participants: 105
Period: 11 days
Judge: ronnyx2017
Total Solo HM: 7
Id: 342
League: ETH
Rank: 1/105
Findings: 3
Award: $6,184.78
🌟 Selected for report: 2
🚀 Solo Findings: 1
909.972 USDC - $909.97
https://github.com/code-423n4/2024-03-revert-lend/blob/435b054f9ad2404173f36f0f74a5096c894b12b7/src/V3Vault.sol#L497 https://github.com/code-423n4/2024-03-revert-lend/blob/435b054f9ad2404173f36f0f74a5096c894b12b7/src/V3Vault.sol#L450-L472
Calling V3Vault.transform()
with some transformers (AutoRange.sol
) opens a re-entrancy opportunity when the old position is replaced and returned to its owner inside V3Vault.onERC721Received()
. If the owner is a contract it can change the tokenId
or distort debt calculations by changing debtExchangeRateX96
or change the state in a way not expected inside transform()
When calling V3Vault.transform()
this is what happens:
function transform( uint256 tokenId, address transformer, bytes calldata data ) external override returns (uint256 newTokenId) { // Get debtEchangeRate (uint256 newDebtExchangeRateX96, ) = _updateGlobalInterest(); ..... //@audit-ok - Can re-enter from AutoRange.sol (bool success, ) = transformer.call(data); // <--------- call Transformer if (!success) { revert TransformFailed(); } ..... // Check that debt is healthy based on debtEchangeRate uint256 debt = _convertToAssets( loans[tokenId].debtShares, newDebtExchangeRateX96, // <------ No longer accurate, after re-entrancy Math.Rounding.Up ); _requireLoanIsHealthy(tokenId, debt); transformedTokenId = 0; return tokenId; }
The 3 important steps here are:
debtExchangeRateX96
is updated and cached, to be used for calculating debt value after the transformation and confirm it is healthySome transformers (like AutoRange.sol
) move current tokenId
assets to a new position with a new tokenId
. The new token is send back to V3Vault
, where the onERC721Received()
function handles replacing the old tokeId with the new and sending the old token to its owner:
The problem here is that the old NFT is transferred in the same transaction (through _cleanupLoan()
)
If the recipient is a contract, this will call it's own onERC721Received()
method which can be used to re-enter V3Vault
.
Some possible actions the malicious contract can take are:
create()
to open a position with some arbitrary tokenId
. This would transfer the NFT to V3Vault
and call again onERC721Received()
. Since the vault will be in a transformation mode (the exploiter re-entered before transformation is over) the position will be moved to the token he just deposited, instead off the one provided by the transformerborrowing
, repaying
etc.) and manipulate debt health check in the end of transform()
, which uses a cached value of the debt rateManual Review
Consider adding a separate function, that the owner must call to receive his old NFT instead of transferring it during the transform()
transaction
Reentrancy
#0 - c4-pre-sort
2024-03-20T08:22:00Z
0xEVom marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-03-20T08:22:14Z
0xEVom marked the issue as primary issue
#2 - c4-sponsor
2024-03-26T16:29:36Z
kalinbas (sponsor) confirmed
#3 - jhsagd76
2024-04-01T13:23:32Z
Impact 1 is slur. Impact 2 is impossiable. So it only get 50%.
#4 - c4-judge
2024-04-01T13:24:29Z
jhsagd76 marked the issue as duplicate of #323
#5 - c4-judge
2024-04-01T13:24:37Z
jhsagd76 marked the issue as partial-50
#6 - c4-judge
2024-04-01T15:08:55Z
jhsagd76 changed the severity to 3 (High Risk)
🌟 Selected for report: b0g0
5257.616 USDC - $5,257.62
Any account holding a position inside V3Vault
can transform any NFT position outside the vault that has been delegated to Revert operators for transformation(AutoRange
, AutoCompound
and all other transformers that manage positions outside of the vault).
The exploiter can pass any params
at any time, affecting positions they do not own and their funds critically.
In order to borrow from V3Vault
an account must first create a collaterized position by sending his position NFT through the create()
function
Any account that has a position inside the vault can use the transform()
function to manage the NFT, while it is owned by the vault:
function transform(uint256 tokenId, address transformer, bytes calldata data) external override returns (uint256 newTokenId) { .... //@audit -> tokenId inside data not checked (uint256 newDebtExchangeRateX96,) = _updateGlobalInterest(); address loanOwner = tokenOwner[tokenId]; // only the owner of the loan, the vault itself or any approved caller can call this if (loanOwner != msg.sender && !transformApprovals[loanOwner][tokenId][msg.sender]) { revert Unauthorized(); } // give access to transformer nonfungiblePositionManager.approve(transformer, tokenId); (bool success,) = transformer.call(data); if (!success) { revert TransformFailed(); } .... // check owner not changed (NEEDED because token could have been moved somewhere else in the meantime) address owner = nonfungiblePositionManager.ownerOf(tokenId); if (owner != address(this)) { revert Unauthorized(); } .... return tokenId; }
The user passes an approved transformer address and the calldata to execute on it. The problem here is that the function only validates the ownership of the uint256 tokenId
input parameter, however it never checks if the tokenId
encoded inside bytes calldata data
parameter belongs to msg.sender
.
This allows any vault position holder to call an allowed transformer with arbitrary params encoded as calldata and change any position delegated to that transformer.
This will impact all current and future transformers that manage Vault positions.
To prove the exploit I'm providing a coded POC using the AutoCompound
tranformer.
A short explanation of the POC:
Alice
is an account outside the vault that approves her position ALICE_NFT
to be auto-compounded by Revert controlled operators(bots)Bob
decides to act maliciously and transform Alice
positionBob
opens a position in the vault with his BOB_NFT
so that he can call transform()
Bob
calls V3Vault.transform()
with BOB_NFT
as tokenId
param to pass validation but encodes ALICE_NFT
inside dataBob
successfully transforms Alice
position with his paramsYou can add the following test to V3Vault.t.sol
and run forge test --contracts /test/V3Vault.t.sol --mt testTransformExploit -vvvv
function testTransformExploit() external { // Alice address ALICE_ACCOUNT = TEST_NFT_ACCOUNT; uint256 ALICE_NFT = TEST_NFT; // Malicious user address EXPLOITER_ACCOUNT = TEST_NFT_ACCOUNT_2; uint256 EXPLOITER_NFT = TEST_NFT_2; // Set up an auto-compound transformer AutoCompound autoCompound = new AutoCompound( NPM, WHALE_ACCOUNT, WHALE_ACCOUNT, 60, 100 ); vault.setTransformer(address(autoCompound), true); autoCompound.setVault(address(vault), true); // Set fee to 2% uint256 Q64 = 2 ** 64; autoCompound.setReward(uint64(Q64 / 50)); // Alice decides to delegate her position to // Revert bots (outside of vault) to be auto-compounded vm.prank(ALICE_ACCOUNT); NPM.approve(address(autoCompound), ALICE_NFT); // Exploiter opens a position in the Vault vm.startPrank(EXPLOITER_ACCOUNT); NPM.approve(address(vault), EXPLOITER_NFT); vault.create(EXPLOITER_NFT, EXPLOITER_ACCOUNT); vm.stopPrank(); // Exploiter passes ALICE_NFT as param AutoCompound.ExecuteParams memory params = AutoCompound.ExecuteParams( ALICE_NFT, false, 0 ); // Exploiter account uses his own token to pass validation // but transforms Alice position vm.prank(EXPLOITER_ACCOUNT); vault.transform( EXPLOITER_NFT, address(autoCompound), abi.encodeWithSelector(AutoCompound.execute.selector, params) ); }
Since the exploiter can control the calldata send to the transformer, he can impact any approved position in various ways. In the case of AutoCompound
it can be:
swap0To1
& amountIn
parameters to execute swaps in unfavourable market conditions, leading to loss of funds or value extractionThose are only a couple of ideas. The impact can be quite severe depending on the transformer and parameters passed.
Manual Review, Foundry
Consider adding a check inside transform()
to make sure the provided tokenId
and the one encoded as calldata are the same. This way the caller will not be able to manipulate other accounts positions.
Invalid Validation
#0 - c4-pre-sort
2024-03-20T08:24:06Z
0xEVom marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-03-20T08:24:09Z
0xEVom marked the issue as primary issue
#2 - c4-sponsor
2024-03-26T15:53:11Z
kalinbas (sponsor) confirmed
#3 - c4-judge
2024-03-31T10:47:56Z
jhsagd76 marked the issue as satisfactory
#4 - c4-judge
2024-04-01T15:33:39Z
jhsagd76 marked the issue as selected for report
#5 - kalinbas
2024-04-10T22:48:55Z
🌟 Selected for report: b0g0
Also found by: 0x175, 0xAlix2, 0xblackskull, 0xspryon, 14si2o_Flint, Fitro, Giorgio, MSaptarshi, MohammedRizwan, Silvermist, boredpukar, crypticdefense, grearlake, kfx, maxim371, y0ng0p3
17.1926 USDC - $17.19
V3Oracle::getValue()
is used to calculate the value of a position. The value is the product of the oracle price * the amounts held in the position
. Price manipulation is prevented by checking for differences between Chainlink oracle and Uniswap TWAP.
However the amounts (amount0
,amount1
) of the tokens in the position are calculated based on the current pool price (pool.spot0()
), which means they can be manipulated. And since the value of the total position is calculated from amount0
,amount1
it can be manipulated as well.
Invoking V3Oracle::getValue()
first calls the getPositionBreakdown()
to calculate the amount0
and amount1
of the tokens in the position based on spot price:
(address token0, address token1, uint24 fee,, uint256 amount0, uint256 amount1, uint256 fees0, uint256 fees1) = getPositionBreakdown(tokenId);
Under the hood this calls _initializeState()
which get's the current price in the pool:
https://github.com/code-423n4/2024-03-revert-lend/blob/435b054f9ad2404173f36f0f74a5096c894b12b7/src/V3Oracle.sol#L395
(state.sqrtPriceX96, state.tick,,,,,) = state.pool.slot0();
Based on this value the amount0
& amount1
(returned from getPositionBreakdown()
are deduced:
function _getAmounts(PositionState memory state) internal view returns (uint256 amount0, uint256 amount1, uint128 fees0, uint128 fees1) { if (state.liquidity > 0) { .... (amount0, amount1) = LiquidityAmounts.getAmountsForLiquidity( state.sqrtPriceX96, state.sqrtPriceX96Lower, state.sqrtPriceX96Upper, state.liquidity ); } .... }
After that the prices are fetched from Uniswap & Chainlink and compared.
(price0X96, cachedChainlinkReferencePriceX96) = _getReferenceTokenPriceX96(token0, cachedChainlinkReferencePriceX96); (price1X96, cachedChainlinkReferencePriceX96) = _getReferenceTokenPriceX96(token1, cachedChainlinkReferencePriceX96);
Finally the value of the positions tokens and fees are calculated in the following formula:
value = (price0X96 * (amount0 + fees0) / Q96 + price1X96 * (amount1 + fees1) / Q96) * Q96 / priceTokenX96; feeValue = (price0X96 * fees0 / Q96 + price1X96 * fees1 / Q96) * Q96 / priceTokenX96; price0X96 = price0X96 * Q96 / priceTokenX96; price1X96 = price1X96 * Q96 / priceTokenX96;
Basically the position value is a product of 2 parameters price0X96/price1X96
& amount0/amount1
:
price0X96/price1X96
- those are the prices derived from the oracles - they are validated and cannot be manipulatedamount0/amount1
- those are calculated based on the spot price and can be manipulatedSince amount0
& amount1
can be increased/decreased if a malicious user decides to distort the pool price in the current block (through a flash loan for example), this will directly impact the calculated value, even though the price itself cannot be manipulated since it is protected against manipulation
The check in the end _checkPoolPrice()
only verifies that the price from the oracles is in the the acceptable ranges. This however does not safeguard the value calculation, which as explained above also includes the amounts
parameters.
It should be noted that _checkPoolPrice
uses the uniswap TWAP price for comparison, which is the price over an extended period of time making it very hard to manipulate in a single block. And exactly this property of the TWAP price can allow an attacker to manipulate the spot price significantly, without affecting the TWAP much, which means the price difference won't change much and _checkPoolPrice
will pass.
A short example:
V3Oracle
has been configured to use a TWAP duration of 1 hourborrow
| repay
on a V3Vault (which call V3Oracle.getValue()
)amount1
& amoun0
are heavily inflated/deflatedManual Review
Consider calculating amount0
& amount1
based on the oracle price and not on the spot price taken from slot0()
. This way the above exploit will be mitigated
Uniswap
#0 - c4-pre-sort
2024-03-19T10:01:31Z
0xEVom marked the issue as primary issue
#1 - c4-pre-sort
2024-03-19T10:02:30Z
0xEVom marked the issue as sufficient quality report
#2 - 0xEVom
2024-03-25T09:46:43Z
Grouping all submissions for "price manipulation due to usage of slot0
" that provide a plausible attack path under this finding. Invalid submissions are grouped under #191.
#348 covers all cases but does not provide a detailed proof of concept, #132 and #257 only point out the _validateSwap()
case, and this and #223 only the _checkPoolPrice()
case.
#3 - c4-sponsor
2024-03-26T15:41:35Z
kalinbas (sponsor) disputed
#4 - kalinbas
2024-03-26T15:43:50Z
The check in _checkPoolPrice() does limit the price manipulation. The current pool price (it is NOT the TWAP price as you mention) is compared to the derived pool price of the Chainlink oracle prices.
The amounts may be slightly manipulated for wide range positions, and more heavily manipulated for tight range positions. But the protection with _checkPoolPrice() should be enough to protect this from being a problem.
Also repay() does not need any oracles.
#5 - jhsagd76
2024-03-31T02:40:32Z
I believe that there should be no disagreement on the technical details of such issues among the sponsor, the warden, and myself, and I think it is reasonable to classify this issue as M.
Firstly, I think that the possibility of exploitation exists only within the _checkPoolPrice method. Therefore, I consider this issue to be the primary issue rather than #348.
Secondly, although it would require very fringe (and even incorrect) configuration parameters to really cause un-dusty losses, given that similar attacks have occurred on the mainnet with substantial losses (refer to the Gamma gDai TWAP verification range configuration error, even though it's unrelated to slot0), I believe it's worth adding necessary safeguards, especially for tight range positions and tokens.
Of course, further solicit the sponsor's opinion, and for the time being, classify this issue as M.
#6 - c4-judge
2024-03-31T02:41:31Z
jhsagd76 changed the severity to 2 (Med Risk)
#7 - c4-judge
2024-03-31T02:41:41Z
jhsagd76 marked the issue as satisfactory
#8 - c4-judge
2024-04-01T15:34:16Z
jhsagd76 marked the issue as selected for report
#9 - kalinbas
2024-04-01T21:21:50Z
We agree to change it to use the oracle price for position token calculation.
#10 - c4-sponsor
2024-04-01T21:21:56Z
kalinbas (sponsor) confirmed
#11 - kalinbas
2024-04-10T19:21:56Z