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: 4/58
Findings: 5
Award: $3,222.01
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 Selected for report: hihen
Also found by: HollaDieWaldfee, bin2chen, rvierdiiev
1330.4109 USDC - $1,330.41
https://github.com/with-backed/papr/blob/9528f2711ff0c1522076b9f93fba13f88d5bd5e6/src/PaprController.sol#L264-L294 https://github.com/with-backed/papr/blob/9528f2711ff0c1522076b9f93fba13f88d5bd5e6/src/PaprController.sol#L297-L345 https://github.com/with-backed/papr/blob/9528f2711ff0c1522076b9f93fba13f88d5bd5e6/src/PaprController.sol#L326 https://github.com/with-backed/papr/blob/9528f2711ff0c1522076b9f93fba13f88d5bd5e6/src/PaprController.sol#L290-L293 https://github.com/with-backed/papr/blob/9528f2711ff0c1522076b9f93fba13f88d5bd5e6/src/PaprController.sol#L283-L284
This report deals with the PaprController.purchaseLiquidationAuctionNFT
function (https://github.com/with-backed/papr/blob/9528f2711ff0c1522076b9f93fba13f88d5bd5e6/src/PaprController.sol#L264-L294).
If there are mutliple auctions going on at the same time, leaving 0 NFTs in the vault, the first auction that is finished will cause the debt of the vault to be set to 0.
Since the debt is reset, all earnings from the future NFT sales will go to the borrower (after deducting liquidation fees).
So if there are multiple NFT auctions going on at the same time (which is totally possible) not all debt might be repaid even though there are more earnings available from future sales.
This is a problem for two reasons:
How big of an issue this second point is depends on the value of parameters (e.g. auctionDecayPeriod
, liquidationAuctionMinSpacing
), current market conditions, the liquidity of the NFTs used as collateral.
E.g. if all auctions are completed before liquidationAuctionMinSpacing
is reached, there is no issue.
However the protocol does not prohibit auctions from overlapping and so if auctions take a long time (Time Weighted Average Price (TWAP) well above market price) and borrowers intentionally choose collateral that is hard to sell, it can become a loophole that borrowers very intentionally use to lose less if they get liquidated.
In principle (although very unlikely) it is even possible that borrowers can earn money from doing this instead of only alleviating their losses due to liquidation.
PaprController.startLiquidationAuction
(https://github.com/with-backed/papr/blob/9528f2711ff0c1522076b9f93fba13f88d5bd5e6/src/PaprController.sol#L297-L345) to start an auction with NFT1liquidationAuctionMinSpacing
is reached (currently 2 days)PaprController.startLiquidationAuction
to start an auction with NFT2. At this point info.count
will be equal to 0
(https://github.com/with-backed/papr/blob/9528f2711ff0c1522076b9f93fba13f88d5bd5e6/src/PaprController.sol#L326) since it was decremented two times.PaprController.purchaseLiquidationAuctionNFT
. The variable bool isLastCollateral
is true
since there are no more NFTs in the vault. So if the price of the NFT sale is not enough to cover the debt, remaining
is greater than 0
. So all remaining debt will be reduced to 0
(https://github.com/with-backed/papr/blob/9528f2711ff0c1522076b9f93fba13f88d5bd5e6/src/PaprController.sol#L290-L293).excess
because debt is 0
and uint256 neededToSaveVault = maxDebtCached > debtCached ? 0 : debtCached - maxDebtCached;
evaluates to 0
. The sale price will be paid to the borrower after deducting liquidation fees (https://github.com/with-backed/papr/blob/9528f2711ff0c1522076b9f93fba13f88d5bd5e6/src/PaprController.sol#L283-L284).VSCode
If there is debt left without any NFTs remaining, the debt of the vault should only be set to 0
if there are no more auctions running.
You can implement this by keeping track of running auctions in the IPaprController.VaultInfo
struct (you must decide on the size of the uint
):
struct VaultInfo { /// @dev number of collateral tokens in the vault uint16 count; /// @dev start time of last auction the vault underwent, 0 if no auction has been started uint40 latestAuctionStartTime; /// @dev debt of the vault, expressed in papr token units uint200 debt; + uint auctionsCount; }
Increment this by one when an auction is started in PaprController.startLiquidationAuction
:
info.latestAuctionStartTime = uint40(block.timestamp); info.count -= 1; + info.auctionsCount += 1; emit RemoveCollateral(account, collateral.addr, collateral.id);
Decrement this by one when a purchase is made in PaprController.purchaseLiquidationAuctionNFT
and check that auctionsCount==0
before resetting debt:
@@ -279,6 +279,7 @@ contract PaprController is uint256 price = _purchaseNFTAndUpdateVaultIfNeeded(auction, maxPrice, sendTo); uint256 excess = price > neededToSaveVault ? price - neededToSaveVault : 0; uint256 remaining; + _vaultInfo[auction.nftOwner][auction.auctionAssetContract].auctionsCount -= 1; if (excess > 0) { remaining = _handleExcess(excess, neededToSaveVault, debtCached, auction); @@ -287,7 +288,7 @@ contract PaprController is remaining = debtCached - price; } - if (isLastCollateral && remaining != 0) { + if (isLastCollateral && remaining != 0 && _vaultInfo[auction.nftOwner][auction.auctionAssetContract].auctionsCount == 0) { /// there will be debt left with no NFTs, set it to 0 _reduceDebtWithoutBurn(auction.nftOwner, auction.auctionAssetContract, remaining); }
#0 - c4-judge
2022-12-25T10:15:21Z
trust1995 marked the issue as satisfactory
#1 - c4-judge
2022-12-25T14:02:17Z
trust1995 marked the issue as primary issue
#2 - wilsoncusack
2023-01-03T18:42:21Z
Duplicate to #97
#3 - c4-sponsor
2023-01-03T18:42:31Z
wilsoncusack marked the issue as sponsor confirmed
#4 - c4-judge
2023-01-04T10:17:31Z
trust1995 marked the issue as duplicate of #97
#5 - c4-judge
2023-01-04T10:18:46Z
trust1995 changed the severity to 3 (High Risk)
🌟 Selected for report: HE1M
Also found by: HollaDieWaldfee
985.4895 USDC - $985.49
https://github.com/with-backed/papr/blob/9528f2711ff0c1522076b9f93fba13f88d5bd5e6/src/PaprController.sol#L481 https://github.com/with-backed/papr/blob/9528f2711ff0c1522076b9f93fba13f88d5bd5e6/src/PaprController.sol#L486-L489
There are two ways to reduce debt.
The first is by calling PaprController.reduceDebt
(https://github.com/with-backed/papr/blob/9528f2711ff0c1522076b9f93fba13f88d5bd5e6/src/PaprController.sol#L148) which burns papr token from msg.sender
directly.
The second is the PaprController.buyAndReduceDebt
function (https://github.com/with-backed/papr/blob/9528f2711ff0c1522076b9f93fba13f88d5bd5e6/src/PaprController.sol#L208) which swaps the underlying token for papr token and then burns the papr token.
Both ways internally call PaprController._reduceDebt
(https://github.com/with-backed/papr/blob/9528f2711ff0c1522076b9f93fba13f88d5bd5e6/src/PaprController.sol#L481).
The PaprController._reduceDebt
function reverts if the amount
parameter is bigger than the actual debt in the vault.
This is a problem for two reasons:
When a user wants to pay all his debt using the PaprController.buyAndReduceDebt
function he must perform complex math in order to calculate the amount of input token that results in the exact amount of papr token
An attacker can front-run a transaction that repays all debt by paying back 1 Wei. The transaction to pay back the whole debt then reverts
The first issue is more a usability issue than a security issue.
However the second issue can be used to DOS the application. Especially if other protocols integrate with the papr protocol and are not flexible enough to pay back amounts that are smaller than the maximum.
PaprController._reduceDebt
calls PaprController._reduceDebtWithoutBurn
(https://github.com/with-backed/papr/blob/9528f2711ff0c1522076b9f93fba13f88d5bd5e6/src/PaprController.sol#L486-L489):
function _reduceDebtWithoutBurn(address account, ERC721 asset, uint256 amount) internal { _vaultInfo[account][asset].debt = uint200(_vaultInfo[account][asset].debt - amount); emit ReduceDebt(account, asset, amount); }
This reverts if amount > _vaultInfo[account][asset]
.
VSCode
If PaprController._reduceDebt
is called with an amount > _vaultInfo[account][asset].debt
, then amount
should be set to _vaultInfo[account][asset].debt
.
So PaprController._reduceDebt
should look like this:
function _reduceDebt(address account, ERC721 asset, address burnFrom, uint256 amount) internal { if (amount > _vaultInfo[account][asset].debt) { amount = _vaultInfo[account][asset].debt; } _reduceDebtWithoutBurn(account, asset, amount); PaprToken(address(papr)).burn(burnFrom, amount); }
#0 - c4-judge
2022-12-25T15:54:50Z
trust1995 marked the issue as duplicate of #92
#1 - c4-judge
2022-12-25T15:54:55Z
trust1995 marked the issue as satisfactory
🌟 Selected for report: HollaDieWaldfee
518.8602 USDC - $518.86
https://github.com/with-backed/papr/blob/9528f2711ff0c1522076b9f93fba13f88d5bd5e6/src/PaprController.sol#L208-L232 https://github.com/with-backed/papr/blob/9528f2711ff0c1522076b9f93fba13f88d5bd5e6/src/libraries/UniswapHelpers.sol#L31-L61
The PaprController.buyAndReduceDebt
function (https://github.com/with-backed/papr/blob/9528f2711ff0c1522076b9f93fba13f88d5bd5e6/src/PaprController.sol#L208-L232) should work like this:
msg.sender
swaps some amount of the underlying token for papr tokenaccount
parametermsg.sender
and account
can be different addresses such that one can repay anyone's debt.
However there is a mistake in the function which leads to this behavior:
msg.sender
swaps some amount of the underlying token for papr tokenaccount
addressmsg.sender
msg.sender
is used to pay back the debt of the account
addressThe issue is that the swapped papr token are sent to account
but the papr token are burnt from msg.sender
.
In the best scenario when calling this function, the msg.sender does not have enough papr token to burn so the function call reverts.
In the scenario that is worse, the msg.sender
has enough papr token to be burnt.
So the account
address receives the swapped papr token and the debt of account
is paid as well by the msg.sender
.
Thereby the msg.sender
pays double the amount he wants to.
Once by swapping his underlying tokens for papr.
The second time because his papr token are burnt.
The PaprController.buyAndReduceDebt
function (https://github.com/with-backed/papr/blob/9528f2711ff0c1522076b9f93fba13f88d5bd5e6/src/PaprController.sol#L208-L232) calls UniswapHelpers.swap
(https://github.com/with-backed/papr/blob/9528f2711ff0c1522076b9f93fba13f88d5bd5e6/src/libraries/UniswapHelpers.sol#L31-L61):
(uint256 amountOut, uint256 amountIn) = UniswapHelpers.swap( pool, account, token0IsUnderlying, params.amount, params.minOut, params.sqrtPriceLimitX96, abi.encode(msg.sender) );
The second parameter which has the value account
is the recipient of the swap.
The last parameter which is msg.sender
is the address paying the input amount for the swap.
So the msg.sender
pays some amount of underlying and the papr that the underlying is swapped for is sent to the account
.
But then the debt of account
is reduced by burning papr token from msg.sender
:
_reduceDebt({account: account, asset: collateralAsset, burnFrom: msg.sender, amount: amountOut});
However the papr token from the swap were received by account
. So the msg.sender
pays twice and account
receives twice.
VSCode
The swapped papr token should be sent to the msg.sender
instead of account
such that they can then be burnt from msg.sender
.
In order to achieve this, a single line in PaprController.buyAndReduceDebt
must be changed:
(uint256 amountOut, uint256 amountIn) = UniswapHelpers.swap( pool, - account, + msg.sender, token0IsUnderlying, params.amount, params.minOut, params.sqrtPriceLimitX96, abi.encode(msg.sender) );
#0 - c4-judge
2022-12-25T15:19:48Z
trust1995 marked the issue as duplicate of #112
#1 - c4-judge
2022-12-25T15:19:52Z
trust1995 marked the issue as satisfactory
#2 - c4-sponsor
2023-01-03T17:37:16Z
wilsoncusack marked the issue as sponsor confirmed
#3 - c4-judge
2023-01-04T09:26:02Z
trust1995 marked the issue as selected for report
#4 - C4-Staff
2023-01-10T22:32:59Z
JeeberC4 marked the issue as not a duplicate
#5 - C4-Staff
2023-01-10T22:33:46Z
JeeberC4 marked the issue as primary issue
🌟 Selected for report: Jeiwan
Also found by: 0x52, Franfran, HollaDieWaldfee, KingNFT, Saintcode_, bin2chen, evan, fs0c, noot, poirots, rvierdiiev, stealthyz, teawaterwire, unforgiven
33.3998 USDC - $33.40
Judge has assessed an item in Issue #172 as M risk. The relevant finding follows:
[L-02]
#0 - c4-judge
2023-01-06T21:10:53Z
trust1995 marked the issue as duplicate of #20
#1 - c4-judge
2023-01-06T21:11:25Z
trust1995 marked the issue as satisfactory
#2 - C4-Staff
2023-01-10T22:32:21Z
JeeberC4 marked the issue as duplicate of #196
🌟 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
Risk | Title | File | Instances |
---|---|---|---|
L-00 | Check that targetMarkRatioMax > targetMarkRatioMin | UniswapOracleFundingRateController.sol | 1 |
L-01 | Liquidation Fees are burnt and cannot be withdrawn by admin | PaprController.sol | 1 |
L-02 | buyAndReduceDebt function reverts if swap fees are used | PaprController.sol | 1 |
N-00 | Function state mutability can be restricted to pure | - | 2 |
N-01 | Function state mutability can be restricted to view | - | 1 |
N-02 | onERC721Received function: check amount > 0 instead of minOut > 0 | PaprController.sol | 1 |
targetMarkRatioMax > targetMarkRatioMin
In the constructor of UniswapOracleFundingRateController
it should be checked that targetMarkRatioMax > targetMarkRatioMin
as otherwise the values do not make sense and cause issues in the calculation of the funding rate.
The Whitepaper (https://backed.mirror.xyz/8SslPvU8of0h-fxoo6AybCpm51f30nd0qxPST8ep08c) states that:
If there is excess papr, a liquidation fee is subtracted and given to the protocol (added to an insurance fund) and the remainder is used to further reduce the borrowers debt
In order to make use of the Liquidation fees and to transfer them to the insurance fund, the PaprController has the sendPaprFromAuctionFees
function (https://github.com/with-backed/papr/blob/9528f2711ff0c1522076b9f93fba13f88d5bd5e6/src/PaprController.sol#L382-L384).
However the liquidation fees are instantly burnt once they are calculated after the NFT purchase:
https://github.com/with-backed/papr/blob/9528f2711ff0c1522076b9f93fba13f88d5bd5e6/src/PaprController.sol#L540
So there are no fees to be withdrawn.
In order to fix this, just remove the above line that burns the fees.
buyAndReduceDebt
function reverts if swap fees are usedThe PaprController.buyAndReduceDebt
function mistakenly sends the swap fees (which is an optional feature the msg.sender may or may not use) from the PaprController contract to the swapFeeTo
address:
https://github.com/with-backed/papr/blob/9528f2711ff0c1522076b9f93fba13f88d5bd5e6/src/PaprController.sol#L225-L227
This is wrong. The swap fees should be transferred from msg.sender
to the swapFeeTo
address.
The PaprController does not hold any underlying
tokens and so if swap fees are supposed to be used, the function call will revert, making the optional swap fee feature unusable.
To fix this, change the line that transfers the tokens to:
underlying.transferFrom(msg.sender, params.swapFeeTo, amountIn * params.swapFeeBips / BIPS_ONE);
In Solidity, functions should make use of the most restrictive state mutability modifier possible.
The following functions can be pure
:
In Solidity, functions should make use of the most restrictive state mutability modifier possible.
The following functions can be view
:
onERC721Received
function: check amount > 0
instead of minOut > 0
The PaprController.onERC721Received
function allows to optionally call _increaseDebtAndSell
or _increaseDebt
.
_increaseDebtAndSell
is called when request.swapParams.minOut > 0
(https://github.com/with-backed/papr/blob/9528f2711ff0c1522076b9f93fba13f88d5bd5e6/src/PaprController.sol#L170).
Instead of request.swapParams.minOut > 0
it should be checked that request.swapParams.amount > 0
.
This is because minOut
should be an optional parameter that can be left at zero whereas amount
is a required parameter for the swap.
#0 - c4-judge
2022-12-25T15:00:11Z
trust1995 marked the issue as grade-a