Contents

SekaiCTF 2023

https://i.imgur.com/bZdSqfl.png
SekaiCTF 2023

Last weekend, I was spending some of my free time to participate in SekaiCTF 2023 with goals trying to get some bounty from solving the blockchain challenges. I had two attempts for two blockchain challenges called Play For Free and Re-Remix. Fortunately, I can get the second blood for the Play For Free challenge, but failed to get blood for the Re-Remix challenge.

This is my writeup for the Play For Free challenge in the SekaiCTF 2023 (I didn’t try The Bidding challenge because when I finished the Play For Free challenge, the bounty has been claimed by the others).

Blockchain

Play For Free

Description

Tokens are consistently left around the arcade by generous individuals. We can gather these tokens and play 1pc for free!

Author: Y4nhu1

Initial Analysis

For this challenge, we were given a zip file called dist.zip. After I try to unzip it, turns out the setup is quite similar to the usual solana ctf challenge. As usual, let’s start by inspecting the server/src/main.rs first, so that we know what is our goal in this challenge.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
use solana_sdk::{
    signature::{Keypair, Signer},
    pubkey::Pubkey,
    instruction::{Instruction, AccountMeta},
};
use solana_program_test::tokio;
use solana_program::{system_program, system_instruction};
use sol_ctf_framework::ChallengeBuilder;
use rand::{
    rngs::StdRng,
    SeedableRng,
    Rng,
    distributions::{Alphanumeric, DistString},
};
use borsh::ser::BorshSerialize;

use std::{
    fs,
    io::Write,
    error::Error,
    str::FromStr,
    net::{TcpListener, TcpStream},
};

#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
    let listener = TcpListener::bind("0.0.0.0:8080")?;
    println!("Listening on port 8080 ...");
    for stream in listener.incoming() {
        let stream = stream.unwrap();

        tokio::spawn(async move {
            if let Err(err) = handle_connection(stream).await {
                println!("error: {:?}", err);
            }
        });
    }
    Ok(())
}

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

    // load programs
    let pub_str = "ArciEpQvGwZk5yegHEiy27afRZaDLKU8B3kj5MWc38rq";
    let program_id = builder.add_program("Arcade.so", Some(Pubkey::from_str(&pub_str)?));
    let solve_id = builder.input_program().unwrap();

    let mut chall = builder.build().await;
    let payer_keypair = &chall.ctx.payer;
    let payer = payer_keypair.pubkey();

    // create user
    let user = Keypair::new();

    // fund user
    chall.run_ix(system_instruction::transfer(
        &payer,
        &user.pubkey(),
        1_000_000_000,  // 1 SOL
    )).await?;

    // create data account
    let data_account = Keypair::new();

    let mut constructor_data = vec![0x87, 0x2c, 0xcd, 0xc6, 0x19, 0x01, 0x48, 0xbc];    // discriminator

    let mut rng = StdRng::from_entropy();
    constructor_data.extend_from_slice(&rng.gen::<u64>().to_be_bytes());
    constructor_data.extend_from_slice(&rng.gen::<u64>().to_be_bytes());
    constructor_data.extend_from_slice(&rng.gen::<u64>().to_be_bytes());

    let random_address = Keypair::new().pubkey();
    constructor_data.extend_from_slice(&random_address.to_bytes());

    let random_string = Alphanumeric.sample_string(&mut rng, 8);
    constructor_data.extend_from_slice(&BorshSerialize::try_to_vec(&random_string)?);

    // initialize
    chall.run_ixs_full(
        &[Instruction::new_with_bytes(
            program_id,
            &constructor_data,
            vec![
                AccountMeta::new(data_account.pubkey(), true),
                AccountMeta::new(user.pubkey(), true),
                AccountMeta::new_readonly(system_program::id(), false),
            ],
        )],
        &[&data_account, &user],
        &user.pubkey(),
    ).await?;

    // create data account for user program
    let user_data = Keypair::new();

    writeln!(socket, "program: {}", program_id)?;
    writeln!(socket, "data account: {}", data_account.pubkey())?;
    writeln!(socket, "user: {}", user.pubkey())?;
    writeln!(socket, "user data: {}", user_data.pubkey())?;
    
    // run solve
    let solve_ix = chall.read_instruction(solve_id)?;
    chall.run_ixs_full(
        &[solve_ix],
        &[&user_data, &user],
        &user.pubkey(),
    ).await?;

    // check solve
    let account_data = chall.ctx.banks_client.get_account(data_account.pubkey()).await?.unwrap().data;
    let play_count = u32::from_le_bytes(account_data[20..24].try_into().unwrap());
    if play_count > 0 {
        let flag = fs::read_to_string("flag.txt").unwrap();
        writeln!(socket, "Oops, new registered players can also play 1pc for free > <")?;
        writeln!(socket, "{}", flag)?;
    } else {
        writeln!(socket, "You haven't played yet :<")?;
    }

    Ok(())
}

