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
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