$1,000,000 in security audit grants are live now, Apply here →

← Resource Hub

External Call Vulnerabilities in Solidity: 4 Attacks

Updated 9 min read

Quick answer. Every untrusted external call in Solidity carries four risks: reentrancy, denial of service, gas griefing and unsafe return values. GMX V2 handles all four with a global reentrancy guard, try/catch, capped gas and no return data copying.

In our experience, external calls to untrusted addresses are the second most common cause of critical vulnerabilities, behind only oracle manipulation.

So external call vulnerabilities in Solidity deserve a fixed routine.

There are four things to examine every time you see an external call, and more often than not, one of them leads to a significant bug.

This guide covers all four, then walks through how GMX V2 addresses each one when it calls arbitrary, untrusted addresses.

1. Reentrancy

Most developers know reentrancy. Fewer know all the buckets it breaks into.

Classic reentrancy

The canonical case is a single function. An attacker calls function foo, foo makes an external call to the attacker's address, and the attacker's contract calls back into foo.

Depending on the logic in foo, re-entering the same function lets the attacker manipulate the system.

Cross-function reentrancy

This is close to the classic case. Instead of calling back into foo, the attacker's contract calls a different function, bar, in the same contract.

That can also have unintended consequences.

Cross-contract reentrancy

As DeFi systems grow more complex, one system becomes many contracts.

GMX V2 is a good example, with an exchange router, a deposit handler, a withdrawal handler and an order handler, all separate contracts in one system.

A user calls one contract in the system, and somewhere a callback reaches an arbitrary address. That arbitrary contract does not call back into the contract the user first called.

It calls into another contract in the same system.

If the first contract reads storage from that second one, and the attacker can change that storage mid-execution, that is another form of reentrancy.

It can lead to a critical vulnerability, and it is harder to protect against.

Read-only reentrancy

Read-only reentrancy is the most mystical of the four. It is not even within one system: the attacker enters a different system.

Say the victim protocol relies on data from another system, such as Curve. It reads something from Curve, like the price of a pool.

The attacker calls into the victim protocol, which at some point hands control to the attacker's address, through a callback or a native ether transfer.

Instead of re-entering the victim, the attacker calls into Curve.

Mid-execution, the attacker changes the data the victim reads. By manipulating what the dependency returns, the attacker can create a critical vulnerability.

Several of these issues have appeared as more DeFi protocols integrate with one another.

ERC-721 and ERC-777 reentrancy

A few more types come from token standards. An NFT safeTransfer makes an external call, and an ERC-777 token calls the sender or the receiver when you transfer it.

For worked examples of every type, see our guide to reentrancy attacks.

2. Denial of Service

For denial of service (DoS), the attacker looks for a way to make the transaction revert. If it reverts, nobody can use the system.

That can be critical when it causes a loss of funds. If you deposit and an attacker DoSes the withdrawal, every withdrawal reverts and the money you deposited is gone.

So for every external call, ask how it could make the transaction revert. Here are the main ways.

  • The callee reverts. An untrusted address can simply revert and make the external call fail.
  • A blacklist. Transferring a token like USDC to a blacklisted address reverts. This mostly hits liquidations, or anywhere a fee is sent to an address.
  • Ether the contract cannot accept. Sending ether to a contract with no payable receive function reverts.
  • A function that does not exist. Calling a missing function reverts if there is no fallback function, or if the fallback reverts.
  • Unexpected return data. If you decode the return data into a struct or a boolean, and the data is missing or badly formatted, the decoding itself reverts.

For more tokens that behave in surprising ways on transfer, see the weird ERC-20 tokens checklist.

3. Gas Griefing

Gas griefing is perhaps the most interesting of the four, and few people talk about it.

If an external call forwards all gas, you hand the gas money of whoever pays for the transaction to an arbitrary, untrusted address.

Strictly, the call forwards 63/64 of the remaining gas. The 63/64 rule keeps 1/64 back so the caller has something left to finish the transaction.

If the untrusted address burns everything it receives, you are left with that 1/64.

Sometimes it is not enough to finish the function, the transaction reverts out of gas, and gas griefing becomes a DoS.

Even when the transaction does go through, it hurts. The callee was configured by someone else, and it could be an infinite loop that soaks up all the gas.

Nobody wants to pay for a wild while (true) loop. It has a material economic effect on users and on the system, because every interaction becomes extremely expensive.

So ask: how much gas did we forward? Did we forward all of it, meaning 63/64? Can the untrusted address spend an abnormal amount?

4. Return Values

Return values carry three risks.

Unexpected return format

This is the same point as under DoS. If the callee returns a format you did not expect, or no data when you expected some, decoding can revert.

Return data gas griefing

This is another form of gas griefing, and it is especially dangerous because limiting the gas you forward does not stop it.

A high-level .call automatically loads all the returned bytes into memory, whether you want them or not. Allocating memory gets quadratically more expensive the more you add.

So you might cap an external call at a million gas, and the callee returns a huge string.

Loading it into memory costs gas outside the call's cap, and that cost can balloon.

Unexpected values

Beyond the format, you may expect a certain set of values or a range. But a call can return any bytes at all.

If you expect a number from 0 to 4, you could get 7 million.

Never assume an untrusted external call returns exactly what you expect.