Looking at some of the LOCs located near the end of the file, we can see that the goal is to make the play_count from the challenge contract become larger than 0. So now, let’s move to the challenge contract.

I was surprised because the challenge was written in solidity. Looking through the given program/Makefile, turns out that the contract (Arcade.sol) was written in solang. Googling a little bit about it, solang is a Solidity compiler that can compile Solidity for Solana. Below is the Arcade.sol file

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

@program_id("ArciEpQvGwZk5yegHEiy27afRZaDLKU8B3kj5MWc38rq")
contract Arcade {
    int32 public tokens;
    uint32 public playCount;
    uint64 private forgotten;
    uint64[] private stuckInGap;
    uint64[1] private atBottom;
    address private somewhere;
    string private lookForIt;

    @payer(payer)
    @space(160)
    constructor(uint64[3] memory locations, address _addr, string memory _loc) {
        // drop some tokens :>
        forgotten = locations[0];
        stuckInGap.push(locations[1]);
        atBottom[0] = locations[2];
        somewhere = _addr;
        lookForIt = _loc;
    }

    // find_string_uint64
    function find(string calldata machine, uint64 location) external {
        require(tx.accounts[0].owner == type(Arcade).program_id, "Invalid");
        if (machine == "Token Dispenser") {
            if (location == forgotten && (tokens & 1 == 0)) {
                tokens ^= 1;
                print("Picked it up owo");
                return;
            }
        } else if (machine == "Token Counter") {
            if (location == stuckInGap[0] && (tokens & 2 == 0)) {
                tokens ^= 2;
                print("Saved it >w<");
                return;
            }
        } else if (machine == "Arcade Machine") {
            if (location == atBottom[0] && (tokens & 4 == 0)) {
                tokens ^= 4;
                print("Got it :3");
                return;
            }
        }
        print("Nothing here :(");
    }

    // find_bytes32
    function find(address location) external {
        require(tx.accounts[0].owner == type(Arcade).program_id, "Invalid");
        if (location == somewhere && (tokens & 8 == 0)) {
            tokens ^= 8;
            print("Found it :D");
            return;
        }
        print("Nothing here :(");
    }

    // find_string
    function find(string calldata location) external {
        require(tx.accounts[0].owner == type(Arcade).program_id, "Invalid");
        if(location == lookForIt && (tokens & 16 == 0)) {
            tokens ^= 16;
            print("!!!");
            return;
        }
        print("Nothing here :(");
    }

    function play() external {
        require(tx.accounts[0].owner == type(Arcade).program_id, "Invalid");
        if (tokens == 0x1f) {
            playCount++;
            print("Played 1pc for FREE! XD");
            tokens = -1; // Σ(0v0)
            return;
        } else if (tokens != -1) {
            uint32 cnt = 0;
            for (uint8 i = 0; i < 5; ) {
                if (tokens & (1 << i) > 0) {
                    cnt++;
                }
                unchecked {
                    ++i;
                }
            }
            print("{} / 5 ...".format(cnt));
            return;
        }
    }
}

I didn’t have any knowledge yet about solang, so I try to skim the contract and getting the big picture on what should we do. The contract was quite straightforward. To increase the playCount, we need to make the tokens state variable to 0x1f. In order to set the tokens to 0x1f, we need to call every find functions with the correct parameters (There are 5 different params that need to be sent to set 5 bits of the tokens).

However, observed that the 5 different params that we need to fetch was stored as a private state variable. So the challenge here is how do we fetch this parameters. After we’re able to fetch this parameter, then it should be very easy to do that.

