[Code4rena] Biconomy

0xDave·2023년 1월 10일
0

Ethereum

목록 보기
83/112
post-thumbnail

Overview


Biconomy Smart Account is a smart contract wallet that builds on core concepts of Gnosis / Argent safes and implements an interface to support calls from account abstraction Entry Point contract. We took all the the good parts of existing smart contract wallets.

These smart wallets have a single owner (1/1 Multisig) and are designed in such a way that it is

  • Cheap to deploy copies of a base wallet
  • Wallet addresses are counterfactual in nature (you can know the address in advance and users will have the same address across all EVM chains)
  • Deployment cost can be sponsored (gasless transactions by a relayer)
  • Modules can be used to extend the functionality of the smart contract wallet. Concepts like multi-sig, session keys, etc also can be implemented using the MultiSig Module, SessionKey Module & so on.

EIP-4337


이더리움에서 Account는 두 종류로 나뉜다.

  1. Externally Owned Account : 메타마스크를 사용하는 일반적인 유저
  2. Contract Account : 스마트컨트랙트

EIP-4337(Account Abstraction)의 주요 골자는 두 종류로 나뉘는 Account를 하나로 추상화하자는 얘기다. 그렇다면 왜 이런 제안이 나온걸까? 현재 EOA의 경우, private key를 통해 트랜잭션에 서명을 한다. 이러한 과정을 통해 자신이 해당 Account의 소유주임을 증명하는 것이다. 그런데 만약 소유주가 private key를 분실한다면 소유주임을 증명할 수 없고 지갑에 있던 자산들은 모두 분실된다.

또 다른 문제점은 지갑 디자인의 한계점이다. 지갑에서 서명을 생성하는 로직은 이더리움 프로토콜 단에서 ECDSA 방식을 활용한다. 하지만 해당 방식은 양자 컴퓨터에 취약하기 때문에 더 다양한 알고리즘을 활용할 필요가 있다.

EIP-4337(Account Abstraction)을 통해 얻는 장점은 무엇일까? 추상화를 위해서는 이더리움 프로토콜에 하드코딩 되어있는 트랜잭션의 검증 로직을 스마트 컨트랙트로 수행할 수 있도록 변경해야 한다.(조금 더 자세한 내용은 jeff lee님의 글을 참고하자) 이러한 변경을 거치면 다양한 시도가 가능하다. EOA만 지갑으로 사용하던 방식에서 스마트컨트랙트를 지갑으로 사용할 수 있도록 디자인 할 수 있고, 각 Account의 검증 로직을 커스텀 할 수도 있다. 심지어 ETH가 아닌 다른 토큰으로 가스비를 내도록 할 수 있으며, 미리 컨트랙트 지갑에 가스비로 사용되는 토큰을 넣어놔서 사용자가 가스비를 지불하지 않도록 만들 수도 있다!

이를 통해 Biconomy에서는 Smart Contract Wallet(SCW)을 활용하는 SDK를 제공한다. 유저는 자신의 EOA로 SCW를 생성하고, SCW를 통해 dApp을 사용한다. 실제 dApp에 사용하는 지갑과 유저가 소유하고 있는 EOA를 분리함으로써 보안 측면에서 강점을 제공한다. 또한 SCW를 통해 사용자는 dApp 내에서 Custom Transaction Bundling, Gasless transactions 등 다양한 이점을 얻을 수 있다.


Architecture



내가 생각한 취약점


1. 유저는 executeBatch를 호출할 때 value 값을 사용하는 dApp을 사용할 수 없음

//SmartAccount.sol

    function execute(address dest, uint value, bytes calldata func) external onlyOwner{
        _requireFromEntryPointOrOwner();
        _call(dest, value, func);
    }

    function executeBatch(address[] calldata dest, bytes[] calldata func) external onlyOwner{
        _requireFromEntryPointOrOwner();
        require(dest.length == func.length, "wrong array lengths");
        for (uint i = 0; i < dest.length;) {
            _call(dest[i], 0, func[i]);
            unchecked {
                ++i;
            }
        }
    }

같은 컨트랙트에서 executeexecuteBatch는 모두 _call 함수를 호출하게 되어있다. 하지만 executeBatch를 통해 호출할 경우 _call 함수의 value 값이 0으로 하드코딩 된다. 이는 향후 value 값을 사용하는 dApp을 이용하지 못하는 것으로 이어진다. 아래와 같은 상황을 생각해보자.

  1. dApp A는 입금과 출금을 지원하는 간단한 스테이킹 DeFi 프로젝트다.
  2. 아래와 같은 deposit 함수가 있다고 가정해보자.(예시를 위해 간단한 함수를 작성했다.)
function deposit(uint256 _value) public {
  bool success = msg.sender.call{value: _value}("");
  require(success, "Failed to send value");
  balances[msg.sender] += _value;
}
  1. 유저는 executeBatch를 통해 deposit 함수를 호출한다.
  2. 하지만 _call 함수의 value 값이 0으로 하드코딩 되어있기 때문에 유저는 입금할 수 없다.
  3. 유저는 SmartAccount를 통해 해당 dApp을 사용할 수 없으며 이는 유저뿐만 아니라 dApp을 운영하는 프로젝트에도 좋지 않다.

