Pular para o conteúdo principal

Alguns truques usados por tokens fraudulentos e como detectá-los

fraude
Solidity
erc-20
JavaScript
TypeScript
Intermediário
Ori Pomerantz
15 de setembro de 2023
16 minutos de leitura

Neste tutorial, dissecamos um token fraudulento (opens in a new tab) para ver alguns dos truques que os golpistas usam e como eles os implementam. Ao final do tutorial, você terá uma visão mais abrangente dos contratos de token ERC-20, suas capacidades e por que o ceticismo é necessário. Em seguida, analisamos os eventos emitidos por esse token fraudulento e vemos como podemos identificar automaticamente que ele não é legítimo.

Tokens fraudulentos - o que são, por que as pessoas os criam e como evitá-los

Um dos usos mais comuns do Ethereum é para um grupo criar um token negociável, em certo sentido, sua própria moeda. No entanto, onde quer que existam casos de uso legítimos que tragam valor, também existem criminosos que tentam roubar esse valor para si mesmos.

Você pode ler mais sobre este assunto em outro lugar no ethereum.org a partir da perspectiva do usuário. Este tutorial se concentra em dissecar um token fraudulento para ver como ele é feito e como pode ser detectado.

Como sei que o wARB é um golpe?

O token que dissecamos é o wARB (opens in a new tab), que finge ser equivalente ao token ARB (opens in a new tab) legítimo.

A maneira mais fácil de saber qual é o token legítimo é olhar para a organização de origem, a Arbitrum (opens in a new tab). Os endereços legítimos são especificados em sua documentação (opens in a new tab).

Por que o código-fonte está disponível?

Normalmente, esperaríamos que as pessoas que tentam aplicar golpes em outras fossem sigilosas e, de fato, muitos tokens fraudulentos não têm seu código disponível (por exemplo, este (opens in a new tab) e este (opens in a new tab)).

No entanto, tokens legítimos geralmente publicam seu código-fonte, então, para parecerem legítimos, os autores de tokens fraudulentos às vezes fazem o mesmo. O wARB (opens in a new tab) é um desses tokens com código-fonte disponível, o que torna mais fácil entendê-lo.

Embora os implantadores de contratos possam escolher se publicam ou não o código-fonte, eles não podem publicar o código-fonte errado. O explorador de blocos compila o código-fonte fornecido de forma independente e, se não obtiver exatamente o mesmo bytecode, ele rejeita esse código-fonte. Você pode ler mais sobre isso no site do Etherscan (opens in a new tab).

Comparação com tokens ERC-20 legítimos

Vamos comparar este token com tokens ERC-20 legítimos. Se você não está familiarizado com a forma como os tokens ERC-20 legítimos são normalmente escritos, veja este tutorial.

Constantes para endereços privilegiados

Os contratos às vezes precisam de endereços privilegiados. Contratos que são projetados para uso a longo prazo permitem que algum endereço privilegiado altere esses endereços, por exemplo, para permitir o uso de um novo contrato multisig. Existem várias maneiras de fazer isso.

O contrato de token HOP (opens in a new tab) usa o padrão Ownable (opens in a new tab). O endereço privilegiado é mantido no armazenamento, em um campo chamado _owner (veja o terceiro arquivo, Ownable.sol).

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

