The most complicated part about solving the challenge was the fact that there was a hidden unknown address contract deployed at a remote server that had to return the exact byte code size to enable the winning magic number.
Knowing the contract bytecode, deployer address, and deterministic nature of the blockchains, my first take was to impersonate the deployer address and account nonce to generate a local deployment of the contract. Obviously, the author offuscated the bytecode to avoid this possible solution.
My second idea? brute force (fuzzing).
I will go over my detailed approach to finding this mysterious address with the help of a constrained fuzzing function that called ~2000 possible addresses on the network until it found the winning one.Details
Contracts:
A
: single function contractMyToken
: simple ERC20 tokenGuessGame
: Guess 4 random values forguessGame()
function based on the following init code:
contract GuessGame {
uint256 private immutable random01;
uint256 private immutable random02;
uint256 private immutable random03;
A private immutable random04;
MyToken private immutable mytoken;
constructor(A _a) {
mytoken = new MyToken();
random01 = uint160(msg.sender);
random02 = uint256(keccak256(address(new A()).code));
random03 = block.timestamp;
random04 = _a;
pureFunc();
}
// pureFunc() runs in constructor memory context
function pureFunc() pure internal {
assembly{
mstore(0x80,1) // offset 128, sets random01 to 1
mstore(0xa0,2) // offset 160, sets random02 to 2
mstore(0xc0,32) // offset 192, sets random03 to 32
}
}
...
After constructor
random01: 1
random02: 2
random03: 32
random04:
A contract instance
Main game function:
function guess(uint256 _random01, uint256 _random02, uint256 _random03, uint256 _random04) external payable returns(bool){
if(msg.value > 100 ether){
// 100 eth! you are VIP!
}else{
uint256[] memory arr;
uint256 money = msg.value;
assembly{
mstore(_random01, money)
}
require(random01 == arr.length,"wrong number01");
}
uint256 y = ( uint160(address(msg.sender)) + random01 + random02 + random03 + _random02) & 0xff;
require(random02 == y,"wrong number02");
require(uint160(_random03) < uint160(0x0000000000fFff8545DcFcb03fCB875F56bedDc4));
(,bytes memory data) = address(uint160(_random03)).staticcall("Fallbacker()");
require(random03 == data.length,"wrong number03");
require(random04.number() == _random04, "wrong number04");
mytoken.transfer(msg.sender,100);
payable(msg.sender).transfer(address(this).balance);
return true;
}
Guessing
param _random01:
Constraint
require(random01 == arr.length,"wrong number01");
random01 =
'0x60
or96
because of Memory reserved spaces
param _random02:
- constraint:
uint256 y = ( uint160(address(msg.sender)) + random01 + random02 + random03 + _random02) & 0xff;
& 0xff: masks the sum result and return the last 8 bits in binary.
I set random02 value to 0, comment out the masking
& 0xff
and print they
value, the result was abig int _1
(long number)I converted that number
big int _1
to binary representation using this chrome extension and modified the binary to make the last 8 bits represent thenumber 2
or00000010
, becasue I knew from constructor thatrandom02 = 2
I converted the modified binary to back to decimal to get another big number
big int _2
, then I got the difference between both big integersbig int _1
&big int _2
and in decimals and got the correct number to set, in my case_random02 = 121
.- param _random03:
constraints:
require(uint160(_random03) < uint160(0x0000000000fFff8545DcFcb03fCB875F56bedDc4));
(,bytes memory data) = address(uint160(_random03)).staticcall("Fallbacker()");
require(random03 == data.length,"wrong number03");
At this point I know that
random03 = 32
so I used a single function helper contract that returns 32Then some magic foundry fuzz to solve
try helperContract.ctf(_random03)returns(uint256 dataSize){
vm.assume(dataSize >= 32);
}catch { console.log("Fuzzing..."); }
Ironically the right number was 3 so
_random03 = 3
- param _random04 was always 10
- I used cast to get address at stora slot 1 of
SetUp
contract:
cast storage <metatrust deployed address> 1
contract SetUp {
A public a ;
GuessGame public guessGame;
constructor() payable {
a = new A();
guessGame = new GuessGame(a);
}
function isSolved() public view returns(bool) {
return guessGame.isSolved();
}
}
Finally, I sent my guessGame() numbers with cast encoding the function name and parameters.
I used AI assistant phind.com to find in a faster way the foundry cast commands to query remote contracts from the command line.
Conclusion
Fuzzing can be a useful approach to breaking protocol invariants, especially (like in this case) if we know beforehand the logical statements that need to hold for the protocol to maintain its functional expectations.