Saltar al contenido principal

Algunos trucos utilizados por los tokens fraudulentos y cómo detectarlos

estafa
Solidity
erc-20
JavaScript
TypeScript
Intermedio
Ori Pomerantz
15 de septiembre de 2023
16 minutos de lectura

En este tutorial analizamos un token fraudulento (opens in a new tab) para ver algunos de los trucos que utilizan los estafadores y cómo los implementan. Al final del tutorial, tendrá una visión más completa de los contratos de tokens ERC-20, sus capacidades y por qué es necesario el escepticismo. Luego, analizamos los eventos emitidos por ese token fraudulento y vemos cómo podemos identificar automáticamente que no es legítimo.

Tokens fraudulentos: qué son, por qué la gente los crea y cómo evitarlos

Uno de los usos más comunes de Ethereum es que un grupo cree un token negociable, en cierto sentido, su propia moneda. Sin embargo, dondequiera que haya casos de uso legítimos que aporten valor, también hay delincuentes que intentan robar ese valor para sí mismos.

Puede leer más sobre este tema en otras partes de ethereum.org desde la perspectiva del usuario. Este tutorial se centra en analizar un token fraudulento para ver cómo se hace y cómo se puede detectar.

¿Cómo sé que wARB es una estafa?

El token que analizamos es wARB (opens in a new tab), que pretende ser equivalente al token ARB (opens in a new tab) legítimo.

La forma más fácil de saber cuál es el token legítimo es observar a la organización de origen, Arbitrum (opens in a new tab). Las direcciones legítimas se especifican en su documentación (opens in a new tab).

¿Por qué está disponible el código fuente?

Normalmente esperaríamos que las personas que intentan estafar a otras sean reservadas y, de hecho, muchos tokens fraudulentos no tienen su código disponible (por ejemplo, este (opens in a new tab) y este (opens in a new tab)).

Sin embargo, los tokens legítimos suelen publicar su código fuente, por lo que, para parecer legítimos, los autores de los tokens fraudulentos a veces hacen lo mismo. wARB (opens in a new tab) es uno de esos tokens con código fuente disponible, lo que facilita su comprensión.

Si bien los implementadores de contratos pueden elegir si publicar o no el código fuente, no pueden publicar el código fuente incorrecto. El explorador de bloques compila el código fuente proporcionado de forma independiente y, si no obtiene exactamente el mismo código de bytes, rechaza ese código fuente. Puede leer más sobre esto en el sitio de Etherscan (opens in a new tab).

Comparación con tokens ERC-20 legítimos

Vamos a comparar este token con tokens ERC-20 legítimos. Si no está familiarizado con cómo se escriben normalmente los tokens ERC-20 legítimos, consulte este tutorial.

Constantes para direcciones privilegiadas

Los contratos a veces necesitan direcciones privilegiadas. Los contratos que están diseñados para uso a largo plazo permiten que alguna dirección privilegiada cambie esas direcciones, por ejemplo, para habilitar el uso de un nuevo contrato de multifirma. Hay varias formas de hacer esto.

El contrato de token HOP (opens in a new tab) utiliza el patrón Ownable (opens in a new tab). La dirección privilegiada se mantiene en el almacenamiento, en un campo llamado _owner (consulte el tercer archivo, Ownable.sol).

abstract contract Ownable is Context {
    address private _owner;
    .
    .
    .
}

El contrato de token ARB (opens in a new tab) no tiene una dirección privilegiada directamente. Sin embargo, no la necesita. Se encuentra detrás de un proxy (opens in a new tab) en la dirección 0xb50721bcf8d664c30412cfbc6cf7a15145234ad1 (opens in a new tab). Ese contrato tiene una dirección privilegiada (consulte el cuarto archivo, ERC1967Upgrade.sol) que se puede utilizar para actualizaciones.

    /**
     * @dev Almacena una nueva dirección en la ranura de administrador EIP1967.
     */
    function _setAdmin(address newAdmin) private {
        require(newAdmin != address(0), "ERC1967: new admin is the zero address");
        StorageSlot.getAddressSlot(_ADMIN_SLOT).value = newAdmin;
    }

Por el contrario, el contrato wARB tiene un contract_owner codificado de forma rígida.

El propietario de este contrato (opens in a new tab) no es un contrato que pueda ser controlado por diferentes cuentas en diferentes momentos, sino una cuenta de propiedad externa. Esto significa que probablemente esté diseñado para el uso a corto plazo por parte de un individuo, en lugar de como una solución a largo plazo para controlar un ERC-20 que seguirá siendo valioso.

Y de hecho, si miramos en Etherscan vemos que el estafador solo usó este contrato durante 12 horas (desde la primera transacción (opens in a new tab) hasta la última transacción (opens in a new tab)) durante el 19 de mayo de 2023.

