Signer Authorization
Summary
- Use Signer Checks to verify that specific accounts have signed a transaction. Without appropriate signer checks, accounts may be able to execute instructions they shouldn’t be authorized to perform.
- To implement a signer check in Rust, simply check that an account’s
is_signer
property istrue
if !ctx.accounts.authority.is_signer {
return Err(ProgramError::MissingRequiredSignature.into());
} - In Anchor, you can use the
Signer
account type in your account validation struct to have Anchor automatically perform a signer check on a given account - Anchor also has an account constraint that will automatically verify that a given account has signed a transaction
Lesson
Signer checks are used to verify that a given account’s owner has authorized a transaction. Without a signer check, operations whose execution should be limited to only specific accounts can potentially be performed by any account. In the worst case scenario, this could result in wallets being completely drained by attackers passing in whatever account they want to an instruction.
Missing Signer Check
The example below shows an oversimplified version of an instruction that updates
the authority
field stored on a program account.
Notice that the authority
field on the UpdateAuthority
account validation
struct is of type AccountInfo
. In Anchor, the AccountInfo
account type
indicates that no checks are performed on the account prior to instruction
execution.
Although the has_one
constraint is used to validate the authority
account
passed into the instruction matches the authority
field stored on the vault
account, there is no check to verify the authority
account authorized the
transaction.
This means an attacker can simply pass in the public key of the authority
account and their own public key as the new_authority
account to reassign
themselves as the new authority of the vault
account. At that point, they can
interact with the program as the new authority.
use anchor_lang::prelude::*;
declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS");
#[program]
pub mod insecure_update{
use super::*;
...
pub fn update_authority(ctx: Context<UpdateAuthority>) -> Result<()> {
ctx.accounts.vault.authority = ctx.accounts.new_authority.key();
Ok(())
}
}
#[derive(Accounts)]
pub struct UpdateAuthority<'info> {
#[account(
mut,
has_one = authority
)]
pub vault: Account<'info, Vault>,
pub new_authority: AccountInfo<'info>,
pub authority: AccountInfo<'info>,
}
#[account]
pub struct Vault {
token_account: Pubkey,
authority: Pubkey,
}
Add signer authorization checks
All you need to do to validate that the authority
account signed is to add a
signer check within the instruction. That simply means checking that
authority.is_signer
is true
, and returning a MissingRequiredSignature
error if false
.
if !ctx.accounts.authority.is_signer {
return Err(ProgramError::MissingRequiredSignature.into());
}
By adding a signer check, the instruction would only process if the account
passed in as the authority
account also signed the transaction. If the
transaction was not signed by the account passed in as the authority
account,
then the transaction would fail.
use anchor_lang::prelude::*;
declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS");
#[program]
pub mod secure_update{
use super::*;
...
pub fn update_authority(ctx: Context<UpdateAuthority>) -> Result<()> {
if !ctx.accounts.authority.is_signer {
return Err(ProgramError::MissingRequiredSignature.into());
}
ctx.accounts.vault.authority = ctx.accounts.new_authority.key();
Ok(())
}
}
#[derive(Accounts)]
pub struct UpdateAuthority<'info> {
#[account(
mut,
has_one = authority
)]
pub vault: Account<'info, Vault>,
pub new_authority: AccountInfo<'info>,
pub authority: AccountInfo<'info>,
}
#[account]
pub struct Vault {
token_account: Pubkey,
authority: Pubkey,
}
Use Anchor’s Signer
account type
However, putting this check into the instruction function muddles the separation between account validation and instruction logic.
Fortunately, Anchor makes it easy to perform signer checks by providing the
Signer
account type. Simply change the authority
account’s type in the
account validation struct to be of type Signer
, and Anchor will check at
runtime that the specified account is a signer on the transaction. This is the
approach we generally recommend since it allows you to separate the signer check
from instruction logic.
In the example below, if the authority
account does not sign the transaction,
then the transaction will fail before even reaching the instruction logic.
use anchor_lang::prelude::*;
declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS");
#[program]
pub mod secure_update{
use super::*;
...
pub fn update_authority(ctx: Context<UpdateAuthority>) -> Result<()> {
ctx.accounts.vault.authority = ctx.accounts.new_authority.key();
Ok(())
}
}
#[derive(Accounts)]
pub struct UpdateAuthority<'info> {
#[account(
mut,
has_one = authority
)]
pub vault: Account<'info, Vault>,
pub new_authority: AccountInfo<'info>,
pub authority: Signer<'info>,
}
#[account]
pub struct Vault {
token_account: Pubkey,
authority: Pubkey,
}
Note that when you use the Signer
type, no other ownership or type checks are
performed.
Use Anchor’s #[account(signer)]
constraint
While in most cases, the Signer
account type will suffice to ensure an account
has signed a transaction, the fact that no other ownership or type checks are
performed means that this account can’t really be used for anything else in the
instruction.
This is where the signer
constraint comes in handy. The #[account(signer)]
constraint allows you to verify the account signed the transaction, while also
getting the benefits of using the Account
type if you wanted access to it’s
underlying data as well.
As an example of when this would be useful, imagine writing an instruction that
you expect to be invoked via CPI that expects one of the passed in accounts to
be both a **signer****** on the transaciton and a *******data
source*******. Using the Signer
account type here removes the
automatic deserialization and type checking you would get with the Account
type. This is both inconvenient, as you need to manually deserialize the account
data in the instruction logic, and may make your program vulnerable by not
getting the ownership and type checking performed by the Account
type.
In the example below, you can safely write logic to interact with the data
stored in the authority
account while also verifying that it signed the
transaction.
use anchor_lang::prelude::*;
declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS");
#[program]
pub mod secure_update{
use super::*;
...
pub fn update_authority(ctx: Context<UpdateAuthority>) -> Result<()> {
ctx.accounts.vault.authority = ctx.accounts.new_authority.key();
// access the data stored in authority
msg!("Total number of depositors: {}", ctx.accounts.authority.num_depositors);
Ok(())
}
}
#[derive(Accounts)]
pub struct UpdateAuthority<'info> {
#[account(
mut,
has_one = authority
)]
pub vault: Account<'info, Vault>,
pub new_authority: AccountInfo<'info>,
#[account(signer)]
pub authority: Account<'info, AuthState>
}
#[account]
pub struct Vault {
token_account: Pubkey,
authority: Pubkey,
}
#[account]
pub struct AuthState{
amount: u64,
num_depositors: u64,
num_vaults: u64
}
Lab
Let’s practice by creating a simple program to demonstrate how a missing signer check can allow an attacker to withdraw tokens that don’t belong to them.
This program initializes a simplified token “vault” account and demonstrates how a missing signer check could allow the vault to be drained.
1. Starter
To get started, download the starter code from the starter
branch of
this repository. The
starter code includes a program with two instructions and the boilerplate setup
for the test file.
The initialize_vault
instruction initializes two new accounts: Vault
and
TokenAccount
. The Vault
account will be initialized using a Program Derived
Address (PDA) and store the address of a token account and the authority of the
vault. The authority of the token account will be the vault
PDA which enables
the program to sign for the transfer of tokens.
The insecure_withdraw
instruction will transfer tokens in the vault
account’s token account to a withdraw_destination
token account. However, the
authority
account in the InsecureWithdraw
struct has a type of
UncheckedAccount
. This is a wrapper around AccountInfo
to explicitly
indicate the account is unchecked.
Without a signer check, anyone can simply provide the public key of the
authority
account that matches authority
stored on the vault
account and
the insecure_withdraw
instruction would continue to process.
While this is somewhat contrived in that any DeFi program with a vault would be more sophisticated than this, it will show how the lack of a signer check can result in tokens being withdrawn by the wrong party.
use anchor_lang::prelude::*;
use anchor_spl::token::{self, Mint, Token, TokenAccount};
declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS");
#[program]
pub mod signer_authorization {
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 amount = ctx.accounts.token_account.amount;
let seeds = &[b"vault".as_ref(), &[*ctx.bumps.get("vault").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.vault.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,
seeds = [b"vault"],
bump
)]
pub vault: Account<'info, Vault>,
#[account(
init,
payer = authority,
token::mint = mint,
token::authority = vault,
)]
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> {
#[account(
seeds = [b"vault"],
bump,
has_one = token_account,
has_one = authority
)]
pub vault: Account<'info, Vault>,
#[account(mut)]
pub token_account: Account<'info, TokenAccount>,
#[account(mut)]
pub withdraw_destination: Account<'info, TokenAccount>,
pub token_program: Program<'info, Token>,
/// CHECK: demo missing signer check
pub authority: UncheckedAccount<'info>,
}
#[account]
pub struct Vault {
token_account: Pubkey,
authority: Pubkey,
}
2. Test insecure_withdraw
instruction
The test file includes the code to invoke the initialize_vault
instruction
using wallet
as the authority
on the vault. The code then mints 100 tokens
to the vault
token account. Theoretically, the wallet
key should be the only
one that can withdraw the 100 tokens from the vault.
Now, let’s add a test to invoke insecure_withdraw
on the program to show that
the current version of the program allows a third party to in fact withdraw
those 100 tokens.
In the test, we’ll still use the public key of wallet
as the authority
account, but we’ll use a different keypair to sign and send the transaction.
describe("signer-authorization", () => {
...
it("Insecure withdraw", async () => {
const tx = await program.methods
.insecureWithdraw()
.accounts({
vault: vaultPDA,
tokenAccount: tokenAccount.publicKey,
withdrawDestination: withdrawDestinationFake,
authority: wallet.publicKey,
})
.transaction()
await anchor.web3.sendAndConfirmTransaction(connection, tx, [walletFake])
const balance = await connection.getTokenAccountBalance(
tokenAccount.publicKey
)
expect(balance.value.uiAmount).to.eq(0)
})
})
Run anchor test
to see that both transactions will complete successfully.
signer-authorization
✔ Initialize Vault (810ms)
✔ Insecure withdraw (405ms)
Since there is no signer check for the authority
account, the
insecure_withdraw
instruction will transfer tokens from the vault
token
account to the withdrawDestinationFake
token account as long as the public key
of theauthority
account matches the public key stored on the authority field
of the vault
account. Clearly, the insecure_withdraw
instruction is as
insecure as the name suggests.
3. Add secure_withdraw
instruction
Let’s fix the problem in a new instruction called secure_withdraw
. This
instruction will be identical to the insecure_withdraw
instruction, except
we’ll use the Signer
type in the Accounts struct to validate the authority
account in the SecureWithdraw
struct. If the authority
account is not a
signer on the transaction, then we expect the transaction to fail and return an
error.
use anchor_lang::prelude::*;
use anchor_spl::token::{self, Mint, Token, TokenAccount};
declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS");
#[program]
pub mod signer_authorization {
use super::*;
...
pub fn secure_withdraw(ctx: Context<SecureWithdraw>) -> Result<()> {
let amount = ctx.accounts.token_account.amount;
let seeds = &[b"vault".as_ref(), &[*ctx.bumps.get("vault").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.vault.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(
seeds = [b"vault"],
bump,
has_one = token_account,
has_one = authority
)]
pub vault: Account<'info, Vault>,
#[account(mut)]
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
With the instruction in place, return to the test file to test the
secure_withdraw
instruction. Invoke the secure_withdraw
instruction, again
using the public key of wallet
as the authority
account and the
withdrawDestinationFake
keypair as the signer and withdraw destination. Since
the authority
account is validated using the Signer
type, we expect the
transaction to fail the signer check and return an error.
describe("signer-authorization", () => {
...
it("Secure withdraw", async () => {
try {
const tx = await program.methods
.secureWithdraw()
.accounts({
vault: vaultPDA,
tokenAccount: tokenAccount.publicKey,
withdrawDestination: withdrawDestinationFake,
authority: wallet.publicKey,
})
.transaction()
await anchor.web3.sendAndConfirmTransaction(connection, tx, [walletFake])
} catch (err) {
expect(err)
console.log(err)
}
})
})
Run anchor test
to see that the transaction will now return a signature
verification error.
Error: Signature verification failed
That’s it! This is a fairly simple thing to avoid, but incredibly important. Make sure to always think through who should who should be authorizing instructions and make sure that each is a signer on the transaction.
If you want to take a look at the final solution code you can find it on the
solution
branch of
the repository.
Challenge
At this point in the course, we hope you've started to work on programs and projects outside the labs and Challenges provided in these lessons. For this and the remainder of the lessons on security vulnerabilities, the Challenge for each lesson will be to audit your own code for the security vulnerability discussed in the lesson.
Alternatively, you can find open source programs to audit. There are plenty of programs you can look at. A good start if you don't mind diving into native Rust would be the SPL programs.
So for this lesson, take a look at a program (whether yours or one you've found online) and audit it for signer checks. If you find a bug in somebody else's program, please alert them! If you find a bug in your own program, be sure to patch it right away.
Push your code to GitHub and tell us what you thought of this lesson!