Skip to main content

Some tricks used by scam tokens and how to detect them

scamsolidityerc-20javascripttypescript
Intermediate
Ori Pomerantz
September 15, 2023
15 minute read minute read

In this tutorial we dissect a scam token(opens in a new tab) to see some of the tricks that scammers play and how they implement them. By the end of the tutorial you will have a more comprehensive view of ERC-20 token contracts, their capabilities, and why skepticism is necessary. Then we look at the events emitted by that scam token and see how we can identify that it is not legitimate automatically.

Scam tokens - what are they, why do people do them, and how to avoid them

One of the most common uses for Ethereum is for a group to create a tradable token, in a sense their own currency. However, anywhere there are legitimate use cases that bring value, there are also criminals who try to steal that value for themselves.

You can read more about this subject elsewhere on ethereum.org from a user perspective. This tutorial focuses on dissecting a scam token to see how it's done and how it can be detected.

How do I know wARB is a scam?

The token we dissect is wARB(opens in a new tab), which pretends to be equivalent to the legitimate ARB token(opens in a new tab).

The easiest way to know which is the legitimate token is looking at the originating organization, Arbitrum(opens in a new tab). The legitimate addresses are specified in their documentation(opens in a new tab).

Why is the source code available?

Normally we'd expect people who try to scam others to be secretive, and indeed many scam tokens do not have their code available (for example, this one(opens in a new tab) and this one(opens in a new tab)).

However, legitimate tokens usually publish their source code, so to appear legitimate scam tokens' authors' sometimes do the same. wARB(opens in a new tab) is one of those tokens with source code available, which makes it easier to understand it.

While contract deployers can choose whether or not to publish the source code, they can't publish the wrong source code. The block explorer compiles the provided source code independently, and if doesn't get the exact same bytecode, it rejects that source code. You can read more about this on the Etherscan site(opens in a new tab).

Comparison to legitimate ERC-20 tokens

We are going to compare this token to legitimate ERC-20 tokens. If you are not familiar with how legitimate ERC-20 tokens are typically written, see this tutorial.

Constants for privileged addresses

Contracts sometimes need privileged addresses. Contracts that are designed for long term use allow some privileged address to change those addresses, for example to enable the use of a new multisig contract. There are several ways to do this.

The HOP token contract(opens in a new tab) uses the Ownable(opens in a new tab) pattern. The privileged address is kept in storage, in a field called _owner (see the third file, Ownable.sol).

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

The ARB token contract(opens in a new tab) does not have a privileged address directly. However, it does not need one. It sits behind a proxy(opens in a new tab) at address 0xb50721bcf8d664c30412cfbc6cf7a15145234ad1(opens in a new tab). That contract has a privileged address (see the fourth file, ERC1967Upgrade.sol) that be used for upgrades.

