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.
usesolana_sdk::{signature::{Keypair,Signer},pubkey::Pubkey,instruction::{Instruction,AccountMeta},};usesolana_program_test::tokio;usesolana_program::{system_program,system_instruction};usesol_ctf_framework::ChallengeBuilder;userand::{rngs::StdRng,SeedableRng,Rng,distributions::{Alphanumeric,DistString},};useborsh::ser::BorshSerialize;usestd::{fs,io::Write,error::Error,str::FromStr,net::{TcpListener,TcpStream},};#[tokio::main]asyncfnmain()-> Result<(),Box<dynError>>{letlistener=TcpListener::bind("0.0.0.0:8080")?;println!("Listening on port 8080 ...");forstreaminlistener.incoming(){letstream=stream.unwrap();tokio::spawn(asyncmove{ifletErr(err)=handle_connection(stream).await{println!("error: {:?}",err);}});}Ok(())}asyncfnhandle_connection(mutsocket: TcpStream)-> Result<(),Box<dynError>>{letmutbuilder=ChallengeBuilder::try_from(socket.try_clone().unwrap()).unwrap();// load programs
letpub_str="ArciEpQvGwZk5yegHEiy27afRZaDLKU8B3kj5MWc38rq";letprogram_id=builder.add_program("Arcade.so",Some(Pubkey::from_str(&pub_str)?));letsolve_id=builder.input_program().unwrap();letmutchall=builder.build().await;letpayer_keypair=&chall.ctx.payer;letpayer=payer_keypair.pubkey();// create user
letuser=Keypair::new();// fund user
chall.run_ix(system_instruction::transfer(&payer,&user.pubkey(),1_000_000_000,// 1 SOL
)).await?;// create data account
letdata_account=Keypair::new();letmutconstructor_data=vec![0x87,0x2c,0xcd,0xc6,0x19,0x01,0x48,0xbc];// discriminator
letmutrng=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());letrandom_address=Keypair::new().pubkey();constructor_data.extend_from_slice(&random_address.to_bytes());letrandom_string=Alphanumeric.sample_string(&mutrng,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
letuser_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
letsolve_ix=chall.read_instruction(solve_id)?;chall.run_ixs_full(&[solve_ix],&[&user_data,&user],&user.pubkey(),).await?;// check solve
letaccount_data=chall.ctx.banks_client.get_account(data_account.pubkey()).await?.unwrap().data;letplay_count=u32::from_le_bytes(account_data[20..24].try_into().unwrap());ifplay_count>0{letflag=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
@program_id("ArciEpQvGwZk5yegHEiy27afRZaDLKU8B3kj5MWc38rq")contractArcade{int32publictokens;uint32publicplayCount;uint64privateforgotten;uint64[]privatestuckInGap;uint64[1]privateatBottom;addressprivatesomewhere;stringprivatelookForIt;@payer(payer)@space(160)constructor(uint64[3]memorylocations,address_addr,stringmemory_loc){// drop some tokens :>
forgotten=locations[0];stuckInGap.push(locations[1]);atBottom[0]=locations[2];somewhere=_addr;lookForIt=_loc;}// find_string_uint64
functionfind(stringcalldatamachine,uint64location)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;}}elseif(machine=="Token Counter"){if(location==stuckInGap[0]&&(tokens&2==0)){tokens^=2;print("Saved it >w<");return;}}elseif(machine=="Arcade Machine"){if(location==atBottom[0]&&(tokens&4==0)){tokens^=4;print("Got it :3");return;}}print("Nothing here :(");}// find_bytes32
functionfind(addresslocation)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
functionfind(stringcalldatalocation)external{require(tx.accounts[0].owner==type(Arcade).program_id,"Invalid");if(location==lookForIt&&(tokens&16==0)){tokens^=16;print("!!!");return;}print("Nothing here :(");}functionplay()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;}elseif(tokens!=-1){uint32cnt=0;for(uint8i=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.
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:
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:
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.
# sample solve script to interface with the serverimportpwn# feel free to change thisaccount_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)withopen("solve/Solve.so","rb")asf: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={}forlinp.recvuntil(b"num accounts: \n",drop=True).strip().split(b"\n"):[name,pubkey]=l.decode().split(": ")accounts[name]=pubkeyaccounts["system program"]='11111111111111111111111111111111'p.sendline(str(len(account_metas)).encode())for(name,perms)inaccount_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.
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.