Draining a Solana Vault via Pointer Corruption on BlazCTF
Earlier in September a few of the Neplox team members joined up to participate in the Web3 BlazCTF (ctf.blaz.ai), hosted by the awesome Fuzzland team (fuzz.land). We missed it in 2023, and regretted it once we got around to looking at that year's challenges, so this year we were ready to take on the interesting challenges and novel ideas BlazCTF had to offer!
We ended up solving nearly all of the 17 challenges, sadly missing a few of the high-scoring ones,
which placed us 5th out of the 200+ teams (see the scoreboard on CTFTime, ctftime.org/
As usual, the CTF mostly emphasized Solidity smart contract security, but what we liked was that the authors
also tried to innovate with tasks such as "Chisel as a Service", which required achieving RCE (remote code execution)
through the Chisel Solidity REPL of the Foundry toolkit (book.getfoundry.sh/
I personally enjoyed solving the Solana challenges the most,
since they are rarely presented in Web3 CTFs, and are even more
rarely written up, so in this article we'll go through a step-by-step
analysis and solution construction of the "Solalloc" challenge.
If you're interested in checking out BlazCTF writeups by our team for 7 other tasks,
visit our GitHub CTF writeup repo at github.com/
Solalloc
As already said, Solalloc was a Solana-based challenge,
developed using OtterSec's sol-ctf-framework
(github.com/
Tony, chasing the Solana hype, hastily ported his Ethereum contracts, playing fast and loose with Solana's memory allocations. As chaos ensued, Tony realized his overconfidence might cost him dearly. Help Tony unravel the mess he's made.
Let's pick apart how the challenge is setup, figure out the goal we need to achieve to solve it, and then look at it's code to find the vulnerable code which we'll exploit.
Analysis: Challenge setup
Since solalloc is not a classic Solidity foundry-based Web3 CTF challenge,
lets start by unwinding the task's infrastructure in order to understand how to actually
get the flag. The Dockerfile
given alongside the source code of the challenge specifies
this command which will be run to start the instance:
CMD kctf_setup && \
kctf_persist_env && \
kctf_drop_privs nsjail --config /nsjail.cfg -- /bin/kctf_restore_env /home/user/server/bin
/home/user/server/bin
itself is initially built using cargo
from the Rust server
source code. server
's main.rs
(blazctf-2024/sol_ctf_framework::ChallengeBuilder
for each connection with the following setup steps:
-
The
solalloc.so
program is loaded into the execution context. It is built in theDockerfile
from theserver/program
directory containing the program source code in C and aMakefile
using Solana'ssbf.mk
(solana.com/docs/ programs/ lang-c). -
The player's compiled
.so
program is received via the connection and also loaded into the context. -
A random
admin
wallet is generated and funded with 100_000_000_000 lamports (Solana's native token, equivalent to 100 SOL):
let admin_keypair = Keypair::new(); let admin = admin_keypair.pubkey(); chall .run_ix(system_instruction::transfer( &payer, &admin, 100_000_000_000, )) .await?;
-
In the same fashion, the player's wallet (called
user
) is generated, however it is funded with only 1_000_000_000 lamports (1 SOL). -
A program-derived address (PDA, solana.com/
docs/ core/ pda) is computed for the challenge program with the string BLAZ
as the seed. Such addresses are derived from a specific program's "ID" (its public key, pretty much) and arbitrary number of seeds, and allow the program to store data and sign "modifications" on behalf of the PDA, since Solana can check that the address was derived for a specific program. In this case, the PDA is used as a "storage" account by the challenge program, but more about that later. Each derived PDA is returned alongside an additional "bump" seed byte, which is needed for PDA derivation to generate correct addresses with arbitrary user-provided seeds. -
An "instruction" (a single part of a Solana transaction) is executed, specifying three bytes as the instruction data, and the admin, data, program, and system accounts with different permissions. Solana requires instructions to specify the read/write capabilities of all accounts to be involved in an instruction execution, partially for security, partially for optimization reasons. Here, all accounts except the system program account are writable, and the admin account is additionally marked as the "signer" of the instruction:
let ix = Instruction { program_id, accounts: vec![ AccountMeta::new(admin, true), AccountMeta::new(data_addr, false), AccountMeta::new(program_id, false), AccountMeta::new_readonly(system_program::id(), false), ], data: vec![1, data_bump, 0], }; chall.run_ixs_full(&[ix], &[&admin_keypair], &admin).await?;
- A player-provided instruction is then read from the connection and executed
using the player's
user
wallet:
let solve_ix = chall.read_instruction(solve_id)?; chall .run_ixs_full(&[solve_ix], &[&user_keypair], &user_keypair.pubkey()) .await?;
- Finally, the challenge checks that the
user
wallet has over 2 SOL balance (remember that we are initially given only 1 SOL), and, if so, the flag is sent from an environment variable:
if let Some(account) = chall.ctx.banks_client.get_account(user).await? { if account.lamports > 2_000_000_000 { writeln!( socket, "Good job!\n{}", std::env::var("FLAG").unwrap_or_else(|_| "flag{test_flag}".to_string()) )?; ...
Analysis: Challenge program
Now that we know the challenge solution requirements, lets figure out how we
can interact with the program in order to get an additional SOL sent to us.
As described in Solana's C development documentation (solana.com/entrypoint
function (blazctf-2024/SolParameters
and SolAccountInfo
structures and performing basic validation of the specified accounts to make sure that
the first account (labeled CALLER
) is the signer and the third account (labeled PROGRAM_ID
)
actually matches the current program's ID:
if (!sol_deserialize(input, ¶ms, SOL_ARRAY_SIZE(accounts))) { return ERROR_INVALID_ARGUMENT; } memcpy(&caller_key, accounts[CALLER].key, SIZE_PUBKEY); memcpy(&data_account_key, accounts[DATA_ACCOUNT].key, SIZE_PUBKEY); memcpy(&program_id, accounts[PROGRAM_ID].key, SIZE_PUBKEY); memcpy(&system_id, accounts[SYSTEM_ID].key, SIZE_PUBKEY); if (!accounts[CALLER].is_signer) { return ERROR_BLAZ + 0; } if (memcmp(&program_id, params.program_id, SIZE_PUBKEY) != 0) { return ERROR_BLAZ + 1; }
The instruction data is then consumed as a list of "UserInput" structs, with the first byte specifying the number of total inputs:
typedef struct __attribute__((packed)) { uint8_t bump; uint8_t type; uint64_t amount; uint64_t msg_size; uint8_t msg[0]; } UserInput; ... len_actions = params.data[0]; if (len_actions == 0 || len_actions > 3) { return ERROR_BLAZ; } uint64_t offset = 1; while (len_actions--) { UserInput *current_input = (UserInput *)(params.data + offset);
Each input's bump
is used to derive a PDA and verify that it matches the
DATA_ACCOUNT
account specified in the instruction:
offset += sizeof(uint8_t); user_bump = current_input->bump; if (sol_create_program_address(seeds, SOL_ARRAY_SIZE(seeds), &program_id, &data_account_key_verify) != SUCCESS) { return ERROR_BLAZ + 3; } if (memcmp(&data_account_key, &data_account_key_verify, SIZE_PUBKEY) != 0) { return ERROR_BLAZ + 4; }
The input is then processed based on its type
byte, with INIT
, DEPOSIT
, and WITHDRAW
being the possible types. If we recall the instruction executed during
main.rs
, it was: vec![1, data_bump, 0]
, where the 0
corresponds to the INIT
type.
INIT
(blazctf-2024/data_len == 0
), and set its data
field
to the CALLER
account's address. Since we know the solution requirements,
lets immediatelly head for the WITHDRAW
method to see how we can exploit it to extort funds judging only by its name:
case WITHDRAW: { offset += sizeof(uint64_t); uint64_t amount = current_input->amount; offset += sizeof(uint64_t); char *message = (char *)malloc(allocator, current_input->msg_size); if (message != NULL) { strcpy(message, (char *)¤t_input->msg); offset += strlen(message) + 1; } // only owner can withdraw if (memcmp(accounts[DATA_ACCOUNT].data, &caller_key, SIZE_PUBKEY) != 0) { return ERROR_BLAZ; } *accounts[DATA_ACCOUNT].lamports -= amount; *accounts[CALLER].lamports += amount; break; }
As we can see, the WITHDRAW
method specifically verifies that the CALLER
account
from the current instruction matches the CALLER
account which was used to initialize
the PDA account with INIT
. Since the player's instruction is executed with the user
wallet, this check will always fail. What's strange however, is that it uses a stack-local
caller_key
variable to perform the comparison, instead of the actual accounts[CALLER].key
value. Both the WITHDRAW
and DEPOSIT
methods also read an arbitrary message
null-terminated byte-string from the instruction data, which isn't even used for anything
afterwards. Knowing that the task is related to memory management bugs, lets see how these "messages" are allocated and used.
Solana's C development documentation mentions memory allocation in the "Heap" section (solana.com/calloc
syscall or implement their own allocators
using the 32KB region starting at the 0x300000000
address. solalloc
implements
its own malloc
function using a so-called BlazAllocator
structure, which consists
of a single uint64_t free_ptr
field. BlazAllocator
is initialized using the init_allocator
function like so:
BlazAllocator *init_allocator() { BlazAllocator *allocator = (BlazAllocator *)HEAP_START_ADDRESS_; allocator->free_ptr = HEAP_START_ADDRESS_ + sizeof(BlazAllocator); return allocator; }
Where HEAP_START_ADDRESS_
is equal to the 0x300000000
address constant we saw mentioned
in the documentation. malloc
calls then simply increase the free_ptr
field by the
amount specified, checking that the pointer does not overflow the 32KB heap region end:
void *malloc(BlazAllocator *self, uint64_t size) { if (size == 0) { return NULL; } uint64_t size_aligned = (size + 7) & ~7; if (self->free_ptr + size_aligned > HEAP_END_ADDRESS_) { return NULL; } uint64_t *ptr = (uint64_t *)self->free_ptr; self->free_ptr += size_aligned; return ptr; }
Aha! As we can see, the HEAP_END_ADDRESS_
check does not check for uint64_t
overflow,
which can easily occur considering that we are able to pass an arbitrary uint64_t size
using the msg_size
field of the UserInput
structure. This would allow us to overflow
the free_ptr
field to one of the other memory regions available in Solana (solana.com/WITHDRAW
checks that the
caller_key
stack variable matches the original program initializer.
Despite it being set to the accounts[CALLER].key
value, due to the free_ptr
overflow,
we can get malloc
to return a pointer to the caller_key
stack variable,
and overwrite its value using the message
field of our input.
This allows us to bypass the ownership check in WITHDRAW
and drain funds from the
admin.
Solution
Let's start by summarizing the solution plan. First, we need to overflow the free_ptr
pointer to the address at which the caller_key
variable is stored on the stack.
This address can be found by logging it using the sol_log_64
method if we launch the task locally.
However, since WITHDRAW
would stop the instruction with an error, we need to use
the DEPOSIT
method for this step, which also accepts arbitrary message
strings.
Then, we need to call WITHDRAW
with the admin account public key as the message,
which would be used to override the caller_key
address returned by malloc
.
An additional requirement is that all of this is done in a single instruction,
since the BlazAllocator
is initialized at the start of each execution.
This is not a problem, since solalloc
allows us to specify up to 3 inputs per instruction.
Since the task creators have provided a sample solve script,
all we need to do is modify it to specify correct account addresses and data
for our solve.c
Solana program, which is sent to the server in its compiled form once we connect to it.
solve.c
itself contains a pretty simple implementation of the above plan,
sending an instruction to the task program with these 2 inputs:
UserInput ixInputDeposit = { .bump = bump, .type = 1, .amount = 1000, // 0x300000000 is HEAP_START_ADDRESS_, 8 bytes are used by `free_ptr` .msg_size = ((uint64_t)(0x200000fde) - (uint64_t)(0x300000000 + 8)), .msg = "\x00", }; UserInput ixInputWithdraw = { .bump = bump, .type = 2, .amount = 2_000_000_000, // 2 SOL .msg_size = 8, .msg = "\x00", // replaced with admin's pubkey in the final instruction data };
solve.py
simply sends all the USER
, ADMIN
, DATA_ACCOUNT
, PROGRAM_ID
, and SYSTEM_ID
addresses to our program,
alongside the DATA_ACCOUNT
's bump seed, which is calculated using the solders
' library Pubkey.find_program_address
method.
The full solution files are available in our ctfwriteups
GitHub repository, blazctf-2024/
In conclusion
Thanks to the BlazCTF 2024 organizers for hosting a great Web3 CTF, of which there are only a few overall, but even less of them are this diversified in terms of the categories and technologies used to build vulnerable challenges. Neplox will be looking forward to the next one, and we'll be sure to prepare to participate with a larger team now that we know the quality of the event!
Being founders of a very successful CTF team ourselves, C4T BuT S4D (check us out on CTFTime), we're always fond of seeing CTF organization and development taken seriously and with passion.
Interested in working with us, joining up forces for a CTF or Bug Bounty?
Looking for a security audit from experts who'll dig deep into your project instead of just picking the low-hanging fruit?
Drop your contact info at neplox.security/