Quelques astuces utilisées par les jetons frauduleux et comment les détecter
Dans ce tutoriel, nous décortiquons un jeton frauduleux (opens in a new tab) pour voir certaines des astuces utilisées par les escrocs et comment ils les mettent en œuvre. À la fin de ce tutoriel, vous aurez une vue plus complète des contrats de jetons ERC-20, de leurs capacités et des raisons pour lesquelles le scepticisme est nécessaire. Ensuite, nous examinerons les événements émis par ce jeton frauduleux et verrons comment nous pouvons identifier automatiquement qu'il n'est pas légitime.
Jetons frauduleux - ce qu'ils sont, pourquoi les gens les créent et comment les éviter
L'une des utilisations les plus courantes d'Ethereum est la création par un groupe d'un jeton négociable, en un sens leur propre monnaie. Cependant, partout où il y a des cas d'utilisation légitimes qui apportent de la valeur, il y a aussi des criminels qui essaient de voler cette valeur pour eux-mêmes.
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 il est conçu et comment il peut être détecté.
Comment savoir que wARB est une escroquerie ?
Le jeton que nous décortiquons est le wARB (opens in a new tab), qui prétend être équivalent au jeton ARB (opens in a new tab) légitime.
La façon la plus simple de savoir quel est le jeton légitime est de regarder l'organisation d'origine, Arbitrum (opens in a new tab). Les adresses légitimes sont spécifiées dans leur documentation (opens in a new tab).
Pourquoi le code source est-il disponible ?
Normalement, on s'attendrait à ce que les personnes qui essaient d'escroquer les autres soient secrètes, et en effet, de nombreux jetons frauduleux n'ont pas leur code disponible (par exemple, celui-ci (opens 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 de même. wARB (opens in a new tab) est l'un de ces jetons dont le code source est disponible, ce qui facilite sa compréhension.
Bien que les déployeurs de contrats puissent 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 lire plus à ce sujet sur le site d'Etherscan (opens 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 n'êtes pas familier avec la façon dont 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 conçus pour une utilisation à long terme permettent à une adresse privilégiée de modifier ces adresses, par exemple pour permettre l'utilisation d'un nouveau contrat multisig. Il existe plusieurs façons de le faire.
Le contrat de jeton HOP (opens in a new tab) utilise le modèle Ownable (opens 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).
abstract contract Ownable is Context {
address private _owner;
.
.
.
}
Le contrat de jeton ARB (opens in a new tab) n'a pas d'adresse privilégiée directement. Cependant, il n'en a pas besoin. Il se trouve derrière un proxy (opens in a new tab) à l'adresse 0xb50721bcf8d664c30412cfbc6cf7a15145234ad1 (opens 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.
/**
* @dev Stocke une nouvelle adresse dans l'emplacement d'administrateur EIP1967.
*/
function _setAdmin(address newAdmin) private {
require(newAdmin != address(0), "ERC1967: new admin is the zero address");
StorageSlot.getAddressSlot(_ADMIN_SLOT).value = newAdmin;
}
En revanche, le contrat wARB a un contract_owner codé en dur.
contract WrappedArbitrum is Context, IERC20 {
.
.
.
address deployer = 0xB50721BCf8d664c30412Cfbc6cf7a15145234ad1;
address public contract_owner = 0xb40dE7b1beE84Ff2dc22B70a049A07A13a411A33;
.
.
.
}
Ce propriétaire de contrat (opens in a new tab) n'est pas un contrat qui pourrait être contrôlé par différents comptes à différents moments, mais un compte détenu en 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 conservera sa 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 transaction (opens in a new tab) à la dernière transaction (opens in a new tab)) au cours du 19 mai 2023.
La fausse fonction _transfer
Il est courant que les transferts réels se produisent en utilisant une fonction interne _transfer.
Dans wARB, cette fonction semble presque légitime :
function _transfer(address sender, address recipient, uint256 amount) internal virtual{
require(sender != address(0), "ERC20: transfer from the zero address");
require(recipient != address(0), "ERC20: transfer to the zero address");
_beforeTokenTransfer(sender, recipient, amount);
_balances[sender] = _balances[sender].sub(amount, "ERC20: transfer amount exceeds balance");
_balances[recipient] = _balances[recipient].add(amount);
if (sender == contract_owner){
sender = deployer;
}
emit Transfer(sender, recipient, amount);
}
La partie suspecte est :
if (sender == contract_owner){
sender = deployer;
}
emit Transfer(sender, recipient, amount);
Si le propriétaire du contrat envoie des jetons, pourquoi l'événement Transfer indique-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 internal. Et le code que nous avons n'inclut aucun appel à _transfer. De toute évidence, elle est là comme leurre.
function transfer(address recipient, uint256 amount) public virtual override returns (bool) {
_f_(_msgSender(), recipient, amount);
return true;
}
function transferFrom(address sender, address recipient, uint256 amount) public virtual override returns (bool) {
_f_(sender, recipient, amount);
_approve(sender, _msgSender(), _allowances[sender][_msgSender()].sub(amount, "ERC20: transfer amount exceeds allowance"));
return true;
}
Lorsque nous regardons 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_
function _f_(address sender, address recipient, uint256 amount) internal _mod_(sender,recipient,amount) virtual {
require(sender != address(0), "ERC20: transfer from the zero address");
require(recipient != address(0), "ERC20: transfer to the zero address");
_beforeTokenTransfer(sender, recipient, amount);
_balances[sender] = _balances[sender].sub(amount, "ERC20: transfer amount exceeds balance");
_balances[recipient] = _balances[recipient].add(amount);
if (sender == contract_owner){
sender = deployer;
}
emit Transfer(sender, recipient, amount);
}
Il y a deux signaux d'alarme potentiels dans cette fonction.
-
L'utilisation du modificateur de fonction (opens in a new tab)
_mod_. Cependant, lorsque nous regardons dans le code source, nous voyons que_mod_est en fait inoffensif.modifier _mod_(address sender, address recipient, uint256 amount){ _; } -
Le même problème que nous avons vu dans
_transfer, à savoir que lorsquecontract_ownerenvoie des jetons, ils semblent provenir dedeployer.
La fausse fonction d'événements dropNewTokens
Nous en arrivons maintenant à quelque chose qui ressemble à une véritable escroquerie. J'ai un peu modifié la fonction pour des raisons de lisibilité, mais elle est fonctionnellement équivalente.
function dropNewTokens(address uPool,
address[] memory eReceiver,
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.
modifier auth() {
require(msg.sender == contract_owner, "Not allowed to interact");
_;
}
Cette restriction est tout à fait logique, car nous ne voudrions pas que des comptes aléatoires distribuent des jetons. Cependant, le reste de la fonction est suspect.
{
for (uint256 i = 0; i < eReceiver.length; i++) {
emit Transfer(uPool, eReceiver[i], eAmounts[i]);
}
}
Une fonction pour transférer d'un compte de pool vers un tableau de destinataires un tableau de montants est tout à fait logique. Il existe de nombreux cas d'utilisation dans lesquels vous voudrez distribuer des jetons d'une source unique vers plusieurs destinations, comme la paie, les airdrops, etc. Il est moins cher (en gaz) de le faire en une seule transaction au lieu 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. Elle émet des événements Transfer (opens 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 signalant un transfert qui n'a pas vraiment eu lieu.
La fonction Approve pour brûler
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 descend 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.
function Approve(
address[] memory holders)
Cette fonction est appelée avec un tableau d'adresses pour les détenteurs du jeton.
public approver() {
Le modificateur approver() s'assure que seul contract_owner est autorisé à appeler cette fonction (voir ci-dessous).
for (uint256 i = 0; i < holders.length; i++) {
uint256 amount = _balances[holders[i]];
_beforeTokenTransfer(holders[i], 0x0000000000000000000000000000000000000001, amount);
_balances[holders[i]] = _balances[holders[i]].sub(amount,
"ERC20: burn amount exceeds balance");
_balances[0x0000000000000000000000000000000000000001] =
_balances[0x0000000000000000000000000000000000000001].add(amount);
}
}
Pour chaque adresse de détenteur, la fonction déplace le solde entier du détenteur vers l'adresse 0x00...01, le brûlant effectivement (le véritable burn 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 rendent suspect. Les entreprises organisées telles qu'Arbitrum ne publient généralement pas de code aussi mauvais.
La fonction mount
Bien que ce ne soit pas spécifié dans la norme (opens in a new tab), en règle générale, la fonction qui crée de nouveaux jetons est appelée mint.
Si nous regardons dans le constructeur 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é.
constructor () public {
_name = "Wrapped Arbitrum";
_symbol = "wARB";
_decimals = 18;
uint256 initialSupply = 1000000000000;
mount(deployer, initialSupply*(10**18)/5);
mount(deployer, initialSupply*(10**18)/5);
mount(deployer, initialSupply*(10**18)/5);
mount(deployer, initialSupply*(10**18)/5);
mount(deployer, initialSupply*(10**18)/5);
}
La fonction mount elle-même est également suspecte.
function mount(address account, uint256 amount) public {
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 only owner is allowed to mint ou quelque chose de similaire. Au lieu de cela, il s'agit du message non pertinent ERC20: mint to the zero address. Le test correct pour la frappe vers l'adresse zéro est require(account != address(0), "<error message>"), que le contrat ne prend jamais la peine de vérifier.
_totalSupply = _totalSupply.add(amount);
_balances[contract_owner] = _balances[contract_owner].add(amount);
emit Transfer(address(0), account, amount);
}
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 decontract_owner. -
Bien que le solde augmenté appartienne à
contract_owner, l'événement émis montre un transfert versaccount.
Pourquoi à la fois auth et approver ? Pourquoi le mod qui ne fait rien ?
Ce contrat contient trois modificateurs : _mod_, auth et approver.
modifier _mod_(address sender, address recipient, uint256 amount){
_;
}
_mod_ prend trois paramètres et n'en fait rien. Pourquoi l'avoir ?
modifier auth() {
require(msg.sender == contract_owner, "Not allowed to interact");
_;
}
modifier approver() {
require(msg.sender == contract_owner, "Not allowed to interact");
_;
}
auth et approver sont plus logiques, car ils vérifient que le contrat a été appelé par contract_owner. On s'attendrait à ce que certaines actions privilégiées, telles que la frappe, soient limitées à ce compte. Cependant, quel est l'intérêt d'avoir deux fonctions distinctes qui font exactement la même chose ?
Que pouvons-nous détecter automatiquement ?
Nous pouvons voir que wARB est un jeton frauduleux en regardant sur Etherscan. Cependant, il s'agit d'une solution centralisée. En théorie, Etherscan pourrait être subverti ou piraté. Il est préférable de pouvoir déterminer de manière indépendante si un jeton est légitime ou non.
Il existe quelques astuces que nous pouvons utiliser pour identifier qu'un jeton ERC-20 est suspect (soit une escroquerie, soit très mal écrit), en examinant les événements qu'il émet.
Événements Approval suspects
Les événements Approval (opens in a new tab) ne devraient se produire qu'avec une requête directe (contrairement aux événements Transfer (opens in a new tab) qui peuvent se produire à la suite d'une allocation). Consultez la documentation de Solidity (opens 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édiées par un contrat.
Cela signifie que les événements Approval qui approuvent les dépenses à partir d'un compte détenu en externe doivent provenir de transactions qui ont pour origine ce compte, et dont la destination est le contrat ERC-20. Tout autre type d'approbation provenant d'un compte détenu en externe est suspect.
Voici un programme qui identifie ce type d'événement (opens in a new tab), en utilisant Viem (opens in a new tab) et TypeScript (opens in a new tab), une variante de JavaScript avec sécurité de typage. Pour l'exécuter :
- Copiez
.env.examplevers.env. - Modifiez
.envpour fournir l'URL vers un nœud du réseau principal Ethereum. - Exécutez
pnpm installpour installer les paquets nécessaires. - Exécutez
pnpm susApprovalpour rechercher les approbations suspectes.
Voici une explication ligne par ligne :
import {
Address,
TransactionReceipt,
createPublicClient,
http,
parseAbiItem,
} from "viem"
import { mainnet } from "viem/chains"
Importez les définitions de type, les fonctions et la définition de la chaîne depuis viem.
import { config } from "dotenv"
config()
Lisez .env pour obtenir l'URL.
const client = createPublicClient({
chain: mainnet,
transport: http(process.env.URL),
})
Créez un client Viem. Nous n'avons besoin que de lire depuis la chaîne de blocs, ce client n'a donc pas besoin d'une clé privée.
const testedAddress = "0xb047c8032b99841713b8e3872f06cf32beb27b82"
const fromBlock = 16859812n
const 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).
const approvalEvents = await client.getLogs({
address: testedAddress,
fromBlock,
toBlock,
event: parseAbiItem(
"event Approval(address indexed _owner, address indexed _spender, uint256 _value)"
),
})
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.
const isContract = async (addr: Address): boolean =>
await client.getBytecode({ address: addr })
Notre algorithme n'est applicable qu'aux comptes détenus en externe. S'il y a un bytecode renvoyé par client.getBytecode, cela signifie qu'il s'agit d'un contrat et nous devrions simplement l'ignorer.
Si vous n'avez jamais utilisé TypeScript auparavant, la définition de la fonction peut sembler un peu étrange. 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.
const getEventTxn = async (ev: Event): TransactionReceipt =>
await client.getTransactionReceipt({ hash: ev.transactionHash })
Cette fonction obtient le reçu de transaction à partir d'un événement. Nous avons besoin du reçu pour nous assurer de connaître la destination de la transaction.
const 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 renvoyer soit un Event soit null. Nous renvoyons null si l'événement n'est pas suspect.
const 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.
// Les approbations par des contrats ne sont pas suspectes
if (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 est jamais parvenue au contrat propriétaire, et si ce contrat a appelé le contrat ERC-20 directement. C'est beaucoup plus coûteux en ressources que ce que nous aimerions faire.
const txn = await getEventTxn(ev)
Si l'approbation provient d'un compte détenu en externe, obtenez la transaction qui l'a causée.
// L'approbation est suspecte si elle provient d'un propriétaire d'EOA qui n'est pas le `from` de la transaction
if (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, elles contiennent donc 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 des erreurs (opens in a new tab).
Mais si la transaction ne provient pas du propriétaire, et que ce propriétaire est détenu en externe, alors nous avons une transaction suspecte.
// C'est également suspect si la destination de la transaction n'est pas le contrat ERC-20 que nous
// examinons
if (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 en cours d'investigation, alors elle est suspecte.
// S'il n'y a aucune raison d'avoir des soupçons, renvoyer null.
return null
}
Si aucune des conditions n'est vraie, alors l'événement Approval n'est pas suspect.
const testPromises = approvalEvents.map((ev) => suspiciousApprovalEvent(ev))
const testResults = (await Promise.all(testPromises)).filter((x) => x != null)
console.log(testResults)
Une fonction async (opens in a new tab) renvoie un objet Promise. Avec la syntaxe courante, await x(), nous attendons que cette Promise soit remplie avant de poursuivre le traitement. C'est simple à programmer et à suivre, mais c'est aussi inefficace. Pendant que nous attendons que la Promise pour un événement spécifique soit remplie, nous pouvons déjà commencer à travailler sur l'événement suivant.
Ici, nous utilisons map (opens in a new tab) pour créer un tableau d'objets Promise. Ensuite, nous utilisons Promise.all (opens in a new tab) pour attendre que toutes ces promesses soient résolues. Nous utilisons ensuite filter (opens in a new tab) sur 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, des transferts depuis des comptes qui n'ont pas autant de jetons. Vous pouvez voir comment mettre en œuvre ce test (opens 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égatifs (opens in a new tab), car une escroquerie peut utiliser un contrat de jeton ERC-20 parfaitement normal qui ne représente tout simplement rien de réel. Vous devriez donc toujours essayer d'obtenir l'adresse du jeton à partir d'une source de confiance.
La détection automatisée peut aider dans certains cas, comme les éléments de la DeFi, où il y a de nombreux jetons et où ils doivent être gérés automatiquement. Mais comme toujours, la prudence est de mise (opens in a new tab), faites vos propres recherches et encouragez vos utilisateurs à faire de même.
Voir ici pour plus de mon travail (opens in a new tab).
Dernière mise à jour de la page : 3 avril 2026