La función _transfer falsa

Es estándar que las transferencias reales se realicen utilizando una función _transfer interna.

En wARB esta función parece casi legítima:

La parte sospechosa es:

        if (sender == contract_owner){
            sender = deployer;
        }
        emit Transfer(sender, recipient, amount);

Si el propietario del contrato envía tokens, ¿por qué el evento Transfer muestra que provienen de deployer?

Sin embargo, hay un problema más importante. ¿Quién llama a esta función _transfer? No se puede llamar desde el exterior, está marcada como internal. Y el código que tenemos no incluye ninguna llamada a _transfer. Claramente, está aquí como un señuelo.

Cuando observamos las funciones a las que se llama para transferir tokens, transfer y transferFrom, vemos que llaman a una función completamente diferente, _f_.

La verdadera función _f_

Hay dos posibles señales de alerta en esta función.

  • El uso del modificador de función (opens in a new tab) _mod_. Sin embargo, cuando miramos el código fuente vemos que _mod_ es en realidad inofensivo.

    modifier _mod_(address sender, address recipient, uint256 amount){
      _;
    }
    
  • El mismo problema que vimos en _transfer, que es cuando contract_owner envía tokens, parecen provenir de deployer.

La función de eventos falsos dropNewTokens

Ahora llegamos a algo que parece una estafa real. Edité un poco la función para facilitar la lectura, pero es funcionalmente equivalente.

function dropNewTokens(address uPool,
                       address[] memory eReceiver,
                       uint256[] memory eAmounts) public auth()

Esta función tiene el modificador auth(), lo que significa que solo puede ser llamada por el propietario del contrato.

modifier auth() {
    require(msg.sender == contract_owner, "Not allowed to interact");
    _;
}

Esta restricción tiene mucho sentido, porque no querríamos que cuentas aleatorias distribuyeran tokens. Sin embargo, el resto de la función es sospechoso.

{
    for (uint256 i = 0; i < eReceiver.length; i++) {
        emit Transfer(uPool, eReceiver[i], eAmounts[i]);
    }
}

Una función para transferir desde una cuenta de fondo común a una matriz de receptores una matriz de cantidades tiene mucho sentido. Hay muchos casos de uso en los que querrá distribuir tokens desde una sola fuente a múltiples destinos, como nóminas, airdrops, etc. Es más barato (en gas) hacerlo en una sola transacción en lugar de emitir múltiples transacciones, o incluso llamar al ERC-20 varias veces desde un contrato diferente como parte de la misma transacción.

Sin embargo, dropNewTokens no hace eso. Emite eventos Transfer (opens in a new tab), pero en realidad no transfiere ningún token. No hay ninguna razón legítima para confundir a las aplicaciones fuera de la cadena informándoles de una transferencia que en realidad no ocurrió.

La función de quemado Approve

Se supone que los contratos ERC-20 tienen una función approve para asignaciones, y de hecho nuestro token fraudulento tiene tal función, e incluso es correcta. Sin embargo, debido a que Solidity desciende de C, distingue entre mayúsculas y minúsculas. "Approve" y "approve" son cadenas diferentes.

Además, la funcionalidad no está relacionada con approve.

    function Approve(
        address[] memory holders)

