Passer au contenu principal

Quelques astuces utilisées par les jetons frauduleux et comment les détecter

escroquerie
Solidity
erc-20
JavaScript
TypeScript
Intermédiaire
Ori Pomerantz
15 septembre 2023
16 minutes de lecture

Dans ce tutoriel, nous disséquons un jeton frauduleuxopens in a new tab pour voir certaines des astuces que les escrocs utilisent et comment ils les mettent en œuvre. À la fin de ce tutoriel, vous aurez une vue plus complète des contrats de jeton ERC-20, de leurs capacités, et de la raison pour laquelle le scepticisme est nécessaire. Ensuite, nous examinons les événements émis par ce jeton frauduleux et voyons comment nous pouvons identifier automatiquement qu'il n'est pas légitime.

Jetons frauduleux - que sont-ils, pourquoi les gens les créent-ils, et comment les éviter

Ethereum est couramment utilisé par des groupes pour créer des jetons échangeables ou, dans un certain sens, leur propre monnaie. Cependant, partout où il existe des cas d'utilisation légitimes qui apportent de la valeur, il y a aussi des criminels qui essaient de voler cette valeur à leur profit.

Vous pouvez en lire plus sur ce sujet ailleurs sur ethereum.org du point de vue de l'utilisateur. Ce tutoriel se concentre sur la dissection d'un jeton frauduleux pour voir comment cela est fait et comment il peut être détecté.

Comment savoir que wARB est une escroquerie ?

Le jeton que nous disséquons est wARBopens in a new tab, qui prétend être équivalent au jeton ARBopens in a new tab légitime.

Le moyen le plus simple de savoir quel est le jeton légitime est de regarder l'organisation d'origine, Arbitrumopens in a new tab. Les adresses légitimes sont spécifiées dans leur documentationopens in a new tab.

Pourquoi le code source est-il disponible ?

Normalement, on s'attendrait à ce que les gens qui essaient d'escroquer les autres soient secrets, et en effet beaucoup de jetons frauduleux n'ont pas leur code disponible (par exemple, celui-ciopens in a new tab et celui-làopens in a new tab).

Cependant, les jetons légitimes publient généralement leur code source, donc pour paraître légitimes, les auteurs de jetons frauduleux font parfois la même chose. wARBopens in a new tab est l'un de ces jetons dont le code source est disponible, ce qui facilite sa compréhension.

Alors que les déployeurs de contrats peuvent choisir de publier ou non le code source, ils ne peuvent pas publier le mauvais code source. L'explorateur de blocs compile le code source fourni de manière indépendante, et s'il n'obtient pas exactement le même bytecode, il rejette ce code source. Vous pouvez en savoir plus à ce sujet sur le site Etherscanopens in a new tab.

Comparaison avec les jetons ERC-20 légitimes

Nous allons comparer ce jeton à des jetons ERC-20 légitimes. Si vous ne savez pas comment les jetons ERC-20 légitimes sont généralement écrits, consultez ce tutoriel.

Constantes pour les adresses privilégiées

Les contrats ont parfois besoin d'adresses privilégiées. Les contrats qui sont conçus pour une utilisation à long terme permettent à une adresse privilégiée de changer ces adresses, par exemple pour permettre l'utilisation d'un nouveau contrat multisig. Il y a plusieurs façons de le faire.

Le contrat de jeton HOPopens in a new tab utilise le modèle Ownableopens in a new tab. L'adresse privilégiée est conservée dans le stockage, dans un champ appelé _owner (voir le troisième fichier, Ownable.sol).

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

Le contrat de jeton ARBopens in a new tab n'a pas directement d'adresse privilégiée. Cependant, il n'en a pas besoin. Il se trouve derrière un proxyopens in a new tab à l'adresse 0xb50721bcf8d664c30412cfbc6cf7a15145234ad1opens in a new tab. Ce contrat a une adresse privilégiée (voir le quatrième fichier, ERC1967Upgrade.sol) qui peut être utilisée pour les mises à niveau.

