Reports
MEDIA[LÓGICA DE NEGOCIO]#3507241

Starknet Snap de MetaMask: cualquier web podía firmar mensajes sin confirmación del usuario con enableAuthorize=false

El Starknet Snap aceptaba de la propia web el parámetro enableAuthorize. Con el valor false se saltaba el diálogo de confirmación y la página obtenía firmas de mensajes o transacciones sin que el usuario las aprobara.

Resumen
Resumen en castellano de un reporte público, no una traducción literal. El código y los comandos se mantienen como en el original.

Resumen

El Starknet Snap de Consensys es una extensión para MetaMask que permite usar cuentas de la red Starknet. Cuando una web le pide firmar algo, el Snap debería mostrar siempre un diálogo para que el usuario revise el contenido y lo acepte o lo rechace. El problema estaba en que la decisión de mostrar o no ese diálogo dependía de un parámetro, enableAuthorize, que envía la propia web que hace la petición.

Es decir, la parte que debía ser vigilada decidía si se la vigilaba. Una web maliciosa solo tenía que mandar enableAuthorize a false para que el Snap firmara directamente con la clave privada de la cuenta, sin preguntar nada.

El fallo está en el manejador de la RPC de firma de mensajes, packages/starknet-snap/src/rpcs/sign-message.ts (línea 77 en la rama main, según el investigador):

  protected async handleRequest(
    params: SignMessageParams,
  ): Promise<SignMessageResponse> {
    const { enableAuthorize, typedDataMessage, address } = params;
    if (
      // Get Starknet expected not to show the confirm dialog, therefore, `enableAuthorize` will set to false to bypass the confirmation
      // TODO: enableAuthorize should set default to true
      enableAuthorize &&
      !(await renderSignMessageUI({
        address,
        typedDataMessage,
        chainId: this.network.chainId,
      }))
    ) {
      throw new UserRejectedOpError() as unknown as Error;
    }

    return await signMessageUtil(
      this.account.privateKey,
      typedDataMessage,
      address,
    );
  }
}

La condición empieza por enableAuthorize &&: si el valor es falso, la expresión se corta ahí, renderSignMessageUI nunca se ejecuta y el código pasa directamente a signMessageUtil con this.account.privateKey. El propio comentario del código explica que el parámetro existía para que Get Starknet pudiera evitar el diálogo, y deja un TODO indicando que debería valer true por defecto.

HackerOne lo clasifica como error de lógica de negocio (Business Logic Errors), con severidad media. El reporte se resolvió y recibió recompensa, cuyo importe no es público.

Pasos de reproducción

  1. Tener MetaMask instalado en el navegador.
  2. Entrar en https://snaps.consensys.io/starknet y pulsar "Connect with MetaMask" para instalar el Starknet Snap.
  3. Descargar la página de prueba que adjuntó el investigador, exp-starknet.html, y servirla en local en el puerto 5555.
  4. Abrir http://localhost:5555/exp-starknet.html.
  5. Pulsar el botón "start PoC".
  6. Aceptar la solicitud de conexión.
  7. Comprobar que la página muestra el resultado de signMessage sin que el usuario lo haya aprobado.

El contenido de exp-starknet.html no se ha hecho público.

Impacto

Basta con que el usuario visite una web maliciosa y acepte conectarla, algo habitual en cualquier aplicación descentralizada. A partir de ahí, la web puede obtener firmas de mensajes arbitrarios con la clave de su cuenta de Starknet sin que el usuario vea qué se firma. Según el investigador, lo mismo permite firmar transacciones sin aprobación, lo que puede acabar en el robo de los fondos de la cuenta.

Qué aprender de este caso

  • En wallets, extensiones y Snaps, revisa los parámetros de las RPC de firma o envío: cualquier opción del estilo enableAuthorize, skipConfirm o silent que llegue de la web es una posible forma de saltarse la confirmación del usuario.
  • La decisión de pedir confirmación tiene que tomarse dentro del componente que guarda la clave, nunca a partir de lo que mande quien pide la firma. Si un cliente concreto necesita evitar el diálogo, debe identificarse por su origen verificado, no por un parámetro.
  • Busca condiciones como flag && !(await confirmar(...)): si flag llega vacío o false, la confirmación desaparece en silencio. Lo seguro es que la ausencia del valor equivalga a pedir confirmación.
  • Los comentarios TODO junto a controles de seguridad, como el de este código, son una buena pista de dónde mirar al auditar un proyecto de código abierto.