Platform: Code4rena
Start Date: 14/03/2024
Pot Size: $49,000 USDC
Total HM: 3
Participants: 51
Period: 7 days
Judge: 3docSec
Id: 350
League: ETH
Rank: 5/51
Findings: 2
Award: $2,054.00
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: doublespending
Also found by: imare
2017.663 USDC - $2,017.66
If the MagicSpend paymaster is called by the EntryPoint with an aggregation of user/s operations the check done inside MagicSpend#validatePaymasterUserOp
for the ETH balance is not enough and can produce a revert when MagicSpend@postOp
is executed putting this paymaster on a bad reputation by wrongly pass the validation and working only with a bundle of size 1 for the user operation.
If a MagicSpend paymaster contract is used with handleAggregatedOps
we get the same revert.
The revert happen becouse 'MagicSpend#validatePaymasterUserOp' and MagicSpend#postOp
are coded to be called sequentially one after the other. But in the EntryPoint
contract both function are called separately in a for loop which hinders the 'MagicSpend#validatePaymasterUserOp' ETH balance validation on this line :
The validation passes and when looping on postOp
function the revert can happen on this line becouse the function SafeTransferLib.forceSafeTransferETH
checks the balance and if its less then amount to send it reverts with ETHTransferFailed()
function forceSafeTransferETH(address to, uint256 amount, uint256 gasStipend) internal { /// @solidity memory-safe-assembly assembly { if lt(selfbalance(), amount) { mstore(0x00, 0xb12d13eb) // `ETHTransferFailed()`. @<- here correctly reverts revert(0x1c, 0x04) } ...
The following POC is a new test inside the Integration.t.sol
file which demonstrates that if we construct an array of UserOps operations that is bigger then one the test will fail with a revert of ETHTransferFailed()
when executing MagicSpend::postOp
.
function test_MoreThanOneUserOpsPaymasterPays() public { uint256 NUMBER_OF_USER_OPS = 2; // @-> to see test succeed change this to 1 UserOperation[] memory ops = new UserOperation[](NUMBER_OF_USER_OPS); for (uint256 i; i<NUMBER_OF_USER_OPS; i++) { nonce = i; // change nonce for paymasterAndData field generation ! UserOperation memory op = _getUserOp(); op.nonce = i; // change use nonce bytes32 hash = entryPoint.getUserOpHash(op); console.logBytes32(hash); (uint8 v, bytes32 r, bytes32 s) = vm.sign(accountOwnerPk, hash); bytes memory userOpSig = abi.encode(CoinbaseSmartWallet.SignatureWrapper({ownerIndex: 0, signatureData: abi.encodePacked(r, s, v)})); op.signature = userOpSig; ops[i] = op; } // assertEq(op.sender.balance, 0); entryPoint.handleOps(ops, payable(address(1))); // will revert with `ETHTransferFailed()` }
Manual review
Check the ETH balance by taking in account the already promised withdraw amounts by introducing a global variable :
contract MagicSpend is Ownable, IPaymaster { + uint256 allWithdraws; function validatePaymasterUserOp(UserOperation calldata userOp, bytes32, uint256 maxCost) external onlyEntryPoint returns (bytes memory context, uint256 validationData) { WithdrawRequest memory withdrawRequest = abi.decode(userOp.paymasterAndData[20:], (WithdrawRequest)); uint256 withdrawAmount = withdrawRequest.amount; if (withdrawAmount < maxCost) { revert RequestLessThanGasMaxCost(withdrawAmount, maxCost); } if (withdrawRequest.asset != address(0)) { revert UnsupportedPaymasterAsset(withdrawRequest.asset); } _validateRequest(userOp.sender, withdrawRequest); bool sigFailed = !isValidWithdrawSignature(userOp.sender, withdrawRequest); validationData = (sigFailed ? 1 : 0) | (uint256(withdrawRequest.expiry) << 160); // Ensure at validation that the contract has enough balance to cover the requested funds. // NOTE: This check is necessary to enforce that the contract will be able to transfer the remaining funds // when `postOp()` is called back after the `UserOperation` has been executed. - if (address(this).balance < withdrawAmount) { + if (address(this).balance - allWithdraws < withdrawAmount) { revert InsufficientBalance(withdrawAmount, address(this).balance); } // NOTE: Do not include the gas part in withdrawable funds as it will be handled in `postOp()`. _withdrawableETH[userOp.sender] += withdrawAmount - maxCost; + allWithdraws += withdrawAmount - maxCost; context = abi.encode(maxCost, userOp.sender); } ...
Don't forget to decrease the allWithdraws
variable when the amount is withdrawn to the user !
Error
#0 - c4-pre-sort
2024-03-22T15:59:38Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-03-22T15:59:53Z
raymondfam marked the issue as duplicate of #110
#2 - raymondfam
2024-03-22T16:01:04Z
See #110. With added test.
#3 - c4-judge
2024-03-27T09:39:36Z
3docSec marked the issue as satisfactory
🌟 Selected for report: 0xmystery
Also found by: 0xbrett8571, 0xhacksmithh, 7ashraf, Bigsam, Circolors, IceBear, Jorgect, Koala, Limbooo, SBSecurity, Tigerfrake, ZanyBonzy, aycozynfada, cheatc0d3, cryptphi, d3e4, doublespending, foxb868, gpersoon, imare, jesjupyter, lsaudit, robriks, shealtielanz, y4y
36.3397 USDC - $36.34
The current implementation of removing an owner allows a single owner to remove all users even himself from the wallet ownership
The following function inherited by the wallet contract allows any owner to remove all (even himself) as a owner
The POC is added to the RemoveOwnerAtIndex.t.sol
tests file :
function testRemoveAllOwners() public { uint256 numOfOwners = mock.nextOwnerIndex(); // console.log("owners", numOfOwners); // @ this call be batched with `CoinbaseSmartWallet#executeBatch` // @!! the current calling account must be the last index as a owner to call the following function vm.startPrank(owner1Address); for (uint256 i = numOfOwners - 1; i> 0; i--) { mock.removeOwnerAtIndex(i); } vm.stopPrank(); }
Main points from the POC are:
ownerAtIndex
returning some value) and batched with CoinbaseSmartWallet#executeBatch
Manual review
Allow the owner to only remove himself from the wallet.
With the current removeOwnerAtIndex
removing a address cannot be simply done becouse some owners register them self as an EC point.
The solution would be to create a new function wich uses a signature to verify (like with CoinbaseSmartWallet_validateSignature
) that the msg.sender is indeed the right user to remove.
Error
#0 - c4-pre-sort
2024-03-21T22:15:45Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-03-21T22:15:51Z
raymondfam marked the issue as duplicate of #18
#2 - raymondfam
2024-03-21T22:16:24Z
See #18.
#3 - c4-pre-sort
2024-03-22T22:32:19Z
raymondfam marked the issue as duplicate of #22
#4 - c4-pre-sort
2024-03-24T14:46:49Z
raymondfam marked the issue as duplicate of #181
#5 - c4-judge
2024-03-27T08:59:05Z
3docSec marked the issue as not a duplicate
#6 - c4-judge
2024-03-27T08:59:52Z
3docSec marked the issue as duplicate of #18
#7 - c4-judge
2024-03-27T10:20:16Z
3docSec changed the severity to QA (Quality Assurance)
#8 - c4-judge
2024-03-27T10:21:59Z
3docSec marked the issue as grade-a