Vulnerabilities of DeFi applications
DeFi is one of the innovative technologies that introduced new financial activities for people and potentially changed the existing financial infrastructure. In this section, we will focus on the vulnerabilities that may occur in DeFi applications, especially the applications we are going to build in this book since hackers can leverage the vulnerabilities of smart contracts to exploit the crypto assets from smart contracts and users' wallets. Figure 1.8 shows that the total value hacked for DeFi has been around $6 billion since mid-2016:
Figure 1.8 – DefiLlama – DeFi loss by month
Fortunately, most of the vulnerabilities have solutions. We will discuss various causes of these vulnerabilities and best practices to prevent these issues in this section. Some knowledge of the Solidity programming language will help you understand the code snippets in this section, but it is not required for you to understand the principles.
Reentrancy
Reentrancy is one of the most destructive security attacks in smart contracts written with Solidity. A reentrancy attack occurs when a function makes an external call to another untrusted contract. Then, the untrusted contract makes a recursive call back to the original function in an attempt to drain funds.
For example, an attack smart contract could implement a fallback function that withdraws funds from a vulnerable smart contract. When the attack smart contract receives the fund, the fallback function will be called automatically, which makes recursive calls, at which point it will withdraw the fund again until the fund in the vulnerable smart contract is drained. Figure 1.9 demonstrates the sequence of actions to perform this attack:
Figure 1.9 – The workflow of a reentrancy attack
To find the relevant code example and learn more about reentrancy attacks, please go to https://solidity-by-example.org/hacks/re-entrancy/.
To prevent a reentrancy attack, we will use ReentrancyGuard
from the OpenZeppelin (https://www.openzeppelin.com/) library when building DeFi applications later in this book.
Self-destruct operation
In the early days of Ethereum, one of the earliest DAO projects lost $3.6 million worth of ETH due to a hack. What’s even worse is that the attack continued for days due to the immutability of the smart contract on the blockchain, so the developer could not add a function to take back the ETH from smart contracts or destroy the smart contract to prevent hacking. In 2016, Ethereum introduced the selfdestruct
function to serve as an exit door for smart contracts in case of an attack. Here is an example of how to use the selfdestruct
function:
contract SelfdestructExample { function killContract(address payable receiver) external { selfdestruct(receiver); } }
This code snippet defines a smart contract called SelfdestructExample
. A person can call the killContract
function to destroy the smart contract, at which point all the ETH held by the smart contract will be transferred to receiver
when selfdestruct
is called.
The behavior of transferring ETH to a specific address could cause a side effect. Hackers can then use this side effect to forcefully send ETH from a self-destruct smart contract to another smart contract to make it vulnerable.
The example at https://solidity-by-example.org/hacks/self-destruct/ shows the act of forcefully transferring ETH to a smart contract to break the rules of the game. There is a game that only allows players to transfer 1 ETH at a time. The person can win when the balance of the smart contract is equal to or greater than 7 ETH, and the winner can take all the ETHs. Although the game smart contract only allows a player to transfer 1 ETH every time, the attacker broke the rule by forcibly transferring more ETHs to the game smart contract in one transaction with the selfdestruct
function.
The solution is using a storage variable in the smart contract to store the balance instead of using address(current_contract).balance
. This will be the source of truth for the smart contract to rely on, and the selfdestruct
function cannot manipulate the variable.
Gas overflow
All the transactions that need to write data on the blockchain need to pay for gas. For EVM-based blockchains, the gas is precalculated before the transaction and the gas is consumed while executing the bytecode of the smart contract. However, the gas estimation can be temporarily or consistently inaccurate due to the indeterminacy of the Solidity programming language and network traffic. As developers, we need to pay attention to the code that could cause this gas variation and try to optimize the code.
For example, a gaming smart contract may implement a function to reward winners, like so:
function rewardPlayers() external { if (isWinner(msg.sender)) { safeTransfer(token, msg.sender, winAmount); emit Win(msg.sender, winAmount); } }
If isWinner(msg.sender)
determines the winner with some randomness at the time of calling it, it would cause differences between the gas estimation and gas actual usage. This means that the gas estimation assumes that the safeTransfer
function is not called, so it assigns a small amount of gas to run the transaction. However, at the time of execution, the caller of the function is selected as the winner, and the safeTransfer
call in the if
statement exceeds the gas limit, which causes a denial-of-service (DoS) attack.
Iterations can also cause gas overflow if the size of the iteration grows over time. Figure 1.10 shows the relationship between gas usage and the number of iterations:
Figure 1.10 – The relationship between gas usage and the number of iterations (benchmarked with Solidity v0.8.3)
Based on the data shown in Figure 1.10, gas usage grows exponentially along with the number of iterations. We need to be careful about arrays of a dynamic size and try to reduce this size when possible.
There are many ways to prevent gas overflow. The key thing is optimizing the Solidity code by following good practices. You can refer to https://dev.to/jamiescript/gas-saving-techniques-in-solidity-324c for some techniques for optimizing your Solidity code to save gas usage.
Random number manipulation
Randomness drives people to play against uncertainties. Nowadays, DeFi projects are increasingly introducing lotteries or other forms of randomness to give bonus rewards to their users and attract more users to use their DeFi applications. However, there is no ideal way to generate random numbers within EVM-compatible blockchains. This may cause attackers to manipulate the random number generation and get the number to steal the assets from the reward pool.
If you want to implement a random number generator with the facilities in EVM, you can use code similar to the following:
/* * Returns a random number. * If the caller of the function gets a random number * that can be divided by 10000, then the caller will win. */ function getRandomNumber() private view returns (uint256) { return uint256(keccak256(abi.encodePacked(block.timestamp, msg.sender))); }
As you can see, the getRandomNumber
function returns a random integer. Inside the function, it concatenates block.timestamp
and msg.sender
to generate a long byte array, then hashes the bytes with the keccak256
algorithm to generate a pseudo-random number. Here, msg.sender
is the caller’s address, and block.timestamp
is the timestamp field of a block in its header. Because the timestamp is set by the miner, a hacker can set the block.timestamp
function of the next block by being a miner to generate a random number that makes them win.
To get a true random number, we can use an oracle service such as Chainlink VRF. It relies on many nodes being on the network to generate a random number that is secure and almost impossible to manipulate by hackers. However, this random number retrieval requires a request and a callback to fulfill the request. The duration between the request and its fulfillment may take dozens of seconds to more than one minute, and each request may take an amount of LINK tokens plus the gas fee. As a result, it is better for a smart contract that relies on random numbers such as lottery games to wait for a certain period to reveal rewards instead of doing that on the spot (for example, reveal a group of winners daily or weekly).
To learn how to get random numbers with Chainlink oracle, go to https://docs.chain.link/vrf/v2/subscription/examples/get-a-random-number/.
There are many more types of vulnerabilities in DeFi. We will discuss this in more detail when we build DeFi applications later in this book. Now, let’s summarize what we have learned so far.