Building a secure NFT gaming experience. A Herdsman’s diary
Experiences and learnings from my continuous security audit for Wolf Game.
The Shepherd never loses a beat. Mere hours after the Great Rescue of 13,809 wolves and sheep, the nimble Shepherd had already specced the next stages of the game and was writing code at the speed of light. After all, players were yearning for their rightfully earned WOOL! Something had to be done, and quickly.
The Shepherd’s latest concoctions consisted of two ingredients:
- Wool pouch: An ERC20-emitting ERC721 token. Users can claim unlocked WOOL from pouches and trade the pouch NFTs on the secondary market.
- Distribution / risky game: Sheep holders can choose between claiming a WOOL pouch containing a set amount of WOOL and fighting wolves for a larger WOOL payout.
I was tasked to continuously audit the Shepherd’s code and consult on design decisions. This diary documents some of the pertinent issues and considerations we encountered during this process.
Cheating randomness
Blockchain games often involve random outcomes. For example, in risky game sheep will defeat wolves with a 50% probability. It sounds simple enough but is surprisingly difficult to get right.
Users can revert unwanted outcomes
Users can attempt to revert their actions if they dislike the random result. Imagine a fixed-price mint function where users receive a regular NFT 95% of the time and a valuable, rare NFT 5% of the time. Evil users could write a smart contract that calls the mint function, checks the result and reverts whenever a regular NFT was minted, thus only receiving (and paying for) the rare NFT.
It may seem as if preventing calls from smart contracts would stop this attack, but that’s not the case (plus, EOA checks are unreliable). You see, miners can be bribed to do extraordinary things. For example, Flashbots searchers can bundle a mint transaction with a second transaction that conditionally reverts the earlier transaction based on the outcome of the random game event (check out revertingTxHashes
).
Random numbers can leak in advance
When using an external source of randomness, attackers can wait for the transaction to show up in the mempool and front-run it to take the optimal action.
One idea here is to hide the transactions using a private mempool service. Unfortunately this approach isn’t very effective, given that Ethereum has an Uncle rate of over 5%. Uncle bandits can take advantage of the fact that hidden transactions can still be disclosed in orphaned blocks (this issue was exploited in a fork of Wolf Game).
TL;DR: It’s fundamentally unsafe to let users enter games with random outcomes and decide the outcomes within the same block.
Reorg-for-hire
A cautious herdsman must employ meticulous forethought to anticipate yet-unknown threats.
The blockchain environment is destined to become even more hostile in the future. For example, it’s not inconceivable that reorg-as-a-service might become widely available. If miners/validators were to offer reorgs for a bribe, attackers could revise decisions from a few blocks earlier.
With that in mind, the safest option is locking user actions for several blocks before a random game event takes place. In Wolf Game, this lock period is 10 blocks (more on this below).
Pseudo-randomness isn’t random
Another challenge is finding a good source of randomness. For example, assume you want to generate a random value between 0 and 1024. One thing you could do is have commit users to enter the game, wait for a number of blocks, then use rand = block.blockhash(block.number — 1) % 1024
as the random number. On the surface, block hashes provide good entropy. The problem here though is that it would be relatively cheap for a miner to craft a block where rand
works out to be a particular number — all they need to do is find a block where blockhash % 1024
results in the desired value.
A better way would be to accumulate entropy from multiple blocks. E.g.: Wait for 10 blocks to be mined, then derive a pseudo-random number from the Keccak hash of some combination of the intermittent block hashes. If done correctly, this would at least significantly increase the difficulty of the attack.
Another alternative is using an Oracle like Chainlink VRF (Verifiable Random Function) , a provably-fair and verifiable source of randomness designed for smart contracts, which is also the option chosen by Wolf Game. This way, we can keep the code simple while also getting sufficiently random values.
Wolf Game implementation
Wolf Game uses the Chainlink request & receive data cycle in combination with a state variable that disables user opt-ins at the time the random number is requested. In the default configuration, Chainlink’s VRF coordinator will wait for 10 blocks until fulfilling the randomness request, which is plenty to account for possible reorgs.
On the safety of WOOL pouches
WOOL pouches are capable of displaying their own WOOL via dynamically generated SVG. From a security perspective, it was important to consider how trading of the pouches on the secondary market would play out.
In early iterations, the rendered pouch would display locked WOOL as well as unlocked, but yet unclaimed, WOOL. Think about what would likely happen here: Malicious sellers could offer pouches containing large amounts of unlocked WOOL on OpenSea, only to claim the WOOL before transferring the NFT to the buyer.
To prevent this kind of attack, we considered implementing a time-lock to disallow transfers for a number of blocks after a claim. However, even with this mechanism in place, sellers could still front-run buyers given a large enough time window. Plus, adding non-standard behavior like this might create a host of unforeseeable problems in the future.
Ultimately, we decided not not to display unlocked WOOL in the rendered image at all — thus, setting the expectation that only locked WOOL will be transferred when the NFT is sold. We did add basic front-running protection that disallows transfers of the NFT if its WOOL was claimed in the same block.
Integer magic
The Shepherd is determined not to waste a single bit of precious storage. Consequently, Wool Pouch data is neatly packed into a 256 bit struct. However, special care must be taken when doing computations on small types and integers in general.
During testing, it turned out that certain functions in WoolPouch inexplicably reverted. To illustrate why, let’s take a a look at a simplified example.
When compiled with solc 0.8.0 or higher, one of the following two functions reverts due to an integer overflow. Can you tell which one?
function function1() external view returns (uint256) {uint8 x = 128;uint256 y = x * 2;}function function2() external view returns (uint256) {uint8 x = 128;uint256 y = x * 100000;
}
It’s the first function that reverts. The reason is an overflow in the intermittent multiplication x * 2
. Solc creates a temporary variable to hold the result of the MUL
operation, using the data type of the smallest operand. In function1
, the second operand is the literal 2
which fits into an uint8.
Because both operands have a size of 8 bit, an uint8
is allocated to hold the result. The operation overflow as the result of 128 * 2
doesn’t fit into uint8
. Conversely, function2
is fine because 100000
happens to compile to a sufficiently large type.
The main lesson here is that it’s always better to explicitly cast operands to uint256
.
Another common issue is loss of precision due to rounding error. Consider the following function that calculates sheep earnings between pausing the Barn and migrating to a new smart contract:
uint128 unstaked_earnings = (MIGRATION_TIMESTAMP - BARN_PAUSE_TIMESTAMP) / 1 days * 10000 ether;
The order of operations in the bytecode will be left to right, i.e.:
(((MIGRATION_TIMESTAMP - BARN_PAUSE_TIMESTAMP) / 86400) * 10000) * 1e18)
(MIGRATION_TIMESTAMP — BARN_PAUSE_TIMESTAMP) / 86400
is calculated first and its result is rounded down before the multiplication with 10000 * 1e18
takes place. Consequently, the staking seconds accumulated on the last day will be “lost”.
As a general rule, one should always perform multiplications before divisions:
uint128 unstaked_earnings = (MIGRATION_TIMESTAMP - BARN_PAUSE_TIMESTAMP) * 10000 ether / 1 days;
Now, the rounding happens in the last operation and sheep can enjoy the full, precisely calculated amount of WOOL.
Final smart contracts and bug bounty
The audited smart contracts were published on Github and added to the bug bounty scope on Dec 4th, followed by a mainnet launch on Dec. 6th. If you spot security issues in the code please participate in the ongoing bug bounty program.
Further reading
- Flashbots: Understanding bundles
- Chainlink: VRF security considerations
- Smart contract best practices: Integer division and multiplication
- Solidity documentation: Types
Disclaimer: This article is not an endorsement or indictment of any particular project or team, and does not guarantee the security of any particular project. It provides no warranty or representation to any third party in any respect, including regarding the bug-free nature of code, the business model or proprietors of any such business model, and the legal compliance of any such business.