Paraspace supports leveraged purchases of NFTs through PoolMarketplace entry points. User calls buyWithCredit with marketplace, calldata to be sent to marketplace, and how many tokens to borrow.
In executeBuyWithCredit, orders are deserialized from the payload user sent to a DataTypes.OrderInfo structure. Each MarketplaceAdapter is required to fulfil that functionality through getAskOrderInfo:
If we take a look at LooksRareAdapter's getAskOrderInfo, it will the consideration parameter using only the MakerOrder parameters, without taking into account TakerOrder params
The OrderInfo constructed, which contains the consideration item from maker, is used in _delegateToPool, called by _buyWithCredit(), called by executeBuyWithCredit:
The total price is charged to msg.sender, and he will pay it with debt tokens + immediate downpayment.
After enough funds are transfered to the Pool contract, it delegatecalls to the LooksRare adapter, which will do the actual call to LooksRareExchange. The exchange will send the money gathered in the pool to maker, and give it the NFT.
The issue is that attacker can supply a different price in the MakerOrder and TakerOrder passed as payload to LooksRare. The maker price will be reflected in the registered price charged to user, but taker price will be the one actually transferred from Pool.
To show taker price is what counts, this is the code in LooksRareExchange.sol:
function matchAskWithTakerBid(OrderTypes.TakerOrder calldata takerBid, OrderTypes.MakerOrder calldata makerAsk)
external
override
nonReentrant
{
require((makerAsk.isOrderAsk) && (!takerBid.isOrderAsk), "Order: Wrong sides");
require(msg.sender == takerBid.taker, "Order: Taker must be the sender");
// Check the maker ask order
bytes32 askHash = makerAsk.hash();
_validateOrder(makerAsk, askHash);
(bool isExecutionValid, uint256 tokenId, uint256 amount) = IExecutionStrategy(makerAsk.strategy)
.canExecuteTakerBid(takerBid, makerAsk);
require(isExecutionValid, "Strategy: Execution invalid");
// Update maker ask order status to true (prevents replay)
_isUserOrderNonceExecutedOrCancelled[makerAsk.signer][makerAsk.nonce] = true;
// Execution part 1/2
_transferFeesAndFunds(
makerAsk.strategy,
makerAsk.collection,
tokenId,
makerAsk.currency,
msg.sender,
makerAsk.signer,
takerBid.price, <--- taker price is what's charged
makerAsk.minPercentageToAsk
);
...
}
Since attacker will be both maker and taker in this flow, he has no problem in supplying a strategy which will accept higher taker price than maker price. It will pass this check:
It is important to note that for this exploit we can pass a 0 credit loan amount, which allows the stolen asset to be any asset, not just ones supported by the pool. This is because of early return in _borrowTo() and \repay() functions.
The attack POC looks as follows:
Taker (attacker) has 10 DAI
Pool has 990 DAI
Maker (attacker) has 1 doodle NFT.
Taker submits buyWithCredit() transaction:
credit amount 0
TakerOrder with 1000 amount
MakerOrder with 10 amount and "accept all" execution strategy
Pool will take the 10 DAI from taker and additional 990 DAI from it's own funds and send to Maker.
Attacker ends up with both 1000 DAI and an nToken of the NFT
Impact
Any ERC20 tokens which exist in the pool contract can be drained by an attacker.
Proof of Concept
In _pool_marketplace_buy_wtih_credit.spec.ts, add this test:
it("looksrare attack", async () => {
const {
doodles,
dai,
pool,
users: [maker, taker, middleman],
} = await loadFixture(testEnvFixture);
const payNowNumber = "10";
const poolVictimNumber = "990";
const payNowAmount = await convertToCurrencyDecimals(
dai.address,
payNowNumber
);
const poolVictimAmount = await convertToCurrencyDecimals(
dai.address,
poolVictimNumber
);
const totalAmount = payNowAmount.add(poolVictimAmount);
const nftId = 0;
// mint DAI to offer
// We don't need to give taker any money, he is not charged
// Instead, give the pool money
await mintAndValidate(dai, payNowNumber, taker);
await mintAndValidate(dai, poolVictimNumber, pool);
// middleman supplies DAI to pool to be borrowed by offer later
//await supplyAndValidate(dai, poolVictimNumber, middleman, true);
// maker mint mayc
await mintAndValidate(doodles, "1", maker);
// approve
await waitForTx(
await dai.connect(taker.signer).approve(pool.address, payNowAmount)
);
console.log("maker balance before", await dai.balanceOf(maker.address))
console.log("taker balance before", await dai.balanceOf(taker.address))
console.log("pool balance before", await dai.balanceOf(pool.address))
await executeLooksrareBuyWithCreditAttack(
doodles,
dai,
payNowAmount,
totalAmount,
0,
nftId,
maker,
taker
);
In marketplace-helper.ts, please copy in the following attack code:
Lines of code
https://github.com/code-423n4/2022-11-paraspace/blob/c6820a279c64a299a783955749fdc977de8f0449/paraspace-core/contracts/misc/marketplaces/LooksRareAdapter.sol#L59 https://github.com/code-423n4/2022-11-paraspace/blob/c6820a279c64a299a783955749fdc977de8f0449/paraspace-core/contracts/protocol/libraries/logic/MarketplaceLogic.sol#L397
Vulnerability details
Description
Paraspace supports leveraged purchases of NFTs through PoolMarketplace entry points. User calls buyWithCredit with marketplace, calldata to be sent to marketplace, and how many tokens to borrow.
In executeBuyWithCredit, orders are deserialized from the payload user sent to a DataTypes.OrderInfo structure. Each MarketplaceAdapter is required to fulfil that functionality through getAskOrderInfo:
If we take a look at LooksRareAdapter's getAskOrderInfo, it will the consideration parameter using only the MakerOrder parameters, without taking into account TakerOrder params
The OrderInfo constructed, which contains the consideration item from maker, is used in _delegateToPool, called by _buyWithCredit(), called by executeBuyWithCredit:
The total price is charged to msg.sender, and he will pay it with debt tokens + immediate downpayment. After enough funds are transfered to the Pool contract, it delegatecalls to the LooksRare adapter, which will do the actual call to LooksRareExchange. The exchange will send the money gathered in the pool to maker, and give it the NFT.
The issue is that attacker can supply a different price in the MakerOrder and TakerOrder passed as payload to LooksRare. The maker price will be reflected in the registered price charged to user, but taker price will be the one actually transferred from Pool.
To show taker price is what counts, this is the code in LooksRareExchange.sol:
Since attacker will be both maker and taker in this flow, he has no problem in supplying a strategy which will accept higher taker price than maker price. It will pass this check:
It is important to note that for this exploit we can pass a 0 credit loan amount, which allows the stolen asset to be any asset, not just ones supported by the pool. This is because of early return in _borrowTo() and \repay() functions.
The attack POC looks as follows:
Impact
Any ERC20 tokens which exist in the pool contract can be drained by an attacker.
Proof of Concept
In _pool_marketplace_buy_wtih_credit.spec.ts, add this test:
In marketplace-helper.ts, please copy in the following attack code:
Finally, we need to change the passed execution strategy. In StrategyStandardSaleForFixedPrice.sol, change canExecuteTakerBid:
We can see the output:
Tools Used
Manual audit
Recommended Mitigation Steps
It is important to validate that the price charged to user is the same price taken from the Pool contract: