Skip to main content

QuillCTF - Gate

· 8 min read
Kaan Caglan

Gate is a Solidity CTF challenge from QuillCTF.

Solution

In this contract, there is only 1 solidity file. Gate.sol. Contract is not deployeda on any test network.

Objective of CTF is

You need to set the opened flag to true via the open function
You need to handwrite the bytecode opcode by opcode and stay within the size of less than 33 bytes

To solve this question we have to call the open() function with an address parameter and that address should belong to a solidity contract. There are 4 conditions in that open function. The first one is the size of the contract should be less than 33. The contract should return address(contract) if we call function f00000000_bvvvdlt, the contract should return tx.origin if we call f00000001_grffjzz and it should revert the transaction if we call fail() function.

pragma solidity ^0.8.17;

interface IGuardian {
function f00000000_bvvvdlt() external view returns (address);

function f00000001_grffjzz() external view returns (address);
}

contract Gate {
bool public opened;

function open(address guardian) external {
uint256 codeSize;
assembly {
codeSize := extcodesize(guardian)
}
require(codeSize < 33, "bad code size");

require(
IGuardian(guardian).f00000000_bvvvdlt() == address(this),
"invalid pass"
);
require(
IGuardian(guardian).f00000001_grffjzz() == tx.origin,
"invalid pass"
);

(bool success, ) = guardian.call(abi.encodeWithSignature("fail()"));
require(!success);

opened = true;
}
}

So we need to implement an opcode that will satisfy all of the required cases. To do that like in the previous question (Collatz Puzzle) we need to write a YUL code or direct OPCode. Yul (previously also called JULIA or IULIA) is an intermediate language that can be compiled to bytecode for different backends.

In .yul code we should write an assembly. Normal structure of YUL is like this

object "Contract" {
// This is the constructor code of the contract.
code {
// Deploy the contract
datacopy(0, dataoffset("runtime"), datasize("runtime"))
return(0, datasize("runtime"))
}

object "runtime" {
code {
// Protection against sending Ether
if gt(callvalue(), 0) {
revert(0, 0)
}

// Dispatcher
switch selector()
case 0x6d4ce63c {
returnUint(get())
}
case 0x371303c0 {
inc()
}
case 0xb3bcfa82 {
dec()
}
default {
revert(0, 0)
}

// ABI
function get() -> counter {
counter := sload(counterSlot())
}

function inc() {
sstore(counterSlot(), add(get(), 1))
}

function dec() {
sstore(counterSlot(), sub(get(), 1))
}

// Helpers
function selector() -> s {
s := div(calldataload(0), 0x100000000000000000000000000000000000000000000000000000000)
}

function returnUint(v) {
mstore(0, v)
return(0, 0x20)
}

// Slots
function counterSlot() -> s { s := 0 }
}
}
}

This piece of code is taken from Evm Playground So we can see that there is a object "ContractName" and that code returns the code under object "runtime" So we have to modify this object "runtime" it in a way that works for us. To do that we can check OPCODES FOR THE EVM.

15  ISZERO  3   a   a == 0      (u)int256 iszero
1C SHR 3 shift, val val >> shift
32 ORIGIN 2 . tx.origin
33 CALLER 2 . msg.sender
35 CALLDATALOAD 3 idx msg.data[idx:idx+32] read word from msg data at index idx
52 MSTORE 3* ost, val . mem[ost:ost+32] := val write a word to memory
F3 RETURN 0* ost, len . return mem[ost:ost+len-1]
FD REVERT 0* ost, len .

All of those opcodes will be enough for us to build our YUL code. Our algorithm is :

  • return caller if the function signature is the same as f00000000_bvvvdlt
  • return origin if the function signature is the same as f00000001_grffjzz
  • revert in all other cases

We can revert the transaction if the called function is not one of that two because there are no other restrictions so calling fail() or success() or anything else doesn't matter we can revert. It will pass the requires in the open function. To get the function signature of incoming data we can do

    div(calldataload(0), 0x100000000000000000000000000000000000000000000000000000000)

However if we do that, we won't have enough space to implement our contract logic because 0x100000000000000000000000000000000000000000000000000000000 variable will take a lot of place in the opcode. If we check the function signatures it can be seen that

>>> web3.sha3(b'f00000000_bvvvdlt()')[0:4].strip().hex()
'00000000'
>>> web3.sha3(b'f00000001_grffjzz()')[0:4].strip().hex()
'00000001'
>>> web3.sha3(b'fail()')[0:4].strip().hex()
'a9cc4718'
>>> 0xa9cc4718
2848737048

So we should have a YUL code something like

object "runtime" {
code {
x := `function signature`
if iszero(x){
mstore(0x20, caller())
return(0x20, 0x20)
}
if eq(x, 1){
mstore(0x20, origin())
return(0x20, 0x20)
}
if eq(x, 2848737048){
revert(0x20, 0x20)
}
}
}

However, we can't do that because firstly we don't know the exact x (function signature) since we didn't divide it with 0x100000000000000000000000000000000000000000000000000000000 and even though we have the x this code will have more than 33 lengths. So we need to optimize this. If we calculate the exact signatures for all 3 functions it can be seen that

>>> 0x100000000000000000000000000000000000000000000000000000000*0x00000000
0
>>> 0x100000000000000000000000000000000000000000000000000000000*0x00000001
26959946667150639794667015087019630673637144422540572481103610249216
>>> 0x100000000000000000000000000000000000000000000000000000000*0xa9cc4718
76801798882816152179971038701967765803267330225417895110049134443494132154368

