Skip to content

Latest commit

 

History

History
586 lines (463 loc) · 21.5 KB

owner-checks.md

File metadata and controls

586 lines (463 loc) · 21.5 KB

Owner Checks

Lesson Objectives

By the end of this lesson, you will be able to:

  • Explain the security risks associated with not performing appropriate owner checks
  • Implement owner checks using long-form Rust
  • Use Anchor’s Account<'info, T> wrapper and an account type to automate owner checks
  • Use Anchor’s #[account(owner = <expr>)] constraint to explicitly define an external program that should own an account

TL;DR

  • Use Owner Checks to verify that accounts are owned by the expected program. Without appropriate owner checks, accounts owned by unexpected programs could be used in an instruction.
  • To implement an owner check in Rust, simply check that an account’s owner matches an expected program ID
if ctx.accounts.account.owner != ctx.program_id {
    return Err(ProgramError::IncorrectProgramId.into());
}
  • Anchor program account types implement the Owner trait which allows the Account<'info, T> wrapper to automatically verify program ownership
  • Anchor gives you the option to explicitly define the owner of an account if it should be anything other than the currently executing program

Overview

Owner checks are used to verify that an account passed into an instruction is owned by an expected program. This prevents accounts owned by an unexpected program from being used in an instruction.

As a refresher, the AccountInfo struct contains the following fields. An owner check refers to checking that the owner field in the AccountInfo matches an expected program ID.

/// Account information
#[derive(Clone)]
pub struct AccountInfo<'a> {
    /// Public key of the account
    pub key: &'a Pubkey,
    /// Was the transaction signed by this account's public key?
    pub is_signer: bool,
    /// Is the account writable?
    pub is_writable: bool,
    /// The lamports in the account.  Modifiable by programs.
    pub lamports: Rc<RefCell<&'a mut u64>>,
    /// The data held in this account.  Modifiable by programs.
    pub data: Rc<RefCell<&'a mut [u8]>>,
    /// Program that owns this account
    pub owner: &'a Pubkey,
    /// This account's data contains a loaded program (and is now read-only)
    pub executable: bool,
    /// The epoch at which this account will next owe rent
    pub rent_epoch: Epoch,
}

Missing owner check

The example below shows an admin_instruction intended to be accessible only by an admin account stored on an admin_config account.

Although the instruction checks the admin account signed the transaction and matches the admin field stored on the admin_config account, there is no owner check to verify the admin_config account passed into the instruction is owned by the executing program.

Since the admin_config is unchecked as indicated by the AccountInfo type, a fake admin_config account owned by a different program could be used in the admin_instruction. This means that an attacker could create a program with an admin_config whose data structure matches the admin_config of your program, set their public key as the admin and pass their admin_config account into your program. This would let them effectively spoof your program into thinking that they are the authorized admin for your program.

This simplified example only prints the admin to the program logs. However, you can imagine how a missing owner check could allow fake accounts to exploit an instruction.

use anchor_lang::prelude::*;

declare_id!("Cft4eTTrt4sJU4Ar35rUQHx6PSXfJju3dixmvApzhWws");

#[program]
pub mod owner_check {
    use super::*;
	...

    pub fn admin_instruction(ctx: Context<Unchecked>) -> Result<()> {
        let account_data = ctx.accounts.admin_config.try_borrow_data()?;
        let mut account_data_slice: &[u8] = &account_data;
        let account_state = AdminConfig::try_deserialize(&mut account_data_slice)?;

        if account_state.admin != ctx.accounts.admin.key() {
            return Err(ProgramError::InvalidArgument.into());
        }
        msg!("Admin: {}", account_state.admin.to_string());
        Ok(())
    }
}

#[derive(Accounts)]
pub struct Unchecked<'info> {
    admin_config: AccountInfo<'info>,
    admin: Signer<'info>,
}

#[account]
pub struct AdminConfig {
    admin: Pubkey,
}

Add owner check

In vanilla Rust, you could solve this problem by comparing the owner field on the account to the program ID. If they do not match, you would return an IncorrectProgramId error.