Solution

In blockchain, nothing is private. In EVM, we can still fetch private variable values by directly scanning the storage of the contract. However, this challenge was written in solang, and I don’t know much about how solang works. So, I decided to read the documentation first on how it works, so that I can understand better on solang and gain comprehension on how to fetch the data.

First, I setup solang in my local by:

  • Install the solang compiler from this link.
  • Install solang extension in vscode.

After that, I tried to read the documentation and the guide to get better understanding how it works.

Luckily, the guide was good enough for beginner. The guide also mentioned the difference between EVM and Solana (Solang) regarding how it stored state data. It stated that Solana will create a new account called data account which is used to store the state data & owned by the program.

After knowing this important information, I started to dig more in the documentation, and found a page called builtins which consists of available builtins in Solang.

Reading through it one-by-one, I observed an interesting array called tx.accounts. Checking through its array type (AccountInfo) documentation, we can see that it has a lot of useful properties such as:

  • address (key)
  • data
  • owner
  • is_writable
  • etc

After reading this, I was thinking that maybe we need to check this tx.accounts content, and then try to check the data value.

So, I started to learn how to code in Solang based on reading through the documentation and following the arcade sol. It’s pretty much the same with Solidity, but there are some different syntax for it. For example:

  • We need to specify program_id in the top of our defined contract
  • Specifying address is in form of address'<address>'
  • etc.

To kickstart it, start by running solang new --target solana solve. This will generate a new folder called solve with placeholder .sol file and the toml file. I rename it to solve.sol, and update the toml file to be:

1
2
3
4
5
6
[package]
input_files = ["solve.sol"]
contracts = ["Solve"]

[target]
name = "solana"

To compile the file, we can use solang compile, and it will generate a .so file that we can send to the server. Now, we can start code our solver.

First, I start by defining the arcade interface, so that I can interact with it inside my solver program. One thing to observe is that the Arcade has many functions with same name (find) but with different parameters. At first, I thought that I can just use it in my defined interface for the Arcade. But it doesn’t work, and then I realized that there is the comment on each function in the given Arcade.sol which is the correct name that we can use for the interface (e.g. find_bytes32). Below is the interface that I use:

1
2
3
4
5
6
interface arcadeInterface {
	function find_string_uint64(string calldata machine, uint64 location) external;
	function find_bytes32(address location) external;
	function find_string(string location) external;
	function play() external;
}

To debug it, I didn’t use the given docker. I just directly run the given arcade-server and then use the given solve.py as my baseline for my script to interact with the server.

Next step is we need to define our contract, and try to debug what is tx.accounts and can we retrieve any data from there. After doing trial and error, I observed that the tx.accounts are actually the account_metas array that we pass in the solve.py script. And observed that there is indeed data account on there. Realizing this, the target here is to access the passed data account’s data inside our solver program.

However, at first, I have difficulty running the solver script because somehow it always return error. After debug it for a while, I observed some things that I need to adjust in the baseline script to make it worked:

  • The order in account_metas are important. I need to re-arrange it
    • Because I have no experience in solana challenge, I basically just try to fill the account_metas slowly until it works. Every time I got an error, the error message in the arcade-server logs are quite informative so that I could understand where’s my mistake.
  • For instruction_data, I need to send the correct discriminator for the constructor. We can easily copy the discriminator that is defined in the main.rs.
    • discriminator is basically a selector in EVM context.
  • The main.rs didn’t print the system_program pubkey, so I need to adjust it
    • I find the system_program pubkey address during reading through the log messages in the arcade-server.

Below is the script that I made to interact with the server, which basically just execute the constructor.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# sample solve script to interface with the server
import pwn

# feel free to change this
account_metas = [
    ("user data", "sw"),
    ("user", "sw"), # signer + writable
    ("data account", "-w"), # writable
    ("program", "-r"),  # read only
    ("system program", "-r"),
]

p = pwn.remote("chals.sekai.team", 5043)

with open("solve/Solve.so", "rb") as f:
    solve = f.read()

p.sendlineafter(b"program pubkey: \n", b"EzciEpQvGwZk5yegEHiy27afRZaDLKU8B3jk5MWc38rq")
p.sendlineafter(b"program len: \n", str(len(solve)).encode())
p.send(solve)