So we know that on our YUL code if we read let x := calldataload(0), f00000000_bvvvdlt function signature will have a 0 value, f00000001_grffjzz function signature will be 26959946667150639794667015087019630673637144422540572481103610249216 and fail function will be 0x100000000000000000000000000000000000000000000000000000000. So to optimize this we can use shr operator which is a logical right-shift operation. We just need to find a correct number to shift incoming x value. It should be same for f00000001_grffjzz and f00000000_bvvvdlt but it should be different for fail function. Luckly we know that fail function's signature is bigger than both of them. So if we find a correct value we can finally write our optimized code.

def find_shr(val1, val2, val3):
x = 1
while 1:
if val1 >> x == 0 and val2 >> x == 0 and val3 >> x > 0:
return("Correct value is: " + str(x))
x += 1

And if we give our variables to this function:

>>> find_shr(0, 26959946667150639794667015087019630673637144422540572481103610249216, 768017988828161521799710387019677 
65803267330225417895110049134443494132154368)
'Correct value is: 225'

So we can build our algorithm with the value 255. Algorithm will be

  • if shr(225,x) == 0 then check for exact x value and return the caller or origin
  • revert otherwise

So the final code will be like this.

object "Attack" {
code {
datacopy(0, dataoffset("runtime"), datasize("runtime"))
return(0, datasize("runtime"))
}
object "runtime" {
code {
let x := calldataload(0)
let y := 0x20
mstore(y, origin())

if iszero(shr(225,x)){
if iszero(x){
mstore(y, caller())
}
return(y, y)
}
revert(y,y)
}
}
}

We are setting origin() value into the address 0x20 in the beginning. Then we are checking if shr(255,x) is 0, and if its 0 then we should check if x is 0. If thats the case store the caller() in to the 0x20 and return address 0x20, revert otherwise.

POC

We can compile our yul contract with solc compiler.

> solc --strict-assembly .\guardian\contracts\attack.yul

======= guardian/contracts/attack.yul (EVM) =======

Pretty printed source:
object "Attack" {
code {
datacopy(0, dataoffset("runtime"), datasize("runtime"))
return(0, datasize("runtime"))
}
object "runtime" {
code {
let x := calldataload(0)
let y := 0x20
mstore(y, origin())
if iszero(shr(225, x))
{
if iszero(x) { mstore(y, caller()) }
return(y, y)
}
revert(y, y)
}
}
}


Binary representation:
601e600d600039601e6000f3fe60003560203281528160e11c601a57816016573381525b8081f35b8081fd

So our binary representation is 601e600d600039601e6000f3fe60003560203281528160e11c601a57816016573381525b8081f35b8081fd We can deploy this bytecode with the help of create function.

Solidity Attack Contract

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;


contract Attack {
uint256 public global_size;
address public global_addr;

function deploy(bytes memory given_code) external returns(uint){
bytes memory code = given_code;
address addr;
uint size;
assembly{
addr := create(0, add(code, 0x20), mload(code))
size := extcodesize(addr)
}
global_addr = addr;
global_size = size;
}
}

Python Exploit Function

from brownie import *
from scripts.setup_general import console

def find_shr(val1, val2, val3):
x = 1
while 1:
if val1 >> x == 0 and val2 >> x == 0 and val3 >> x > 0:
return("Correct value is: " + str(x))
x += 1

def exploit():
admin = accounts[0]
attacker = accounts[1]
attack_contract = Attack.deploy({'from': attacker})
gate = Gate.deploy({'from': admin})
new_data = bytes.fromhex('601e600d600039601e6000f3fe60003560203281528160e11c601a57816016573381525b8081f35b8081fd')
console.yellow("Creating attack contract with compiled YUL binary code.")
attack_contract.deploy(new_data)
assert attack_contract.global_size() < 33
console.yellow("YUL binary code's length is: " + str(attack_contract.global_size()))

console.yellow("Calling -> gate.open(attack_contract.global_addr(), {'from': attacker})")
gate.open(attack_contract.global_addr(), {'from': attacker})
val = gate.opened()
assert val == True
console.green("Return value of open: "+ str(val))

And the output is

>>> run('poc', 'exploit')

Running 'scripts\poc.py::exploit'...
Transaction sent: 0x34393dfd867ec9fdd00f3ebec5133a7d28cade9e56161803a9a5d3aedd6080b3
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 6
Attack.constructor confirmed Block: 9 Gas used: 334564 (2.79%)
Attack deployed at: 0x3EbF54363552bbCeEFacA481BebD832E978482F3

Transaction sent: 0x39ba8c3c3f46614ef5fac600f16b0b0a7f9bacb17fd7ceb0eea50635bb27336e
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 2
Gate.constructor confirmed Block: 10 Gas used: 244796 (2.04%)
Gate deployed at: 0xE7eD6747FaC5360f88a2EFC03E00d25789F69291

+ Creating attack contract with compiled YUL binary code.
Transaction sent: 0x1942fa28a38e52db90dc3d1967f6108f96fc41bb8e0c1ae9042fee5bea4d98aa
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 7
Attack.deploy confirmed Block: 11 Gas used: 102336 (0.85%)

+ YUL binary code's length is: 30
- Calling -> gate.open(attack_contract.global_addr(), {'from': attacker})
Transaction sent: 0x9cc22ebf9af8e561106761fad30083f3ffb37d8ea9c76910a922ad27ec95ab0c
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 8
Gate.open confirmed Block: 12 Gas used: 46813 (0.39%)

+ Return value of open: True