Unboxing ERC20 approve() issues

ERC20 Token standard is the most adapted standard in the Ethereum ecosystem. It is widely used and easy to use. Many users of Ethereum already know what ERC20 token does and what functions it does how.

But there is one function in the standard implementation that causes the most problems than the others, and not many are realising that.

ERC20's approve(spender, amount) main function is to approve any address to spend the tokens on behalf of a user. For example, Alice approves Bob to spend 20 Alice's Token from Alice's wallet. Bob's allowance is 20 Tokens, and he transfer tokens through transferFrom(sender, recipient, amount) function, which does exactly what the name suggests.

What if Alice decides to lower the allowance of the Bob to 10 tokens? Could Bob spend in total 30 Alice's tokens? Yes, here's why.

Approve front-running

Imagine this scenario:

  1. Alice allows Bob to transfer 20 of Alice's tokens by calling approve method on Token smart contract passing Bob's address and 20 as method arguments. approve("0xBob", 20);
  2. After some time, Alice decides to change from 20 to 10 the number of Alice's tokens Bob is allowed to transfer, so she calls approve method again, this time passing Bob's address and 10 as method arguments. approve("0xBob", 10);
  3. Bob notices Alice's second transaction before it was mined and quickly sends another transaction that calls transferFrom method to transfer 20 Alice's tokens to himself. transferFrom("0xAlice", "0xBob", 20);
  4. If Bob's transaction will be executed before Alice's transaction, Bob will successfully transfer 20 Alice's tokens and gain the ability to transfer another 10 tokens.
  5. Before Alice noticed that something went wrong, Bob calls transferFrom method again, this time to transfer 10 Alice's tokens. This results in Bob acquiring 30 Alice's tokens instead of only 10.

How could Bob send his transaction before Alice's transaction?

Ethereum mempool is the place where all pending transactions sit until miner decided to include them into the block. For the most time, transactions with the highest gas prices are included first as the miners get the gas price for including them into the block.

Some have found a way to exploit this miners behaviour. Front-running, works like this: Transaction A is broadcasted with a higher gas price than an already pending transaction B so that A gets mined before B.

If Bob monitors the mempool, he could submit his transaction with a higher gas cost so it would be executed before Alice's TX.

Attack is also possible because approve() overrides current allowance. It doesn't increase/decrease allowance.

Important note: described issue is in the ERC20 API itself, not any specific implementation.

How can we limit against that?

There are two approaches to solving the aforementioned problem.

First: Send one transaction setting allowance to 0, reseting the allowance for Bob. The second transaction is approve of the new desired amount.

The main problem of the above workaround is fact Alice would need to submit two transactions for something that should be an atomic operation. Gas costs are not always low. A simple operation like this could cost Alice quite a lot in gas costs.

Second: Use non-standard functions of ERC20. increaseAllowance() and decreaseAllowance(). These functions are part of standard OpenZeppelin ERC20 implementation most of the people are utilising anyway. The solution is to the problem is available to any ERC20 token implementation that uses OpenZeppelin library.

How does these functions help mitigate the issue? These non-standard functions are increasing and decreasing allowance as an atomic operation through simple trick. Let's see the implementation.

function increaseAllowance(address spender, uint256 addedValue) public virtual returns (bool) {
        _approve(_msgSender(), spender, _allowances[_msgSender()][spender].add(addedValue));
        return true;
    }

function decreaseAllowance(address spender, uint256 subtractedValue) public virtual returns (bool) {
        _approve(_msgSender(), spender, _allowances[_msgSender()][spender].sub(subtractedValue, "ERC20: decreased allowance below zero"));
        return true;
    }

Wait, both functions are still using the underlying _approve() function, so how does it help us? Good question and here's the answer.

Solution lies within the third parameter we send, the amount param. Instead of sending only the value we’re interested in, we’re sending over current allowance with desired change in value i.e.
_allowances[_msgSender()][spender].add(addedValue))
or
_allowances[_msgSender()][spender].sub(subtractedValue).