How GMX V2 Makes Safe Callbacks

Even with all four risks, there is a way to safely call arbitrary, untrusted addresses. You just have to address all four. The GMX V2 codebase (gmx-synthetics) shows how.

Its callback utilities are where GMX V2 makes these arbitrary external calls. Take afterOrderExecution.

After an order executes, GMX V2 calls the order's callback contract, which the user who created the order configures.

It forwards a limited amount of gas, and the call is wrapped in a try/catch. Here is how that design covers each risk.

Reentrancy: a global guard

GMX V2 is as prone to reentrancy as any system, because it is made of many contracts.

An attacker could enter through the order handler, then use the callback to enter the deposit handler or the exchange router.

The order handler's executeOrder function, which kicks off the transaction that ends in the callback, uses a globalNonReentrant modifier.

A basic nonReentrant modifier stops classic and cross-function reentrancy. It only says you cannot re-enter the same contract, so it does nothing against cross-contract reentrancy.

The global guard does not set a flag in its own contract. It points to a separate data store contract and sets the reentrancy status for the entire protocol.

Enter any contract in the system through a guarded function, and the data store records that someone has entered.

Call another guarded function during the callback, and the guard asks the data store, gets a yes, and reverts. Reentrancy into the system as a whole is blocked.

Here is the idea in a simplified sketch, not GMX's code.

modifier globalNonReentrant() {
    require(dataStore.getUint(REENTRANCY_GUARD_STATUS) != ENTERED, "reentrant");
    dataStore.setUint(REENTRANCY_GUARD_STATUS, ENTERED);
    _;
    dataStore.setUint(REENTRANCY_GUARD_STATUS, NOT_ENTERED);
}

Could checks-effects-interactions do the job? GMX V2 has many contracts and many callbacks: after an order is cancelled, updated or executed, after a deposit executes, and more.

In a system that large, checks-effects-interactions is not always easy to support.

The global guard adds a layer on top of following checks-effects-interactions as closely as possible.

Denial of service: try/catch

The callback is wrapped in a try/catch. If it fails, the transaction keeps going: GMX V2 emits an event with the order's key and the order, then carries on.

A failed callback does not hold up the order.

That covers the DoS list. A straight revert or a blacklist revert is caught. A function that does not exist is caught.

There is no ether sent with this call, and no return data parsed.

try ICallbackReceiver(callbackContract).afterExecution{gas: callbackGasLimit}(key) {
} catch {
    emit AfterExecutionError(key);
}

Gas griefing: a capped gas limit

The call forwards only the order's callback gas limit, and that limit is capped by the protocol.

The cap is carefully monitored, so a callback can never push one order past the block gas limit and make the order impossible to execute.

The GMX keeper that executes orders also takes an execution fee up front, sized in proportion to the callback gas limit among other things.

That covers any expense the external call could cause.

Return values: skip the copy

afterOrderExecution has no return values to worry about. For a call where the return data is a risk, look at how GMX V2 sends native tokens.

The obvious version is a high-level call like payable(receiver).call{value: amount}(""), keeping only the bool success.

That blank after the comma does not help: the bytes of return data are still loaded into memory.

Against an untrusted address, that can be extremely costly.

So GMX V2 makes a low-level call in assembly, with the gas limit and an output size of zero, so no return data is loaded into memory.

function sendNative(address receiver, uint256 amount, uint256 gasLimit)
    internal
    returns (bool success)
{
    assembly {
        // out offset and out size are 0: no return data is copied
        success := call(gasLimit, receiver, amount, 0, 0, 0, 0)
    }
}

For the wider system, see GMX V2 explained.

External Call Checklist

Add these four questions to your checklist for every untrusted external call.

  1. Reentrancy: can the callee re-enter the same function, another function, another contract in the system, or a system this one reads from?
  2. Denial of service: can the call revert through a revert, a blacklist, rejected ether, a missing function or bad return data?
  3. Gas griefing: how much gas is forwarded, and what happens if the callee spends all of it?
  4. Return values: is the return data copied into memory, and does the code handle any format and any value?

How Guardian Helps

Guardian's smart contract audits send senior researchers after protocol logic, integrations and privileged flows, with exploit-driven review and proof-of-concept depth.

With Vanguard, two independent teams review the same scope and cross-check each other's findings.

Frequently Asked Questions

What are the main external call vulnerabilities in Solidity?

There are four: reentrancy (classic, cross-function, cross-contract and read-only), denial of service, gas griefing and unsafe return values. Check all four whenever a contract calls an untrusted address.

What is cross-contract reentrancy?

The attacker uses a callback to enter a different contract in the same system. A standard nonReentrant modifier guards only one contract, so GMX V2 keeps a global reentrancy status in a data store contract.

How does the 63/64 rule affect external calls?

A call forwards at most 63/64 of the remaining gas and keeps 1/64 for the caller. If an untrusted callee burns everything it gets, that 1/64 may not finish the function, and the transaction runs out of gas.

Why can return data cause gas griefing?

A high-level call copies all return data into memory, and memory costs grow quadratically. The call's gas limit does not cover that copy. An assembly call with an output size of zero avoids it.

Sources

Make your external calls safe with Guardian

Guardian researchers check every untrusted call, callback and integration before your code holds value.