accounts = {}
for l in p.recvuntil(b"num accounts: \n", drop=True).strip().split(b"\n"):
    [name, pubkey] = l.decode().split(": ")
    accounts[name] = pubkey
accounts["system program"] = '11111111111111111111111111111111'

p.sendline(str(len(account_metas)).encode())
for (name, perms) in account_metas:
    p.sendline(f"{perms} {accounts[name]}".encode())

instruction_data = bytes([0x87, 0x2c, 0xcd, 0xc6, 0x19, 0x01, 0x48, 0xbc])
p.sendlineafter(b"ix len: \n", str(len(instruction_data)).encode())
p.send(instruction_data)

p.interactive()

After this, we can continue building our solver program. I try to print the tx.accounts data, and it is true that the account_metas == tx.accounts. So, we want to see the data account’s data (which is tx.accounts[2] because we passed it as the third element in the account_metas), and try to print it (This will be shown in the arcade-server logs).

I can see all the state data that we need to recover for it, and the next step is to convert it to the necessary data type. The data consist of all the state data, so we need to parse it first one by one. Below is the parser that I made.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
@program_id("EzciEpQvGwZk5yegEHiy27afRZaDLKU8B3jk5MWc38rq")
contract Solve {
	@payer(payer)
	@space(160)
    constructor() {
		uint64 forgotten = 0;
		uint64 stuckInGap = 0;
		uint64 atBottom = 0;
		address somewhere = address'ArciEpQvGwZk5yegHEiy27afRZaDLKU8B3kj5MWc38rq'; // Placeholder
		string memory lookForIt = "";
		
		AccountInfo ai = tx.accounts[2];
        print("Hello solana - START");
		print("Found key  :{}".format(ai.key));
		print("Owner      :{}".format(ai.owner));
		print("is_writable:{}".format(ai.is_writable));
		print("Data length:{}".format(ai.data.length));

		for (uint8 i = 0x18; i < 0x20; i++) {
			forgotten |= uint64(uint8(int8(ai.data[i]))) << (8*((i-0x18)));
		}

		for (uint8 i = 0x60; i < 0x68; i++) {
			stuckInGap |= uint64(uint8(int8(ai.data[i]))) << (8*((i-0x60)));
		}

		for (uint8 i = 0x24; i < 0x2c; i++) {
			atBottom |= uint64(uint8(int8(ai.data[i]))) << (8*((i-0x24)));
		}

		bytes memory result = new bytes(8);
		for (uint8 i = 0x78; i < 0x80; i++) {
			result[i-0x78] = ai.data[i];
		}

		lookForIt = string(result);
		uint256 test = 0;
		for (uint8 i = 0x2c; i < 0x4c; i++) {
			test = test << 8;
			test += uint256(uint8(int8(ai.data[i])));
		}
		somewhere = address(test);

		print("forgotten :{}".format(forgotten));
		print("stuckInGap:{}".format(stuckInGap));
		print("atBottom  :{}".format(atBottom));
		print("lookForIt :{}".format(lookForIt));
		print("Somewhere :{}".format(somewhere));
    }
}

Checking through the logs, we’re able to recover it:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
[2023-09-11T04:40:05.645371062Z DEBUG solana_runtime::message_processor::stable_log] Program log: Hello solana - START
[2023-09-11T04:40:05.645417159Z DEBUG solana_runtime::message_processor::stable_log] Program log: Found key  :8N4bAitF1hfBEQhb3PjCJ8U7428TU6itVCwtCCX4HncJ
[2023-09-11T04:40:05.645463066Z DEBUG solana_runtime::message_processor::stable_log] Program log: Owner      :ArciEpQvGwZk5yegHEiy27afRZaDLKU8B3kj5MWc38rq
[2023-09-11T04:40:05.645468887Z DEBUG solana_runtime::message_processor::stable_log] Program log: is_writable:true
[2023-09-11T04:40:05.645475129Z DEBUG solana_runtime::message_processor::stable_log] Program log: Data length:160
[2023-09-11T04:40:05.645503723Z DEBUG solana_runtime::message_processor::stable_log] Program log: forgotten :2572700995769453388
[2023-09-11T04:40:05.645511879Z DEBUG solana_runtime::message_processor::stable_log] Program log: stuckInGap:7634685008610503945
[2023-09-11T04:40:05.645519283Z DEBUG solana_runtime::message_processor::stable_log] Program log: atBottom  :15369177546453456043
[2023-09-11T04:40:05.645525545Z DEBUG solana_runtime::message_processor::stable_log] Program log: lookForIt :z1CZAgVT
[2023-09-11T04:40:05.645567995Z DEBUG solana_runtime::message_processor::stable_log] Program log: Somewhere :7wswDUP9e8xYTVVqjtfe1MzoZccAdv5Ecv1R9BMkhSrc

