Analysis of the Vulnerability Found in the vaults.sx Smart Contract

Our analysis on the EOS SX vault attack in May 2021.

EOS Costa Rica
8 min readJun 1, 2021

On Friday, May 14th, it was a warm and nice morning in Costa Rica, when this message was posted in the EOS Nation’s Telegram group:

We are investigating an attack on the vault. The majority of the EOS and USDT in the vault have been stolen.❗️SX Vault attackDO NOT DEPOSIT in vaultWe will update EOSX so as to stop people from depositing further ASAP.We'll provide a complete-post mortem as soon as we complete our investigation.

Apparently, an attacker had dried the SX vault, exploiting a vulnerability in its smart contracts. Later, more details were provided in the same Telegram group:

EOS Nation is offering a 100,000 USDT bounty to the white hat hacker who identified the re-entry attack exploit on the flash.sx smart contract.The reward will be transferred to the account of your choice once the 1,180,142.5653 EOS and 461,796.8968 USDT are returned to the flash.sx account.

It seems like EOS Nation was trying to convince the attacker to return back the stolen funds, by offering a reward. The attacker EOS account was also revealed:

https://www.bloks.io/account/potghpfcmocs

Even though there is already a nice analysis of the exploitation method used by the attacker, we decided to carry out our own analysis in order to learn from the bug and understand how to avoid it in the future. Also, our intention is to give many more details involved that were not mentioned, that we had a difficult time understanding. Therefore, the reader may get deeper insights into it. Besides, this kind of important attack needs a view and analysis from different fresh eyes, to be able to improve the involved system's security.

The victim smart contract can be found here: vaults.sx. There, “Users can send EOS tokens to vaults.sx to receive SXEOS tokens. Also, “Users can send SXEOS tokens to vaults.sx to receive back their EOS + any interest accumulated during the time period holding the SXEOS asset.” The other contract used in the attack was flash.sx where users “Borrow any amount of liquidity instantly for near-zero fees & no collateral”.

We used the Dfuse explorer to track the attacker’s activities. Since EOS, like many of the most popular blockchain networks, is public, then all activities such as transactions and action calls are publicly visible. This helps a lot to carry out forensic analysis. Following, we give details about what we could deduce from the attacker’s moves and the exploited vulnerabilities:

  • The attacker potghpfcmocs deposits a certain token, such as USDT and receives back the alternative token SXUSDT, all business as usual.
# Just an example
potghpfcmocs → vaults.sx 2 USDT
vaults.sx → flash.sx 2 USDT
token.sx issued 20 SXUSDT to vaults.sx
vaults.sx → potghpfcmocs 20 SXUSDT

This set of actions are carried out inside vaults.sx, in the on_transfer function. This function monitors incoming transaction in all the involved tokens: [[eosio::on_notify("*::transfer")]]. The deposit part of the action is processed in the first conditional:

