foundryup
. Here’s how:
audit/src
directory, there are three contracts written in Solidity. We will be using these contracts for this tutorial.
You can do static analysis with Slither in two ways. You can either use it in the terminal or as an extension in VS Code. We’ll explain both options so it’s easy to understand.
In your terminal, run the slither
command:
slither .
analyzes all Solidity files within the current directory, as denoted by the .
symbol. Slither scans these files, generating a comprehensive report that outlines potential vulnerabilities, bugs, and areas of concern regarding code quality within the smart contracts.Lottery.endLottery() (src/OverUnderFlowVul.sol#25-33) uses a weak PRNG
Reentrancy in ReentrancyExample.withdraw(uint256) (src/ReentrancyExample.sol#11-16)
OverUnderVul.sol
contract and ReplayVul.sol
. That’s why it’s important to also manually go through the code one line at a time.
OverUnderVul.sol
, which is a lottery contract, first.
uint
variable, which is 2^256-1, the jackpot variable will reset to zero. This can result in the winner receiving an incomplete prize and cause unexpected behavior in other areas of the contract. 2^256-1 is a very big number, but it’s better to cover every possibility.balances[msg.sender] += msg.value;
line, the balance is also susceptible to overflow.uint
variable. This allows them to withdraw a large amount of funds they do not own and could be exploited by malicious actors to steal funds from the contract.OverUnderVul.sol
contractOverUnderFlowVul.sol
file that includes checks to prevent overflow and underflow vulnerabilities:
buyTicket
function to ensure that the jackpot and balance do not exceed the maximum value of a uint256
variable.
The line require(jackpot + msg.value > jackpot, "Jackpot overflow");
ensures that the new value of jackpot
(after adding msg.value
) is indeed greater than the current jackpot
value. If it’s not, this means an overflow has occurred, and the function will revert due to the require
statement.
The balance check follows the same principle.
require(balances[winner] >= jackpot, "Balance underflow");
ensures that the new balance of the winner
(after adding the jackpot
) is indeed greater than or equal to the jackpot
value. If it’s not, this means an underflow has occurred, and the function will revert due to the require
statement.
endLottery()
function as highlighted by Slither.
The line uint256 winnerIndex = uint256(blockhash(block.number - 1)) % players.length;
is trying to generate a pseudo-random number to select a winner from the players
array. However, Slither is warning you that this method of generating random numbers is not secure.
The blockhash
function returns the hash of the given block number, and in this case, it’s the hash of the previous block (block.number - 1
). The issue is that block hash, block number, and other similar variables are public on the blockchain. This means that validators (or anyone else) who can see these variables could potentially manipulate the outcome to their advantage.
In the context of a lottery, this could mean that a validator might be able to influence the result to ensure they win, which would be a major security vulnerability. Therefore, it’s generally recommended to use a more secure method for generating random numbers in a smart contract, which generally includes a service like Chainlink VRF.
buyTickets
, endLottery
, and withdraw
. The buyTickets
method allows the user to buy a specified number of tickets, and the endLottery
method allows the owner of the lottery contract to end the lottery and select a winner. Finally, the withdraw
method allows players to withdraw their winnings.
These changes were made to prevent vulnerabilities related to overflow and underflow in the lottery contract. By making these changes, the lottery contract is now more secure and fair for all players.
ReplayVul.sol
which is a simple NFT marketplace contract.
buyToken
function. Specifically, if an attacker has already obtained ownership of a token, they can call the buyToken
function with the same _tokenId
parameter and a very low _price
parameter. Since the attacker already owns the token, the tokenOwners[_tokenId] != address(0)
check will pass, and the require(balances[msg.sender] >= _price)
check will also pass since the attacker can set the price to a very low value.
As a result, the attacker’s balance will be decreased by the _price
amount, while the previous owner’s balance will be increased by the same amount. Additionally, the ownership of the token will be transferred to the attacker. This can be repeated multiple times by the attacker to keep acquiring ownership of the same token at a very low cost.
This vulnerability is not a replay attack in the traditional sense, as it doesn’t involve replaying transactions on different networks or with different nonces. Instead, it’s a form of attack where a token owner can “buy” their own token at any price, potentially draining the contract’s balance.
One way to do this is by introducing a nonce parameter in the buyToken
function that must be incremented every time the function is called. This would ensure that each transaction is unique but it doesn’t directly address the issue of a token owner buying their own token.
To address this issue, a more direct solution would be to add a require
statement to the buyToken
function that checks if msg.sender
is not the current owner of the token. This would prevent the token owner from buying their own token, thus mitigating the vulnerability. As always, it’s crucial to thoroughly test and audit your contract before deploying it to a live network.
Here’s the updated contract with the nonce check implemented:
require(tokenOwners[_tokenId] != msg.sender, "Token already owned by buyer");
ensures that the buyer is not already the owner of the token. If they are, the function will revert due to the require
statement. This prevents a token owner from buying their own token, which addresses the vulnerability in the original contract.
ReentrancyExample.sol
contract. The vulnerability identified by Slither was Reentrancy in ReentrancyExample.withdraw(uint256) (src/ReentrancyExample.sol#11-16)
withdraw
function of the ReentrancyExample
contract.
Remember, reentrancy is a type of attack where an attacker exploits a contract’s code to execute a function multiple times before the previous execution has been completed. In this contract, an attacker could potentially call the withdraw
function repeatedly before the balances[msg.sender] -= amount;
line is executed, allowing them to repeatedly withdraw funds from their balance and drain the contract’s balance.
Here’s the potential attack scenario:
withdraw
function. Your contract starts executing the withdraw
function.withdraw
call, before the balance is deducted, the attacker’s contract fallback function is triggered by the call
function.withdraw
function.msg.sender.call
), the contract still sees the balance as not being withdrawn, so it processes the nested withdraw
call, and sends the funds to the attacker.withdraw
function could be external instead of public, and the mappings could be private.Even with this example, Slither will still give a reentrancy warning even though we took action against it; static analysis tools such as Slither analyze code in a very systematic and rule-based way, looking for specific patterns that may suggest potential vulnerabilities.locked
mapping to keep track of which accounts are currently executing the withdraw
function. Before updating the contract’s state or interacting with external contracts, we check if the account is already locked. If it is, we revert the transaction to prevent reentrancy. If it is not locked, we set the locked
flag to true
, update the balance and perform the transfer, and then set the flag back to false
to release the lock.
To demonstrate the vulnerability, an attacker could create a contract that repeatedly calls the withdraw
function of the ReentrancyExample
contract before the previous call has been completed.
withdraw
function permanently.You might want to use a try-catch block to handle potential exceptions from the external call, allowing you to unlock even when the external call fails.ReentrancyAttack
contract and sets the target
to the address of the ReentrancyExample
contract. The attack
function is called repeatedly to execute the withdraw
function of the ReentrancyExample
contract. The receive
function is a fallback function that is called when the contract receives ether. This function also calls the withdraw
function, allowing the attacker to repeatedly withdraw funds from the ReentrancyExample
contract.
To protect against this attack, we can deploy the updated ReentrancyExample
contract with the mutex protection described earlier, and also we could use the reentrancy guard contract from OpenZeppelin.