Skip to content

Commit 24d3f63

Browse files
committed
lint: Add arbitrary_cpi_call lint
1 parent 38f627a commit 24d3f63

File tree

9 files changed

+618
-0
lines changed

9 files changed

+618
-0
lines changed

Cargo.lock

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
[target.'cfg(all())']
2+
rustflags = ["-C", "linker=dylint-link"]
3+
4+
# For Rust versions 1.74.0 and onward, the following alternative can be used
5+
# (see https://github.com/rust-lang/cargo/pull/12535):
6+
# linker = "dylint-link"
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/target
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
[package]
2+
name = "arbitrary_cpi_call"
3+
version = "0.1.0"
4+
authors = ["authors go here"]
5+
description = "description goes here"
6+
edition = "2024"
7+
publish = false
8+
9+
[lib]
10+
crate-type = ["cdylib"]
11+
12+
[dependencies]
13+
clippy_utils = { git = "https://github.com/rust-lang/rust-clippy", rev = "20ce69b9a63bcd2756cd906fe0964d1e901e042a" }
14+
dylint_linting = "5.0.0"
15+
16+
[dev-dependencies]
17+
dylint_testing = "5.0.0"
18+
19+
[package.metadata.rust-analyzer]
20+
rustc_private = true

lints/arbitrary_cpi_call/README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# `arbitraty_cpi_call`
2+
3+
### What it does
4+
Identifies CPI calls made using user-controlled program IDs without validations.
5+
6+
### Why is this bad?
7+
Unvalidated program IDs in CPI calls let users to trigger arbitrary programs, leading to potential security breaches or fund loss.
8+
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[toolchain]
2+
channel = "nightly-2025-09-18"
3+
components = ["llvm-tools-preview", "rustc-dev"]
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
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+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
use rustc_middle::mir::Local;
2+
use rustc_span::Span;
3+
4+
#[derive(Debug)]
5+
pub struct CpiCallsInfo {
6+
pub span: Span,
7+
pub local: Local,
8+
}
9+
10+
#[derive(Debug)]
11+
pub struct CpiContextsInfo {
12+
pub cpi_ctx_local: Local,
13+
pub program_id_local: Local,
14+
}

0 commit comments

Comments
 (0)