if ctx.accounts.admin_config.owner != ctx.program_id {
    return Err(ProgramError::IncorrectProgramId.into());
}

Adding an owner check prevents accounts owned by an unexpected program to be passed in as the admin_config account. If a fake admin_config account was used in the admin_instruction, then the transaction would fail.

use anchor_lang::prelude::*;

declare_id!("Cft4eTTrt4sJU4Ar35rUQHx6PSXfJju3dixmvApzhWws");

#[program]
pub mod owner_check {
    use super::*;
    ...
    pub fn admin_instruction(ctx: Context<Unchecked>) -> Result<()> {
        if ctx.accounts.admin_config.owner != ctx.program_id {
            return Err(ProgramError::IncorrectProgramId.into());
        }

        let account_data = ctx.accounts.admin_config.try_borrow_data()?;
        let mut account_data_slice: &[u8] = &account_data;
        let account_state = AdminConfig::try_deserialize(&mut account_data_slice)?;

        if account_state.admin != ctx.accounts.admin.key() {
            return Err(ProgramError::InvalidArgument.into());
        }
        msg!("Admin: {}", account_state.admin.to_string());
        Ok(())
    }
}

#[derive(Accounts)]
pub struct Unchecked<'info> {
    admin_config: AccountInfo<'info>,
    admin: Signer<'info>,
}

#[account]
pub struct AdminConfig {
    admin: Pubkey,
}

Use Anchor’s Account<'info, T>

Anchor can make this simpler with the Account type.

Account<'info, T> is a wrapper around AccountInfo that verifies program ownership and deserializes underlying data into the specified account type T. This in turn allows you to use Account<'info, T> to easily validate ownership.

For context, the #[account] attribute implements various traits for a data structure representing an account. One of these is the Owner trait which defines an address expected to own an account. The owner is set as the program ID specified in the declare_id! macro.

In the example below, Account<'info, AdminConfig> is used to validate the admin_config. This will automatically perform the owner check and deserialize the account data. Additionally, the has_one constraint is used to check that the admin account matches the admin field stored on the admin_config account.

This way, you don’t need to clutter your instruction logic with owner checks.

use anchor_lang::prelude::*;

declare_id!("Cft4eTTrt4sJU4Ar35rUQHx6PSXfJju3dixmvApzhWws");

#[program]
pub mod owner_check {
    use super::*;
	...
    pub fn admin_instruction(ctx: Context<Checked>) -> Result<()> {
        msg!("Admin: {}", ctx.accounts.admin_config.admin.to_string());
        Ok(())
    }
}

#[derive(Accounts)]
pub struct Checked<'info> {
    #[account(
        has_one = admin,
    )]
    admin_config: Account<'info, AdminConfig>,
    admin: Signer<'info>,
}

#[account]
pub struct AdminConfig {
    admin: Pubkey,
}

Use Anchor’s #[account(owner = <expr>)] constraint

In addition to the Account type, you can use an owner constraint. The owner constraint allows you to define the program that should own an account if it’s different from the currently executing one. This comes in handy if, for example, you are writing an instruction that expects an account to be a PDA derived from a different program. You can use the seeds and bump constraints and define the owner to properly derive and verify the address of the account passed in.

To use the owner constraint, you’ll have to have access to the public key of the program you expect to own an account. You can either pass the program in as an additional account or hard-code the public key somewhere in your program.

use anchor_lang::prelude::*;

declare_id!("Cft4eTTrt4sJU4Ar35rUQHx6PSXfJju3dixmvApzhWws");

#[program]
pub mod owner_check {
    use super::*;
    ...
    pub fn admin_instruction(ctx: Context<Checked>) -> Result<()> {
        msg!("Admin: {}", ctx.accounts.admin_config.admin.to_string());
        Ok(())
    }
}

#[derive(Accounts)]
pub struct Checked<'info> {
    #[account(
        has_one = admin,
    )]
    admin_config: Account<'info, AdminConfig>,
    admin: Signer<'info>,
    #[account(
            seeds = b"test-seed",
            bump,
            owner = token_program.key()
    )]
    pda_derived_from_another_program: AccountInfo<'info>,
    token_program: Program<'info, Token>
}

