Summary
- Closing an account improperly creates an opportunity for reinitialization/revival attacks
- The Nexis Native Chain runtime garbage collects accounts when they are no longer rent exempt. Closing accounts involves transferring the lamports stored in the account for rent exemption to another account of your choosing.
- You can use the Anchor
#[account(close = <address_to_send_lamports>)]
constraint to securely close accounts and set the account discriminator to theCLOSED_ACCOUNT_DISCRIMINATOR
Lesson
While it sounds simple, closing accounts properly can be tricky. There are a number of ways an attacker could circumvent having the account closed if you don’t follow specific steps. To get a better understanding of these attack vectors, let’s explore each of these scenarios in depth.Insecure account closing
At its core, closing an account involves transferring its lamports to a separate account, thus triggering the Nexis Native Chain runtime to garbage collect the first account. This resets the owner from the owning program to the system program. Take a look at the example below. The instruction requires two accounts:account_to_close
- the account to be closeddestination
- the account that should receive the closed account’s lamports
destination
account’s lamports by the amount stored in the account_to_close
and setting the account_to_close
lamports to 0. With this program, after a
full transaction is processed, the account_to_close
will be garbage collected
by the runtime.
Secure account closing
The two most important things you can do to close this loophole are to zero out the account data and add an account discriminator that represents the account has been closed. You need both of these things to avoid unintended program behavior. An account with zeroed out data can still be used for some things, especially if it’s a PDA whose address derivation is used within the program for verification purposes. However, the damage may be potentially limited if the attacker can’t access the previously-stored data. To further secure the program, however, closed accounts should be given an account discriminator that designates it as “closed,” and all instructions should perform checks on all passed-in accounts that return an error if the account is marked closed. Look at the example below. This program transfers the lamports out of an account, zeroes out the account data, and sets an account discriminator in a single instruction in hopes of preventing a subsequent instruction from utilizing this account again before it has been garbage collected. Failing to do any one of these things would result in a security vulnerability.CLOSED_ACCOUNT_DISCRIMINATOR
.
This is simply an account discriminator where each byte is 255
. The
discriminator doesn’t have any inherent meaning, but if you couple it with
account validation checks that return errors any time an account with this
discriminator is passed to an instruction, you’ll stop your program from
unintentionally processing an instruction with a closed account.
Manual Force Defund
There is still one small issue. While the practice of zeroing out account data and adding a “closed” account discriminator will stop your program from being exploited, a user can still keep an account from being garbage collected by refunding the account’s lamports before the end of an instruction. This results in one or potentially many accounts existing in a limbo state where they cannot be used but also cannot be garbage collected. To handle this edge case, you may consider adding an instruction that will allow anyone to defund accounts tagged with the “closed” account discriminator. The only account validation this instruction would perform is to ensure that the account being defunded is marked as closed. It may look something like this:Use the Anchor close
constraint
Fortunately, Anchor makes all of this much simpler with the
#[account(close = <target_account>)]
constraint. This constraint handles
everything required to securely close an account:
- Transfers the account’s lamports to the given
<target_account>
- Zeroes out the account data
- Sets the account discriminator to the
CLOSED_ACCOUNT_DISCRIMINATOR
variant
force_defund
instruction is an optional addition that you’ll have to
implement on your own if you’d like to utilize it.
Lab
To clarify how an attacker might take advantage of a revival attack, let’s work with a simple lottery program that uses program account state to manage a user’s participation in the lottery.1. Setup
Start by getting the code on thestarter
branch from the
following repo.
The code has two instructions on the program and two tests in the tests
directory.
The program instructions are:
enter_lottery
redeem_rewards_insecure
enter_lottery
, the program will initialize an account to
store some state about the user’s lottery entry.
Since this is a simplified example rather than a fully-fledge lottery program,
once a user has entered the lottery they can call the redeem_rewards_insecure
instruction at any time. This instruction will mint the user an amount of Reward
tokens proportional to the amount of times the user has entered the lottery.
After minting the rewards, the program closes the user’s lottery entry.
Take a minute to familiarize yourself with the program code. The enter_lottery
instruction simply creates an account at a PDA mapped to the user and
initializes some state on it.
The redeem_rewards_insecure
instruction performs some account and data
validation, mints tokens to the given token account, then closes the lottery
account by removing its lamports.
However, notice the redeem_rewards_insecure
instruction only transfers out
the account’s lamports, leaving the account open to revival attacks.
2. Test Insecure Program
An attacker that successfully keeps their account from closing can then callredeem_rewards_insecure
multiple times, claiming more rewards than they are
owed.
Some starter tests have already been written that showcase this vulnerability.
Take a look at the closing-accounts.ts
file in the tests
directory. There is
some setup in the before
function, then a test that simply creates a new
lottery entry for attacker
.
Finally, there’s a test that demonstrates how an attacker can keep the account
alive even after claiming rewards and then claim rewards again. That test looks
like this:
- Calls
redeem_rewards_insecure
to redeem the user’s rewards - In the same transaction, adds an instruction to refund the user’s
lottery_entry
before it can actually be closed - Successfully repeats steps 1 and 2, redeeming rewards for a second time.
3. Create a redeem_rewards_secure
instruction
To prevent this from happening we’re going to create a new instruction that
closes the lottery account seucrely using the Anchor close
constraint. Feel
free to try this out on your own if you’d like.
The new account validation struct called RedeemWinningsSecure
should look like
this:
RedeemWinnings
account validation
struct, except there is an additional close = user
constraint on the
lottery_entry
account. This will tell Anchor to close the account by zeroing
out the data, transferring its lamports to the user
account, and setting the
account discriminator to the CLOSED_ACCOUNT_DISCRIMINATOR
. This last step is
what will prevent the account from being used again if the program has attempted
to close it already.
Then, we can create a mint_ctx
method on the new RedeemWinningsSecure
struct
to help with the minting CPI to the token program.
close
constraint in the account validation
struct, the attacker shouldn’t be able to call this instruction multiple times.
4. Test the Program
To test our new secure instruction, let’s create a new test that trys to callredeemingWinningsSecure
twice. We expect the second call to throw an error.
anchor test
to see that the test passes. The output will look something
like this:
force_defund
instruction so
far, but we could. If you’re feeling up for it, give it a try yourself!
The simplest and most secure way to close accounts is using Anchor’s close
constraint. If you ever need more custom behavior and can’t use this constraint,
make sure to replicate its functionality to ensure your program is secure.
If you want to take a look at the final solution code you can find it on the
solution
branch of
the same repository.