|
| 1 | +#![feature(rustc_private)] |
| 2 | +#![warn(unused_extern_crates)] |
| 3 | +#![feature(box_patterns)] |
| 4 | + |
| 5 | +extern crate rustc_data_structures; |
| 6 | +extern crate rustc_hir; |
| 7 | +extern crate rustc_middle; |
| 8 | +extern crate rustc_span; |
| 9 | + |
| 10 | +use clippy_utils::{ |
| 11 | + diagnostics::span_lint, fn_has_unsatisfiable_preds, ty::is_type_diagnostic_item, |
| 12 | +}; |
| 13 | + |
| 14 | +use rustc_data_structures::graph::dominators::Dominators; |
| 15 | +use rustc_hir::{Body as HirBody, FnDecl, def_id::LocalDefId, intravisit::FnKind}; |
| 16 | +use rustc_lint::{LateContext, LateLintPass}; |
| 17 | +use rustc_middle::{ |
| 18 | + mir::{BasicBlock, HasLocalDecls, Local, Operand, TerminatorKind}, |
| 19 | + ty::{self as rustc_ty}, |
| 20 | +}; |
| 21 | +use rustc_span::{Span, Symbol, sym}; |
| 22 | + |
| 23 | +use std::collections::{HashMap, HashSet}; |
| 24 | + |
| 25 | +mod models; |
| 26 | +mod utils; |
| 27 | + |
| 28 | +use models::{CpiCallsInfo, CpiContextsInfo}; |
| 29 | +use utils::*; |
| 30 | + |
| 31 | +dylint_linting::declare_late_lint! { |
| 32 | + /// ### What it does |
| 33 | + /// Detects potential **arbitrary Cross-Program Invocations (CPIs)** where the target |
| 34 | + /// program ID appears to be user-controlled without validation. |
| 35 | + /// |
| 36 | + /// ### Why is this bad? |
| 37 | + /// Allowing user-controlled program ID in CPI calls can lead to |
| 38 | + /// **security vulnerabilities**, such as unauthorized fund transfers, privilege |
| 39 | + /// escalation, or unintended external calls. All CPI targets should be strictly |
| 40 | + /// validated or hardcoded to ensure safe execution. |
| 41 | + /// |
| 42 | + pub ARBITRARY_CPI_CALL, |
| 43 | + Warn, |
| 44 | + "arbitrary CPI detected — target program ID may be user-controlled" |
| 45 | +} |
| 46 | + |
| 47 | +impl<'tcx> LateLintPass<'tcx> for ArbitraryCpiCall { |
| 48 | + fn check_fn( |
| 49 | + &mut self, |
| 50 | + cx: &LateContext<'tcx>, |
| 51 | + _kind: FnKind<'tcx>, |
| 52 | + _: &FnDecl<'tcx>, |
| 53 | + _body: &HirBody<'tcx>, |
| 54 | + fn_span: Span, |
| 55 | + def_id: LocalDefId, |
| 56 | + ) { |
| 57 | + // skip macro expansions |
| 58 | + if fn_span.from_expansion() { |
| 59 | + return; |
| 60 | + } |
| 61 | + // skip functions with unsatisfiable predicates |
| 62 | + if fn_has_unsatisfiable_preds(cx, def_id.to_def_id()) { |
| 63 | + return; |
| 64 | + } |
| 65 | + |
| 66 | + let anchor_cpi_sym = Symbol::intern("AnchorCpiContext"); |
| 67 | + let mir = cx.tcx.optimized_mir(def_id.to_def_id()); |
| 68 | + |
| 69 | + let dominators = mir.basic_blocks.dominators(); |
| 70 | + |
| 71 | + // build variables assignment, reverse assignment and transitive reverse assignment maps |
| 72 | + let (assignment_map, reverse_assignment_map) = build_assign_and_reverse_assignment_map(mir); |
| 73 | + let transitive_assignment_reverse_map = |
| 74 | + build_transitive_reverse_map(&reverse_assignment_map); |
| 75 | + |
| 76 | + // Need to identify: |
| 77 | + // A) CPI calls |
| 78 | + // B) CPI contexts with user controllable program id |
| 79 | + // C) Conditional blocks for program id |
| 80 | + // Then we check all CPI contexts where a CPI call is reachable from the context |
| 81 | + // and the program ID is not validated in any conditional blocks |
| 82 | + |
| 83 | + let mut cpi_calls: HashMap<BasicBlock, CpiCallsInfo> = HashMap::new(); |
| 84 | + let mut cpi_contexts: HashMap<BasicBlock, CpiContextsInfo> = HashMap::new(); |
| 85 | + let mut switches: Vec<IfThen> = Vec::new(); |
| 86 | + let mut program_id_cmps: Vec<Cmp> = Vec::new(); |
| 87 | + |
| 88 | + for (bb, bbdata) in mir.basic_blocks.iter_enumerated() { |
| 89 | + let terminator_kind = &bbdata.terminator().kind; |
| 90 | + if let TerminatorKind::Call { |
| 91 | + func: Operand::Constant(func_const), |
| 92 | + args, |
| 93 | + fn_span, |
| 94 | + destination, |
| 95 | + .. |
| 96 | + } = terminator_kind |
| 97 | + && let rustc_ty::FnDef(fn_def_id, _) = func_const.ty().kind() |
| 98 | + { |
| 99 | + // check if the function takes a CPI context |
| 100 | + if takes_cpi_context(cx, mir, args) |
| 101 | + && let Some(instruction) = args.get(0) |
| 102 | + && let Operand::Copy(place) | Operand::Move(place) = &instruction.node |
| 103 | + && let Some(local) = place.as_local() |
| 104 | + && let Some(ty) = mir.local_decls().get(local).map(|d| d.ty.peel_refs()) |
| 105 | + && is_type_diagnostic_item(cx, ty, anchor_cpi_sym) |
| 106 | + { |
| 107 | + if let Some(cpi_ctx_local) = get_local_from_operand(args.get(0)) { |
| 108 | + cpi_calls.insert( |
| 109 | + bb, |
| 110 | + CpiCallsInfo { |
| 111 | + span: *fn_span, |
| 112 | + local: cpi_ctx_local, |
| 113 | + }, |
| 114 | + ); |
| 115 | + } |
| 116 | + // check if the function returns a CPI context |
| 117 | + } else if let fn_sig = cx.tcx.fn_sig(*fn_def_id).skip_binder() |
| 118 | + && let fn_sig_unbounded = fn_sig.skip_binder() |
| 119 | + && let return_ty = fn_sig_unbounded.output() |
| 120 | + && is_type_diagnostic_item(cx, return_ty, anchor_cpi_sym) |
| 121 | + { |
| 122 | + // check if CPI context with user controllable program id |
| 123 | + if let Some(program_id) = args.get(0) |
| 124 | + && let Operand::Copy(place) | Operand::Move(place) = &program_id.node |
| 125 | + && let Some(local) = place.as_local() |
| 126 | + && is_pubkey_type(cx, mir, &local) |
| 127 | + && let Some(cpi_ctx_return_local) = destination.as_local() |
| 128 | + && let origin = |
| 129 | + origin_of_operand(cx, mir, &assignment_map, &program_id.node) |
| 130 | + && let Origin::Parameter | Origin::Unknown = origin |
| 131 | + { |
| 132 | + cpi_contexts.insert( |
| 133 | + bb, |
| 134 | + CpiContextsInfo { |
| 135 | + cpi_ctx_local: cpi_ctx_return_local, |
| 136 | + program_id_local: local, |
| 137 | + }, |
| 138 | + ); |
| 139 | + } |
| 140 | + } else { |
| 141 | + if cx.tcx.is_diagnostic_item(sym::cmp_partialeq_eq, *fn_def_id) |
| 142 | + && let (Some(lhs), Some(rhs)) = |
| 143 | + check_function_args_has_pubkey_type(cx, mir, args) |
| 144 | + && let Some(ret) = destination.as_local() |
| 145 | + { |
| 146 | + program_id_cmps.push(Cmp { lhs, rhs, ret }); |
| 147 | + } |
| 148 | + } |
| 149 | + } |
| 150 | + // Find if/else switches which may be the result of a comparison |
| 151 | + else if let TerminatorKind::SwitchInt { |
| 152 | + discr: Operand::Move(discr), |
| 153 | + targets, |
| 154 | + } = terminator_kind |
| 155 | + && let Some(discr) = discr.as_local() |
| 156 | + && let Some(discr_decl) = mir.local_decls().get(discr) |
| 157 | + && discr_decl.ty.is_bool() |
| 158 | + { |
| 159 | + if let Some((val, then, els)) = targets.as_static_if() { |
| 160 | + let then = if val == 1 { then } else { els }; |
| 161 | + switches.push(IfThen { discr, then }); |
| 162 | + } |
| 163 | + } |
| 164 | + } |
| 165 | + |
| 166 | + // check if the CPI call is reachable from a CPI context |
| 167 | + // and the program ID is not validated in conditional blocks |
| 168 | + for (bb, cpi_ctx_info) in cpi_contexts.into_iter() { |
| 169 | + if let Some(cpi_call_bb) = |
| 170 | + cpi_invocation_is_reachable_from_cpi_context(&mir.basic_blocks, bb, &cpi_calls) |
| 171 | + && check_cpi_context_variables_are_same( |
| 172 | + &cpi_ctx_info.cpi_ctx_local, |
| 173 | + &cpi_calls[&cpi_call_bb].local, |
| 174 | + &mut HashSet::new(), |
| 175 | + &reverse_assignment_map, |
| 176 | + ) |
| 177 | + { |
| 178 | + if pubkey_checked_in_this_block( |
| 179 | + cpi_call_bb, |
| 180 | + cpi_ctx_info.program_id_local, |
| 181 | + dominators, |
| 182 | + &program_id_cmps, |
| 183 | + &switches, |
| 184 | + &transitive_assignment_reverse_map, |
| 185 | + ) || !check_program_id_included_in_conditional_blocks( |
| 186 | + &cpi_ctx_info.program_id_local, |
| 187 | + &program_id_cmps, |
| 188 | + &transitive_assignment_reverse_map, |
| 189 | + ) { |
| 190 | + span_lint( |
| 191 | + cx, |
| 192 | + ARBITRARY_CPI_CALL, |
| 193 | + cpi_calls[&cpi_call_bb].span, |
| 194 | + "arbitrary CPI detected — program id appears user-controlled", |
| 195 | + ); |
| 196 | + } |
| 197 | + } |
| 198 | + } |
| 199 | + } |
| 200 | +} |
| 201 | + |
| 202 | +#[derive(Debug, Clone, Copy)] |
| 203 | +struct Cmp { |
| 204 | + lhs: Local, |
| 205 | + rhs: Local, |
| 206 | + ret: Local, |
| 207 | +} |
| 208 | + |
| 209 | +/// A switch on `discr`, where a truthy value leads to `then` |
| 210 | +#[derive(Debug, Clone, Copy)] |
| 211 | +struct IfThen { |
| 212 | + discr: Local, |
| 213 | + then: BasicBlock, |
| 214 | +} |
| 215 | + |
| 216 | +/// For a given pubkey [`Local`], identify the [`BasicBlock`]s where its value is known/checked |
| 217 | +fn known_pubkey_basic_blocks( |
| 218 | + pk: Local, |
| 219 | + cmps: &[Cmp], |
| 220 | + switches: &[IfThen], |
| 221 | + assignment_map: &HashMap<Local, Vec<Local>>, |
| 222 | +) -> Vec<BasicBlock> { |
| 223 | + fn is_same(lhs: Local, rhs: Local, map: &HashMap<Local, Vec<Local>>) -> bool { |
| 224 | + map.values().any(|v| v.contains(&lhs) && v.contains(&rhs)) |
| 225 | + } |
| 226 | + cmps.iter() |
| 227 | + // Find comparisons on this pubkey local |
| 228 | + .filter_map(|cmp| { |
| 229 | + (is_same(cmp.lhs, pk, assignment_map) || is_same(cmp.rhs, pk, assignment_map)) |
| 230 | + .then_some(cmp.ret) |
| 231 | + }) |
| 232 | + // Find switches on the comparison result, then get the truthy blocks |
| 233 | + .flat_map(|cmp_res| { |
| 234 | + switches |
| 235 | + .iter() |
| 236 | + .filter_map(move |switch| (switch.discr == cmp_res).then_some(switch.then)) |
| 237 | + }) |
| 238 | + .collect() |
| 239 | +} |
| 240 | + |
| 241 | +/// Check if `pk` has been checked to be a known value at the point this basic block is reached |
| 242 | +fn pubkey_checked_in_this_block( |
| 243 | + block: BasicBlock, |
| 244 | + pk: Local, |
| 245 | + dominators: &Dominators<BasicBlock>, |
| 246 | + cmps: &[Cmp], |
| 247 | + switches: &[IfThen], |
| 248 | + assignment_map: &HashMap<Local, Vec<Local>>, |
| 249 | +) -> bool { |
| 250 | + let known_bbs = known_pubkey_basic_blocks(pk, cmps, switches, assignment_map); |
| 251 | + known_bbs.iter().any(|bb| !dominators.dominates(*bb, block)) |
| 252 | +} |
0 commit comments