CCM call instructions
The CCM call instructions perform cross-program invocations on the local Solana chain. Cross-program invocations on Solana pass signer privileges to the called program when the account is passed through in the invocation. Notably, the agg_key
is present as a signer on the CCM call instruction. This can potentially lead to a scenario where the called program calls back into the protocol's programs with the agg_key
as a signer, escalating privileges. We have verified that this is not possible.
The protocol uses the following mitigation for the above scenario.
check_remaining_accounts(
ctx.remaining_accounts,
&[
ctx.program_id,
&ctx.accounts.data_account.agg_key,
&ctx.accounts.data_account.token_vault_pda,
],
)?;
fn check_remaining_accounts(
remaining_accounts: &[AccountInfo<'_>],
keys: &[&Pubkey],
) -> Result<()> {
for account in remaining_accounts.iter() {
for &key in keys.iter() {
require_keys_neq!(*account.key, *key, VaultError::InvalidRemainingAccount);
}
require!(
!account.is_signer,
VaultError::InvalidRemainingAccountSigner
);
}
Ok(())
}
This function is called on the remaining_accounts
, which are passed into the called program. It requires that the agg_key
and the program ID of the vault are not present. This prevents a call into the vault (since the program ID is missing) and prevents the agg_key
from being used (since only accounts present in the caller can be passed to the callee). Furthermore, the function verifies that none of the accounts passed into the called program are signers. On Solana, an account can only be passed as a signer into a called program if it was a signer in the calling program.
The security of the above requires the following properties of Solana:
The program ID of a called program must exist as an account in the calling program.
Only accounts that are present in the caller can be passed into the callee.
Accounts that are passed as a signer or writable must have that property in the caller.
We have verified that the three properties hold on the current standard Solana validator client, Agave.
Cross-program invocations use the following Agave function.
fn cpi_common(
invoke_context: &mut InvokeContext,
instruction_addr: u64,
account_infos_addr: u64,
account_infos_len: u64,
signers_seeds_addr: u64,
signers_seeds_len: u64,
memory_mapping: &MemoryMapping,
)
In turn, cpi_common
calls InvokeContext::prepare_instruction
.
Property 1
The following code in prepare_instruction
searches for the callee_program_id
in the accounts of the current instruction (i.e., the caller). This code returns an error when it is not found.
// Find and validate executables / program accounts
let callee_program_id = instruction.program_id;
let program_account_index = instruction_context
.find_index_of_instruction_account(self.transaction_context, &callee_program_id)
.ok_or_else(|| {
ic_msg!(self, "Unknown program {}", callee_program_id);
InstructionError::MissingAccount
})?;
let borrowed_program_account = instruction_context
.try_borrow_instruction_account(self.transaction_context, program_account_index)?;
if !borrowed_program_account.is_executable() {
ic_msg!(self, "Account {} is not executable", callee_program_id);
return Err(InstructionError::AccountNotExecutable);
}
Property 2
The following code in prepare_instruction
tries to find every account that is passed into the callee in the caller instruction. It returns an error when it is not found.
let index_in_caller = instruction_context
.find_index_of_instruction_account(
self.transaction_context,
&account_meta.pubkey,
)
.ok_or_else(|| {
ic_msg!(
self,
"Instruction references an unknown account {}",
account_meta.pubkey,
);
InstructionError::MissingAccount
})?;
Property 3
The following code in prepare_instruction
checks that the privileges of the account in the callee cannot exceed the caller.
let borrowed_account = instruction_context.try_borrow_instruction_account(
self.transaction_context,
instruction_account.index_in_caller,
)?;
// Readonly in caller cannot become writable in callee
if instruction_account.is_writable && !borrowed_account.is_writable() {
ic_msg!(
self,
"{}'s writable privilege escalated",
borrowed_account.get_key(),
);
return Err(InstructionError::PrivilegeEscalation);
}
// To be signed in the callee,
// it must be either signed in the caller or by the program
if instruction_account.is_signer
&& !(borrowed_account.is_signer() || signers.contains(borrowed_account.get_key()))
{
ic_msg!(
self,
"{}'s signer privilege escalated",
borrowed_account.get_key()
);
return Err(InstructionError::PrivilegeEscalation);
}