II. Part II - Environment Setup
III. Part III - Exploitation
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.
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…
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):
0xdeadbeef
..text
section of the solfire.so
binary:llvm-objcopy solfire.so --dump-section .text=solfire.text.bin
entrypoint
, solfire
, handle_create
, handle_withdraw
, and handle_deposit
) extract the bytecode from solfire.text.bin
and run it through uBPF, resulting in some x86_64 bytecode..rodata
section, and correct the assembly such that it properly references the static strings contained there.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).
We can now analyze each of those functions of interest in turn:
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
.
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:
accounts[0]
(NOTE: this is probably a lot of computation)"C1ock"
"C1ock"
was not found, log "bad C1ock account"
and quit"C1ock"
was found, read 8 bytes from that account’s data, at offset 0x20
, and compare that value to 0x6230b800
0x6230b800
log the message "it is too late to do this challenge :("
and quit0x6230b800
, ensure that we were given at least 4 bytes of instruction data0
, call handle_create
1
, call handle_deposit
2
, call handle_withdraw
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.
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:
accounts[1]
key is all zeros. (In b58 encoding, all zeros is represented as 11111111111111111111111111111111
- which is the system program)handle_create
in the first place).accounts[4]
(SIGN
+ WRITE
permissions)accounts[2]
(SIGN
+ WRITE
permissions)0x34
bytes of instruction data: CreateAccount
instruction0x01
padded with zeros] - Number of lamports to transfer into the new account0x2800
padded with zeros] - Space to allocate (in data) for the new accountprogram_id
] - Owner of the new accountCreateAccount
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 for accounts[4]
is user (who starts out with 10 lamports and can sign the transaction). (The program itself is signing on behalf of accounts[2]
as this is a Program Derived Address - more on that later).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:
accounts[1]
key is all zeros (again).accounts[2]
has an owner and the public key matches program_id
(ie: that account should be owned by the current program). NOTE: I will refer to accounts[2]
as the “ledger” account.accounts[3]
MUST have signed the requestinput[0]
was 1
if we got to handle_deposit
in the first placeinput[1]
MUST be less than or equal to 0x280
input[2]
cannot be zero.accounts[3]
(SIGN
+ WRITE
permissions)accounts[4]
(WRITE
permissions)0x0c
bytes of instruction data: 0x02
padded with zeros] - Indicates we want the Transfer
instruction.input[2]
padded with zeros] - Number of lamports to transfer into destination accountTransfer
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).accounts[2]
’s data (which only the owner can do, but that was already verified). We index into the data by 0x10
* 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?
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
.
accounts[1]
key is all zeros. (In b58 encoding, all zeros is represented as 11111111111111111111111111111111
- which is the system program).accounts[2]
has an owner and the public key matches program_id
(ie: that account should be owned by the current program). NOTE: I will refer to accounts[2]
as the “ledger” account.accounts[3]
MUST have signed the requestin_data[0]
was 2
if we got to handle_withdraw
in the first placein_data[1]
MUST be less than or equal to 0x280
in_data[2]
cannot be zero.in_data[3]
the very first byte must be the seed/nonce used to sign the transactionaccounts[4]
(SIGN
+ WRITE
permissions)accounts[3]
(WRITE
permissions)0x0c
bytes of instruction data: 0x02
padded with zeros] - Indicates we want the Transfer
instruction.in_data[2]
padded with zeros] - number of lamports to transfer into the new accountTransfer
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 that accounts[4]
should be the vault account.accounts[2]
’s data (which only the owner can do, but that was already verified). We index into the data by 0x10
* 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 in handle_deposit
).For handle_deposit (0x01)
/ handle_withdraw (0x02)
the accounts are expected in the following order:
C1ock
addressWhile for handle_create (0x00)
the accounts are expected in the following order:
C1ock
addressRecap:
accounts[0]
must contain C1ock
(when b58 encoded) but cannot be the C1ock
SysVaraccounts[1]
must be the System addressaccounts[2]
is always the ledger account (which is expected to be generated by a call to handle_create
)handle_deposit
transfers from accounts[3]
(user - must be signed) into accounts[4]
(could be any). It takes an index into the ledger data and the number of lamports to transfer.handle_withdraw
transfers into accounts[3]
(could be any, but we probably want it to be user) from accounts[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.CreateAccount
and Transfer
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.