1 /**
2 * @dev Stocke une nouvelle adresse dans l'emplacement d'administration 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 }

En revanche, le contrat wARB a un contract_owner codé en dur.

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

Ce propriétaire de contratopens in a new tab n'est pas un contrat qui pourrait être contrôlé par différents comptes à différents moments, mais un compte externe. Cela signifie qu'il est probablement conçu pour une utilisation à court terme par un individu, plutôt que comme une solution à long terme pour contrôler un ERC-20 qui restera de valeur.

Et en effet, si nous regardons dans Etherscan, nous voyons que l'escroc n'a utilisé ce contrat que pendant 12 heures (de la première transactionopens in a new tab à la dernière transactionopens in a new tab) le 19 mai 2023.

La fausse fonction _transfer

Il est standard que les transferts réels se produisent en utilisant une fonction _transfer interne.

Dans wARB, cette fonction semble presque légitime :

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 }
Afficher tout

La partie suspecte est :

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

Si le propriétaire du contrat envoie des jetons, pourquoi l'événement Transfer montre-t-il qu'ils proviennent de deployer ?

Cependant, il y a un problème plus important. Qui appelle cette fonction _transfer ? Elle ne peut pas être appelée de l'extérieur, elle est marquée comme internal. Et le code que nous avons n'inclut aucun appel à _transfer. Clairement, il est ici comme un leurre.

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 }
Afficher tout

Lorsque nous examinons les fonctions qui sont appelées pour transférer des jetons, transfer et transferFrom, nous voyons qu'elles appellent une fonction complètement différente, _f_.

La vraie fonction _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");
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 }
Afficher tout

Il y a deux signaux d'alarme potentiels dans cette fonction.

  • L'utilisation du modificateur de fonctionopens in a new tab _mod_. Cependant, quand nous examinons le code source, nous voyons que _mod_ est en fait inoffensif.

    1modifier _mod_(address sender, address recipient, uint256 amount){
    2 _;
    3}
  • Le même problème que nous avons vu dans _transfer, qui est que lorsque contract_owner envoie des jetons, ils semblent provenir de deployer.

La fausse fonction d'événements dropNewTokens

Nous arrivons maintenant à quelque chose qui ressemble à une véritable escroquerie. J'ai un peu modifié la fonction pour la lisibilité, mais elle est fonctionnellement équivalente.

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

Cette fonction a le modificateur auth(), ce qui signifie qu'elle ne peut être appelée que par le propriétaire du contrat.

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

Cette restriction est parfaitement logique, car nous ne voudrions pas que des comptes aléatoires distribuent des jetons. Cependant, le reste de la fonction est suspect.

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

Une fonction pour transférer depuis un compte de pool vers un tableau de récepteurs un tableau de montants est parfaitement logique. Il existe de nombreux cas d'utilisation dans lesquels vous voudrez distribuer des jetons d'une source unique à plusieurs destinations, comme les paies, les airdrops, etc. Il est moins cher (en gaz) de le faire en une seule transaction plutôt que d'émettre plusieurs transactions, ou même d'appeler l'ERC-20 plusieurs fois à partir d'un contrat différent dans le cadre de la même transaction.

Cependant, dropNewTokens ne fait pas cela. Il émet des événements Transferopens in a new tab, mais ne transfère en réalité aucun jeton. Il n'y a aucune raison légitime de semer la confusion dans les applications hors chaîne en leur parlant d'un transfert qui n'a pas vraiment eu lieu.

La fonction Approve de burn

Les contrats ERC-20 sont censés avoir une fonction approve pour les allocations, et en effet notre jeton frauduleux a une telle fonction, et elle est même correcte. Cependant, comme Solidity est un dérivé du C, il est sensible à la casse. "Approve" et "approve" sont des chaînes de caractères différentes.

De plus, la fonctionnalité n'est pas liée à approve.

1 function Approve(
2 address[] memory holders)

Cette fonction est appelée avec un tableau d'adresses pour les détenteurs du jeton.

1 public approver() {

Le modificateur approver() s'assure que seul contract_owner est autorisé à appeler cette fonction (voir ci-dessous).

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
Afficher tout

Pour chaque adresse de détenteur, la fonction déplace l'intégralité du solde du détenteur vers l'adresse 0x00...01, le brûlant (« burn ») de fait (le burn réel dans la norme modifie également l'offre totale, et transfère les jetons vers 0x00...00). Cela signifie que contract_owner peut supprimer les actifs de n'importe quel utilisateur. Cela ne semble pas être une fonctionnalité que vous voudriez dans un jeton de gouvernance.

Problèmes de qualité du code

Ces problèmes de qualité du code ne prouvent pas que ce code est une escroquerie, mais ils le font paraître suspect. Les entreprises organisées telles qu'Arbitrum ne publient généralement pas de code d'aussi mauvaise qualité.

La fonction mount

Bien que cela ne soit pas spécifié dans la normeopens in a new tab, de manière générale, la fonction qui crée de nouveaux jetons est appelée mintopens in a new tab.

Si nous regardons dans le constructeur de wARB, nous voyons que la fonction de frappe a été renommée en mount pour une raison quelconque, et est appelée cinq fois avec un cinquième de l'offre initiale, au lieu d'une seule fois pour le montant total par souci d'efficacité.

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 }
Afficher tout

La fonction mount elle-même est également suspecte.

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

En regardant le require, nous voyons que seul le propriétaire du contrat est autorisé à frapper. C'est légitime. Mais le message d'erreur devrait être seul le propriétaire est autorisé à frapper ou quelque chose comme ça. Au lieu de cela, c'est l'inapproprié ERC20: mint to the zero address. Le test correct pour la frappe vers l'adresse nulle est require(account != address(0), "<message d'erreur>"), que le contrat ne prend jamais la peine de vérifier.

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

Il y a deux autres faits suspects, directement liés à la frappe :

  • Il y a un paramètre account, qui est vraisemblablement le compte qui devrait recevoir le montant frappé. Mais le solde qui augmente est en fait celui de contract_owner.

  • Alors que le solde augmenté appartient à contract_owner, l'événement émis montre un transfert vers account.

Pourquoi auth et approver à la fois ? Pourquoi le mod qui ne fait rien ?

Ce contrat contient trois modificateurs : _mod_, auth, et approver.

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

_mod_ prend trois paramètres et n'en fait rien. Pourquoi l'avoir ?

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 }
Afficher tout

auth et approver sont plus logiques, car ils vérifient que le contrat a été appelé par contract_owner. Nous nous attendrions à ce que certaines actions privilégiées, comme la frappe, soient limitées à ce compte. Cependant, quel est l'intérêt d'avoir deux fonctions distinctes qui font précisément la même chose ?

Que pouvons-nous détecter automatiquement ?

Nous pouvons voir que wARB est un jeton frauduleux en regardant sur Etherscan. Cependant, c'est une solution centralisée. En théorie, Etherscan pourrait être subverti ou piraté. Il est préférable de pouvoir déterminer indépendamment si un jeton est légitime ou non.

Il y a quelques astuces que nous pouvons utiliser pour identifier qu'un jeton ERC-20 est suspect (soit une escroquerie, soit très mal écrit), en regardant les événements qu'il émet.

Événements Approval suspects

Les événements Approvalopens in a new tab ne devraient se produire qu'avec une demande directe (contrairement aux événements Transferopens in a new tab qui peuvent se produire à la suite d'une allocation). Consultez la documentation de Solidityopens in a new tab pour une explication détaillée de ce problème et pourquoi les requêtes doivent être directes, plutôt que médiatisées par un contrat.

Cela signifie que les événements Approval qui approuvent les dépenses d'un compte externe doivent provenir de transactions qui proviennent de ce compte, et dont la destination est le contrat ERC-20. Toute autre type d'approbation d'un compte externe est suspect.

Voici un programme qui identifie ce type d'événementopens in a new tab, utilisant viemopens in a new tab et TypeScriptopens in a new tab, une variante de JavaScript avec une sécurité de type. Pour l'exécuter :

  1. Copiez .env.example dans .env.
  2. Modifiez .env pour fournir l'URL d'un nœud du réseau principal Ethereum.
  3. Exécutez pnpm install pour installer les paquets nécessaires.
  4. Exécutez pnpm susApproval pour rechercher les approbations suspectes.

Voici une explication ligne par ligne :

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

Importez les définitions de type, les fonctions, et la définition de la chaîne depuis viem.

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

Lisez .env pour obtenir l'URL.

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

Créez un client Viem. Nous n'avons besoin que de lire à partir de la blockchain, donc ce client n'a pas besoin d'une clé privée.

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

L'adresse du contrat ERC-20 suspect, et les blocs dans lesquels nous chercherons des événements. Les fournisseurs de nœuds limitent généralement notre capacité à lire les événements car la bande passante peut devenir coûteuse. Heureusement, wARB n'a pas été utilisé pendant une période de dix-huit heures, nous pouvons donc rechercher tous les événements (il n'y en avait que 13 au 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})

C'est la façon de demander à Viem des informations sur les événements. Lorsque nous lui fournissons la signature exacte de l'événement, y compris les noms de champs, il analyse l'événement pour nous.

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

Notre algorithme ne s'applique qu'aux comptes externes. Si un bytecode est retourné par client.getBytecode, cela signifie qu'il s'agit d'un contrat et que nous devrions simplement l'ignorer.

Si vous n'avez jamais utilisé TypeScript auparavant, la définition de la fonction peut sembler un peu bizarre. Nous ne lui disons pas seulement que le premier (et unique) paramètre s'appelle addr, mais aussi qu'il est de type Address. De même, la partie : boolean indique à TypeScript que la valeur de retour de la fonction est un booléen.

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

Cette fonction obtient le reçu de transaction d'un événement. Nous avons besoin du reçu pour nous assurer que nous connaissons la destination de la transaction.

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

C'est la fonction la plus importante, celle qui décide réellement si un événement est suspect ou non. Le type de retour, (Event | null), indique à TypeScript que cette fonction peut retourner soit un Event, soit null. Nous retournons null si l'événement n'est pas suspect.

1const owner = ev.args._owner

Viem a les noms de champs, il a donc analysé l'événement pour nous. _owner est le propriétaire des jetons à dépenser.

1// Les approbations par les contrats ne sont pas suspectes
2if (await isContract(owner)) return null

Si le propriétaire est un contrat, supposez que cette approbation n'est pas suspecte. Pour vérifier si l'approbation d'un contrat est suspecte ou non, nous devrons tracer l'exécution complète de la transaction pour voir si elle a atteint le contrat propriétaire, et si ce contrat a appelé directement le contrat ERC-20. C'est beaucoup plus coûteux en ressources que ce que nous aimerions faire.

1const txn = await getEventTxn(ev)

Si l'approbation provient d'un compte externe, obtenez la transaction qui l'a causée.

1// L'approbation est suspecte si elle provient d'un propriétaire EOA qui n'est pas le `from` de la transaction
2if (owner.toLowerCase() != txn.from.toLowerCase()) return ev

Nous ne pouvons pas simplement vérifier l'égalité des chaînes de caractères car les adresses sont hexadécimales, donc elles contiennent des lettres. Parfois, par exemple dans txn.from, ces lettres sont toutes en minuscules. Dans d'autres cas, comme ev.args._owner, l'adresse est en casse mixte pour l'identification d'erreursopens in a new tab.

Mais si la transaction ne provient pas du propriétaire, et que ce propriétaire est détenu par un externe, alors nous avons une transaction suspecte.

1// C'est aussi suspect si la destination de la transaction n'est pas le contrat ERC-20 que nous
2// examinons
3if (txn.to.toLowerCase() != testedAddress) return ev

De même, si l'adresse to de la transaction, le premier contrat appelé, n'est pas le contrat ERC-20 sous investigation, alors c'est suspect.

1 // S'il n'y a aucune raison d'être suspect, retourner null.
2 return null
3}

Si aucune des deux conditions n'est vraie, alors l'événement Approval n'est pas suspect.

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

Une fonction asyncopens in a new tab retourne un objet Promise. Avec la syntaxe courante, await x(), nous attendons que cette Promise soit remplie avant de continuer le traitement. C'est simple à programmer et à suivre, mais c'est aussi inefficace. Pendant que nous attendons que la Promise d'un événement spécifique soit remplie, nous pouvons déjà commencer à travailler sur l'événement suivant.

Ici, nous utilisons mapopens in a new tab pour créer un tableau d'objets Promise. Ensuite, nous utilisons Promise.allopens in a new tab pour attendre que toutes ces promesses soient résolues. Nous filtronsopens in a new tab ensuite ces résultats pour supprimer les événements non suspects.

Événements Transfer suspects

Une autre façon possible d'identifier les jetons frauduleux est de voir s'ils ont des transferts suspects. Par exemple, les transferts provenant de comptes qui n'ont pas autant de jetons. Vous pouvez voir comment implémenter ce testopens in a new tab, mais wARB n'a pas ce problème.

Conclusion

La détection automatisée des escroqueries ERC-20 souffre de faux négatifsopens in a new tab, car une escroquerie peut utiliser un contrat de jeton ERC-20 parfaitement normal qui ne représente simplement rien de réel. Vous devriez donc toujours essayer d'obtenir l'adresse du jeton d'une source de confiance.

La détection automatisée peut aider dans certains cas, comme pour les éléments de la DeFi, où il y a de nombreux jetons qui doivent être gérés automatiquement. Mais comme toujours caveat emptoropens in a new tab, faites vos propres recherches, et encouragez vos utilisateurs à faire de même.

Voir ici pour plus de mon travailopens in a new tab.

Dernière mise à jour de la page : 25 février 2026

Ce tutoriel vous a été utile ?