#[account]
pub struct AdminConfig {
    admin: Pubkey,
}

Demo

In this demo we’ll use two programs to demonstrate how a missing owner check could allow a fake account to drain the tokens from a simplified token “vault” account (note that this is very similar to the demo from the Signer Authorization lesson).

To help illustrate this, one program will be missing an account owner check on the vault account it withdraws tokens to.

The second program will be a direct clone of the first program created by a malicious user to create an account identical to the first program’s vault account.

Without the owner check, this malicious user will be able to pass in the vault account owned by their “faked” program and the original program will still execute.

1. Starter

To get started, download the starter code from the starter branch of this repository. The starter code includes two programs clone and owner_check and the boilerplate setup for the test file.

The owner_check program includes two instructions:

  • initialize_vault initializes a simplified vault account that stores the addresses of a token account and an authority account
  • insecure_withdraw withdraws tokens from the token account, but is missing an owner check for the vault account
use anchor_lang::prelude::*;
use anchor_spl::token::{self, Mint, Token, TokenAccount};

declare_id!("HQYNznB3XTqxzuEqqKMAD9XkYE5BGrnv8xmkoDNcqHYB");

#[program]
pub mod owner_check {
    use super::*;

    pub fn initialize_vault(ctx: Context<InitializeVault>) -> Result<()> {
        ctx.accounts.vault.token_account = ctx.accounts.token_account.key();
        ctx.accounts.vault.authority = ctx.accounts.authority.key();
        Ok(())
    }

    pub fn insecure_withdraw(ctx: Context<InsecureWithdraw>) -> Result<()> {
        let account_data = ctx.accounts.vault.try_borrow_data()?;
        let mut account_data_slice: &[u8] = &account_data;
        let account_state = Vault::try_deserialize(&mut account_data_slice)?;

        if account_state.authority != ctx.accounts.authority.key() {
            return Err(ProgramError::InvalidArgument.into());
        }

        let amount = ctx.accounts.token_account.amount;

        let seeds = &[
            b"token".as_ref(),
            &[*ctx.bumps.get("token_account").unwrap()],
        ];
        let signer = [&seeds[..]];

        let cpi_ctx = CpiContext::new_with_signer(
            ctx.accounts.token_program.to_account_info(),
            token::Transfer {
                from: ctx.accounts.token_account.to_account_info(),
                authority: ctx.accounts.token_account.to_account_info(),
                to: ctx.accounts.withdraw_destination.to_account_info(),
            },
            &signer,
        );

        token::transfer(cpi_ctx, amount)?;
        Ok(())
    }
}

#[derive(Accounts)]
pub struct InitializeVault<'info> {
    #[account(
        init,
        payer = authority,
        space = 8 + 32 + 32,
    )]
    pub vault: Account<'info, Vault>,
    #[account(
        init,
        payer = authority,
        token::mint = mint,
        token::authority = token_account,
        seeds = [b"token"],
        bump,
    )]
    pub token_account: Account<'info, TokenAccount>,
    pub mint: Account<'info, Mint>,
    #[account(mut)]
    pub authority: Signer<'info>,
    pub token_program: Program<'info, Token>,
    pub system_program: Program<'info, System>,
    pub rent: Sysvar<'info, Rent>,
}

#[derive(Accounts)]
pub struct InsecureWithdraw<'info> {
    /// CHECK:
    pub vault: UncheckedAccount<'info>,
    #[account(
        mut,
        seeds = [b"token"],
        bump,
    )]
    pub token_account: Account<'info, TokenAccount>,
    #[account(mut)]
    pub withdraw_destination: Account<'info, TokenAccount>,
    pub token_program: Program<'info, Token>,
    pub authority: Signer<'info>,
}

#[account]
pub struct Vault {
    token_account: Pubkey,
    authority: Pubkey,
}

The clone program includes a single instruction:

  • initialize_vault initializes a “vault” account that mimics the vault account of the owner_check program. It stores the address of the real vault’s token account, but allows the malicious user to put their own authority account.
