NEO is one of the most promising blockchain networks today. Currently, NEO is focused on creating a quality smart contract platform. The main goal of the NEO team is to create a โsmart economyโ by digitizing real-world assets and enabling the use of smart contract technology. But just like other blockchain projects, NEO has its flaws and security issues.
In this article, we research one of the recently found security problems in NEO โ the NEP-5 storage injection vulnerability. We look closer at the vulnerable function and ways we can prevent attackers from executing this vulnerability.
Contents
NEO smart contracts
NEO is a non-profit community-based blockchain project that was launched back in 2014 as the AntShares blockchain. In the middle of 2017, AntShares was rebranded and received a new name โ NEO.
Two of the most interesting features of the NEO blockchain are:
- the proof of stake consensus algorithm thatโs implemented using delegated Byzantine fault tolerance;
- fast confirmation of transactions with up to 1000 transactions per second and block times of 15 to 20 seconds.
You can read more about NEO in the projectโs white paper.
Another unique feature of NEO is that smart contracts can be written in C#, Python, Java, and Kotlin, with planned support for GO, C/C++, and JavaScript. This means that in order to write smart contracts, developers donโt need to learn a completely new language, as is the case with the Ethereum blockchain and Solidity. Instead, developers can use tools and development environments they already know and feel comfortable working with.
Smart contracts are executed on the NEO Virtual Machine (NeoVM), a virtual machine similar to the Java Virtual Machine (JVM) and .Net Common Language Runtime (CLR). NeoVM runs bytecode compiled from a high-level programming language. You can visit NEOโs website to learn more about NeoVM.
Now letโs look at a recently discovered vulnerability in NEO smart contracts.
Discovering the NEP-5 vulnerability
In May 2018, Red4Sec conducted a security audit of the NEO network and several of its smart contracts. What they discovered was a vulnerability caused by NEP-5 tokens on the NEO blockchain. However, the vulnerability was never officially disclosed. The only information that official statements provide is that the issue isnโt critical and that the NEO team remains โin unified commitment to protect the NEO ecosystem from potential security threats.โ
Since the issue is minor and concerns mostly third-party developers of smart contracts, it should have been publicly explained in greater detail. Instead, the NEO Global Development team has contacted the developers of potentially affected projects and these developers have quickly and discreetly patched any problems that could possibly be caused by the vulnerability. Some of these developers have released their own updates on the vulnerability discovered in NEO tokens, but these updates also donโt give us many details on the problem.
So letโs try to figure out the real cause of the NEO NEP-5 tokenโs vulnerability on our own.
NEP-5 vulnerability in detail
In order to avoid similar mistakes and to understand the flaw that all of the affected smart contracts had, we need to investigate NEOโs NEP-5 vulnerability ourselves. The only report that provides any information about the vulnerability was posted by the DeepBrain Chain project. In that report, DeepBrain Chain experts describe how the discovered storage injection vulnerability allows anyone to change the tokenโs total supply limit by transferring their own tokens to an unspecified address. And this is the key to finding the problem in NEO smart contracts.
The total supply value is usually stored within a smart contractโs storage. In the NEO blockchain, a smart contractโs persistent storage is an array of key-value pairs (a map) thatโs written onto the blockchain.
The main problem is that the length of the key thatโs used to access the data isnโt fixed, so we can use different identifiers to store different data. For example, the string totalSupply can be used to store the total supply just like an address of an account can be used to store the accountโs balance. On the one hand, this ensures flexibility of the blockchain. But on the other hand, if the input parameters arenโt validated properly, this flexibility leads to the security issue that was discovered by Red4Sec.
The most common source of this problem for NEP-5 tokens was likely in the transfer function. Hereโs a scheme of the basic functionality of this function:
- Receive three input parameters: from, to, and amount
- Check if the address in the from parameter is the same as the address of the sender by calling CheckWitness (we donโt want other people transferring our tokens)
- Check if the user has enough tokens to transfer (if the amount parameter is less than the balance of the from parameter)
- Retrieve balances of the from and to addresses from storage
- Decrease the balance of the from parameter and increase the balance of the to parameter by the specified amount
- Write the updated balances of both addresses back to storage
If the function is implemented this way, thereโs nothing to stop us from passing the string totalSupply (or any other inappropriate value) as the to parameter. In this case, the function will execute correctly, updating the value stored under the totalSupply key, which is the actual total supply value.
Hereโs what an implementation of a vulnerable contract might look like:
using Neo.SmartContract.Framework;
using Neo.SmartContract.Framework.Services.Neo;
using Neo.SmartContract.Framework.Services.System;
using System;
using System.ComponentModel;
using System.Numerics;
namespace Neo.SmartContract
{
public class MyIco : Framework.SmartContract
{
// Unrelated things like token settings, events, some functions, and most of the ICO parameters are omitted from this sample for clarity.
// They would be identical to the current ICO_template at GitHub.
private const ulong pre_ico_cap = 100000000; // Initial total amount value. This value is constant and is stored within the contract code, not in persistent storage.
public static Object Main(string operation, params object[] args)
{
// Some unrelated parts of this function are omitted as well.
if (Runtime.Trigger == TriggerType.Application)
{
// ...
if (operation == "deploy") return Deploy(); // Called to initialize the contract and start the ICO
if (operation == "totalSupply") return TotalSupply(); // Returns the total supply value from storage
if (operation == "transfer") // Transfers tokens between accounts
{
if (args.Length != 3) return false; // 1. Receives three input parameters: "from", "to", and "amount"
byte[] from = (byte[])args[0];
byte[] to = (byte[])args[1];
BigInteger value = (BigInteger)args[2];
return Transfer(from, to, value); // Then calls the transfer function itself
}
// ...
}
// ...
return false;
}
public static bool Deploy()
{
byte[] total_supply = Storage.Get(Storage.CurrentContext, "totalSupply");
if (total_supply.Length != 0) return false; // If the total supply has been initialized, then deploy must have been called already, so skip this invocation.
Storage.Put(Storage.CurrentContext, "totalSupply", pre_ico_cap); // Stores the total supply value in the contractโs storage. This value isnโt supposed to change.
return true;
}
public static BigInteger TotalSupply()
{
return Storage.Get(Storage.CurrentContext, "totalSupply").AsBigInteger(); // Retrieves total supply from the storage and returns the value.
}
public static bool Transfer(byte[] from, byte[] to, BigInteger value) // If "totalSupply" is passed instead of an address as the "to" parameter,
// then the value of total supply will be updated just like any other account balance.
{
if (value <= 0) return false;
if (!Runtime.CheckWitness(from)) return false; // 2. Checks if the address in the "from" parameter is the same as the address of the sender by calling CheckWitness.
// To resolve the vulnerability, add this condition here: if (to.Length != 20) return false;
BigInteger from_value = Storage.Get(Storage.CurrentContext, from).AsBigInteger();
if (from_value < value) return false; // 3. Checks if the user has enough tokens to transfer (if amount is less than the balance of "from")
BigInteger to_value = Storage.Get(Storage.CurrentContext, to).AsBigInteger(); // 4. Retrieves balances of the "from" and "to" addresses from storage ("from" was retrieved earlier in this function).
if (from_value == value)
Storage.Delete(Storage.CurrentContext, from);
else
Storage.Put(Storage.CurrentContext, from, from_value - value); // 5. Decreases the balance of "from" by the specified amount...
Storage.Put(Storage.CurrentContext, to, to_value + value); // ...and increases the balance of "to" by the same amount.
// 6. Writes the updated balances back into storage (happens at the same time as 5)
return true;
}
}
}
To see if exploiting this vulnerability is even possible, we can invoke this contract with the following parameters:
["transfer", [<caller account's script hash>, 746f74616c537570706c79, 500]]
Where:
- โtransferโ is the name of the function to invoke. Every invocation in NEO calls the Main function, and the Main function has to figure out which action to execute. This is one of the ways to implement such behavior.
- The following array of arguments are passed to the function:
- <caller accountโs script hash> as the from parameter. This is the address of the user that executed the function.
- 746f74616c537570706c79 as the to parameter. In a regular function, this is supposed to be an address. But in our case, the array is actually the totalSupply string with every character represented as a byte value.
- 500 as the amount parameter. The from account will send 500 tokens to increase the total supply value with this invocation.
The original specification didnโt specify how to implement any functions, so the example weโve described here is just a common implementation of a function. The NEP-5 specification was updated after the vulnerability was discovered. The new specification explicitly describes how to implement each function, including all of the appropriate checks.
Fixing the problem
To fix the discovered storage injection vulnerability, the NEO developers suggest checking the length of the to parameter. A valid address (hash) of an account is always 20 bytes long. So in order to make sure that the to parameter has the correct length, we need to add one more step to the algorithm above:
- Receive three input parameters: from, to, and amount
- Check if the address in the from parameter is the same as the address of the sender by calling CheckWitness (we donโt want other people transferring our tokens)
- Check if the to value is valid by checking if the length of the to parameter equals 20 bytes
- Check if the user has enough tokens to transfer (if the amount parameter is less than the balance of the from parameter)
- Retrieve balances of the from and to addresses from storage
- Decrease the balance of the from parameter and increase the balance of the to parameter by the specified amount
- Write the updated balances of both addresses back to storage
The official example of the NEP-5 implementation suggests patching this vulnerability in a pretty similar way.
The problem is that this solution only works if you donโt store any variables using a 20-byte key like A20CharacterLongWord or ThisIsVulnerable!!!! If you do, then a more elaborate validation is required, such as checking if the parameter is equal to the name of the variable.
Whatโs the true danger?
At first glance, the danger of NEOโs NEP-5 massive storage injection vulnerability seems to be negligible. No new tokens can be created after changing the limit because the old ones were effectively burned (technically, they were transferred to an invalid account). So the attackers will be recreating tokens at most.
However, this kind of vulnerability is more dangerous than it seems. If a more advanced DApp were vulnerable, the impact could be devastating. For example, a contract may store its ownerโs address (the address of the account with some extended access) in persistent storage in order for it to be changed at some point. Using the same methods as with the totalSupply parameter, attackers would be able to alter the owner address or any other variable stored in the contractโs persistent storage.
With the transfer function, executing the function requires spending some tokens, which can also reduce the likeliness of such an attack. However, this rule may not work for every smart contract in the NEO network. For instance, attackers may compromise a smart contract in a way that would allow them to reclaim their investments. Or, in the case of a more complex smart contract, attackers could find a vulnerable function other than transfer and perform free writes into the storage. Therefore, developers need to pay more attention to such seemingly negligible vulnerabilities.
Conclusion
The NEP-5 smart contract vulnerability discovered in the NEO blockchain might be a minor issue, but it can still lead to a number of potential security threats. And with NEO smart contracts gaining more popularity day by day, developers should be extremely careful in order to avoid creating another DAO. Smart contract audits are a great way to ensure that no issues get written into the blockchain, regardless of the platform (EOS, NEO, ETH, etc.).
In our next post, learn more about another NEO smart contract issue โ the recently discovered DoS vulnerability.