Skip to content

Approval Vulnerabilities

Approvals are a fundamental component of tokens on Ethereum. They grant a third-party address, either an externally owned account (EOA) or another smart contract, permission to move funds on the owner's behalf. NFT marketplaces and DeFi applications rely on the mechanism to automate transfers once a trade completes or once business logic conditions are met.

Unlimited Approvals

A prevalent misstep is requiring unlimited approvals for certain assets, usually justified by the system's lack of knowledge of the amount needed at a given time. Once granted, an approval is invocable at any point in the future. An attacker who compromises the smart contract system exploits those approvals and drains every previously approved asset for the affected address.

Approval Frontrunning

The approval system is also susceptible to frontrunning. Submitting multiple approve calls opens a window of opportunity for malicious actors:

  1. Using the approve function, a user allows a smart contract system to transfer x of their ERC20 tokens.
  2. Later, they opt to modify the allowance to y and thus send another approve request.
  3. In the meantime, before the user-given transaction gets included, an attacker initiates the transferFrom function to remove x tokens from the user's wallet.
  4. If the attacker's transaction is processed first, followed by the user's new approve transaction, the malicious actor can move an additional y tokens.
  5. The aggregate unauthorized transfer amounts to x+y tokens.

The vulnerability arises because the ERC20 _approve function sets the spender's allowance directly:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
    function _approve(address owner, address spender, uint256 value, bool emitEvent) internal virtual {
        if (owner == address(0)) {
            revert ERC20InvalidApprover(address(0));
        }
        if (spender == address(0)) {
            revert ERC20InvalidSpender(address(0));
        }
        // @audit the new value overwrites the old one, ignoring what was already spent
        _allowances[owner][spender] = value;
        if (emitEvent) {
            emit Approval(owner, spender, value);
        }
    }

The _spendAllowance function verifies only the current allowance against the amount specified in the transferFrom call. It tracks no funds transferred before the latest approve request:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
    function _spendAllowance(address owner, address spender, uint256 value) internal virtual {
        uint256 currentAllowance = allowance(owner, spender);
        if (currentAllowance != type(uint256).max) {
            if (currentAllowance < value) {
                revert ERC20InsufficientAllowance(spender, currentAllowance, value);
            }
            unchecked {
                _approve(owner, spender, currentAllowance - value, false);
            }
        }
    }

Mitigations

The straightforward countermeasure against the frontrunning race is to change how the allowance is managed. Instead of setting new values through direct approve calls, the safeIncreaseAllowance and safeDecreaseAllowance functions from OpenZeppelin's SafeERC20 implementation take a value difference rather than an absolute amount, and internally call the target's approve function.

A second threat, though not a smart contract vulnerability in the strict sense, is approval phishing. Where a frontend is exposed to content injection or other manipulation, attackers modify the underlying code to divert approvals to an address of their own rather than to the intended smart contract.

An allowance system that caps an approval at the immediately necessary amount limits the blast radius of both. Two-step transfers and locking periods reduce the impact of malicious actions further, at the cost of user-friendliness. On the user side, periodic review of granted approvals and revocation of outdated ones closes the exposure, and platforms such as revoke.cash exist for that purpose.