External calls are dangerous

This is an attempt at outlining an exhaustive list of external call footguns in Solana programs with some Solidity counterparts for people that come from that world.

A common class of bugs in both Solana programs and in EVM smart contracts is not doing external calls properly. From DOSing your system to reentrancy to assuming an external call succeeded when it didn’t… There are a lot of mistakes to make when doing external calls.

In an effort to make this listicle as comprehensive as possible, I’ve asked 0xhuy0512 (50+ private security audits with 0xMacro, Spearbit, Zenith, Pashov, and Adevar) to review it. Rest assured, this is as close to exhaustive as I could have delivered myself.

For easy reference, unlike most of my articles, I also have a table of contents for you:

Table of Contents

If you don’t know how it can fail, you don’t know enough

1. Native transfers can fail

In the context of the EVM, calling a transfer of native tokens (i.e.: ETH) to an arbitrary address provided by a user can result in a malicious user passing in a contract that has implemented a forever failing receive() function. This will cause the transfer to always fail and as such the path in your system that attempts the transfer to this address will also always fail.

In the context of the SVM, this too can happen for native SOL transfers. I won’t do OtterSec the injustice of reproducing their article about how SOL transfers can fail. They can fail due to rent exemption rules, legacy executable accounts and reserved accounts. You can mitigate these to various degrees as explained in the linked article, but the point is a malicious user can pass arbitrary addresses that they know to fill these conditions.

If you implement a pull pattern for your transfers, the transfer failing is usually is not an issue. A pull pattern is when a user has to separately withdraw whatever funds/rewards they have by calling a function that only withdraws for themselves and nobody else. If the transfer fails, it only fails for them.

The opposite is a push pattern where your system makes transfers to multiple users (potentially in the same transaction). A malicious user, in this case, could pass in an address that they know will fail to receive SOL and break the whole transaction. This is usually called a DOS attack in web3.

Note that just because you are not transferring to multiple users in the same transaction doesn’t mean you can’t be DOSed or you aren’t using a push pattern. I’ve seen it where each transfer is done in a separate transaction but the system tracks transfers with a strict internal nonce and if transaction 3 fails perpetually it won’t let the system move on to transaction 4 and so on.

Aside from a “simple” SOL transfer, do you know what else can fail?

2. Creating an ATA can fail

An ATA is exactly what the name says: an associated token account.

Usually you’d want to have your Solana program create an ATA for your program or its accounts when creating them. You might even want to create an ATA for a user to ensure they can receive the SPL tokens you want to send.

Creating an ATA with the create instruction seems fine at first sight, but it can fail if someone else has created the ATA before. And the truth is that it can be created by anyone whenever. I could create an ATA for any SPL token for you right now, if I knew your address.

In this case the mitigation is to use the create_associated_token_account_idempotent which creates an ATA if it doesn't exist and fails if it exists but with a different owner. You can find an example of this in Zenith’s GMX audit of Jan/Feb 2025 at H-6 on page 18.

The root problem tho is what I said earlier: if you don’t know how it can fail, you don’t know enough.

We’ve looked at the DOS subclass of external call bugs, now let’s talk about…

3. Unhandled failures

Here’s an EVM example of “gas griefing” where an attacker gives just enough gas for the main function to work but external calls within it to fail. If you’d have just assumed the call succeeded but never checked and updated other state of your smart contract based on that assumption… well, that would obviously be bad. Imagine a function where you change internal accounting of funds based on that assumption. Ouch.

In Solana programs we don’t have “gas griefing”, but nonetheless external calls can fail due to various reasons. If they panic and stop the whole transaction, that’s great because a reverted transaction in Solana reverts absolutely all changes within it. But if they don’t do that you’re in for some trouble. This takes us back to: if you don’t know how it can fail, you don’t know enough.

I think I’ve said that phrase one too many times by now, so let’s move on to…

Possibly the most known Solidity bug in the world

(And yet it still happens in production, what is wrong with you people?!?)

4. Reentrancy is really just an external call bug too

