Algunos trucos utilizados por los tokens fraudulentos y cómo detectarlos
En este tutorial, diseccionamos un token fraudulentoopens 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 token ERC-20, sus capacidades y por qué es necesario el escepticismo. Luego, examinamos 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é se crean y cómo evitarlos?
Uno de los usos más comunes para Ethereum es que un grupo cree un token intercambiable, en cierto sentido su propia moneda. No obstante, en cualquier lugar donde haya casos de uso legítimos que aporten valor, también hay criminales que intentan robar ese valor para sí mismos.
Puede leer más sobre este tema en otro lugar de ethereum.org desde la perspectiva del usuario. Este tutorial se centra en diseccionar un token fraudulento para ver cómo está hecho y cómo se puede detectar.
¿Cómo sé que wARB es un fraude?
El token que diseccionamos es wARBopens in a new tab, que pretende ser equivalente al token ARBopens in a new tab legítimo.
La forma más fácil de saber cuál es el token legítimo es consultar a la organización de origen, Arbitrumopens in a new tab. Las direcciones legítimas se especifican en su documentaciónopens 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, esteopens in a new tab y este otroopens 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 tokens fraudulentos a veces hacen lo mismo. wARBopens in a new tab es uno de esos tokens con el código fuente disponible, lo que facilita su comprensión.
Aunque 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 bytecode, rechaza ese código fuente. Puede leer más sobre esto en el sitio de Etherscanopens in a new tab.
Comparación con los tokens ERC-20 legítimos
Vamos a comparar este token con los tokens ERC-20 legítimos. Si no está familiarizado con la forma en que 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 su uso a largo plazo permiten que algunas direcciones privilegiadas cambien esas direcciones, por ejemplo, para permitir el uso de un nuevo contrato multifirma. Hay varias maneras de hacer esto.
El contrato de token HOPopens in a new tab utiliza el patrón Ownableopens in a new tab. La dirección privilegiada se mantiene en el almacenamiento, en un campo llamado _owner (véase el tercer archivo, Ownable.sol).
1abstract contract Ownable is Context {2 address private _owner;3 .4 .5 .6}El contrato de token ARBopens in a new tab no tiene una dirección privilegiada directamente. Sin embargo, no la necesita. Se encuentra detrás de un proxyopens in a new tab en la dirección 0xb50721bcf8d664c30412cfbc6cf7a15145234ad1opens in a new tab. Ese contrato tiene una dirección privilegiada (consulte el cuarto archivo, ERC1967Upgrade.sol) que se puede utilizar para las actualizaciones.
1 /**2 * @dev Almacena una nueva dirección en la ranura de administrador de EIP1967.3 */4 function _setAdmin(address newAdmin) private {5 require(newAdmin != address(0), "ERC1967: new admin is the zero address");6 StorageSlot.getAddressSlot(_ADMIN_SLOT).value = newAdmin;7 }Por el contrario, el contrato wARB tiene un contract_owner codificado.
1contract WrappedArbitrum is Context, IERC20 {2 .3 .4 .5 address deployer = 0xB50721BCf8d664c30412Cfbc6cf7a15145234ad1;6 address public contract_owner = 0xb40dE7b1beE84Ff2dc22B70a049A07A13a411A33;7 .8 .9 .10}Mostrar todoEl propietario de este contratoopens 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 utilizó este contrato durante 12 horas (de la primera transacciónopens in a new tab a la última transacciónopens 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:
1 function _transfer(address sender, address recipient, uint256 amount) internal virtual{2 require(sender != address(0), "ERC20: transfer from the zero address");3 require(recipient != address(0), "ERC20: transfer to the zero address");45 _beforeTokenTransfer(sender, recipient, amount);67 _balances[sender] = _balances[sender].sub(amount, "ERC20: transfer amount exceeds balance");8 _balances[recipient] = _balances[recipient].add(amount);9 if (sender == contract_owner){10 sender = deployer;11 }12 emit Transfer(sender, recipient, amount);13 }Mostrar todoLa parte sospechosa es:
1 if (sender == contract_owner){2 sender = deployer;3 }4 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 fuera, está marcada como internal. Y el código que tenemos no incluye ninguna llamada a _transfer. Claramente, está aquí como un señuelo.
1 function transfer(address recipient, uint256 amount) public virtual override returns (bool) {2 _f_(_msgSender(), recipient, amount);3 return true;4 }56 function transferFrom(address sender, address recipient, uint256 amount) public virtual override returns (bool) {7 _f_(sender, recipient, amount);8 _approve(sender, _msgSender(), _allowances[sender][_msgSender()].sub(amount, "ERC20: transfer amount exceeds allowance"));9 return true;10 }Mostrar todoCuando 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_
1 function _f_(address sender, address recipient, uint256 amount) internal _mod_(sender,recipient,amount) virtual {2 require(sender != address(0), "ERC20: transfer from the zero address");3 require(recipient != address(0), "ERC20: transfer to the zero address");45 _beforeTokenTransfer(sender, recipient, amount);67 _balances[sender] = _balances[sender].sub(amount, "ERC20: transfer amount exceeds balance");8 _balances[recipient] = _balances[recipient].add(amount);9 if (sender == contract_owner){1011 sender = deployer;12 }13 emit Transfer(sender, recipient, amount);14 }Mostrar todoHay dos posibles señales de alerta en esta función.
-
El uso del modificador de funciónopens in a new tab
_mod_. Sin embargo, cuando examinamos el código fuente, vemos que_mod_es en realidad inofensivo.1modifier _mod_(address sender, address recipient, uint256 amount){2 _;3} -
El mismo problema que vimos en
_transfer, que es que cuandocontract_ownerenvía tokens, parecen provenir dedeployer.
La función de eventos falsos dropNewTokens
Ahora llegamos a algo que parece un verdadero fraude. He editado un poco la función para facilitar la lectura, pero es funcionalmente equivalente.
1function dropNewTokens(address uPool,2 address[] memory eReceiver,3 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.
1modifier auth() {2 require(msg.sender == contract_owner, "Not allowed to interact");3 _;4}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.
1{2 for (uint256 i = 0; i < eReceiver.length; i++) {3 emit Transfer(uPool, eReceiver[i], eAmounts[i]);4 }5}Una función para transferir desde una cuenta de fondo común a un conjunto de receptores un conjunto de cantidades tiene mucho sentido. Hay muchos casos de uso en los que querrá distribuir tokens desde una única 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 Transferopens 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 quema Approve
Se supone que los contratos ERC-20 tienen una función approve para los permisos, y de hecho nuestro token fraudulento tiene una función así, y es incluso correcta. Sin embargo, como 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.
1 function Approve(2 address[] memory holders)A esta función se le llama con un array de direcciones para los poseedores del token.
1 public approver() {El modificador approver() se asegura de que solo a contract_owner se le permita llamar a esta función (véase más abajo).
1 for (uint256 i = 0; i < holders.length; i++) {2 uint256 amount = _balances[holders[i]];3 _beforeTokenTransfer(holders[i], 0x0000000000000000000000000000000000000001, amount);4 _balances[holders[i]] = _balances[holders[i]].sub(amount,5 "ERC20: burn amount exceeds balance");6 _balances[0x0000000000000000000000000000000000000001] =7 _balances[0x0000000000000000000000000000000000000001].add(amount);8 }9 }10Mostrar todoPara cada dirección de poseedor, la función mueve el saldo completo del poseedor a la dirección 0x00...01, quemándolo de manera efectiva (la función 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 querría en un token de gobernanza.
Problemas de calidad del código
Estos problemas de calidad del código no demuestran que este código sea un fraude, pero hacen que parezca sospechoso. Empresas organizadas como Arbitrum no suelen publicar código tan malo.
La función mount
Si bien no se especifica en el estándaropens in a new tab, en términos generales, la función que crea nuevos tokens se llama mintopens in a new tab.
Si miramos en el constructor de wARB, vemos que la función de acuñación ha sido renombrada a mount por alguna razón, y se la llama cinco veces con una quinta parte del suministro inicial, en lugar de una vez por la cantidad total por eficiencia.
1 constructor () public {23 _name = "Wrapped Arbitrum";4 _symbol = "wARB";5 _decimals = 18;6 uint256 initialSupply = 1000000000000;78 mount(deployer, initialSupply*(10**18)/5);9 mount(deployer, initialSupply*(10**18)/5);10 mount(deployer, initialSupply*(10**18)/5);11 mount(deployer, initialSupply*(10**18)/5);12 mount(deployer, initialSupply*(10**18)/5);13 }Mostrar todoLa función mount en sí misma también es sospechosa.
1 function mount(address account, uint256 amount) public {2 require(msg.sender == contract_owner, "ERC20: mint to the zero address");Al observar el require, vemos que solo el propietario del contrato tiene permitido acuñar. Eso es legítimo. Pero el mensaje de error debería ser solo el propietario tiene permiso para acuñar o algo así. En cambio, es el irrelevante ERC20: acuñar a la dirección cero. La prueba correcta para acuñar a la dirección cero es require(account != address(0), "<mensaje de error>"), que el contrato nunca se molesta en comprobar.
1 _totalSupply = _totalSupply.add(amount);2 _balances[contract_owner] = _balances[contract_owner].add(amount);3 emit Transfer(address(0), account, amount);4 }Hay dos hechos más sospechosos, directamente relacionados con la acuñación:
-
Hay un parámetro de
cuenta, que es presumiblemente la cuenta que debería recibir la cantidad acuñada. Pero el saldo que aumenta es en realidad el decontract_owner. -
Mientras que el saldo aumentado pertenece a
contract_owner, el evento emitido muestra una transferencia aaccount.
¿Por qué tanto auth como approver? ¿Por qué el mod que no hace nada?
Este contrato contiene tres modificadores: _mod_, auth y approver.
1 modifier _mod_(address sender, address recipient, uint256 amount){2 _;3 }_mod_ toma tres parámetros y no hace nada con ellos. ¿Por qué tenerlo?
1 modifier auth() {2 require(msg.sender == contract_owner, "Not allowed to interact");3 _;4 }56 modifier approver() {7 require(msg.sender == contract_owner, "Not allowed to interact");8 _;9 }Mostrar todoauth y approver tienen más sentido, porque comprueban 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, ¿qué sentido tiene tener dos funciones separadas que hacen precisamente lo mismo?
¿Qué podemos detectar automáticamente?
Podemos ver que wARB es un token fraudulento mirando 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 un fraude o muy mal escrito), observando los eventos que emiten.
Eventos Approval sospechosos
Los eventos Approvalopens in a new tab solo deberían ocurrir con una solicitud directa (a diferencia de los eventos Transferopens in a new tab que pueden ocurrir como resultado de un permiso). Consulte la documentación de Solidityopens 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 deben 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 sospechoso.
Aquí hay un programa que identifica este tipo de eventoopens in a new tab, usando viemopens in a new tab y TypeScriptopens in a new tab, una variante de JavaScript con seguridad de tipos. Para ejecutarlo:
- Copie
.env.exampleen.env. - Edite
.envpara proporcionar la URL a un nodo de la red principal de Ethereum. - Ejecute
pnpm installpara instalar los paquetes necesarios. - Ejecute
pnpm susApprovalpara buscar aprobaciones sospechosas.
Aquí hay una explicación línea por línea:
1import {2 Address,3 TransactionReceipt,4 createPublicClient,5 http,6 parseAbiItem,7} from "viem"8import { mainnet } from "viem/chains"Importar definiciones de tipo, funciones y la definición de la cadena de viem.
1import { config } from "dotenv"2config()Lea .env para obtener la URL.
1const client = createPublicClient({2 chain: mainnet,3 transport: http(process.env.URL),4})Cree un cliente Viem. Solo necesitamos leer de la blockchain, por lo que este cliente no necesita una clave privada.
1const testedAddress = "0xb047c8032b99841713b8e3872f06cf32beb27b82"2const fromBlock = 16859812n3const toBlock = 16873372nLa dirección del contrato ERC-20 sospechoso y los bloques dentro de los cuales buscaremos eventos. Los proveedores de nodos suelen limitar nuestra capacidad para leer eventos porque el ancho de banda puede ser caro. Afortunadamente, wARB no se usó durante un período de dieciocho horas, por lo que podemos buscar todos los eventos (solo hubo 13 en total).
1const approvalEvents = await client.getLogs({2 address: testedAddress,3 fromBlock,4 toBlock,5 event: parseAbiItem(6 "event Approval(address indexed _owner, address indexed _spender, uint256 _value)"7 ),8})Esta es la forma de solicitar información de eventos a Viem. Cuando le proporcionamos la firma exacta del evento, incluidos los nombres de los campos, analiza el evento por nosotros.
1const isContract = async (addr: Address): boolean =>2 await client.getBytecode({ address: addr })Nuestro algoritmo solo es aplicable a las cuentas de propiedad externa. Si client.getBytecode devuelve algún bytecode, significa que se trata de un contrato y debemos 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. Del mismo modo, la parte : boolean le dice a TypeScript que el valor de retorno de la función es un booleano.
1const getEventTxn = async (ev: Event): TransactionReceipt =>2 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 que sabemos cuál fue el destino de la transacción.
1const 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.
1const owner = ev.args._ownerViem tiene los nombres de los campos, por lo que analizó el evento por nosotros. _owner es el propietario de los tokens que se van a gastar.
1// Las aprobaciones por parte de contratos no son sospechosas2if (await isContract(owner)) return nullSi el propietario es un contrato, asuma que esta aprobación no es sospechosa. Para comprobar si la aprobación de un contrato es sospechosa o no, tendremos que rastrear la ejecución completa de la transacción para ver si alguna vez llegó al contrato del propietario y si ese contrato llamó directamente al contrato ERC-20. Eso consume muchos más recursos de los que nos gustaría.
1const txn = await getEventTxn(ev)Si la aprobación proviene de una cuenta de propiedad externa, obtenga la transacción que la causó.
1// La aprobación es sospechosa si proviene de un propietario de EOA que no es el `from` de la transacción2if (owner.toLowerCase() != txn.from.toLowerCase()) return evNo podemos simplemente comprobar 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 erroresopens 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.
1// También es sospechoso si el destino de la transacción no es el contrato ERC-20 que estamos2// investigando3if (txn.to.toLowerCase() != testedAddress) return evDel mismo modo, si la dirección to de la transacción, el primer contrato llamado, no es el contrato ERC-20 bajo investigación, entonces es sospechoso.
1 // Si no hay razón para sospechar, devolver nulo.2 return null3}Si ninguna de las dos condiciones es cierta, el evento Approval no es sospechoso.
1const testPromises = approvalEvents.map((ev) => suspiciousApprovalEvent(ev))2const testResults = (await Promise.all(testPromises)).filter((x) => x != null)34console.log(testResults)Una función asyncopens in a new tab devuelve un objeto Promise. Con la sintaxis común, await x(), esperamos a que se cumpla esa Promise antes de continuar con el procesamiento. Esto es sencillo de programar y seguir, pero también es ineficiente. Mientras esperamos que se cumpla la Promise para un evento específico, ya podemos empezar a trabajar en el siguiente evento.
Aquí usamos mapopens in a new tab para crear un array de objetos Promise. Luego usamos Promise.allopens in a new tab para esperar a que se resuelvan todas esas promesas. Luego filtramosopens in a new tab esos resultados para eliminar los eventos no sospechosos.
Eventos Transfer sospechosos
Otra forma posible de identificar los tokens fraudulentos es ver si tienen alguna transferencia sospechosa. Por ejemplo, transferencias desde cuentas que no tienen tantos tokens. Puede ver cómo implementar esta pruebaopens in a new tab, pero wARB no tiene este problema.
Conclusión
La detección automatizada de fraudes ERC-20 sufre de falsos negativosopens in a new tab, porque un fraude puede utilizar 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 de confianza.
La detección automatizada puede ayudar en ciertos casos, como en las piezas de DeFi, donde hay muchos tokens y deben manejarse automáticamente. Pero como siempre caveat emptoropens in a new tab, investigue por su cuenta y anime a sus usuarios a hacer lo mismo.
Vea aquí más de mi trabajoopens in a new tab.
Última actualización de la página: 4 de septiembre de 2025