Now that we have recovered the data to be passed to the arcadeProgram, it’s time to update our contract to call the arcade program. To do it, we need to do cross-program-invocation. We can see the example published in the documentation repo to understand how to invoke the CPI. Basically, the syntax is quite similar with how we passed msg.value in EVM, but instead of msg.value, we need to pass the AccountMeta. Below is the full solver that I use to solve the challenge.

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

interface arcadeInterface {
	function find_string_uint64(string calldata machine, uint64 location) external;
	function find_bytes32(address location) external;
	function find_string(string location) external;
	function play() external;
}

@program_id("EzciEpQvGwZk5yegEHiy27afRZaDLKU8B3jk5MWc38rq")
contract Solve {
	@payer(payer)
	@space(160)
    constructor() {
		uint64 forgotten = 0;
		uint64 stuckInGap = 0;
		uint64 atBottom = 0;
		address somewhere = address'ArciEpQvGwZk5yegHEiy27afRZaDLKU8B3kj5MWc38rq'; // Placeholder
		string memory lookForIt = "";
		
		AccountInfo ai = tx.accounts[2];
        print("Hello solana - START");
		print("Found key  :{}".format(ai.key));
		print("Owner      :{}".format(ai.owner));
		print("is_writable:{}".format(ai.is_writable));
		print("Data length:{}".format(ai.data.length));

		for (uint8 i = 0x18; i < 0x20; i++) {
			forgotten |= uint64(uint8(int8(ai.data[i]))) << (8*((i-0x18)));
		}

		for (uint8 i = 0x60; i < 0x68; i++) {
			stuckInGap |= uint64(uint8(int8(ai.data[i]))) << (8*((i-0x60)));
		}

		for (uint8 i = 0x24; i < 0x2c; i++) {
			atBottom |= uint64(uint8(int8(ai.data[i]))) << (8*((i-0x24)));
		}

		bytes memory result = new bytes(8);
		for (uint8 i = 0x78; i < 0x80; i++) {
			result[i-0x78] = ai.data[i];
		}

		lookForIt = string(result);
		uint256 test = 0;
		for (uint8 i = 0x2c; i < 0x4c; i++) {
			test = test << 8;
			test += uint256(uint8(int8(ai.data[i])));
		}
		somewhere = address(test);

		print("forgotten :{}".format(forgotten));
		print("stuckInGap:{}".format(stuckInGap));
		print("atBottom  :{}".format(atBottom));
		print("lookForIt :{}".format(lookForIt));
		print("Somewhere :{}".format(somewhere));

		address programId = address'ArciEpQvGwZk5yegHEiy27afRZaDLKU8B3kj5MWc38rq';
		arcadeInterface arcadeProgram = arcadeInterface(programId);
		AccountMeta[1] metas = [
            AccountMeta({pubkey: ai.key, is_writable: true, is_signer: false})
        ];
		arcadeProgram.find_string_uint64{accounts: metas}("Token Dispenser", forgotten);
		arcadeProgram.find_string_uint64{accounts: metas}("Token Counter", stuckInGap);
		arcadeProgram.find_string_uint64{accounts: metas}("Arcade Machine", atBottom);
		arcadeProgram.find_bytes32{accounts: metas}(somewhere);
		arcadeProgram.find_string{accounts: metas}(lookForIt);
        print("Hello solana - END");
		arcadeProgram.play{accounts: metas}();
    }
}

After doing sequence of CPIs in the above code, we will be able to call play and the arcade-server will give us the flag :D.

Flag: SEKAI{P13453_tAk3_C4r3_0f_Ur_t0k3n5_oR_Oth3r5_w1ll?}

Social Media

Follow me on twitter