When you make an ETH transfer to an arbitrary address (or even any custom data calls), if that address is a smart contract it can take control of the execution flow through functions like receive().

Earlier I’ve told you how an attacker can DOS you with this, but they can also re-enter your contract. They basically call a function of your contract again. Depending on your implementation, this could be bad.

Imagine a withdrawal function where your contract:

  1. checks if the user has ETH in your vault

  2. transfers the ETH

  3. updates the user’s balance in the contract

A malicious user can take over the execution flow at the transfer point, call the withdrawal function again and receive another transfer because the balance hasn’t been updated yet. Here’s an example by Cyfrin.

The same thing can happen in Solana and has happened before, but it tends to not be as bad because the Solana runtime restricts CPIs to a maximum depth of four.

I.e.: an attacker in EVM could loop and re-enter as many times as they need to drain your entire contract, in SVM they could only do it 4 times per transaction. Still bad, but better.

The way to mitigate this is to employ a CEI (checks-effects-interactions) pattern and potentially use reentrancy guards. CEI = all checks and state changes happen before doing external calls. A reentrancy guard is similar to a mutex lock if you’re familiar, otherwise do look at the Cyfrin example above.

The above example is relatively simple tho…

5. Reentrancy can get pretty complex

One case is when the attacker re-enters through a function different than the initial one. That’s hopefully straightforward to imagine so I’ll focus on a more interesting type of reentrancy.

There are cases called “read-only reentrancy” which I think could also often be named “third-party reentrancy”.

The core idea behind this type of reentrancy is that the attacker uses it to make read-only functions return stale data which in turn can be used to damage or steal a(nother) system somehow.

You can see an example of this here, but let me describe the execution flow of the hack nonetheless:

  1. Hacker adds liquidity to the pool that’s used for prices by the vulnerable contract increasing the pool.get_virtual_price() price

  2. Hacker stakes money in the vulnerable contract

  3. Hacker removes liquidity from pool which triggers receive() call

  4. Hacker calls getReward() within receive() which operates on the old (higher) price before the hacker removed the liquidity

  5. Hacker receives their rewards and the liquidity withdrawal and the price decreases back to normal

This example wasn’t necessarily useful for Solana security, but I think it laid the ground well for a somewhat similar bug, in the sense of operating on stale data, and that is…

6. Account reloading

You can see a simple example here, but what you need to understand is that in the Solana runtime accounts are loaded once at the beginning of your function/instruction and they are not automatically reloaded (not even by Anchor) later if you make a CPI.

If the CPI you made changes any data in those accounts and you keep reading from them, you will read stale data. You have to manually reload them with .reload() if you want their data to be fresh.

One last way to get external calls wrong in Solana

7. Say hello to my little friend: “arbitrary CPI”

Yes, that was a Scarface reference. Also you can see an official solana.com explanation of this bug here.

I think I could also throw this in the “incomplete validation” class of bugs (a future article maybe), but let’s do it here since it is, after all, an external call.

The core idea of this bug is that somehow in your logic you’ve let the user pass in an arbitrary unvalidated program as the target of the CPI. The program that is called could be malicious in this case.

If you use Anchor, it usually validates this for you. For example, if you have a token_program: Program<'info, anchor_spl::token::Token> and then make a call to it, Anchor validates that the account has the program ID of Token.

The problem comes when, as it often happens with Solana validation bugs, you use unvalidated account types like AccountInfo<'info> (equivalent to UncheckedAccount<‘info>). Anchor doesn’t validate these at all in any way so it’s on you to validate that this has the correct program ID.

Did I stretch the definition of “external call bugs” too far?

I don’t think so. Ultimately all of these stem from how you understand & integrate external calls.

I hope you are now better armed to work with them and hopefully there will be less bugs to find in my next audit.

P.S.: If you have a Solana protocol and need an audit, DM me for a solo audit or Guardian Audits for a team audit where I’ll likely also be a part of it.

P.P.S.: Totally unrelated, but I’m now wearing glasses. If you haven’t ever been at an eye control, please go. You’ve no idea what you might be missing out on! I now see a world of details I never realized existed.