Ok, but how does it help us? Wouldn't just the attacker use the same attack method? Of course, he could, but it wouldn't have the same impact as the original attack.

  1. Alice allows Bob to transfer 20 of Alice's tokens by calling approve method on Token smart contract passing Bob's address and 20 as method arguments. approve("0xBob", 20);
  2. After some time, Alice decides to change from 20 to 10 the number of Alice's tokens Bob is allowed to transfer, so she calls decreaseAllowance() method, this time passing Bob's address and 10 as method arguments. decreaseAllowance("0xBob", 10);
  3. Bob notices Alice's second transaction before it was mined and quickly sends another transaction that calls transferFrom method to transfer 20 Alice's tokens to himself. transferFrom("0xAlice", "0xBob", 20);
  4. If Bob's transaction is executed before Alice's transaction, then Bob will successfully transfer 20 Alice's tokens, but Alice's transaction will revert due to sub() on the current allowance[0xBob][0xAlice], which is 0. Bob is only able to spend the original allowance.

The above scenario also shows the decreaseAllowance protects Alice against double-spending her tokens by Bob.

  1. Alice allows Bob to transfer 20 of Alice's tokens by calling approve method on Token smart contract passing Bob's address and 20 as method arguments. approve("0xBob", 20);
  2. After some time, Alice decides to change from 20 to 30 the number of Alice's tokens Bob is allowed to transfer, so she calls increaseAllowance() method, this time passing Bob's address and 10 as method arguments. increaseAllowance("0xBob", 10);
  3. Bob notices Alice's second transaction before it was mined and quickly sends another transaction that calls transferFrom method to transfer 20 Alice's tokens to himself. transferFrom("0xAlice", "0xBob", 20);
  4. If Bob's transaction is executed before Alice's transaction, then Bob will successfully transfer 20 Alice's tokens. Alice's transaction will pass, taking the current allowance of Bob (0) and increasing it by 10. So Bob is able to spend an additional 10.

In the above scenario, if all would be based on approve(), Bob would spend 50 instead of 30.

That was a lot, but I hope the explanation was simple enough.

Approve MAX uint on smart contracts.

For example, many protocols that deal with users' funds, like Uniswap,  ask users to approve a certain amount or max uint (2^256) on their contracts. This allows them to efficiently perform swaps of the user's tokens without asking users for approval before every transaction.

That's ok behavior with known, trusted, and verified contracts.

The issue is if we do the same for the protocol that just has launched or don't have all contracts verified on etherscan. This time we don't know what exactly is happening, and we can't check the code logic.

Let's take this scenario.

  1. Malicious AMM protocol asks Alice to approve its contracts for amount 2^256 of DAI.
  2. After a while, the contract's owner decides to do a rug pull.
  3. It steals all the money inside the pools.
  4. As the contract have allowance from Alice on DAI, an attacker could steal DAI funds from Alice's wallet.

Looks scary and not probable? Well, this exactly what happened today to StableMagnet. Malicious contract used unverified SwapUtil library which contained the attack functions. Apart from stealing all of the funds from the pools, attackers were also able to steal directly from user's wallets as they did approve max uint on the malicious contract. rekt.news did a great write-up of the rug-pull

Rekt - StableMagnet - REKT
DeFi / Crypto - A few hours before the attack, we received a message from an anonymous source suggesting that StableMagnet would rugpull. We couldn’t verify the claims, so our hands were tied.

Workaround?

Approve only the amount you want to spend on the protocol. You never know what lurks in the dark and if contracts can be trusted. This way, you "only" lose the amount that is already in the contract.

The current DeFi landscape contains a lot of predators. Be aware of them and always do your own diligence and read a lot before you start your journey with any new protocol.


Thanks for reading, and if you like my writing, you can subscribe to my blog to receive the daily newsletter as I'm currently in the middle of 100 days of blogging challenge. Subscription box below 👇

If the newsletter is not your thing, check out my Twitter @adrianhetman, where I post and share exciting news from the Blockchain world and security.

See you tomorrow!