O contrato de token ARB (opens in a new tab) não tem um endereço privilegiado diretamente. No entanto, ele não precisa de um. Ele fica atrás de um proxy (opens in a new tab) no endereço 0xb50721bcf8d664c30412cfbc6cf7a15145234ad1 (opens in a new tab). Esse contrato tem um endereço privilegiado (veja o quarto arquivo, ERC1967Upgrade.sol) que pode ser usado para atualizações.

    /**
     * @dev Armazena um novo endereço no slot 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;
    }

Em contraste, o contrato wARB tem um contract_owner codificado de forma rígida (hardcoded).

Este proprietário do contrato (opens in a new tab) não é um contrato que poderia ser controlado por contas diferentes em momentos diferentes, mas uma conta de propriedade externa. Isso significa que provavelmente foi projetado para uso a curto prazo por um indivíduo, em vez de uma solução a longo prazo para controlar um ERC-20 que permanecerá valioso.

E, de fato, se olharmos no Etherscan, vemos que o golpista usou este contrato por apenas 12 horas (da primeira transação (opens in a new tab) à última transação (opens in a new tab)) durante o dia 19 de maio de 2023.

A função _transfer falsa

É padrão que as transferências reais ocorram usando uma função _transfer interna.

Em wARB, esta função parece quase legítima:

A parte suspeita é:

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

Se o proprietário do contrato envia tokens, por que o evento Transfer mostra que eles vêm de deployer?

No entanto, há um problema mais importante. Quem chama essa função _transfer? Ela não pode ser chamada de fora, pois está marcada como internal. E o código que temos não inclui nenhuma chamada para _transfer. Claramente, ela está aqui como uma isca.

Quando olhamos para as funções que são chamadas para transferir tokens, transfer e transferFrom, vemos que elas chamam uma função completamente diferente, _f_.

A verdadeira função _f_

Existem dois possíveis sinais de alerta nesta função.

  • O uso do modificador de função (opens in a new tab) _mod_. No entanto, quando olhamos para o código-fonte, vemos que _mod_ é, na verdade, inofensivo.

    modifier _mod_(address sender, address recipient, uint256 amount){
      _;
    }
    
  • O mesmo problema que vimos em _transfer, que é quando contract_owner envia tokens, eles parecem vir de deployer.

A função de eventos falsos dropNewTokens

Agora chegamos a algo que parece um golpe real. Editei a função um pouco para facilitar a leitura, mas ela é funcionalmente equivalente.

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

Esta função tem o modificador auth(), o que significa que ela só pode ser chamada pelo proprietário do contrato.

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

Essa restrição faz todo o sentido, porque não gostaríamos que contas aleatórias distribuíssem tokens. No entanto, o restante da função é suspeito.

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

Uma função para transferir de uma conta de pool para uma matriz de destinatários uma matriz de valores faz todo o sentido. Existem muitos casos de uso em que você desejará distribuir tokens de uma única fonte para vários destinos, como folha de pagamento, airdrops, etc. É mais barato (em gás) fazer isso em uma única transação em vez de emitir várias transações, ou até mesmo chamar o ERC-20 várias vezes de um contrato diferente como parte da mesma transação.

No entanto, dropNewTokens não faz isso. Ela emite eventos Transfer (opens in a new tab), mas na verdade não transfere nenhum token. Não há razão legítima para confundir aplicativos offchain informando-os sobre uma transferência que não aconteceu de verdade.

A função de queima Approve

Os contratos ERC-20 devem ter uma função approve para permissões e, de fato, nosso token fraudulento tem essa função, e ela está até correta. No entanto, como a linguagem Solidity descende de C, ela diferencia maiúsculas de minúsculas (case-sensitive). "Approve" e "approve" são strings diferentes.

Além disso, a funcionalidade não está relacionada a approve.

    function Approve(
        address[] memory holders)

Esta função é chamada com uma matriz de endereços para os detentores do token.

    public approver() {

O modificador approver() garante que apenas contract_owner tenha permissão para chamar esta função (veja abaixo).

Para cada endereço de detentor, a função move todo o saldo do detentor para o endereço 0x00...01, efetivamente queimando-o (a verdadeira função burn no padrão também altera o suprimento total e transfere os tokens para 0x00...00). Isso significa que contract_owner pode remover os ativos de qualquer usuário. Isso não parece um recurso que você desejaria em um token de governança.

Problemas de qualidade de código

Esses problemas de qualidade de código não provam que este código é um golpe, mas o fazem parecer suspeito. Empresas organizadas como a Arbitrum geralmente não lançam códigos tão ruins.

A função mount

Embora não seja especificado no padrão (opens in a new tab), de modo geral, a função que cria novos tokens é chamada de mint.

Se olharmos no construtor wARB, vemos que a função de cunhagem (mint) foi renomeada para mount por algum motivo, e é chamada cinco vezes com um quinto do suprimento inicial, em vez de uma vez para o valor total por uma questão de eficiência.

A própria função mount também é suspeita.

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

Olhando para o require, vemos que apenas o proprietário do contrato tem permissão para cunhar. Isso é legítimo. Mas a mensagem de erro deveria ser only owner is allowed to mint (apenas o proprietário tem permissão para cunhar) ou algo parecido. Em vez disso, é a irrelevante ERC20: mint to the zero address (ERC20: cunhar para o endereço zero). O teste correto para cunhar para o endereço zero é require(account != address(0), "<error message>"), o que o contrato nunca se preocupa em verificar.

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

Existem mais dois fatos suspeitos, diretamente relacionados à cunhagem:

  • Há um parâmetro account, que presumivelmente é a conta que deve receber o valor cunhado. Mas o saldo que aumenta é, na verdade, o de contract_owner.

  • Embora o saldo aumentado pertença a contract_owner, o evento emitido mostra uma transferência para account.

Por que ambos auth e approver? Por que o mod que não faz nada?

Este contrato contém três modificadores: _mod_, auth e approver.

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

_mod_ recebe três parâmetros e não faz nada com eles. Por que tê-lo?

auth e approver fazem mais sentido, porque eles verificam se o contrato foi chamado por contract_owner. Esperaríamos que certas ações privilegiadas, como a cunhagem, fossem limitadas a essa conta. No entanto, qual é o sentido de ter duas funções separadas que fazem exatamente a mesma coisa?

O que podemos detectar automaticamente?

Podemos ver que wARB é um token fraudulento olhando no Etherscan. No entanto, essa é uma solução centralizada. Em teoria, o Etherscan poderia ser subvertido ou hackeado. É melhor ser capaz de descobrir de forma independente se um token é legítimo ou não.

Existem alguns truques que podemos usar para identificar que um token ERC-20 é suspeito (seja um golpe ou muito mal escrito), observando os eventos que eles emitem.

Eventos Approval suspeitos

Os eventos Approval (opens in a new tab) só devem acontecer com uma solicitação direta (em contraste com os eventos Transfer (opens in a new tab) que podem acontecer como resultado de uma permissão). Veja a documentação da Solidity (opens in a new tab) para uma explicação detalhada desse problema e por que as solicitações precisam ser diretas, em vez de mediadas por um contrato.

Isso significa que os eventos Approval que aprovam gastos de uma conta de propriedade externa devem vir de transações que se originam nessa conta e cujo destino é o contrato ERC-20. Qualquer outro tipo de aprovação de uma conta de propriedade externa é suspeito.

Aqui está um programa que identifica esse tipo de evento (opens in a new tab), usando Viem (opens in a new tab) e TypeScript (opens in a new tab), uma variante do JavaScript com segurança de tipo. Para executá-lo:

  1. Copie .env.example para .env.
  2. Edite .env para fornecer a URL para um nó da Rede Principal do Ethereum.
  3. Execute pnpm install para instalar os pacotes necessários.
  4. Execute pnpm susApproval para procurar aprovações suspeitas.

Aqui está uma explicação linha por linha:

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

Importe definições de tipo, funções e a definição da cadeia de viem.

import { config } from "dotenv"
config()

Leia .env para obter a URL.

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

Crie um cliente Viem. Só precisamos ler da blockchain, então este cliente não precisa de uma chave privada.

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

O endereço do contrato ERC-20 suspeito e os blocos dentro dos quais procuraremos eventos. Os provedores de nós normalmente limitam nossa capacidade de ler eventos porque a largura de banda pode ficar cara. Felizmente, wARB não esteve em uso por um período de dezoito horas, então podemos procurar por todos os eventos (houve apenas 13 no total).

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

Esta é a maneira de pedir informações de eventos ao Viem. Quando fornecemos a assinatura exata do evento, incluindo os nomes dos campos, ele analisa o evento para nós.

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

Nosso algoritmo é aplicável apenas a contas de propriedade externa. Se houver algum bytecode retornado por client.getBytecode, isso significa que este é um contrato e devemos simplesmente ignorá-lo.

Se você não usou TypeScript antes, a definição da função pode parecer um pouco estranha. Não dizemos apenas que o primeiro (e único) parâmetro é chamado addr, mas também que é do tipo Address. Da mesma forma, a parte : boolean diz ao TypeScript que o valor de retorno da função é um booleano.

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

Esta função obtém o recibo da transação de um evento. Precisamos do recibo para garantir que sabemos qual foi o destino da transação.

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

Esta é a função mais importante, aquela que realmente decide se um evento é suspeito ou não. O tipo de retorno, (Event | null), diz ao TypeScript que esta função pode retornar um Event ou null. Retornamos null se o evento não for suspeito.

const owner = ev.args._owner

O Viem tem os nomes dos campos, então ele analisou o evento para nós. _owner é o proprietário dos tokens a serem gastos.

// Aprovações por contratos não são suspeitas
if (await isContract(owner)) return null

Se o proprietário for um contrato, assuma que esta aprovação não é suspeita. Para verificar se a aprovação de um contrato é suspeita ou não, precisaremos rastrear a execução completa da transação para ver se ela chegou ao contrato do proprietário e se esse contrato chamou o contrato ERC-20 diretamente. Isso consome muito mais recursos do que gostaríamos de gastar.

const txn = await getEventTxn(ev)

Se a aprovação vier de uma conta de propriedade externa, obtenha a transação que a causou.

// A aprovação é suspeita se vier de um proprietário de EOA que não é o `from` da transação
if (owner.toLowerCase() != txn.from.toLowerCase()) return ev

Não podemos simplesmente verificar a igualdade de strings porque os endereços são hexadecimais, então eles contêm letras. Às vezes, por exemplo em txn.from, essas letras são todas minúsculas. Em outros casos, como ev.args._owner, o endereço está em letras maiúsculas e minúsculas para identificação de erros (opens in a new tab).

Mas se a transação não for do proprietário, e esse proprietário for de propriedade externa, então temos uma transação suspeita.

// Também é suspeito se o destino da transação não for o contrato ERC-20 que estamos
// investigando
if (txn.to.toLowerCase() != testedAddress) return ev

Da mesma forma, se o endereço to da transação, o primeiro contrato chamado, não for o contrato ERC-20 sob investigação, então é suspeito.

    // Se não houver motivo para suspeita, retorne null.
    return null
}

Se nenhuma das condições for verdadeira, então o evento Approval não é suspeito.

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

console.log(testResults)

Uma função async (opens in a new tab) retorna um objeto Promise. Com a sintaxe comum, await x(), esperamos que esse Promise seja cumprido antes de continuarmos o processamento. Isso é simples de programar e acompanhar, mas também é ineficiente. Enquanto esperamos que o Promise de um evento específico seja cumprido, já podemos começar a trabalhar no próximo evento.

Aqui usamos map (opens in a new tab) para criar uma matriz de objetos Promise. Em seguida, usamos Promise.all (opens in a new tab) para esperar que todas essas promessas sejam resolvidas. Em seguida, usamos filter (opens in a new tab) nesses resultados para remover os eventos não suspeitos.

Eventos Transfer suspeitos

Outra maneira possível de identificar tokens fraudulentos é ver se eles têm alguma transferência suspeita. Por exemplo, transferências de contas que não têm tantos tokens. Você pode ver como implementar este teste (opens in a new tab), mas wARB não tem esse problema.

Conclusão

A detecção automatizada de golpes ERC-20 sofre de falsos negativos (opens in a new tab), porque um golpe pode usar um contrato de token ERC-20 perfeitamente normal que simplesmente não representa nada real. Portanto, você deve sempre tentar obter o endereço do token de uma fonte confiável.

A detecção automatizada pode ajudar em certos casos, como em peças de finanças descentralizadas (DeFi), onde há muitos tokens e eles precisam ser tratados automaticamente. Mas, como sempre, caveat emptor (opens in a new tab) (o risco é do comprador), faça sua própria pesquisa e incentive seus usuários a fazerem o mesmo.

Veja aqui mais do meu trabalho (opens in a new tab).