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
15 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. Então, olhamos para os eventos emitidos por esse token fraudulento e vemos como podemos identificar que ele não é legítimo automaticamente.

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

Um dos usos mais comuns do Ethereum é a criação por um grupo de pessoas de um token negociável que, de certa forma, criam sua própria moeda. Entretanto, sempre onde há casos de uso legítimos que agregam valor, também haverá criminosos que tentam roubar esse valor.

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

Como eu sei que o wARB é uma fraude?

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 na documentação deles (opens in a new tab).

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

Normalmente, esperaríamos que pessoas que tentam enganar 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, os 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 facilita o seu entendimento.

Embora os implantadores de contratos possam escolher publicar 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 o mesmo bytecode exato, 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 projetados para uso a longo prazo permitem que alguns endereços privilegiados alterem esses endereços, por exemplo, para permitir o uso de um novo contrato multisig. Existem várias maneiras de fazer isso.

O contrato do 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).

1abstract contract Ownable is Context {
2 address private _owner;
3 .
4 .
5 .
6}

O contrato do token ARB (opens in a new tab) não tem um endereço privilegiado diretamente. No entanto, ele não precisa de um. Ele está por trá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.

1 /**
2 * @dev Armazena um novo endereço no slot de administrador do 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 }

Em contraste, o contrato wARB tem um contract_owner codificado permanentemente.

1contract WrappedArbitrum is Context, IERC20 {
2 .
3 .
4 .
5 address deployer = 0xB50721BCf8d664c30412Cfbc6cf7a15145234ad1;
6 address public contract_owner = 0xb40dE7b1beE84Ff2dc22B70a049A07A13a411A33;
7 .
8 .
9 .
10}
Exibir tudo

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 ele provavelmente foi projetado para uso a curto prazo por um indivíduo, em vez de uma solução de 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 (primeira transação (opens in a new tab) até a ú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 aconteçam usando uma função _transfer interna.

No wARB, esta função parece quase 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");
4
5 _beforeTokenTransfer(sender, recipient, amount);
6
7 _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 }
Exibir tudo

A parte suspeita é:

1 if (sender == contract_owner){
2 sender = deployer;
3 }
4 emit Transfer(sender, recipient, amount);

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

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

1 function transfer(address recipient, uint256 amount) public virtual override returns (bool) {
2 _f_(_msgSender(), recipient, amount);
3 return true;
4 }
5
6 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 }
Exibir tudo

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 função _f_ real

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");
4
5 _beforeTokenTransfer(sender, recipient, amount);
6
7 _balances[sender] = _balances[sender].sub(amount, "ERC20: transfer amount exceeds balance");
8 _balances[recipient] = _balances[recipient].add(amount);
9 if (sender == contract_owner){
10
11 sender = deployer;
12 }
13 emit Transfer(sender, recipient, amount);
14 }
Exibir tudo

Há duas bandeiras vermelhas em potencial nesta função.

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

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

A função de eventos falsos dropNewTokens

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

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

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

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

Essa restrição faz todo o sentido, porque não queremos que contas aleatórias distribuam tokens. No entanto, o resto da função é suspeito.

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

Uma função para transferir de uma conta de pool para uma matriz de receptores e 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 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. Ele 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 aplicações off-chain informando-as de uma transferência que não aconteceu de verdade.

A função de queima Approve

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 o Solidity descende do C, ele diferencia maiúsculas de minúsculas. "Approve" e "approve" são strings diferentes.

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

1 function Approve(
2 address[] memory holders)

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

1 public approver() {

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

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 }
10
Exibir tudo

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

Problemas de qualidade do código

Esses problemas de qualidade do código não provam que este código é uma fraude, mas o tornam 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), geralmente, a função que cria novos tokens é chamada de mint (opens in a new tab).

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

1 constructor () public {
2
3 _name = "Wrapped Arbitrum";
4 _symbol = "wARB";
5 _decimals = 18;
6 uint256 initialSupply = 1000000000000;
7
8 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 }
Exibir tudo

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

1 function mount(address account, uint256 amount) public {
2 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 apenas o proprietário tem permissão para cunhar ou algo parecido. Em vez disso, é o irrelevante ERC20: cunhar para o endereço zero. O teste correto para cunhagem para o endereço zero é require(account != address(0), "<mensagem de erro>"), que o contrato nunca se preocupa em verificar.

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

Existem mais dois fatos suspeitos, diretamente relacionados à cunhagem:

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

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

Por que tanto auth quanto approver? Por que o mod que não faz nada?

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

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

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

1 modifier auth() {
2 require(msg.sender == contract_owner, "Not allowed to interact");
3 _;
4 }
5
6 modifier approver() {
7 require(msg.sender == contract_owner, "Not allowed to interact");
8 _;
9 }
Exibir tudo

auth e approver fazem mais sentido, porque verificam se o contrato foi chamado pelo 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 precisamente a mesma coisa?

O que podemos detectar automaticamente?

Podemos ver que o 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 independentemente se um token é legítimo ou não.

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

Eventos Approval suspeitos

Eventos de Approval (opens in a new tab) devem ocorrer apenas com uma solicitação direta (em contraste com os eventos de Transfer (opens in a new tab), que podem ocorrer como resultado de uma permissão). Veja a documentação do Solidity (opens in a new tab) para uma explicação detalhada sobre este 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 o gasto 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 o 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:

1import {
2 Address,
3 TransactionReceipt,
4 createPublicClient,
5 http,
6 parseAbiItem,
7} from "viem"
8import { mainnet } from "viem/chains"

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

1import { config } from "dotenv"
2config()

Leia .env para obter o URL.

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

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

1const testedAddress = "0xb047c8032b99841713b8e3872f06cf32beb27b82"
2const fromBlock = 16859812n
3const toBlock = 16873372n

O endereço do contrato ERC-20 suspeito e os blocos dentro dos quais procuraremos por eventos. Os provedores de nó normalmente limitam nossa capacidade de ler eventos porque a largura de banda pode ficar cara. Felizmente, o wARB não foi usado por um período de dezoito horas, então podemos procurar por todos os eventos (havia apenas 13 no 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 é a maneira de solicitar 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.

1const isContract = async (addr: Address): boolean =>
2 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 se trata de um contrato e devemos simplesmente pulá-lo.

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

1const getEventTxn = async (ev: Event): TransactionReceipt =>
2 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.

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

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

1const 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.

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

Se o proprietário for um contrato, presuma que essa 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.

1const txn = await getEventTxn(ev)

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

1// A aprovação é suspeita se vier de um proprietário de EOA que não é o `from` da transação
2if (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 maiúsculas e minúsculas misturadas 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.

1// Também é suspeito se o destino da transação não for o contrato ERC-20 que estamos
2// investigando
3if (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.

1 // Se não houver motivo para suspeitar, retorne nulo.
2 return null
3}

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

1const testPromises = approvalEvents.map((ev) => suspiciousApprovalEvent(ev))
2const testResults = (await Promise.all(testPromises)).filter((x) => x != null)
3
4console.log(testResults)

Uma função async (opens in a new tab) retorna um objeto Promise. Com a sintaxe comum, await x(), esperamos que essa Promise seja cumprida antes de continuarmos o processamento. Isso é simples de programar e seguir, mas também é ineficiente. Enquanto esperamos que a Promise de um evento específico seja cumprida, 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, filter (opens in a new tab) esses 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 o wARB não tem este problema.

Conclusão

A detecção automatizada de fraudes ERC-20 sofre de falsos negativos (opens in a new tab), porque uma fraude 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 peças de DeFi, onde há muitos tokens e eles precisam ser tratados automaticamente. Mas como sempre caveat emptor (opens in a new tab), 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).

Última atualização da página: 25 de fevereiro de 2026

Este tutorial foi útil?