code4rena-2023-01-biconomy-h02

[H-02] Theft of funds under relaying the transaction

보고서

Summary

Relayer가 파라미터를 조작하여 실제 비용보다 많은 가스 비용을 환급받을 수 있다.

Keyword

theft, input validation, gas, signature, relayer, frontrunning

Vulnerability

SmartAccount 계정으로 컨트랙트 콜을 하기 위해서는 SmartAccount.execTransaction 를 호출해야 한다. 그렇다면 누가 execTransaction 를 호출하는가? 바로 Relayer다. Relayer는 사용자가 호출 요청한 트랜잭션을 모아 SmartAccount의 execTransaction 를 호출해준다. 정확히는 EntryPoint를 거쳐 호출된다. 이 때, 사용된 Gas는 Relayer 에게 환급된다.

Relayer에게 돌려줘야 하는 Gas 양을 계산하는 데 오류가 있어 취약점이 발생했다. 공격자가 calldata를 조작하여 실제로 받아야 할 Gas보다 많은 양을 받아낼 수 있다.

돌려줄 Gas는 다음과 같이 계산된다.

  1. startGas를 구한다. Relayer가 컨트랙트 콜을 실행하기 위해 초기에 든 비용을 포함한다. uint256 startGas = gasleft() + 21000 + msg.data.length * 8;
  2. 유저가 원하는 작업을 수행한다.
  3. 사용한 가스(gasUsed) 를 startGas - gasleft() 로 계산하고 handlePayment를 호출한다.
  4. 실제로 돌려줄 가스는 payment = (gasUsed + baseGas) * (gasPrice < tx.gasprice ? gasPrice : tx.gasprice); 로 계산한다.

여기서 1번, uint256 startGas = gasleft() + 21000 + msg.data.length * 8; 을 주목하자. startGas는 현 시점에 남은 gas 양과 relayer가 컨트랙트콜을 하기 위해 기본적으로 필요한 21000 gas, 그리고 컨트랙트콜을 하기 위해 필요한 파라미터(calldata)를 보내는데 든 gas를 계산한다.

// initial gas = 21k + non_zero_bytes * 16 + zero_bytes * 4
//            ~= 21k + calldata.length * [1/3 * 16 + 2/3 * 4]
uint256 startGas = gasleft() + 21000 + msg.data.length * 8;

calldata의 바이트당 gas는 다음과 같이 부여된다.

  • 값이 0인 경우: 4 gas
  • 값이 0이 아닌 경우: 16 gas

startGas를 계산하는 코드 위에는 왜 16도 4도 아닌 8 gas로 계산했는지에 대한 주석이 있다. calldata를 순회하며 정확한 비용을 계산할 수도 있겠지만, 이는 불필요하게 gas를 낭비하는 일이다. calldata 길이만큼을 순회하며 정확한 비용을 계산하는게 의미가 있을까? 이를 계산하는 일은 calldata를 보내는 비용보다 더 많은 비용이 발생한다. 따라서 대충 중간인 8 gas 로 가정하고 계산한다.

약간의 손해 또는 이익을 감수한다는 의도 자체는 괜찮다. 하지만, relayer가 임의로 파라미터를 조작하여 calldata에 0x00을 추가, 정상 상태보다 더 많은 환급을 유도할 수 있다면 어떨까? 어느정도 손해를 감수하는 것과 의도적으로 더 받아낼 수 있는 것은 다른 문제이다. 취약점이 될 수 있다.

/// @dev Allows to execute a Safe transaction confirmed by required number of owners and then pays the account that submitted the transaction.
/// Note: The fees are always transferred, even if the user transaction fails.
/// @param _tx Wallet transaction 
/// @param batchId batchId key for 2D nonces
/// @param refundInfo Required information for gas refunds
/// @param signatures Packed signature data ({bytes32 r}{bytes32 s}{uint8 v})
function execTransaction(
    Transaction memory _tx,
    uint256 batchId,
    FeeRefund memory refundInfo,
    bytes memory signatures
)