1 /**
2 * @dev Stores a new address in the EIP1967 admin slot.
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 }
Copy

In contrast, the wARB contract has a hard coded contract_owner.

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

This contract owner(opens in a new tab) is not a contract that could be controlled by different accounts at different times, but an externally owned account. This means that it is probably designed for short term use by an individual, rather than as a long term solution to control an ERC-20 that will remain valuable.

And indeed, if we look in Etherscan we see that the scammer only used this contract for only 12 hours (first transaction(opens in a new tab) to last transaction(opens in a new tab)) during May 19th, 2023.

The fake _transferfunction

It is standard to have actual transfers happen using an internal _transfer function.

In wARB this function looks almost legitimate:

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 }
Show all
Copy

The suspicious part is:

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

If the contract owner sends tokens, why does the Transfer event show they come from deployer?

However, there is a more important issue. Who calls this _transfer function? It can't be called from the outside, it is marked internal. And the code we have doesn't include any calls to _transfer. Clearly, it is here as a decoy.

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 }
Show all
Copy

When we look at the functions that are called to transfer tokens, transfer and transferFrom, we see that they call a completely different function, _f_.

The real _f_function

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 }
Show all
Copy

There are two potential red flags in this function.

  • The use of the function modifier(opens in a new tab) _mod_. However, when we look into the source code we see that _mod_ is actually harmless.

    1modifier _mod_(address sender, address recipient, uint256 amount){
    2 _;
    3}
    Copy
  • The same issue we saw in _transfer, which is when contract_owner sends tokens they appear to come from deployer.

The fake events function dropNewTokens

Now we come to something that looks like an actual scam. I edited the function a bit for readability, but it's functionally equivalent.

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

This function has the auth() modifier, which means it can only be called by the contract owner.

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

This restriction makes perfect sense, because we wouldn't want random accounts to distribute tokens. However, the rest of the function is suspicious.

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

A function to transfer from a pool account to an array of receivers an array of amounts makes perfect sense. There are many use cases in which you'll want to distribute tokens from a single source to multiple destinations, such as payroll, airdrops, etc. It is cheaper (in gas) to do in a single transaction instead of issuing multiple transactions, or even calling the ERC-20 multiple times from a different contract as part of the same transaction.

However, dropNewTokens doesn't do that. It emits Transfer events(opens in a new tab), but does not actually transfer any tokens. There is no legitimate reason to confuse offchain applications by telling them of a transfer that did not really happen.

The burning Approvefunction

ERC-20 contracts are supposed to have an approve function for allowances, and indeed our scam token has such a function, and it is even correct. However, because Solidity is descended from C it is case significant. "Approve" and "approve" are different strings.

Also, the functionality is not related to approve.

1 function Approve(
2 address[] memory holders)
Copy

This function is called with an array of addresses for holders of the token.

1 public approver() {
Copy

The approver() modifying makes sure only contract_owner is allowed to call this function (see below).

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
Show all
Copy

For every holder address the function moves the holder's entire balance to the address 0x00...01, effectively burning it (the actual burn in the standard also changes the total supply, and transfers the tokens to 0x00...00). This means that contract_owner can remove the assets of any user. That doesn't seem like a feature you'd want in a governance token.

Code quality issues

These code quality issues don't prove that this code is a scam, but they make it appear suspicious. Organized companies such as Arbitrum don't usually release code this bad.

The mountfunction

While it is not specified in the standard(opens in a new tab), generally speaking the function that creates new tokens is called mint(opens in a new tab).

If we look in the wARB constructor, we see the time mint function has been renamed to mount for some reason, and is called five times with a fifth of the initial supply, instead of once for the entire amount for efficiency.

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 }
Show all
Copy

The mount function itself is also suspicious.

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

Looking at the require, we see that only the contract owner is allowed to mint. That is legitimate. But the error message should be only owner is allowed to mint or something like that. Instead, it is the irrelevant ERC20: mint to the zero address. The correct test for minting to the zero address is require(account != address(0), "<error message>"), which the contract never bothers to check.

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

There are two more suspicious facts, directly related to minting:

  • There is an account parameter, which is presumably the account that should receive the minted amount. But the balance that increases is actually contract_owner's.

  • While the balance increased belongs to contract_owner, the event emitted shows a transfer to account.

Why both auth and approver? Why the modthat does nothing?

This contract contains three modifiers: _mod_, auth, and approver.

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

_mod_ takes three parameters and doesn't do anything with them. Why have it?

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 }
Show all
Copy

auth and approver make more sense, because they check that the contract was called by contract_owner. We'd expect certain privileged actions, such as minting, to be limited to that account. However, what is the point of having two separate functions that do precisely the same thing?

What can we detect automatically?

We can see that wARB is a scam token by looking at Etherscan. However, that is a centralized solution. In theory, Etherscan could be subverted or hacked. It is better to be able to figure out independently if a token is legitimate or not.

There are some tricks we can use to identify that an ERC-20 token is suspicious (either a scam or very badly written), by looking at the events they emit.

Suspicious Approvalevents

Approval events(opens in a new tab) should only happen with a direct request (in contrast to Transfer events(opens in a new tab) which can happen as a result of an allowance). See the Solidity docs(opens in a new tab) for a detailed explanation of this issue and why the requests need to be direct, rather than mediated by a contract.

This means that Approval events that approve spending from an externally owned account have to come from transactions that originate in that account, and whose destination is the ERC-20 contract. Any other kind of approval from an externally owned account is suspicious.

Here is a program that identifies this kind of event(opens in a new tab), using viem(opens in a new tab) and TypeScript(opens in a new tab), a JavaScript variant with type safety. To run it:

  1. Copy .env.example to .env.
  2. Edit .env to provide the URL to an Ethereum mainnet node.
  3. Run pnpm install to install the necessary packages.
  4. Run pnpm susApproval to look for suspicious approvals.

Here is a line by line explanation:

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

Import type definitions, functions, and the chain definition from viem.

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

Read .env to get the URL.

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

Create a Viem client. We only need to read from the blockchain, so this client does not need a private key.

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

The address of the suspicious ERC-20 contract, and the blocks within which we'll look for events. Node providers typically limit our ability to read events because the bandwidth can get expensive. Luckily wARB wasn't in use for an eighteen hour period, so we can look for all the events (there were only 13 in 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})

This is the way to ask Viem for event information. When we provide it with the exact event signature, including field names, it parses the event for us.

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

Our algorithm is only applicable to externally owned accounts. If there is any bytecode returned by client.getBytecode it means that this is a contract and we should just skip it.

If you haven't used TypeScript before, the function definition might look a bit weird. We don't just tell it the first (and only) parameter is called addr, but also that it is of type Address. Similarly, the : boolean part tells TypeScript that the return value of the function is a boolean.

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

This function gets the transaction receipt from an event. We need the receipt to ensure we know what was the transaction destination.

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

This is the most important function, the one that actually decides if an event is suspicious or not. The return type, (Event | null), tells TypeScript that this function can return either an Event or null. We return null if the event is not suspicious.

1const owner = ev.args._owner

Viem has the field names, so it parsed the event for us. _owner is the owner of the tokens to be spent.

1// Approvals by contracts are not suspicious
2if (await isContract(owner)) return null

If the owner is a contract, assume this approval is not suspicious. To check if a contract's approval is suspicious or not we'll need to trace the full execution of the transaction to see if it ever got to the owner contract, and if that contract called the ERC-20 contract directly. That is a lot more resource expensive than we'd like to do.

1const txn = await getEventTxn(ev)

If the approval comes from an externally owned account, get the transaction that caused it.

1// The approval is suspicious if it comes an EOA owner that isn't the transaction's `from`
2if (owner.toLowerCase() != txn.from.toLowerCase()) return ev

We can't just check for string equality because addresses are hexadecimal, so they contain letters. Sometimes, for example in txn.from, those letters are all lowercase. In other cases, such as ev.args._owner, the address is in mixed-case for error identification(opens in a new tab).

But if the transaction isn't from the owner, and that owner is externally owned, then we have a suspicious transaction.

1// It is also suspicious if the transaction destination isn't the ERC-20 contract we are
2// investigating
3if (txn.to.toLowerCase() != testedAddress) return ev

Similarly, if the transaction's to address, the first contract called, isn't the ERC-20 contract under investigation then it is suspicious.

1 // If there is no reason to be suspicious, return null.
2 return null
3}

If neither condition is true then the Approval event is not suspicious.

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

An async function(opens in a new tab) returns a Promise object. With the common syntax, await x(), we wait for that Promise to be fulfilled before we continue processing. This is simple to program and follow, but it is also inefficient. While we are waiting for the Promise for a specific event to be fulfilled we can already get working on the next event.

Here we use map(opens in a new tab) to create an array of Promise objects. Then we use Promise.all(opens in a new tab) to wait for all of those promises to the resolved. We then filter(opens in a new tab) those results to remove the non-suspicious events.

Suspicious Transferevents

Another possible way to identify scam tokens is to see if they have any suspicious transfers. For example, transfers from accounts that don't have that many tokens. You can see how to implement this test(opens in a new tab), but wARB doesn't have this issue.

Conclusion

Automated detection of ERC-20 scams suffers from false negatives(opens in a new tab), because a scam can use a perfectly normal ERC-20 token contract that just doesn't represent anything real. So you should always attempt to get the token address from a trusted source.

Automated detection can help in certain cases, such as DeFi pieces, where there are many tokens and they need to be handled automatically. But as always caveat emptor(opens in a new tab), do your own research, and encourage your users to do likewise.

Last edit: , September 25, 2023

Was this tutorial helpful?