따라서 _call 함수의 value 값이 0으로 하드코딩 되지 않고 다른 프로젝트의 함수를 호출할 수 있도록 수정이 필요하다.


2. execFromEntryPoint는 항상 revert 될 것이다.

    function execute(address dest, uint value, bytes calldata func) external onlyOwner{
        _requireFromEntryPointOrOwner();
        _call(dest, value, func);
    }

    function execFromEntryPoint(address dest, uint value, bytes calldata func, Enum.Operation operation, uint256 gasLimit) external onlyEntryPoint returns (bool success) {        
        success = execute(dest, value, func, operation, gasLimit);
        require(success, "Userop Failed");
    }

execFromEntryPoint는 entrypoint만 호출할 수 있는 함수다. entrypoint가 해당 함수를 호출하면 execute를 호출하게 되어있다. 그런데 execute는 owner만 호출할 수 있다. 함수 안에 _requireFromEntryPointOrOwner();가 있어서 entrypoint도 호출할 수 있을 것 같지만 execute는 onlyOwner modifier를 사용하고 있기 때문에 owner가 아니면 호출할 수 없다.

    modifier onlyOwner {
        require(msg.sender == owner, "Smart Account:: Sender is not authorized");
        _;
    }

   modifier onlyEntryPoint {
        require(msg.sender == address(entryPoint()), "wallet: not from EntryPoint");
        _; 
   }

따라서 execFromEntryPoint는 항상 revert될 것이며, 다음과 같이 mixedAuth modifier를 사용하도록 수정해야 한다. _requireFromEntryPointOrOwner();를 삭제하더라도 owner는mixedAuth를 통해 접근할 수 있다.

    function execute(address dest, uint value, bytes calldata func) external mixedAuth{
        _call(dest, value, func);
    }

3. Smart Account는 중복되어 생성될 수 있으며 Attacker가 지갑 생성 과정을 frontrunning 할 수 있다.

//SmartAccountFactory.sol

    function deployCounterFactualWallet(address _owner, address _entryPoint, address _handler, uint _index) public returns(address proxy){
        bytes32 salt = keccak256(abi.encodePacked(_owner, address(uint160(_index))));
        bytes memory deploymentData = abi.encodePacked(type(Proxy).creationCode, uint(uint160(_defaultImpl)));
        // solhint-disable-next-line no-inline-assembly
        assembly {
            proxy := create2(0x0, add(0x20, deploymentData), mload(deploymentData), salt)
        }
        require(address(proxy) != address(0), "Create2 call failed");

        // EOA + Version tracking
        emit SmartAccountCreated(proxy,_defaultImpl,_owner, VERSION, _index);
        BaseSmartAccount(proxy).init(_owner, _entryPoint, _handler);
        isAccountExist[proxy] = true;
    }

    function deployWallet(address _owner, address _entryPoint, address _handler) public returns(address proxy){ 
        bytes memory deploymentData = abi.encodePacked(type(Proxy).creationCode, uint(uint160(_defaultImpl)));
        // solhint-disable-next-line no-inline-assembly
        assembly {
            proxy := create(0x0, add(0x20, deploymentData), mload(deploymentData))
        }
        BaseSmartAccount(proxy).init(_owner, _entryPoint, _handler);
        isAccountExist[proxy] = true;
    }

사용자는 deployCounterFactualWallet 또는 deployWallet을 이용해 지갑을 생성한다. 하지만 현재 지갑이 이미 존재하는지 체크하는 코드가 없으며 항상 isAccountExist[proxy]에 true를 전달하기 때문에 지갑은 중복되어 생성될 수 있다. Attacker는 이를 활용해 사용자가 만들기 전에 지갑을 미리 만들어 놓을 수 있다.

  1. 유저의 지갑을 생성하는 트랜잭션이 mempool에 올라오면 Attacker는 가스비를 올려 두 가지 트랜잭션을 제출한다.
  2. 첫 번째는 유저와 같은 지갑을 생성하는 트랜잭션을 제출해 먼저 지갑을 생성한다.
  3. 두 번째로 해당 지갑으로 malicious한 컨트랙트에 서명을 하는 트랜잭션을 제출한다.
  4. 이후 유저는 같은 지갑을 생성하고 해당 지갑을 통해 여러 dApp을 사용한다.
  5. 지갑에 충분한 자금이 들어오면 Attacker는 이전에 서명한 malicious 컨트랙트를 통해 유저의 자금을 탈취한다.

이를 방지하기 위해 현재 지갑이 이미 존재하는지 확인하는 코드가 필요하다.

require(isAccountExist[proxy] != true, "Address is aleary exist")

새로 배운 것


v,r,s

v,r,s는 서명의 결과물이라고 할 수 있다. 이 값들을 통해 Account의 public key를 얻을 수 있다. 이더리움에서는 ECDSA 방식을 사용해 서명(Signature)을 생성한다. 이 때 생성된 v는 recovery id를 말하며 rs는 ECDSA 결과값을 말한다.


출처 및 참고자료


  1. Account Abstraction & ERC 4337
profile
Just BUIDL :)

0개의 댓글