Esta función se llama con una matriz de direcciones para los titulares del token.

    public approver() {

El modificador approver() se asegura de que solo contract_owner tenga permiso para llamar a esta función (consulte a continuación).

Para cada dirección de titular, la función mueve el saldo completo del titular a la dirección 0x00...01, quemándolo efectivamente (el burn real en el estándar también cambia el suministro total y transfiere los tokens a 0x00...00). Esto significa que contract_owner puede eliminar los activos de cualquier usuario. Esa no parece una característica que desearía en un token de gobernanza.

Problemas de calidad del código

Estos problemas de calidad del código no prueban que este código sea una estafa, pero lo hacen parecer sospechoso. Las empresas organizadas como Arbitrum no suelen publicar un código tan malo.

La función mount

Si bien no se especifica en el estándar (opens in a new tab), en términos generales, la función que crea nuevos tokens se llama mint.

Si miramos en el constructor de wARB, vemos que la función de acuñación de tiempo ha sido renombrada a mount por alguna razón, y se llama cinco veces con una quinta parte del suministro inicial, en lugar de una vez por la cantidad total por eficiencia.

La función mount en sí también es sospechosa.

    function mount(address account, uint256 amount) public {
        require(msg.sender == contract_owner, "ERC20: mint to the zero address");

Al observar el require, vemos que solo el propietario del contrato tiene permiso para acuñar. Eso es legítimo. Pero el mensaje de error debería ser only owner is allowed to mint (solo el propietario tiene permiso para acuñar) o algo similar. En cambio, es el irrelevante ERC20: mint to the zero address (ERC20: acuñar a la dirección cero). La prueba correcta para acuñar a la dirección cero es require(account != address(0), "<error message>"), que el contrato nunca se molesta en verificar.

        _totalSupply = _totalSupply.add(amount);
        _balances[contract_owner] = _balances[contract_owner].add(amount);
        emit Transfer(address(0), account, amount);
    }

Hay dos hechos más sospechosos, directamente relacionados con la acuñación:

  • Hay un parámetro account, que presumiblemente es la cuenta que debería recibir la cantidad acuñada. Pero el saldo que aumenta es en realidad el de contract_owner.

  • Si bien el saldo incrementado pertenece a contract_owner, el evento emitido muestra una transferencia a account.

¿Por qué tanto auth como approver? ¿Por qué el mod que no hace nada?

Este contrato contiene tres modificadores: _mod_, auth y approver.

    modifier _mod_(address sender, address recipient, uint256 amount){
        _;
    }

_mod_ toma tres parámetros y no hace nada con ellos. ¿Por qué tenerlo?

auth y approver tienen más sentido, porque verifican que el contrato fue llamado por contract_owner. Esperaríamos que ciertas acciones privilegiadas, como la acuñación, se limitaran a esa cuenta. Sin embargo, ¿cuál es el punto de tener dos funciones separadas que hacen exactamente lo mismo?

¿Qué podemos detectar automáticamente?

Podemos ver que wARB es un token fraudulento al mirar en Etherscan. Sin embargo, esa es una solución centralizada. En teoría, Etherscan podría ser subvertido o hackeado. Es mejor poder averiguar de forma independiente si un token es legítimo o no.

Hay algunos trucos que podemos usar para identificar que un token ERC-20 es sospechoso (ya sea una estafa o está muy mal escrito), observando los eventos que emiten.

Eventos Approval sospechosos

Los eventos Approval (opens in a new tab) solo deberían ocurrir con una solicitud directa (a diferencia de los eventos Transfer (opens in a new tab) que pueden ocurrir como resultado de una asignación). Consulte la documentación de Solidity (opens in a new tab) para obtener una explicación detallada de este problema y por qué las solicitudes deben ser directas, en lugar de estar mediadas por un contrato.

Esto significa que los eventos Approval que aprueban el gasto de una cuenta de propiedad externa tienen que provenir de transacciones que se originan en esa cuenta, y cuyo destino es el contrato ERC-20. Cualquier otro tipo de aprobación de una cuenta de propiedad externa es sospechosa.

Aquí hay un programa que identifica este tipo de evento (opens in a new tab), utilizando Viem (opens in a new tab) y TypeScript (opens in a new tab), una variante de JavaScript con seguridad de tipos. Para ejecutarlo:

  1. Copie .env.example a .env.
  2. Edite .env para proporcionar la URL a un nodo de la red principal de Ethereum.
  3. Ejecute pnpm install para instalar los paquetes necesarios.
  4. Ejecute pnpm susApproval para buscar aprobaciones sospechosas.

Aquí hay una explicación línea por línea:

import {
  Address,
  TransactionReceipt,
  createPublicClient,
  http,
  parseAbiItem,
} from "viem"
import { mainnet } from "viem/chains"

Importe definiciones de tipos, funciones y la definición de la cadena desde viem.

import { config } from "dotenv"
config()

Lea .env para obtener la URL.

const client = createPublicClient({
  chain: mainnet,
  transport: http(process.env.URL),
})

Cree un cliente de Viem. Solo necesitamos leer de la cadena de bloques, por lo que este cliente no necesita una clave privada.

const testedAddress = "0xb047c8032b99841713b8e3872f06cf32beb27b82"
const fromBlock = 16859812n
const toBlock = 16873372n

La dirección del contrato ERC-20 sospechoso y los bloques dentro de los cuales buscaremos eventos. Los proveedores de nodos generalmente limitan nuestra capacidad de leer eventos porque el ancho de banda puede resultar costoso. Afortunadamente, wARB no estuvo en uso durante un período de dieciocho horas, por lo que podemos buscar todos los eventos (solo hubo 13 en total).

const approvalEvents = await client.getLogs({
  address: testedAddress,
  fromBlock,
  toBlock,
  event: parseAbiItem(
    "event Approval(address indexed _owner, address indexed _spender, uint256 _value)"
  ),
})

Esta es la forma de pedirle a Viem información sobre eventos. Cuando le proporcionamos la firma exacta del evento, incluidos los nombres de los campos, analiza el evento por nosotros.

const isContract = async (addr: Address): boolean =>
  await client.getBytecode({ address: addr })

Nuestro algoritmo solo es aplicable a cuentas de propiedad externa. Si hay algún código de bytes devuelto por client.getBytecode, significa que se trata de un contrato y simplemente deberíamos omitirlo.

Si no ha usado TypeScript antes, la definición de la función puede parecer un poco extraña. No solo le decimos que el primer (y único) parámetro se llama addr, sino también que es de tipo Address. De manera similar, la parte : boolean le dice a TypeScript que el valor de retorno de la función es un booleano.

const getEventTxn = async (ev: Event): TransactionReceipt =>
  await client.getTransactionReceipt({ hash: ev.transactionHash })

Esta función obtiene el recibo de la transacción de un evento. Necesitamos el recibo para asegurarnos de saber cuál fue el destino de la transacción.

const suspiciousApprovalEvent = async (ev : Event) : (Event | null) => {

Esta es la función más importante, la que realmente decide si un evento es sospechoso o no. El tipo de retorno, (Event | null), le dice a TypeScript que esta función puede devolver un Event o null. Devolvemos null si el evento no es sospechoso.

const owner = ev.args._owner

Viem tiene los nombres de los campos, por lo que analizó el evento por nosotros. _owner es el propietario de los tokens que se gastarán.

// Las aprobaciones por contratos no son sospechosas
if (await isContract(owner)) return null

Si el propietario es un contrato, asuma que esta aprobación no es sospechosa. Para verificar si la aprobación de un contrato es sospechosa o no, necesitaremos rastrear la ejecución completa de la transacción para ver si alguna vez llegó al contrato del propietario, y si ese contrato llamó al contrato ERC-20 directamente. Eso consume muchos más recursos de lo que nos gustaría hacer.

const txn = await getEventTxn(ev)

Si la aprobación proviene de una cuenta de propiedad externa, obtenga la transacción que la causó.

// La aprobación es sospechosa si proviene de un propietario de EOA que no es el `from` de la transacción
if (owner.toLowerCase() != txn.from.toLowerCase()) return ev

No podemos simplemente verificar la igualdad de cadenas porque las direcciones son hexadecimales, por lo que contienen letras. A veces, por ejemplo en txn.from, esas letras están todas en minúsculas. En otros casos, como ev.args._owner, la dirección está en mayúsculas y minúsculas para la identificación de errores (opens in a new tab).

Pero si la transacción no es del propietario, y ese propietario es de propiedad externa, entonces tenemos una transacción sospechosa.

// También es sospechoso si el destino de la transacción no es el contrato ERC-20 que estamos
// investigando
if (txn.to.toLowerCase() != testedAddress) return ev

De manera similar, si la dirección to de la transacción, el primer contrato llamado, no es el contrato ERC-20 bajo investigación, entonces es sospechosa.

    // Si no hay razón para sospechar, devuelve null.
    return null
}

Si ninguna de las condiciones es verdadera, entonces el evento Approval no es sospechoso.

const testPromises = approvalEvents.map((ev) => suspiciousApprovalEvent(ev))
const testResults = (await Promise.all(testPromises)).filter((x) => x != null)

console.log(testResults)

Una función async (opens in a new tab) devuelve un objeto Promise. Con la sintaxis común, await x(), esperamos a que se cumpla ese Promise antes de continuar con el procesamiento. Esto es fácil de programar y seguir, pero también es ineficiente. Mientras esperamos a que se cumpla el Promise para un evento específico, ya podemos empezar a trabajar en el siguiente evento.

Aquí usamos map (opens in a new tab) para crear una matriz de objetos Promise. Luego usamos Promise.all (opens in a new tab) para esperar a que se resuelvan todas esas promesas. Luego aplicamos filter (opens in a new tab) a esos resultados para eliminar los eventos no sospechosos.

Eventos Transfer sospechosos

Otra forma posible de identificar tokens fraudulentos es ver si tienen transferencias sospechosas. Por ejemplo, transferencias desde cuentas que no tienen tantos tokens. Puede ver cómo implementar esta prueba (opens in a new tab), pero wARB no tiene este problema.

Conclusión

La detección automatizada de estafas ERC-20 sufre de falsos negativos (opens in a new tab), porque una estafa puede usar un contrato de token ERC-20 perfectamente normal que simplemente no representa nada real. Por lo tanto, siempre debe intentar obtener la dirección del token de una fuente confiable.

La detección automatizada puede ayudar en ciertos casos, como en las piezas de finanzas descentralizadas (DeFi), donde hay muchos tokens y deben manejarse automáticamente. Pero como siempre, caveat emptor (opens in a new tab) (el riesgo es del comprador), haga su propia investigación y anime a sus usuarios a hacer lo mismo.

Vea aquí más de mi trabajo (opens in a new tab).