Excerpt from my speech given on 5/30/2023 for Offchain Milan
For years, blockchain enthusiasts have fantasized about the arrival of mass adoption. To date, this has not yet occurred for several reasons, including an unsatisfactory user experience.
One of the worst aspects of the user experience in cryptos is security.
The website, https://rekt.news, reports information on the main attacks against smart contracts in the last three years; thus, it excludes hacks against users’ wallets, which are certainly much more numerous.
In this article, we try to understand why so many compromises to crypto occur and whether there are ways to prevent them that are within the reach of nonexpert users.
Native and Non-Native Tokens
Before we get into the meat of the topic, let us recall what a native token of a blockchain is and what a nonnative token is.
BTC and ETH are native tokens on their respective blockchains, this means that the respective networks know what a BTC or ETH is and how they can be moved from one wallet to another.
To move ETHs, I need to sign a transaction with my wallet’s private key, the Ethereum network understands the transaction directly, and there is no alternative way to move an ETH from one wallet to another.
On Ethereum, however, it is possible to create tokens of different types; let’s now look at an ERC-20, one of the standards I can leverage to create tokens on Ethereum.
An ERC-20 is a database that contains information such as the number of tokens held by each wallet plus several methods (pieces of code) that can be called from wallets or other smart contracts.
The Ethereum network does not “understand” ERC-20s, it does not know how they can be moved, so the transfer is done by invoking one of the methods exposed by the token itself, that is, code written by the developer who created it.
The ERC-20 standard requires a method called transfer() to be used to move a token to a new address.
What, then, are the differences from the point of view of safety in using an ERC-20 from that of an ETH?
- I have to execute code written by someone trusting that it actually does what I asked it to do and not (through inexperience or malice) something different.
Of course, I could analyze the code, but this is not a possibility within the reach of anyone. - There may be (through inexperience, malice, or for reasons functional to the token itself) other ways my tokens can be moved, perhaps even by parties other than me.
Indeed, at least one such method certainly exists, called transferFrom(), and it is precisely for moving other people’s tokens(!!!).
The standard requires that I preemptively authorize third parties to move my tokens through the approve() method; again, it is by no means a given that things always go according to plan.
How Does a dApp Work?
Let’s look at how Uniswap works to understand how many smart contracts operate, in general, on Ethereum.
From a conceptual point of view, the operation of Uniswap is simple: it allows me to exchange a certain amount of one ERC-20 (which we call A) for a certain amount of another ERC-20 (which we call B).
I would then expect to send A to Uniswap and, in return, receive B. Unfortunately, this approach would not be reliable: the transaction transferring A to Uniswap and the transaction by which Uniswap sends B to me would be two separate transactions; if, for some reason, one of them fails while the other is successful, we would have a problem.
This on Ethereum requires the same party to move tokens A from me to Uniswap and tokens B in the opposite direction.
So what actually happens is that I have to call the approve() method to authorize Uniswap to take my A tokens and then call the trade() method within which Uniswap performs (after a series of checks) transferFrom() on A and transfer() on B.
In this way, the two transfers occur in a single transaction.
What Could Possibly Go Wrong?
There are at least two critical issues in the scheme shown:
- I am forced, via the approve() method, to trust a third party to do what it wants with my A tokens.
Again, I am having to analyze the operation of one or more smart contracts or trust them.
Worse yet, if someone managed to compromise Uniswap, they could also steal the A tokens inside my wallet, so I not only have to worry that Uniswap is not malicious but also safe from future attacks by third parties. - Uniswap must use external methods to transfer tokens A and B.
We have already discussed the risk concerning my wallet, but here the situation is even more delicate: the two tokens A and B may have been developed after Uniswap; the Uniswap developer may therefore have no chance to verify that these are not malicious; at the time of writing the code he was facing a completely unknown adversary.
There are several expedients through which tokens A and B could attack a smart contract executing one of their methods, the most common being reentrance.
Reentrance
Reentrance is the ability of a called smart contract to invoke a method of the calling smart contract.
If not handled properly, this possibility can subvert the logic of operation of the calling smart contract.
Let us imagine a smart contract that allows a user to deposit tokens A and subsequently withdraw them via a withdraw() method that accepts as an argument the name of the token to be withdrawn and its quantity.
When depositing, an appropriate internal variable of the smart contract (let’s call it deposited) will be valued with the number of tokens deposited by the specific wallet.
A hypothetical implementation of the withdraw(A, n) method could be:
check that n is less than or equal to deposited[A, wallet_caller], otherwise fail
A.transfer(wallet_caller, n)
update deposited[A, wallet_caller] = deposited[A, wallet_caller] - n
It is so simple that it seems impossible to get it wrong, and yet this pseudocode is vulnerable to a reentrance attack.
Suppose the transfer() method of token A calls, in turn, withdraw(A, n). Suppose further that wallet_caller has previously deposited 100 A tokens. In our smart contract, the execution flow of withdraw(A, 100) would be this:
check that 100 is less than or equal to 100, otherwise fail
A.transfer(wallet_caller, 100)
check that 100 is less than or equal to 100, otherwise fail
A.transfer(wallet_caller, 100)
update deposited[A, wallet_caller] = 100 - 100
update deposited[A, wallet_caller] = 0 - 100
We had deposited 100 tokens A but withdrew 200!
The third, fourth, and fifth lines represent the reentrance attack of A.transfer() against withdraw().
Eventually, the value stored in the variable deposited[A, wallet_caller] will be -100. The problem is that we go to update this variable too late, when it’s too late.
Indeed, if A.transfer() kept calling withdraw() every time, we would go on to have multiple calls grafted until all the tokens A deposited by all the users in our smart contract were drained.
Note that by reversing the second and third lines of our pseudocode, the method would no longer be vulnerable, and the execution flow would become this:
check that 100 is less than or equal to 100, otherwise fail
update deposited[A, wallet_caller] = 100 - 100
A.transfer(wallet_caller, 100)
check that 100 is less than or equal to 0, otherwise fail
The last line causes code execution to stop abruptly.
It is the developer’s responsibility to understand the possible consequences of the code they write in the face of strange and unexpected behavior of code written by others.
If the attack on just three lines of pseudocode seemed nonobvious to you, imagine what this could mean for an average complex smart contract.
Possible Countermeasures
Those who are familiar with the Ethereum world will probably have turned their noses up to this point because they know that there are possible countermeasures to the critical issues highlighted; let’s look at what these countermeasures are and whether they are effective, especially from the perspective of a neophyte.
Revoke
The website, https://revoke.cash/, allows me to verify all smart contracts to which I have previously granted approve() and revoke their ability to spend my tokens.
In my opinion, there are three reasons why this is not a truly effective solution:
- Smart contract interfaces guide me in the approve() but not in the revoke; the user must know that this site exists and must remember to use it.
Generally, a security tool should have stringent defaults, especially one intended for nonsmart users. - Even the user who knows about the existence of Revoke might be reluctant to use it since it costs them time and money.
In fact, running Revoke requires spending some fees and forces me to re-run approve() (which also has a cost) the next time I use the same smart contract.
It is much more practical and economical not to use Revoke for smart contracts that I use frequently. - Even in the case where I use Revoke, this only narrows the time window during which I am vulnerable: it does not eliminate the vulnerability but merely turns it into a race condition
People today use bots for everything, nothing easier than attacking me as soon as I do approve() without giving me time to use Revoke.
Audits and the Test of Time
There are companies that specialize in verifying the security of smart contracts (audits); I can pay one of these companies to obtain a certificate with which to boast to my possible users.
Unfortunately, although audited smart contracts are generally more secure than those that have not been audited, the aforementioned rekt.news is full of compromises of smart contracts deemed secure.
This also happens because an audit has a not inconsiderable cost and would have to be rerun every time the smart contract is modified, which the developer only sometimes has the will/resources to do.
Add to that, the poor end user needs help to tell whether the audit refers to the current version of the smart contract or a previous one.
The seniority and popularity of a smart contract are also often considered indications of its security.
But this belief is valid up to a point: there are, unfortunately, smart contracts that have been successfully attacked years after their publication.
There are also smart contracts that have been hacked, whose flaw has been repaired, and have been hacked again and recorrected repeatedly.
But why can’t you determine with certainty whether a smart contract is secure and fix it permanently?
The problem arises from the undecidability of the software: given a sufficiently complex piece of code, it is impossible to determine whether all possible paths of execution are correct or whether certain conditions may not cause undesirable behavior.
In short, one would have to test one by one all possible situations that might occur to know whether any of them are problematic.
In other words: you can’t know if the software has defects until you find one.
Transaction Simulation and Intent-Based Transactions
Blind signing is the term commonly used to refer to the fact that a wallet cannot show in an intelligible form to the user what effect the transaction he is going to sign will have.
This is not just an interface issue: the wallet usually has no way of knowing what calling a certain method of a certain smart contract entails.
Some wallets try to overcome this problem by running a simulation of the transaction itself internally; that is, there is an EVM (Ethereum Virtual Machine) within the wallet that executes the smart contract and displays the result obtained in the simulated environment.
Even this expedient, although it may seem decisive, has major flaws.
Indeed, not all transactions have an immediately visible effect: just think of the approve() method. It does not seem to produce any effect but can be very harmful if I authorize a malicious or buggy smart contract to move my tokens.
Moreover, nothing guarantees me that the result of the simulation will be identical to what will happen when I perform the transaction.
Boundary conditions may change (e.g., the output returned by an oracle), but the behavior of the smart contract itself may also change.
Undecidability once again breaks our eggs in the basket: there is no easy way to determine whether a smart contract can have different behaviors.
A very trivial example: let’s imagine a smart contract that behaves honestly 50% of the time and maliciously the remaining 50% of the time completely randomly. Well, in this situation, there is a 25% probability that the simulation will show me the transaction as safe, while when I go and execute it, I will be cheated.
There is a lot of talk these days about intent-based transactions: a human-understandable language in which one can express what one wishes to do and what result is expected.
The problem with this approach is that the Ethereum network is not capable of processing transactions expressed in this language; there will be an entity somewhere that will translate the intent-based transaction into an Ethereum transaction.
Here we fall back to the previous problem: since the transaction is not always predictable, we have no way of knowing whether the intention expressed by the user will be fulfilled.
The Reaction of Communities to a Compromise
Too often, crypto communities are overprotective of the projects they love, just as a fan dotes on his favorite team.
The fact that there are several known problems and related buffer solutions to them (even if not entirely satisfactory) often makes us blame user inexperience or the developer.
In other words, we take it for granted that anyone approaching crypto must have an extensive skill set.
This mindset, however, goes in the opposite direction of mass adoption: we are creating a gap between those who can use crypto and those who cannot.
Think of the state of mind of the user who has experienced theft and is blamed for his own inexperience.
Adding insult to injury, there can be no worse user experience!
All widely successful technologies, on the other hand, tend to be usable by a neophyte user: you don’t need to know exactly what a lambda sensor is for to drive a car, and you can make a phone call even if you have never even heard of spread spectrum and beamforming.
Non-EVM Platforms
Up to here, I have always explicitly mentioned the Ethereum platform; other networks, such as BSC and Avalanche, have chosen the path of Ethereum compatibility, implementing EVM in their nodes.
Of course, all the considerations so far apply equally to these networks.
However, some platforms have developed their own smart contract technology, perhaps taking a cue from EVM and solving some known problems.
This is the case with Solana, Aptos, Near, MultiversX, etc.
Most of these platforms have systematically solved reentrance, preventing it at the platform level.
Unfortunately, reentrance is only one of the ways a smart contract can be attacked.
The two critical issues I pointed out at the beginning (the need for the user to delegate the handling of their tokens and the need for the smart contract to use unknown external code to transfer tokens) remain fundamentally unchanged.
By way of example: it is very common on Solana that by putting funds into a wallet previously used to purchase an NFT, they disappear; it has happened not only to clueless people but also to a well-known influencer who reused a wallet used six months earlier to purchase an NFT. It seems that Solana’s equivalent of approve() is even worse than Ethereum’s: it also allows you to steal SOL!
Turing completeness
Some platforms, in an attempt to eliminate undecidability, have adopted non-Turing complete programming languages. A non-Turing complete language lacks certain constructs that cause undecidability, it is possible to prove the correctness or incorrectness of software written in one of these languages; in other words, non-Turing complete languages are deterministic.
The price is that some algorithms may be more complex to implement or even impossible.
Unfortunately, if the code has to call unknown code written by a third party, the whole thing becomes unpredictable again.
Still, let’s say that a non-Turing complete language coupled with the inability to do reentrance is certainly a big step forward regarding security.
An example of a non-Turing complete programming language is Move, devised by Meta (Facebook) and adopted by Aptos and Sui.
Unfortunately, these countermeasures proved insufficient if, shortly after the launch, Sui developers felt compelled to warn users. Again, the user must pay attention to address the platform’s shortcomings.
Platforms With Native Tokens
Let us now imagine a platform in which all tokens are native, in which it is unnecessary to use code written by a third party to transfer a token because the network knows how to move that token. Let’s look at some of them:
In January 2014, the NXT blockchain was born, which caused a stir and immediately came in second place on CoinMarketCap. One of the features of this platform is that you can create your own tokens simply from the wallet without having to write a line of code.
The network always knows how to manipulate the newly created tokens, which are true coin natives and NXTs themselves.
In the wake of NXT’s success, NEM was created, which in the founder’s idea, was supposed to be a clone of NXT with better coin distribution; soon, however, the founder was kicked out, and the developers decided to implement their own ideas.
On NEM, it is possible to create a coin via the wallet, again without having to write code, but being able to associate several features with suiting different use cases (transferable/non-transferable, adding royalties…).
I am very attached to this project, partly because in 2014, a few months before the mainnet was launched, I discovered a replay attack vulnerability in the testnet and received a generous donation from the developers.
Both projects, after initial success, have seen their capitalization decline, especially following the launch of Ethereum and the rise of the smart contract narrative.
The Cardano blockchain also allows the user to create native tokens without writing code, but unlike its predecessors, it also allows the user to write smart contracts to realize dApp.
Cardano’s smart contract language is Haskell, a non-Turing complete language. These features make Cardano, to date, the most secure platform for smart contract development: these in fact do not have to execute external code to move tokens. Cardano is also the only platform, so far analyzed, whose smart contracts are fully deterministic.
Unfortunately, various issues not strictly related to security, including difficult developer experience, have limited the success of this platform so far.
Radix represents the next step in the evolution of these platforms: it is a network in which all the architecture components have been thought out, not following the mainstream traced by Ethereum, but to make the best possible platform for decentralized finance.
I will only talk about smart contracts and transactions in this article, leaving out the underlying layers, such as consensus. The launch of smart contracts will take place between July and August 2023, in conjunction with the update called Babylon.
Scrypto
Scrypto is the name of the smart contract programming language on Radix, it is based on Rust which is a language known for its robustness and performance.
Rust is Turing complete, but thanks to several unique concepts, such as variables ownership, Rust code is almost completely deterministic.
Due to the good level of determinism, the Rust compiler is able, already at compile time, to detect and report as errors many anomalies that in other languages would be discovered only at the time of code execution (run-time). Moreover, the compiler forces the programmer to handle every possible exceptional situation during code execution.
In practice, despite Turing completeness, no unhandled abnormal situations occur in the execution of Rust code.
It is known that programmers make many mistakes while developing code; Rust makes it much harder to make these mistakes by improving the developer experience.
A better developer experience will result in greater productivity and probably a better user experience.
What differentiates Scrypto from Rust is that it is asset oriented. That is, while other languages can only manipulate numbers and character strings, in Scrypto, you can manipulate tokens in a natural way .
It is possible, for example, to pass a token as an argument to a method inside a special container called a bucket. Scrypto’s run-time has also been extended to check for situations in which a smart contract is not handling the received assets correctly; for example, if a method does not fetch all the tokens in the received bucket, the run-time causes the transaction to fail.
Again, passing a token to a method is the only possible way to deposit it into a smart contract: the smart contract’s internal wallet (called a vault) cannot be addressed from outside the smart contract itself.
Thus, the smart contract is empowered to analyze (and possibly fail) incoming transactions as well; this eliminates many types of attacks we have so far not discussed that consists precisely of giving unexpected tokens to a smart contract.
Going back to the initial example of how Uniswap works: with Scrypto, you can really develop it by following the simple logic, “You send token A to the smart contract, and the smart contract sends you token B in return.”
No approve(), no transfer(), and no risks associated with them!
Instead of building a sieve and then scrambling to plug a hundred holes it has, we built a container with no holes.
Transaction Manifest
When discussing transaction simulation and intent-based transactions, we saw that these could never be 100% reliable because smart contracts are not deterministic.
A Radix transaction can contain not only token transfer and calls to smart contract methods: it is also possible to include asserts, which is the definition of conditions that we wish to be verified during or at the end of the execution of the transaction.
Returning to the Uniswap example, my wallet could generate a transaction like “I send token A to the decentralized exchange, and I receive at least one token B in return.”
I have established within the transaction that I expect to receive token B; if I do not, the transaction will fail.
Apparently, Uniswap already contains such a feature: slippage, which allows me to specify the tolerance in the A/B exchange ratio that I am willing to tolerate. However, slippage is implemented within the Uniswap code, not in the Ethereum platform; this means that:
- Not all smart contracts implement a mechanism similar to slippage.
- A smart contract (because of malice, because of developer inexperience, because an attack…) may fail to meet it.
By using the Radix wallet, on the other hand, the user is empowered to place as many assets as they want within their transactions and is assured that the platform itself will verify that these are met.
In practice, Radix implements intent-based transactions natively and reliably, solving the issue of blind signing once and for all.
Insights
RadFi2022, the Radix team’s vision
Series of articles from which this speech was inspired:
- https://www.radixdlt.com/blog/the-problem-with-smart-contracts-today
- https://www.radixdlt.com/blog/radix-engine-v2-an-asset-oriented-smart-contract-environment
- https://www.radixdlt.com/blog/scrypto-an-asset-oriented-smart-contract-language
- https://www.radixdlt.com/blog/the-problem-with-crypto-transactions-today
- https://www.radixdlt.com/blog/radixs-asset-oriented-transactions
Series of articles analyzing some glaring hacks:
- https://www.radixdlt.com/blog/rekt-retweet-1-why-the-48-million-cashio-hack-on-solana-could-never-happen-on-radix
- https://www.radixdlt.com/blog/rekt-retweet-2-why-the-120m-badger-dao-hack-could-never-happen-on-radix
- https://www.radixdlt.com/blog/rekt-retweet-3-why-the-80m-qubit-finance-hack-could-never-happen-on-radix
- https://www.radixdlt.com/blog/rekt-retweet-4-why-your-bored-apes-and-all-other-nfts-arent-safe-except-on-radix
- https://www.radixdlt.com/blog/rekt-retweet-5-why-the-30m-spartan-protocol-hack-could-never-happen-on-radix
- https://www.radixdlt.com/blog/rekt-retweet-6-re-entrancy-and-flash-loans-why-the-80m-fei-rari-hack-could-never-happen-on-radix
- https://www.radixdlt.com/blog/rekt-retweet-7-why-radix-blueprints-and-components-would-have-prevented-the-11m-value-defi-hack-on-bsc
- https://www.radixdlt.com/blog/rekt-retweet-8-why-the-90m-mirror-protocol-hack-could-never-happen-on-radix
- https://www.radixdlt.com/blog/rekt-retweet-9-the-transaction-manifest-why-the-14m-furucombo-hack-could-never-happen-on-radix
- https://www.radixdlt.com/blog/rekt-retweet-10-badges-why-the-326m-wormhole-hack-on-solana-could-never-happen-on-radix
- https://www.radixdlt.com/blog/rekt-retweet-11-re-entrancy-why-the-11m-agave-and-hundred-finance-hacks-could-never-happen-on-radix
- https://www.radixdlt.com/blog/rekt-retweet-12-native-token-validation-why-the-2-1m-gym-network-hack-could-never-happen-on-radix
Contacts
Telegram group Radix developers
The Security of Smart Contracts was originally published in Better Programming on Medium, where people are continuing the conversation by highlighting and responding to this story.