security: fix unauthorized signer vulnerability in TransactionValidator#369
security: fix unauthorized signer vulnerability in TransactionValidator#369claw-autonomous wants to merge 2 commits intosolana-foundation:mainfrom
Conversation
Greptile SummaryThis PR patches two related security vulnerabilities in the Kora relayer: (1) Key observations:
Confidence Score: 3/5
Sequence DiagramsequenceDiagram
participant Client
participant RPC as Kora RPC
participant VTR as VersionedTransactionResolved
participant IxUtils
participant Validator as TransactionValidator
Client->>RPC: Submit transaction
RPC->>VTR: from_transaction(tx, rpc_client, sig_verify)
VTR->>VTR: Extract header (num_required_signatures,<br/>num_readonly_signed_accounts,<br/>num_readonly_unsigned_accounts)
VTR->>IxUtils: uncompile_instructions(outer_ixs, keys, header…)
Note over IxUtils: is_signer = index < num_required_signatures<br/>is_writable derived from header ranges
IxUtils-->>VTR: Vec<Instruction> (with correct is_signer/is_writable)
VTR->>VTR: fetch_inner_instructions (simulation)
VTR->>IxUtils: uncompile_instructions(inner_ixs, keys, same header…)
Note over IxUtils: ⚠️ CPI signer/writable flags<br/>approximated from outer header
IxUtils-->>VTR: Vec<Instruction> (inner, approximated flags)
VTR->>VTR: all_instructions = outer + inner
VTR-->>RPC: VersionedTransactionResolved
RPC->>Validator: validate_transaction(resolved)
Validator->>Validator: validate_programs() — all programs in allowed list?
Validator->>Validator: validate_transfer_amounts() — async RPC
Validator->>Validator: validate_disallowed_accounts()
Validator->>Validator: validate_fee_payer_usage()
Note over Validator: For each instruction in all_instructions:<br/>If fee_payer is_signer AND program not in<br/>[System, SPL Token, Token2022, ATA, ComputeBudget]<br/>→ reject
Validator-->>RPC: Ok or InvalidTransaction error
RPC-->>Client: Response
|
| let is_allowed_program = | ||
| instruction.program_id == solana_system_interface::program::ID || | ||
| instruction.program_id == spl_token_interface::id() || | ||
| instruction.program_id == spl_token_2022_interface::id() || | ||
| instruction.program_id == spl_associated_token_account_interface::program::id() || | ||
| instruction.program_id.to_string() == "ComputeBudget111111111111111111111111111111"; |
There was a problem hiding this comment.
The ComputeBudget program ID is compared using a string literal rather than a typed constant. All other programs in this block use their typed interface constants. solana_compute_budget_interface is already used elsewhere in the codebase (e.g., crates/lib/src/admin/token_util.rs), so its program ID constant is available.
String-based comparison is slower, more brittle (no compile-time verification), and inconsistent with the adjacent lines.
| let is_allowed_program = | |
| instruction.program_id == solana_system_interface::program::ID || | |
| instruction.program_id == spl_token_interface::id() || | |
| instruction.program_id == spl_token_2022_interface::id() || | |
| instruction.program_id == spl_associated_token_account_interface::program::id() || | |
| instruction.program_id.to_string() == "ComputeBudget111111111111111111111111111111"; | |
| let is_allowed_program = | |
| instruction.program_id == solana_system_interface::program::ID || | |
| instruction.program_id == spl_token_interface::id() || | |
| instruction.program_id == spl_token_2022_interface::id() || | |
| instruction.program_id == spl_associated_token_account_interface::program::id() || | |
| instruction.program_id == solana_compute_budget_interface::id(); |
| let mut resolved = VersionedTransactionResolved { | ||
| transaction: transaction.clone(), | ||
| all_account_keys: transaction.message.static_account_keys().to_vec(), | ||
| all_instructions: IxUtils::uncompile_instructions( | ||
| transaction.message.instructions(), | ||
| transaction.message.static_account_keys(), | ||
| num_required_signatures, | ||
| num_readonly_signed_accounts, | ||
| num_readonly_unsigned_accounts, | ||
| )?, | ||
| parsed_system_instructions: None, | ||
| parsed_spl_instructions: None, | ||
| }) | ||
| }; | ||
|
|
||
| Ok(resolved) |
There was a problem hiding this comment.
resolved is declared as mut but never mutated after construction — the struct is built in-place and immediately returned. This will emit a compiler warning.
| let mut resolved = VersionedTransactionResolved { | |
| transaction: transaction.clone(), | |
| all_account_keys: transaction.message.static_account_keys().to_vec(), | |
| all_instructions: IxUtils::uncompile_instructions( | |
| transaction.message.instructions(), | |
| transaction.message.static_account_keys(), | |
| num_required_signatures, | |
| num_readonly_signed_accounts, | |
| num_readonly_unsigned_accounts, | |
| )?, | |
| parsed_system_instructions: None, | |
| parsed_spl_instructions: None, | |
| }) | |
| }; | |
| Ok(resolved) | |
| let resolved = VersionedTransactionResolved { | |
| transaction: transaction.clone(), | |
| all_account_keys: transaction.message.static_account_keys().to_vec(), | |
| all_instructions: IxUtils::uncompile_instructions( | |
| transaction.message.instructions(), | |
| transaction.message.static_account_keys(), | |
| num_required_signatures, | |
| num_readonly_signed_accounts, | |
| num_readonly_unsigned_accounts, | |
| )?, | |
| parsed_system_instructions: None, | |
| parsed_spl_instructions: None, | |
| }; | |
| Ok(resolved) |
| }); | ||
| }); | ||
|
|
||
| let header = self.transaction.message.header(); | ||
| let num_required_signatures = header.num_required_signatures as usize; | ||
| let num_readonly_signed_accounts = header.num_readonly_signed_accounts as usize; | ||
| let num_readonly_unsigned_accounts = header.num_readonly_unsigned_accounts as usize; | ||
|
|
||
| return IxUtils::uncompile_instructions( | ||
| &compiled_inner_instructions, | ||
| &self.all_account_keys, | ||
| num_required_signatures, | ||
| num_readonly_signed_accounts, | ||
| num_readonly_unsigned_accounts, | ||
| ); |
There was a problem hiding this comment.
uncompile_instructions now derives is_signer and is_writable from the outer transaction's message header. For outer instructions this is correct — the header precisely defines those flags. However, for inner instructions returned by simulation (CPIs), the same header fields do not represent the actual CPI call semantics:
is_signer: In a CPI, the calling program decides which accounts it passes as signers. A PDA signed via invoke_signed has an index ≥ num_required_signatures and will therefore have is_signer = false even though it was effectively a signer. Conversely, the fee_payer (index 0, always < num_required_signatures) will be reconstructed as is_signer = true in every inner instruction that lists it as an account — even if the calling program never passed it as a signer in the CPI. Combined with the new validate_fee_payer_usage check that runs on all_instructions (which includes these inner instructions), this can produce false positives: a legitimate inner instruction from an allowed-but-not-hardcoded-safe program that merely references the fee_payer's account as a read/writable operand (not an authority) will incorrectly trigger the rejection.
is_writable: Similarly, programs can grant write access to accounts during a CPI that the outer header marks as read-only, so reconstructed is_writable values will be wrong for those accounts.
Consider tracking is_signer for inner instructions differently (e.g., treating them as read-only reconstructions, or skipping is_signer restoration for simulated inner instructions), or at least documenting the approximation and ensuring downstream consumers aren't security-sensitive with respect to inner instruction signer metadata.
Problem
Kora Relayer previously failed to correctly identify and restrict the
fee_payeras a signer in instructions. Specifically:IxUtils::uncompile_instructionshardcodedis_signer: false, losing critical permission metadata from the transaction message.TransactionValidatoronly validatedprogram_idfor allowed programs, but did not check if thefee_payerwas used as an unauthorized signer for those programs.This allowed an attacker to craft a transaction where the
fee_payer(relayer) was used as a signer for an arbitrary allowed program (e.g., a swap or escrow), potentially draining relayer funds.Solution
uncompile_instructionsto correctly restoreis_signerandis_writableflags based on the transaction message header.TransactionValidator::validate_fee_payer_usagethat rejects any instruction where thefee_payeris a signer, unless it is a recognized and validated program (System, SPL Token, ATA, Compute Budget).Verification
A PoC test case was created that successfully bypassed validation before the fix and is now correctly rejected. All 300+ existing tests pass.