use anchor_lang::prelude::*;
use anchor_spl::token::TokenAccount;

declare_id!("DUN7nniuatsMC7ReCh5eJRQExnutppN1tAfjfXFmGDq3");

#[program]
pub mod clone {
    use super::*;

    pub fn initialize_vault(ctx: Context<InitializeVault>) -> Result<()> {
        ctx.accounts.vault.token_account = ctx.accounts.token_account.key();
        ctx.accounts.vault.authority = ctx.accounts.authority.key();
        Ok(())
    }
}

#[derive(Accounts)]
pub struct InitializeVault<'info> {
    #[account(
        init,
        payer = authority,
        space = 8 + 32 + 32,
    )]
    pub vault: Account<'info, Vault>,
    pub token_account: Account<'info, TokenAccount>,
    #[account(mut)]
    pub authority: Signer<'info>,
    pub system_program: Program<'info, System>,
}

#[account]
pub struct Vault {
    token_account: Pubkey,
    authority: Pubkey,
}

2. Test insecure_withdraw instruction

The test file includes a test to invoke the initialize_vault instruction on the owner_check program using the provider wallet as the authority and then mints 100 tokens to the token account.

The test file also includes a test to invoke the initialize_vault instruction on the clone program to initialize a fake vault account storing the same tokenPDA account, but a different authority. Note that no new tokens are minted here.

Let’s add a test to invoke the insecure_withdraw instruction. This test should pass in the cloned vault and the fake authority. Since there is no owner check to verify the vaultClone account is owned by the owner_check program, the instruction’s data validation check will pass and show walletFake as a valid authority. The tokens from the tokenPDA account will then be withdrawn to the withdrawDestinationFake account.

describe("owner-check", () => {
	...
    it("Insecure withdraw", async () => {
    const tx = await program.methods
        .insecureWithdraw()
        .accounts({
            vault: vaultClone.publicKey,
            tokenAccount: tokenPDA,
            withdrawDestination: withdrawDestinationFake,
            authority: walletFake.publicKey,
        })
        .transaction()

        await anchor.web3.sendAndConfirmTransaction(connection, tx, [walletFake])

        const balance = await connection.getTokenAccountBalance(tokenPDA)
        expect(balance.value.uiAmount).to.eq(0)
    })

})

Run anchor test to see that the insecure_withdraw completes successfully.

owner-check
  ✔ Initialize Vault (808ms)
  ✔ Initialize Fake Vault (404ms)
  ✔ Insecure withdraw (409ms)

Note that vaultClone deserializes successfully even though Anchor automatically initializes new accounts with a unique 8 byte discriminator and checks the discriminator when deserializing an account. This is because the discriminator is a hash of the account type name.

#[account]
pub struct Vault {
    token_account: Pubkey,
    authority: Pubkey,
}

Since both programs initialize identical accounts and both structs are named Vault, the accounts have the same discriminator even though they are owned by different programs.

3. Add secure_withdraw instruction

Let’s close up this security loophole.

In the lib.rs file of the owner_check program add a secure_withdraw instruction and a SecureWithdraw accounts struct.

In the SecureWithdraw struct, let’s use Account<'info, Vault> to ensure that an owner check is performed on the vault account. We’ll also use the has_one constraint to check that the token_account and authority passed into the instruction match the values stored on the vault account.

#[program]
pub mod owner_check {
    use super::*;
	...

	pub fn secure_withdraw(ctx: Context<SecureWithdraw>) -> Result<()> {
        let amount = ctx.accounts.token_account.amount;

        let seeds = &[
            b"token".as_ref(),
            &[*ctx.bumps.get("token_account").unwrap()],
        ];
        let signer = [&seeds[..]];

        let cpi_ctx = CpiContext::new_with_signer(
            ctx.accounts.token_program.to_account_info(),
            token::Transfer {
                from: ctx.accounts.token_account.to_account_info(),
                authority: ctx.accounts.token_account.to_account_info(),
                to: ctx.accounts.withdraw_destination.to_account_info(),
            },
            &signer,
        );

        token::transfer(cpi_ctx, amount)?;
        Ok(())
    }
}
...

