picoCTF 2022 - Solfire - Part I
Note: This article is part of our picoCTF 2022 Greatest Hits Guide.
Solfire had the fewest solves of all the challenges in picoCTF 2022. Only a handful of teams were able to solve it during the competition. We came to the challenge late, having done all the other challenges first. Our approach isn’t the most elegant, but it did (eventually) get the job done.
This is the first part of a three part series. In Part I, we will cover reversing the eBPF binary we were given.
I. Part I - Reversing the Binary (you are here)
II. Part II - Environment Setup
III. Part III - Exploitation
The Problem
For this challenge we get thrown straight into the deep end. We only have access to a Dockerfile, a solfire.so
binary, and some rust source code. We are also given the following cryptic hint:
What is debt? A perversion of a promise?
Surely one has to pay one’s debts.
Welp. We don’t really know rust. This binary they’ve given us isn’t an x86_64 binary, it’s for an architecture called eBPF
. Looks like we’ll have our work cut out for us.
Decoding the Rust
The first couple lines of the rust source code tells us some basic information about this challenge. First up, this is clearly a Solana
based cryptocurrency/blockchain challenge.
use solana_sdk::{
instruction::{AccountMeta, Instruction},
transaction::Transaction,
};
use solana_program::{
pubkey::Pubkey
};
use anchor_client::solana_sdk::system_instruction::transfer;
use poc_framework::{
solana_sdk::{self, signature::Keypair, signer::Signer},
Environment, LocalEnvironment,
};
To be honest, Solana isn’t something we are familiar with, but we did recently complete the excellent Damn Vulnerable DeFi Ethereum challenges, so maybe some of that knowledge will pay-off here.
It turns out poc_framework is something like Hardhat - a way to deploy a Solana test environment with our own users and smart contracts.
Let’s see what this code is doing:
let mut env_builder = LocalEnvironment::builder();
let mut env = env_builder.build();
let program_pubkey = env.deploy_program("./solfire.so");
let solve_pubkey = env.deploy_program(solve_file.path());
let user = Keypair::new();
writeln!(socket, "program pubkey: {}", program_pubkey)?;
writeln!(socket, "solve pubkey: {}", solve_pubkey)?;
writeln!(socket, "user pubkey: {}", user.pubkey())?;
let (vault, _) = Pubkey::find_program_address(&["vault".as_ref()], &program_pubkey);
Looks like it deploys two smart contracts: the solfire.so
binary and one of our own.
There is also a user account.
There is also a vault account, which is somehow derived from the smart-contract’s public key, but we aren’t given any information about it.
However, we are given the public keys for both of the contracts and the user account.
const TARGET_AMT: u64 = 50_000;
const INIT_BAL: u64 = 10;
const VAULT_BAL: u64 = 1_000_000;
env.execute_as_transaction(
&[transfer(
&env.payer().pubkey(),
&user.pubkey(),
INIT_BAL,
),
transfer(
&env.payer().pubkey(),
&vault,
VAULT_BAL,
)
],
&[&env.payer()],
);
We learn that the user is given 10 lamports (INIT_BAL
) and the vault is given 1,000,000 lamports (VAULT_BAL
). We can surmise that the solfire.so
contract is in control of the vault.
Following that there is a section on parsing some account metadata stuff - to be honest we don’t really understand that part yet. This is new to us and must be specific to Solana - it doesn’t resemble anything we’ve seen while doing the Ethereum challenges.
Finally there’s this bit, which is where we learn how to get the flag:
line.clear();
assert!(reader.read_line(&mut line)? != 0);
let ix_data_len: usize = line.trim().parse()?;
let mut ix_data = vec![0; ix_data_len];
reader.read_exact(&mut ix_data)?;
let ix = Instruction::new_with_bytes(
solve_pubkey,
&ix_data,
metas
);
let tx = Transaction::new_signed_with_payer(
&[ix],
Some(&user.pubkey()),
&vec![&user],
env.get_recent_blockhash(),
);
env.execute_transaction(tx);
let user_bal = env.get_account(user.pubkey()).unwrap().lamports;
writeln!(socket, "user bal: {:?}", user_bal)?;
writeln!(socket, "vault bal: {:?}", env.get_account(vault).unwrap().lamports)?;
if user_bal > TARGET_AMT {
writeln!(socket, "congrats!")?;
if let Ok(flag) = env::var("FLAG") {
writeln!(socket, "flag: {:?}", flag)?;
} else {
writeln!(socket, "flag not found, please contact admin")?;
}
}
Aha! We get to specify some instruction data and call a single instruction on our smart contract. If, after the transaction executes, the user’s account balance is > 50,000 lamports then the flag will be printed.
All we have to do is turn our measly 10 lamports into over 50,000 lamports in a single transaction! How hard could that be…
Decoding the eBPF Smart Contract
The next problem is decoding this pesky solfire.so
binary. Apparently there are some ghidra plugins for that, but getting those to work would probably mean compiling some java. Since compiling java and dealing with setting up the environment and plugins correctly is my least favourite thing in the world - let’s instead see if we do this in a more difficult and time consuming way.
In general readelf
does seem to understand the binary. It’s a dynamic library, so let’s see what functions it exports:
$ readelf --dyn-syms solfire.so
Symbol table '.dynsym' contains 19 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND sol_panic_
2: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND sol_log_
3: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND sol_log_pubkey
4: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND sol_invoke_signed_c
5: 00000000000000e8 376 FUNC GLOBAL DEFAULT 1 itoa
6: 0000000000000878 16 FUNC GLOBAL DEFAULT 1 log_pubkey
7: 0000000000000bd8 248 FUNC GLOBAL DEFAULT 1 is_system_program
8: 0000000000001758 1384 FUNC GLOBAL DEFAULT 1 handle_withdraw
9: 0000000000000888 160 FUNC GLOBAL DEFAULT 1 strncmp
10: 0000000000000260 528 FUNC GLOBAL DEFAULT 1 log_int
11: 0000000000000a78 352 FUNC GLOBAL DEFAULT 1 str_contains
12: 00000000000011d8 208 FUNC GLOBAL DEFAULT 1 check_owner
13: 00000000000012a8 1200 FUNC GLOBAL DEFAULT 1 handle_deposit
14: 0000000000000928 336 FUNC GLOBAL DEFAULT 1 strcmp
15: 0000000000000470 1032 FUNC GLOBAL DEFAULT 1 b58enc
16: 0000000000000cd0 1288 FUNC GLOBAL DEFAULT 1 handle_create
17: 0000000000001cc0 760 FUNC GLOBAL DEFAULT 1 solfire
18: 0000000000001fb8 872 FUNC GLOBAL DEFAULT 1 entrypoint
After a bit of research we decide that we’re probably most interested in the entrypoint
function, and then the solfire
function, and finally the 3 functions with a similar name: handle_create
, handle_withdraw
and handle_deposit
.
Unfortunately, my version of the regular objdump
binary doesn’t seem to like this architecture. However, it does look like llvm-12 has a version of objdump that can read and disassembly eBPF binaries.
$ docker run --rm -it -v $PWD:/work -w /work silkeh/clang:12 llvm-objdump -S solfire.so
solfire.so: file format elf64-bpf
Disassembly of section .text:
00000000000000e8 <itoa>:
29: bf 36 00 00 00 00 00 00 r6 = r3
30: bf 27 00 00 00 00 00 00 r7 = r2
31: bf 18 00 00 00 00 00 00 r8 = r1
32: b7 01 00 00 11 00 00 00 r1 = 17
33: 2d 61 06 00 00 00 00 00 if r1 > r6 goto +6 <LBB0_2>
34: 18 01 00 00 b1 3d 00 00 00 00 00 00 00 00 00 00 r1 = 15793 ll
36: b7 02 00 00 1f 00 00 00 r2 = 31
37: b7 03 00 00 05 00 00 00 r3 = 5
38: b7 04 00 00 00 00 00 00 r4 = 0
39: 85 10 00 00 ff ff ff ff call -1
0000000000000140 <LBB0_2>:
40: b7 01 00 00 01 00 00 00 r1 = 1
41: 15 08 06 00 00 00 00 00 if r8 == 0 goto +6 <LBB0_5>
42: b7 01 00 00 00 00 00 00 r1 = 0
43: bf 82 00 00 00 00 00 00 r2 = r8
0000000000000160 <LBB0_4>:
44: bf 23 00 00 00 00 00 00 r3 = r2
45: 07 01 00 00 01 00 00 00 r1 += 1
46: 3f 62 00 00 00 00 00 00 r2 /= r6
47: 3d 63 fc ff 00 00 00 00 if r3 >= r6 goto -4 <LBB0_4>
0000000000000180 <LBB0_5>:
48: bf 12 00 00 00 00 00 00 r2 = r1
49: 67 02 00 00 20 00 00 00 r2 <<= 32
50: 77 02 00 00 20 00 00 00 r2 >>= 32
51: bf 74 00 00 00 00 00 00 r4 = r7
52: 0f 24 00 00 00 00 00 00 r4 += r2
53: b7 03 00 00 00 00 00 00 r3 = 0
54: 73 34 00 00 00 00 00 00 *(u8 *)(r4 + 0) = r3
55: 07 01 00 00 ff ff ff ff r1 += -1
56: bf 84 00 00 00 00 00 00 r4 = r8
...
We stare at the assembly for a while. We stare some more. We give up.
We’re going to need some tooling support. The generated code is too memory-heavy, with lots of pointers and offsets, it’s really difficult to tell what’s going on.
At some point I stumbled upon uBPF which can jit eBPF bytecode to x86_64 instructions in userland. For better or for worse, I decided to take the following approach (which is admittedly a bit crazy):
- Patch uBPF to dump jitted bytecode, and to basically ignore function calls by always emitting a call to the address
0xdeadbeef
. - Extract the
.text
section of thesolfire.so
binary:
llvm-objcopy solfire.so --dump-section .text=solfire.text.bin
- For each of the functions of interest (
entrypoint
,solfire
,handle_create
,handle_withdraw
, andhandle_deposit
) extract the bytecode fromsolfire.text.bin
and run it through uBPF, resulting in some x86_64 bytecode. - Manually disassemble the x86_64 bytecode and generate the equivalent x86_64 assembly for that routine, correcting the function calls as they were all broken.
- Extract the
.rodata
section, and correct the assembly such that it properly references the static strings contained there. - Compile my own object file with stubs for the missing external functions. Also, define some of the basic structures from emsdk and compile with debugging symbols enabled so that they are available for use by the decompiler.
This was obviously a highly-manual process. I’ve since improved it somewhat by allowing uBPF to directly intake the eBPF .so
and output a static .o
file in x86_64, but that’s a post for another time. In any case, this is what I actually did during the competition.
The result is this binary, which is a normal x86_64 binary that is compatible with all of the regular tools (ie: ghidra).
Binary Analysis
We can now analyze each of those functions of interest in turn:
entrypoint
I didn’t spend a lot of time on this function. The normal behavior for entrypoints is to use the library deserializer and then just call another function, in this case solfire
.
solfire
undefined8 solfire(SolParameters *param_1)
{
bool bVar1;
long lVar2;
uint line;
uint extraout_EDX;
ulong uVar3;
char *str;
ulong uVar4;
byte *b58;
ulong uVar5;
uint64_t buf_size;
byte buf [100];
SolAccountInfo *account_0;
int cmd;
account_0 = param_1->ka;
buf_size = 100;
b58 = buf;
b58enc((char *)b58,&buf_size,account_0->key,0x20);
uVar3 = 0;
line = (uint)buf[0];
while( true ) {
uVar4 = 0;
if (buf[0] != 0) {
uVar4 = 0;
do {
lVar2 = uVar4 + 1;
uVar4 = uVar4 + 1;
} while (buf[lVar2] != 0);
}
if (uVar4 <= uVar3) break;
uVar5 = 0;
while (b58[uVar5] == s_C1ock_00013d50[uVar5]) {
if ((b58[uVar5] == 0) || (bVar1 = 3 < uVar5, uVar5 = uVar5 + 1, bVar1)) {
if (uVar4 <= uVar3) goto lbl6011c;
if (*(ulong *)(account_0->data + 0x20) < 0x6230b800) {
if (param_1->data_len < 4) {
sol_panic(s_./src/solfire/solfire.c_00013d76,0x18,0xc5);
line = extraout_EDX;
}
cmd = *(int *)param_1->data;
if (cmd == 0) {
handle_create(param_1);
}
else {
if (cmd == 1) {
handle_deposit(param_1);
}
else {
if (cmd != 2) {
sol_panic(s_invalid_op_choice_00013d64,0x11,line);
log_int(*(int *)param_1->data);
return 0x200000000;
}
handle_withdraw(param_1);
}
}
return 0;
}
str = s_it_is_too_late_to_do_this_challe_00013de0;
cmd = 0x26;
goto lbl60155;
}
}
b58 = b58 + 1;
uVar3 = uVar3 + 1;
}
lbl6011c:
str = s_bad_C1ock_account_00013d9f;
cmd = 0x11;
lbl60155:
sol_log(str,cmd);
return 0x200000000;
}
This function is a little long, but as the functional “entrypoint” for this smart-contract it does the following every time any of the other functions (handle_create
, handle_deposit
, or handle_withdraw
) are invoked:
- b58 encode the public key for
accounts[0]
(NOTE: this is probably a lot of computation) - Search the encoded address for the sub-string
"C1ock"
- If
"C1ock"
was not found, log"bad C1ock account"
and quit - If
"C1ock"
was found, read 8 bytes from that account’s data, at offset0x20
, and compare that value to0x6230b800
- If the value is greater than (or equal to)
0x6230b800
log the message"it is too late to do this challenge :("
and quit - If the value is less than
0x6230b800
, ensure that we were given at least 4 bytes of instruction data - Treat the first 4 bytes of instruction data as an integer and do the following:
a) If the value is0
, callhandle_create
b) If the value is1
, callhandle_deposit
c) If the value is2
, callhandle_withdraw
d) Otherwise, log the value and quit
Basically, account[0]
must have an address that contains "C1ock"
. It turns out there is a built-in Solana SysVar that has an address that contains the string "C1ock"
. But what is at offset 0x20
? A unix timestamp. And what time is 0x6230b800
? Tue Mar 15 2022 16:00:00 GMT+0000 - ie: the exact start of picoCTF 2022. Obviously we somehow need to use something else here, or maybe somehow invent time travel.
handle_create
undefined8 handle_create(SolParameters *param_1)
{
uint8_t uVar1;
bool bVar2;
ulong uVar3;
uint8_t *puVar4;
SolInstruction inst;
uint8_t inst_buf [52];
SolAccountMeta meta [2];
SolSignerSeeds seeds;
SolSignerSeed seed;
uint8_t inst_data_offset_4;
SolAccountInfo *accounts;
SolPubkey *local_pubkey;
sol_log(s_handle_create_00013e42,0xd);
if (param_1->ka_num != 5) {
sol_panic(s_./src/solfire/solfire.c_00013d76,0x18,0x22);
}
accounts = param_1->ka;
inst_data_offset_4 = param_1->data[4];
inst_buf[24] = '\0';
inst_buf[25] = '\0';
inst_buf[26] = '\0';
inst_buf[27] = '\0';
inst_buf[28] = '\0';
inst_buf[29] = '\0';
inst_buf[30] = '\0';
inst_buf[31] = '\0';
inst_buf._16_4_ = 0;
inst_buf[20] = '\0';
inst_buf[21] = '\0';
inst_buf[22] = '\0';
inst_buf[23] = '\0';
inst_buf._8_4_ = 0;
inst_buf._12_4_ = 0;
inst_buf._0_2_ = 0;
inst_buf._2_2_ = 0;
inst_buf._4_2_ = 0;
inst_buf._6_2_ = 0;
if ((accounts[1].key)->x[0] == '\0') {
uVar3 = 1;
do {
puVar4 = inst_buf + uVar3;
uVar1 = (accounts[1].key)->x[uVar3];
if (uVar1 != *puVar4) break;
bVar2 = uVar3 < 0x1f;
uVar3 = uVar3 + 1;
} while (bVar2);
if (uVar1 == *puVar4) goto lbl30124;
}
sol_panic(s_./src/solfire/solfire.c_00013d76,0x18,0x2a);
lbl30124:
seed.addr = &inst_data_offset_4;
seed.len = 1;
seeds.addr = &seed;
seeds.len = 1;
meta[0].pubkey = accounts[4].key;
meta[0]._8_2_ = 0x101;
meta[1].pubkey = accounts[2].key;
meta[1]._8_2_ = 0x101;
inst_buf._12_4_ = 0x2800;
inst_buf._16_4_ = 0;
inst_buf._2_2_ = 0;
inst_buf._0_2_ = 0;
inst_buf._4_2_ = 1;
inst_buf._6_2_ = 0;
inst_buf._8_4_ = 0;
local_pubkey = param_1->program_id;
inst_buf[20] = local_pubkey->x[0];
inst_buf[21] = local_pubkey->x[1];
inst_buf[22] = local_pubkey->x[2];
inst_buf[23] = local_pubkey->x[3];
inst_buf[24] = local_pubkey->x[4];
inst_buf[25] = local_pubkey->x[5];
inst_buf[26] = local_pubkey->x[6];
inst_buf[27] = local_pubkey->x[7];
inst_buf[28] = local_pubkey->x[8];
inst_buf[29] = local_pubkey->x[9];
inst_buf[30] = local_pubkey->x[10];
inst_buf[31] = local_pubkey->x[0xb];
inst_buf[32] = local_pubkey->x[0xc];
inst_buf[33] = local_pubkey->x[0xd];
inst_buf[34] = local_pubkey->x[0xe];
inst_buf[35] = local_pubkey->x[0xf];
inst_buf._36_8_ = *(undefined8 *)(local_pubkey->x + 0x10);
inst_buf._44_8_ = *(undefined8 *)(local_pubkey->x + 0x18);
inst.program_id = accounts[1].key;
inst.data_len = 0x34;
inst.data = inst_buf;
inst.account_len = 2;
inst.accounts = meta;
sol_invoke_signed_c(&inst,param_1->ka,(int)param_1->ka_num,&seeds,1);
return 0;
}
This function is a little simpler to understand:
- Make sure we’ve passed in exactly 5 accounts
- Make sure
accounts[1]
key is all zeros. (In b58 encoding, all zeros is represented as11111111111111111111111111111111
- which is the system program) - Construct a seed based on 1 byte from the instruction data (at offset 4, which immediately follows the 4 bytes used to specify
handle_create
in the first place). - Construct some account metadata with exactly 2 accounts:
i)accounts[4]
(SIGN
+WRITE
permissions)
ii)accounts[2]
(SIGN
+WRITE
permissions) - Do a cross-program invocation to the SYSTEM program. We send
0x34
bytes of instruction data:- 4 bytes - [all zeros] - Indicates we want the
CreateAccount
instruction - 8 bytes - [
0x01
padded with zeros] - Number of lamports to transfer into the new account - 8 bytes - [
0x2800
padded with zeros] - Space to allocate (in data) for the new account - 32 bytes - [copied from
program_id
] - Owner of the new account
- 4 bytes - [all zeros] - Indicates we want the
- For the
CreateAccount
call, the first account in the metadata (accounts[4]
) is the funding account, and the second account in the metadata (accounts[2]
) is the created account. Since these accounts must both signed, and it costs 1 lamport, the only choice foraccounts[4]
is user (who starts out with 10 lamports and can sign the transaction). (The program itself is signing on behalf ofaccounts[2]
as this is a Program Derived Address - more on that later).
handle_deposit
undefined8 handle_deposit(SolParameters *param_1)
{
uint8_t uVar1;
uint8_t uVar2;
bool bVar3;
long lVar4;
long lVar5;
ulong uVar6;
uint64_t *this_ledger;
SolInstruction instruction;
uint8_t inst_buf [12];
SolAccountMeta meta [2];
SolAccountInfo (*accounts) [5];
uint32_t *input;
SolPubkey *account_2_owner;
SolPubkey *local_pubkey;
sol_log(s_handle_deposit_00013e50,0xe);
if (param_1->ka_num != 5) {
sol_panic(s_./src/solfire/solfire.c_00013d76,0x18,0x50);
}
accounts = (SolAccountInfo (*) [5])param_1->ka;
local_pubkey = (*accounts)[1].key;
instruction.data = (uint8_t *)0x0;
instruction.account_len = 0;
instruction.accounts = (SolAccountMeta *)0x0;
instruction.program_id = (SolPubkey *)0x0;
if (local_pubkey->x[0] == '\0') {
uVar6 = 1;
do {
uVar1 = local_pubkey->x[uVar6];
uVar2 = *(uint8_t *)((long)&instruction.program_id + uVar6);
if (uVar1 != uVar2) break;
bVar3 = uVar6 < 0x1f;
uVar6 = uVar6 + 1;
} while (bVar3);
if (uVar1 != uVar2) goto lbl400ed;
}
else {
lbl400ed:
sol_panic(s_./src/solfire/solfire.c_00013d76,0x18,0x56);
}
local_pubkey = param_1->program_id;
account_2_owner = (*accounts)[2].owner;
if (account_2_owner->x[0] == local_pubkey->x[0]) {
uVar6 = 0;
if (account_2_owner->x[1] == local_pubkey->x[1]) {
uVar6 = 0;
do {
if (uVar6 == 0x1e) goto lbl401cb;
lVar4 = uVar6 + 2;
lVar5 = uVar6 + 2;
uVar6 = uVar6 + 1;
} while (account_2_owner->x[lVar4] == local_pubkey->x[lVar5]);
}
if (0x1e < uVar6) goto lbl401cb;
}
sol_panic(s_./src/solfire/solfire.c_00013d76,0x18,0x44);
lbl401cb:
if ((*accounts)[3].is_signer == false) {
sol_panic(s_./src/solfire/solfire.c_00013d76,0x18,0x5b);
}
if (param_1->data_len - 4 < 8) {
sol_panic(s_./src/solfire/solfire.c_00013d76,0x18,0x5c);
}
input = (uint32_t *)param_1->data;
/* input[1] is an ledger "entry" number */
if (0x280 < input[1]) {
sol_panic(s_./src/solfire/solfire.c_00013d76,0x18,0x5f);
}
/* input[2] is the deposit amount */
if (input[2] == 0) {
sol_panic(s_./src/solfire/solfire.c_00013d76,0x18,0x60);
}
meta[0].pubkey = (*accounts)[3].key;
meta[0]._8_2_ = 0x101;
meta[1].pubkey = (*accounts)[4].key;
meta[1]._8_2_ = 1;
inst_buf._2_2_ = 0;
inst_buf._0_2_ = 2;
inst_buf._4_6_ = (uint6)input[2];
inst_buf._10_2_ = 0;
instruction.program_id = (*accounts)[1].key;
instruction.data_len = 0xc;
instruction.data = inst_buf;
instruction.account_len = 2;
instruction.accounts = meta;
sol_invoke_signed_c(&instruction,param_1->ka,(int)param_1->ka_num,
(SolSignerSeeds *)&stack0xffffffffffffffd0,0);
this_ledger = (uint64_t *)((*accounts)[2].data + (ulong)input[1] * 0x10);
this_ledger[1] = this_ledger[1] + (ulong)input[2];
return 0;
}
This one is a little more complicated than the last, but is still understandable:
- Make sure we’ve passed in exactly 5 accounts (again).
- Make sure
accounts[1]
key is all zeros (again). - Make sure
accounts[2]
has an owner and the public key matchesprogram_id
(ie: that account should be owned by the current program). NOTE: I will refer toaccounts[2]
as the “ledger” account. accounts[3]
MUST have signed the request- There should be at least 12 bytes of data
- Treat the input data as an array of ints with the following restrictions:
input[0]
was1
if we got tohandle_deposit
in the first placeinput[1]
MUST be less than or equal to0x280
input[2]
cannot be zero.
- Construct some account metadata with exactly 2 accounts:
i)accounts[3]
(SIGN
+WRITE
permissions)
ii)accounts[4]
(WRITE
permissions) - Do a cross-program invocation to the SYSTEM program. We send
0x0c
bytes of instruction data:- 4 bytes - [
0x02
padded with zeros] - Indicates we want theTransfer
instruction. - 8 bytes - [
input[2]
padded with zeros] - Number of lamports to transfer into destination account
- 4 bytes - [
- For the
Transfer
call: the first account (accounts[3]
) is the funding account, and the second account (accounts[4]
) is the account receiving the funds. NOTE: The funding account must be owned by the system program, as only the owner of the funding account can decrement it’s lamports (anyone can increment any account, but overall things have to balance at the end of the transaction). - Finally, there is a bit of book keeping. We modify
accounts[2]
’s data (which only the owner can do, but that was already verified). We index into the data by0x10
*input[1]
, and increment the second 8 bytes at that offset by the transferred amount.
This is interesting. There are clearly some flaws here. For one, there are no restrictions on where the transfers go. So, for instance, you could funnel any transaction through this function (including one from and to yourself) and it would increment the requested entry in the ledger account (my name for accounts[2]
). Also, there is no ownership associated with any of the entries in the ledger account. Anyone can specify any entry. It also turns out there’s also another bug here which we’ll get to later. Can you spot it?
handle_withdraw
undefined8 handle_withdraw(SolParameters *param_1)
{
uint8_t uVar1;
uint8_t uVar2;
bool bVar3;
long lVar4;
long lVar5;
ulong uVar6;
uint64_t *this_ledger;
SolInstruction instruction;
uint8_t inst_buf [12];
SolAccountMeta meta [2];
SolSignerSeeds seeds;
SolSignerSeed seed;
char seed_buf [6];
SolAccountInfo (*accounts) [5];
uint32_t *in_data;
SolPubkey *account_2_owner;
uint amount_withdrawn;
SolPubkey *local_pubkey;
uint64_t previously_withdrawn;
sol_log(s_handle_withdraw_00013dd0,0xf);
if (param_1->ka_num != 5) {
sol_panic(s_./src/solfire/solfire.c_00013d76,0x18,0x83);
}
accounts = (SolAccountInfo (*) [5])param_1->ka;
local_pubkey = (*accounts)[1].key;
instruction.data = (uint8_t *)0x0;
instruction.account_len = 0;
instruction.accounts = (SolAccountMeta *)0x0;
instruction.program_id = (SolPubkey *)0x0;
if (local_pubkey->x[0] == '\0') {
uVar6 = 1;
do {
uVar1 = local_pubkey->x[uVar6];
uVar2 = *(uint8_t *)((long)&instruction.program_id + uVar6);
if (uVar1 != uVar2) break;
bVar3 = uVar6 < 0x1f;
uVar6 = uVar6 + 1;
} while (bVar3);
if (uVar1 != uVar2) goto lbl500ed;
}
else {
lbl500ed:
sol_panic(s_./src/solfire/solfire.c_00013d76,0x18,0x89);
}
local_pubkey = param_1->program_id;
account_2_owner = (*accounts)[2].owner;
if (account_2_owner->x[0] == local_pubkey->x[0]) {
uVar6 = 0;
if (account_2_owner->x[1] == local_pubkey->x[1]) {
uVar6 = 0;
do {
if (uVar6 == 0x1e) goto lbl501cb;
lVar4 = uVar6 + 2;
lVar5 = uVar6 + 2;
uVar6 = uVar6 + 1;
} while (account_2_owner->x[lVar4] == local_pubkey->x[lVar5]);
}
if (0x1e < uVar6) goto lbl501cb;
}
sol_panic(s_./src/solfire/solfire.c_00013d76,0x18,0x44);
lbl501cb:
if ((*accounts)[3].is_signer == false) {
sol_panic(s_./src/solfire/solfire.c_00013d76,0x18,0x8e);
}
if (param_1->data_len - 4 < 0xc) {
sol_panic(s_./src/solfire/solfire.c_00013d76,0x18,0x8f);
}
in_data = (uint32_t *)param_1->data;
if (0x280 < in_data[1]) {
sol_panic(s_./src/solfire/solfire.c_00013d76,0x18,0x92);
}
if (in_data[2] == 0) {
sol_panic(s_./src/solfire/solfire.c_00013d76,0x18,0x93);
}
seed_buf[4] = 't';
seed_buf._0_4_ = 0x6c756176;
seed_buf[5] = *(char *)(in_data + 3);
seed.len = 6;
seed.addr = (uint8_t *)seed_buf;
seeds.addr = &seed;
seeds.len = 1;
meta[0].pubkey = (*accounts)[4].key;
meta[0]._8_2_ = 0x101;
meta[1].pubkey = (*accounts)[3].key;
meta[1]._8_2_ = 1;
inst_buf._2_2_ = 0;
inst_buf._0_2_ = 2;
inst_buf._4_6_ = (uint6)in_data[2];
inst_buf._10_2_ = 0;
instruction.program_id = (*accounts)[1].key;
instruction.data_len = 0xc;
instruction.data = inst_buf;
instruction.account_len = 2;
instruction.accounts = meta;
sol_invoke_signed_c(&instruction,param_1->ka,(int)param_1->ka_num,&seeds,1);
this_ledger = (uint64_t *)((*accounts)[2].data + (ulong)in_data[1] * 0x10);
amount_withdrawn = in_data[2];
previously_withdrawn = *this_ledger;
*this_ledger = previously_withdrawn + amount_withdrawn;
/* total deposits should exceend total withdrawls */
if (this_ledger[1] < previously_withdrawn + amount_withdrawn) {
sol_panic(s_./src/solfire/solfire.c_00013d76,0x18,0xaf);
}
return 0;
}
This function has a lot of similarities with handle_deposit
.
- Make sure we’ve passed in exactly 5 accounts.
- Make sure
accounts[1]
key is all zeros. (In b58 encoding, all zeros is represented as11111111111111111111111111111111
- which is the system program). - Make sure
accounts[2]
has an owner and the public key matchesprogram_id
(ie: that account should be owned by the current program). NOTE: I will refer toaccounts[2]
as the “ledger” account. accounts[3]
MUST have signed the request- There should be at least 16 bytes of data
- Treat the input data as an array of ints with the following restrictions:
in_data[0]
was2
if we got tohandle_withdraw
in the first placein_data[1]
MUST be less than or equal to0x280
in_data[2]
cannot be zero.in_data[3]
the very first byte must be the seed/nonce used to sign the transaction
- Construct some account metadata with exactly 2 accounts:
i)accounts[4]
(SIGN
+WRITE
permissions)
ii)accounts[3]
(WRITE
permissions) - Do a cross-program invocation to the SYSTEM program. We send
0x0c
bytes of instruction data:- 4 bytes - [
0x02
padded with zeros] - Indicates we want theTransfer
instruction. - 8 bytes - [
in_data[2]
padded with zeros] - number of lamports to transfer into the new account
- 4 bytes - [
- For the
Transfer
call, the first account in the metadata (accounts[4]
) is the funding account, and the second account in the metadata (accounts[3]
) is the account receiving the funds. There is extra logic this time around because the program is signing the request on behalf of the vault account (the vault account is a program-derived-address, which means that only this program can sign for it, but it must use the correct seed associated with that particular account). Since we go through the processes of signing on behalf of the vault account, and only the first account needs to be signed, we can surmise thataccounts[4]
should be the vault account. - Finally, there is a bit of book keeping. We modify
accounts[2]
’s data (which only the owner can do, but that was already verified). We index into the data by0x10
*in_data[1]
, and increment the first 8 bytes at that offset by the transfered amount. We then verify that the new total of the first 8 bytes does not exceed the value of the second 8 bytes. We can take this to mean that the total withdrawn in this entry should not exceed the total deposited (recall that the second 8 bytes was incremented inhandle_deposit
).
Calling Convention
For handle_deposit (0x01)
/ handle_withdraw (0x02)
the accounts are expected in the following order:
- [0] - The
C1ock
address - [1] - The System address
- [2] - The ledger address
- [3] - The user address
- [4] - The vault address
While for handle_create (0x00)
the accounts are expected in the following order:
- [0] - The
C1ock
address - [1] - The System address
- [2] - The ledger address
- [3] - Not used
- [4] - The user address
Recap:
accounts[0]
must containC1ock
(when b58 encoded) but cannot be theC1ock
SysVaraccounts[1]
must be the System addressaccounts[2]
is always the ledger account (which is expected to be generated by a call tohandle_create
)handle_deposit
transfers fromaccounts[3]
(user - must be signed) intoaccounts[4]
(could be any). It takes an index into the ledger data and the number of lamports to transfer.handle_withdraw
transfers intoaccounts[3]
(could be any, but we probably want it to be user) fromaccounts[4]
(vault - must be signed). It taks an index into the ledger data, the number of lamports to transfer, as well as the nonce for the vault PDA.- The system address is used for cross-program invocations of
CreateAccount
andTransfer
In Part II of this series, we will look at setting up a test environment with some debug logging and deploying our very own smart-contract.
I. Part I - Reversing the Binary (you are here)
II. Part II - Environment Setup
III. Part III - Exploitation
Or, if you want to read about other challenges, head back to the picoCTF 2022 Greatest Hits Guide.