execTransaction 의 파라미터는 _tx, batchId, refundInfo, signatures가 있다. 여기서 마지막 signatures는 bytes 형으로, 파라미터 길이에 제한이 없다. 하지만 signature는 유저의 서명값일 것이다. 조작이 가능할까?

checkSignatures 함수에서 signature를 확인한다.

    function checkSignatures(
        bytes32 dataHash,
        bytes memory data,
        bytes memory signatures
    ) public view virtual {
        uint8 v;
        bytes32 r;
        bytes32 s;
        uint256 i = 0;
        address _signer;
        (v, r, s) = signatureSplit(signatures, i);
        ...
    }

그리고 이 함수에서 signatureSplit 함수를 호출하여 signature의 필드를 분해한다.

function signatureSplit(bytes memory signatures, uint256 pos)
    internal
    pure
    returns (uint8 v, bytes32 r, bytes32 s)
 {
     // The signature format is a compact form of:
     //   {bytes32 r}{bytes32 s}{uint8 v}
     // Compact means, uint8 is not padded to 32 bytes.
     // solhint-disable-next-line no-inline-assembly
     assembly {
         let signaturePos := mul(0x41, pos)
         r := mload(add(signatures, add(signaturePos, 0x20)))
         s := mload(add(signatures, add(signaturePos, 0x40)))
         // Here we are loading the last 32 bytes, including 31 bytes
         // of 's'. There is no 'mload8' to do this.
         //
         // 'byte' is not working due to the Solidity parser, so lets
         // use the second best option, 'and'
         v := and(mload(add(signatures, add(signaturePos, 0x41))), 0xff)
     }
}

signatureSplit 를 호출할 시 pos는 0으로 하드코딩 했다. 이 경우 signature를 파싱할 시 상위 0x41(=65)바이트만을 이용한다. 즉, signatures 파라미터를 아무리 길게 넣어도 상위 65바이트에 서명값이 있다면 나머지 바이트는 무시된다. 따라서 relayer가 execTransaction를 실행할 때, 임의로 파라미터를 수정하여 signatures 파라미터에 0x00을 붙여 보내도 컨트랙트 콜은 정상적으로 처리된다.

// This is the modified transaction
await ownerSCW.connect(accounts[1]).execTransaction(
  transaction,
  1, // batchId
  refundInfo,
  signature + "0".repeat(482000), // !!! This is the critical part !!!
  { gasPrice: safeTx.gasPrice }
);
scwDiff = (await ethers.provider.getBalance(ownerSCW.address)).sub(scwBalanceBefore).add(ethers.utils.parseEther("1")); // Add the 1 ETH that was transfered
bobDiff = (await ethers.provider.getBalance(bob)).sub(bobBalanceBefore);
console.log(`SCW payed ${ethers.utils.formatEther(scwDiff)} ETH`); // Paid a total of 0.2 ETH, 20 times the usual amount!
console.log(`Bob gained ${ethers.utils.formatEther(bobDiff)} ETH`); // Got reimbursed and even takes a profit of 0.067 ETH

signature 파라미터에 0x00을 n개만큼 덧붙여 보낸다면 대략 n * 4 gas 만큼을 추가적으로 소비하지만, 공격을 위해 추가적으로 소비한 가스 역시 환급받을 수 있다. startGas = gasleft() + 21000 + msg.data.length * 8 에 의해 공격 비용을 제외한 약 n * 4 gas 만큼의 가스를 추가적으로 환급받을 것이다.

Impact

Relayer가 트랜잭션을 실행하는 유저의 토큰을 일부 탈취할 수 있다.

Mitigation

signatures 의 길이를 65바이트로 제한해 calldata를 무한히 늘릴 수 없게 한다.

require(signatures.length == 65, "Invalid signature length");

Memo

Solidity에서 storage delete를 하면 Gas를 일부 돌려준다. 이 경우에도 잘 처리될지 막연히 의문이 들었다. 아마 gasleft() 에 적용되어 별 상관은 없을 것 같다.


tags: bughunting, smart contract, biconomy, account abstraction, erc4337, crypto theft, signature, frontrunning, account abstraction bundler, gas, gas refund, lack-of-input-validation-vul, wallet, severity high