#[derive(Accounts)]
pub struct SecureWithdraw<'info> {
    #[account(
       has_one = token_account,
       has_one = authority
    )]
    pub vault: Account<'info, Vault>,
    #[account(
        mut,
        seeds = [b"token"],
        bump,
    )]
    pub token_account: Account<'info, TokenAccount>,
    #[account(mut)]
    pub withdraw_destination: Account<'info, TokenAccount>,
    pub token_program: Program<'info, Token>,
    pub authority: Signer<'info>,
}

4. Test secure_withdraw instruction

To test the secure_withdraw instruction, we’ll invoke the instruction twice. First, we’ll invoke the instruction using the vaultClone account, which we expect to fail. Then, we’ll invoke the instruction using the correct vault account to check that the instruction works as intended.

describe("owner-check", () => {
	...
	it("Secure withdraw, expect error", async () => {
        try {
            const tx = await program.methods
                .secureWithdraw()
                .accounts({
                    vault: vaultClone.publicKey,
                    tokenAccount: tokenPDA,
                    withdrawDestination: withdrawDestinationFake,
                    authority: walletFake.publicKey,
                })
                .transaction()

            await anchor.web3.sendAndConfirmTransaction(connection, tx, [walletFake])
        } catch (err) {
            expect(err)
            console.log(err)
        }
    })

    it("Secure withdraw", async () => {
        await spl.mintTo(
            connection,
            wallet.payer,
            mint,
            tokenPDA,
            wallet.payer,
            100
        )

        await program.methods
        .secureWithdraw()
        .accounts({
            vault: vault.publicKey,
            tokenAccount: tokenPDA,
            withdrawDestination: withdrawDestination,
            authority: wallet.publicKey,
        })
        .rpc()

        const balance = await connection.getTokenAccountBalance(tokenPDA)
        expect(balance.value.uiAmount).to.eq(0)
    })
})

Run anchor test to see that the transaction using the vaultClone account will now return an Anchor Error while the transaction using the vault account completes successfully.

'Program HQYNznB3XTqxzuEqqKMAD9XkYE5BGrnv8xmkoDNcqHYB invoke [1]',
'Program log: Instruction: SecureWithdraw',
'Program log: AnchorError caused by account: vault. Error Code: AccountOwnedByWrongProgram. Error Number: 3007. Error Message: The given account is owned by a different program than expected.',
'Program log: Left:',
'Program log: DUN7nniuatsMC7ReCh5eJRQExnutppN1tAfjfXFmGDq3',
'Program log: Right:',
'Program log: HQYNznB3XTqxzuEqqKMAD9XkYE5BGrnv8xmkoDNcqHYB',
'Program HQYNznB3XTqxzuEqqKMAD9XkYE5BGrnv8xmkoDNcqHYB consumed 5554 of 200000 compute units',
'Program HQYNznB3XTqxzuEqqKMAD9XkYE5BGrnv8xmkoDNcqHYB failed: custom program error: 0xbbf'

Here we see how using Anchor’s Account<'info, T> type can simplify the account validation process to automate the ownership check. Additionally, note that Anchor Errors can specify the account that causes the error (e.g. the third line of the logs above say AnchorError caused by account: vault). This can be very helpful when debugging.

✔ Secure withdraw, expect error (78ms)
✔ Secure withdraw (10063ms)

That’s all you need to ensure you check the owner on an account! Like some other exploits, it’s fairly simple to avoid but very important. Be sure to always think through which accounts should be owned by which programs and ensure that you add appropriate validation.

If you want to take a look at the final solution code you can find it on the solution branch of the repository.

Challenge

Just as with other lessons in this module, your opportunity to practice avoiding this security exploit lies in auditing your own or other programs.

Take some time to review at least one program and ensure that proper owner checks are performed on the accounts passed into each instruction.

Remember, if you find a bug or exploit in somebody else's program, please alert them! If you find one in your own program, be sure to patch it right away.