Solana NFT Theft via Metaplex Auction Persistent Sale Agreement Exploit.



On July 7th, 2022, I found and reported a security flaw in the Metaplex Auction House Program that allowed an attacker to trick a victim into selling their NFT at a fraction of it's currently auctioned value, effectively stealing the NFT.
This is one in a series of Metaplex exploits I discovered.
The Exploit
Let's say Alice is the attacker, and Bob is victim. There is a widely used NFT Marketplace that's 100% legitimate and we'll call it the Acme Auction House. Bob has an NFT, and Alice is going to trick Bob into selling his NFT for way less than it's worth on the Acme Auction House.
Alice carries out the attack like so:
- Alice gets Bob to sell his NFT on Acme with a crafted transaction that looks 100% legitimate even to an expert Solana programmer.
- Alice buys Bob's token on the Acme website for Bob's list price of $10.
- Alice gives or sells back the NFT to Bob.
- Time passes, and Bob's NFT is now worth $100,000, and puts the token back up for auction on Acme.
- Alice buys Bob's token but only pays $10, essentially stealing $99,990 from Bob.
That seems rather involved and requires Alice to risk $10 in hopes that Bob's NFT will become valuable someday. Let's see if we can do it with zero capital risk on Alice's part.
Alice creates a website advertising: “Pump your NFT auction history on the exchange! Add simulated auctions to show demand for your NFT!”
“Get rewards for adding to our transaction history! We are building out an auto auction service and we need a history of sales with high value NFTS!”
Alice starts giving transactions for Bob to submit to create his fake auction history.
Each transaction is a single multisig transaction that will contain these instructions for the Acme Auction House. Here, this will create and execute an auction where Bob's NFT sells to Alice for $10.
<Bob Auction:Sell for $10 on Acme>
<Alice Auction::Buy for $10 on Acme>
<Alice Auction::ExecuteSale on Acme>
<Alice TokenProgram::Transfer NFT to Bob>
<Bob SystemProgram::Transfer $10 to Alice>
<Alice SystemProgram::Transfer 5000 lamports to the indecipherable seller_trade_state address>
(The dollars above are actually SOL currency, and not real dollars but that doesn't matter.)
Nothing about this looks fishy really to Bob. It's a fully self-contained set of instructions that requires both of their signatures. All the instructions have to execute successfully or none of them do, so there's no chance of it partially completing and him losing his NFT. Simulating the transaction in his wallet, Bob sees that he ends up with his NFT returned to him, and that his SOL balance has not changed. The NFT has not been tampered with. All the instructions in the transaction were trusted. All the addresses were trusted. The only thing that may be weird is that Alice transferred 5000 lamports to an address, but that address is owned by the Auction House Program so its not really fishy. That happens all the time in Solana transactions and it's to pay for renting space on the blockchain.
Bob executes a bunch of these over the next few days. He creates an auction history going from $10 up to $80,000. A lot of people do this, and so NFT's in the set are selling for thousands now. Okay, Bob's happy and now goes on the Acme exchange and sells his token for $100,000 after pumping up the auction history.
Alice submits a transaction to Acme, and steal Bob's NFT by only paying $10. She now sells it quickly for $50,000 or holds on to it forever. She is quite happy with the results. Bob is not. He now only has $10 and no NFT.
The Auction House Security Vulnerability
See Metaplex overview here.
The Metaplex Auction House is a Solana blockchain program / smart contract that allows users to sell their NFT's via a particular auction house. The program allows a seller to list their NFT for sale, and someone to buy or offer a different price for that token. The auction house then takes a percentage of the sale.
When a seller wishes to put their item up for sale, the seller creates a trade state account. Unlike most Solana accounts, it doesn't really contain any data about the auction. Instead, the details of the auction listing are smashed together to create the Program Derived Address of this account to represent a unique offering of a token. The buyer trade state address is created the same way, but represents what the buyer is willing to purchase. So for any particular offer of sale or offer to buy, there is an account with an address that captures that unique proposition. When the program actually executes the sale of a token, all it checks for is that there is a buyer trade state and seller trade state that exist with the same identifiers, and the same buy and sell price. If that's the case, the program will execute the swap.
- Seller trade state account key:
[seller_token_account_key, auction_house_key, token_account_key, auction_house_treasury_mint, token_mint_key, selling_price, number_of_tokens_being_sold]
- Buyer trade state account key:
[buyer_token_account_key, auction_house_key, token_account_key, auction_house_treasury_mint, token_mint_key, buyer_price, number_of_tokens_willing_to_by]
The "data" inside the trade state accounts are just a single byte that is the PDA bump of the trade state account. This is already weird, but the Auction House Program also overloads what a "bump" means. They decided that a bump of zero means something special. It means that the trade state is no longer valid and it is should be a closed trade state. (Don't do this folks. It's confusing and prone to error.)
To see if the both the buyer and seller have both agreed to a sale, there should be a trade state account for the buyer showing they are willing to buy an NFT at a certain price and there should be a trade state for the seller showing they are willing to sell their NFT at a price.
The flaw is in the execute_sale
function.
At the beginning of the function, the buyer bump is checked to see if it is zero and whether the buyer trade state account is open. However, the seller trade state bump is unfortunately not checked. It is only checked to see if the account is still open. Well, there is the security hole.
if ts_bump == 0 || buyer_ts_data.len() == 0 || seller_ts_data.len() == 0 {
return Err(ErrorCode::BothPartiesNeedToAgreeToSale.into());
}
If we can somehow figure out how to keep the seller trade state open after executing a sale then whenever the seller sells the same NFT again, that seller will always have an open sell order on the auction house for the same price, and anyone will be able to buy it from them at that price if they know what they're doing.
In execute_sale
The only canonical bump that is checked is for the buyer trade state, here.
For the seller trade state bump, seller trade state is in the accounts definition, here
Here in the execute_sale
function, all trade state accounts are attempted to be closed.
let curr_seller_lamp = seller_trade_state.lamports();
**seller_trade_state.lamports.borrow_mut() = 0;
sol_memset(&mut *seller_ts_data, 0, TRADE_STATE_SIZE);
The accounts are zeroed out via mem_set
and lamport's set to zero in order to attempt to close these accounts. This is an attempt to ensure that a bid and sale cannot be reused.
Attack Attack Attack
Let's try to defeat both of those checks.
Vulnerability: Insecure Closing of Accounts.
The first part of the attack is that an execute_sale
instruction can be combined with a simple transfer of SOL to any account and therefore cover that account’s rent. Remember that innocent looking transfer of 5000 lamports to the trade state account? This is that. In doing so these trade state accounts stay open despite the intentions of the program.
The Solana garbage collector will not run until the entire transaction has completed, and when it does it will see that the seller trade state account still has enough rent to stay open. We now have the program in an unintended state. We could have done this with any account that was attempted be closed and is a basic exploit that is common in Solana programs.
This means the buyer now has control of whether the seller_trade_state account is still open after sale execution.
Vulnerability: Improper bump usage.
It's a bad idea to use a bump as an overloaded flag and passing bumps as arguments.
When putting an item up for sale, the trade state bump is passed as an instruction argument. This can be any bump we want as long as it results in a valid off curve PDA. Remember, the number zero is a valid bump for statistically 50% of all PDAs. That means, if someone unwittingly passes a zero to the Sell
instruction, the bump could be accepted as valid and stored in the seller_trade_state.
There appear to be no checks in the sell function to ensure the seller bump is canonical. See here. This means we can put an item up for sale with any bump we want as long as it's a valid PDA. To a seller, a Sell
instruction with a zero bump will in no way be detectable as fishy as verifying all the accounts involved look legitimate.
The combination of above means that a seller who unwittingly creates a first sale with a trade_state_bump
of 0 can be exploited by a buyer to always accept a bid and sale of a previous auction if the seller ever reacquires the same token and puts it up for auction.
All an attacker needs to do is to get the seller to sign a transaction with a trade state bump of zero with all other parameters set to things that are legitimate and safe.
The Bad Transaction
// seller trade_state_bump is set to 0
<Bob Auction:Sell for $10 on Acme>
<Alice Auction::Buy for $10 on Acme>
<Alice Auction::ExecuteSale on Acme>
<Alice TokenProgram::Transfer NFT to Bob>
<Bob SystemProgram::Transfer $10 to Alice>
//We keep the seller trade state account open
<Alice SystemProgram::Transfer 5000 lamports to the indecipherable seller_trade_state address>
Lessons
- Use fully defined Rust structs with real fields and names for your bumps.
- Always enforce canonical bumps.
- Don't overload concepts in your code.
- Never use the account open/closed state as a decision point in your logic.
- Test your code.