// deposit - handle issuance (ex: EOS => SXEOS)
if ( deposit_itr != _vault.end() ) {
  • Now that the attacker has the alternative token funds, she can transfer them to the vaults.sx contract to obtain the original token, which in theory would have collected interests over time, but the attacker does it all immediately:
potghpfcmocs → vaults.sx 10 SXUSDT

However, this transfer is not directly processed by vaults.sx. It first goes to the transfer actions of the custom token.sx that manages SX... funds. This section of code adds both the attacker and vaults.sx as accounts to be notified when the transfer actions are completed:

require_recipient( from );
require_recipient( to );

The above code section gives back the attacker account the execution flow control before the recipient smart contract can make any state update. This is where the vulnerability can be exploited! In EOSIO, notifications (invoked by require_recipient) send a copy of the current transaction to the involved accounts. Recipient accounts can then make some processing based on such notifications. That’s what happens in on_transfer of the vaults.sx contract. However, the invoked functions can also invoke actions in other contracts named inline actions. The order in which notifications and inline actions are called in EOSIO is the following:

All notifications and their inner processing are executed first. Then if the functions that manage the notifications invoke inline actions, they are executed after all the notifications in a depth-first order i.e. all inline actions of the first notified account will be executed first, then the second’s, and so on.

To understand the implications of this to the current vulnerability, first, let’s take a look at what the normal execution flow would look like inside vaults.sx. The on_transfer action would receive the notification, but in this case, the withdrawal part of the conditional would be followed:

// withdraw - handle retire (ex: SXEOS => EOS)
} else if ( supply_itr != _vault_by_supply.end() ) {

In this section of the conditional, an important operation for the exploitation is executed:

const extended_asset out = calculate_retire( id, quantity );extended_asset sx::vaults::calculate_retire( const symbol_code id, const asset payment )
...
const int64_t S0 = vault.deposit.quantity.amount;
const int64_t R0 = vault.supply.quantity.amount;
const int64_t p = (uint128_t(payment.amount) * S0) / R0;
return { p, vault.deposit.get_extended_symbol() };

The amount of funds that the user can withdraw is proportional to the current amount of deposit of the specific token saved on the vault. After this, the calculated amount is transferred to the user and it is subtracted from the available deposit:

// update internal deposit & supply
_vault_by_supply.modify( supply_itr, get_self(), [&]( auto& row ) {
row.deposit -= out;
row.supply.quantity -= quantity;
row.last_updated = current_time_point();

Finally, the calculated funds would be returned to the user:

transfer( account, get_self(), out, get_self().to_string() );
// send underlying assets to sender
transfer( get_self(), from, out, get_self().to_string() );

All great, but wait a moment! Before, we said that if the notified function called inline actions (such as a transfer), it would have to wait until all the inline actions of the first notified (the attacker) had finished their execution. This means that at this point, vaults.sx has changed its internal state like it had transferred back the fund to the user, but actually, the transfer hasn’t occurred yet, and neither the funds have been collected from flash.sx. The attacker, the first notified, takes advantage of this inconsistency by invoking the borrow action of the flash.sx contract:

flash.sx - borrow 0.0001
flash.sx → potghpfcmocs 0.0001 USDT
potghpfcmocs → flash.sx 0.0002 USDT
vaults.sx - update id: USDT

This happens here. flash.sx lends a certain amount of funds to the user and checks that the user has returned the same or higher amount before the borrow action ends. There is no direct exploitation in this action and everything goes as expected. However, the interesting part comes when the action calls the update action in the vaults.sx contract:

// get balance from account
const asset balance = eosio::token::get_balance( contract, account, sym.code() );
...
// update balance
_vault.modify( vault, get_self(), [&]( auto& row ) {
row.deposit.quantity = balance + staked;
row.staked.quantity = staked;
row.last_updated = current_time_point();
});

The vaults.sx contract is updating the internal state balance based on the current flash.sx balance that has not been reduced yet!. After the borrow action ends, the control is still on the attacked side, so she sends another SXUSDT transfer to vaults.sx, this time without interrupting the normal flow. If we take a look again of calculate_retire in vaults.sx:

const int64_t S0 = vault.deposit.quantity.amount;
const int64_t R0 = vault.supply.quantity.amount;
const int64_t p = (uint128_t(payment.amount) * S0) / R0;

Therefore, we have a situation with an inflated deposit (given the incorrect state update) and a supply that has not been reduced accordingly. As the execution flows go on:

// Just an example
potghpfcmocs → vaults.sx 10 SXUSDT
vaults.sx → potghpfcmocs 2 USDT

The attacker withdraws an inflated amount, given the incorrect update. Hence she receives the profits of the attack! After all the inline actions of the attacker contract end their execution, the control is given back to the second notified (vaults.sx), and the first redeem is finalized:

// Just an example
token.sx - retire quantity: 10 SXUSDT
vaults.sx → potghpfcmocs 1 USDT
token.sx - retire quantity: 10 SXUSDT

This time, since the withdrawal was calculated before the attack with a normal state, the amount is what she would have redeemed normally. In the end, the internal state is updated with the proper values.

Since this same attack can be performed automatically thousands of times in a matter of seconds, the attacker managed to steal 1,180,142.5653 EOS and 461,796.8968 USDT. However, some hours after the incident this message was posted in EOS Nation Telegram group:

Block producers reached consensus to uphold the intent of code.Approximately 1.2M EOS and 462,000 USDT was stolen in a re-entry attack exploit on the flash.sx flash loan smart contract that began on May 14 at 11:28 UTC.The vaults.sx and flash.sx smart contracts were open-source, MSIGed, and passed security audits, however the re-entry exploit was not identified.All of the funds are safe under control of eosio.prods and will be returned to depositors.

Therefore, if all the funds and the possible accounts created were tracked successfully, the attacker was left with nothing of the stolen funds. However, this is an extreme measure that the Block Producers would like to avoid, and it must not be carried out on every security incident in the EOS blockchain.

A good example of a nice trace sequence of the attack can be observed here. Although this has been named as a re-entry attack exploit, we would argue that this is a different scenario. In a typical re-entry vulnerability, a contract would make a transfer to the attacker, before actually updating the internal state of the contract. The attacker then would recursively call the same action. What we have here is actually the opposite. Maybe that’s the reason the security audits failed to catch the vaults.sx bug. The problem here seems more related to the application managing the internal state in two different contracts that call each other, and the EOSIO execution flow that can interrupt the contract in the middle of an internal state update among the two involved contracts.

To avoid this kind of bug in the future, the developers should take care that a flow interruption doesn’t interfere in the middle of a state update that an attacker can control. Maybe managing state updates in a single contract and within single atomic action would be a good solution.

A good set of EOSIO secure development practices can be found here.

— Written by Andrés Gómez, cybersecurity developer at EOS Costa Rica.

EOS Costa Rica operates since 2018. Our team develops blockchain-based solutions with great attention to detail in creating user-centered dapps for enterprise use. We also provide resources to the EOSIO infrastructure and promote the local EOSIO ecosystem. Let’s talk about how you can implement this technology into your organization.

Follow us on social media:

--

--

EOS Costa Rica

EOS Block Producer candidate in the heart of the Americas. We stand for liberty and equality. Mainnet BP: costaricaeos · https://t.me/eoscr