Skip to main content

forge_lint/sol/low/
reentrancy_events.rs

1use super::{ReentrancyEvents, calls_loop::is_state_mutating_external_call};
2use crate::{
3    linter::{LateLintPass, LintContext},
4    sol::{
5        Severity, SolLint,
6        analysis::{
7            DEFAULT_HELPER_ANALYSIS_CACHE_LIMIT, HelperAnalysisCache, dispatched_function,
8            for_each_child, is_exit_call, loop_stmts, loop_update,
9        },
10    },
11};
12use solar::{
13    interface::Span,
14    sema::{
15        Gcx,
16        hir::{
17            self, BinOpKind, Block, ContractId, Expr, ExprKind, Function, FunctionId, LoopSource,
18            Stmt, StmtKind,
19        },
20    },
21};
22use std::collections::{HashMap, HashSet};
23
24declare_forge_lint!(
25    REENTRANCY_EVENTS,
26    Severity::Low,
27    "reentrancy-events",
28    "event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on"
29);
30
31impl<'gcx> LateLintPass<'gcx> for ReentrancyEvents {
32    fn check_function(&mut self, ctx: &LintContext, gcx: Gcx<'gcx>, func: &'gcx Function<'gcx>) {
33        let Some(body) = func.body else { return };
34        Analyzer::new(ctx, gcx, func.contract).analyze_callable(func, body, false);
35    }
36}
37
38type Placeholder<'gcx> = Option<(&'gcx [hir::Modifier<'gcx>], usize, Block<'gcx>)>;
39
40/// How control can leave a piece of code. Each exit kind records whether an external call was
41/// seen on some path reaching it; `None` means no path exits that way. Aborting paths
42/// (`revert`, `require(false)`, ...) are simply absent, so they cannot taint later statements.
43#[derive(Clone, Copy, Debug, Default)]
44struct Exits {
45    fallthrough: Option<bool>,
46    return_: Option<bool>,
47    break_: Option<bool>,
48    continue_: Option<bool>,
49}
50
51impl Exits {
52    fn fallthrough(tainted: bool) -> Self {
53        Self { fallthrough: Some(tainted), ..Self::default() }
54    }
55
56    fn return_(tainted: bool) -> Self {
57        Self { return_: Some(tainted), ..Self::default() }
58    }
59
60    fn break_(tainted: bool) -> Self {
61        Self { break_: Some(tainted), ..Self::default() }
62    }
63
64    fn continue_(tainted: bool) -> Self {
65        Self { continue_: Some(tainted), ..Self::default() }
66    }
67
68    fn merge(&mut self, other: Self) {
69        self.fallthrough = join(self.fallthrough, other.fallthrough);
70        self.return_ = join(self.return_, other.return_);
71        self.break_ = join(self.break_, other.break_);
72        self.continue_ = join(self.continue_, other.continue_);
73    }
74
75    /// State of the paths that return to the caller normally.
76    fn normal(self) -> Option<bool> {
77        join(self.fallthrough, self.return_)
78    }
79}
80
81/// Joins two path states: reachable if either is, tainted if either is.
82fn join(lhs: Option<bool>, rhs: Option<bool>) -> Option<bool> {
83    match (lhs, rhs) {
84        (Some(lhs), Some(rhs)) => Some(lhs || rhs),
85        _ => lhs.or(rhs),
86    }
87}
88
89#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
90struct InlineCallKey {
91    func_id: FunctionId,
92    external_call_seen: bool,
93    suppress_inline_reports: bool,
94}
95
96struct Analyzer<'ctx, 's, 'c, 'gcx> {
97    ctx: &'ctx LintContext<'s, 'c>,
98    gcx: Gcx<'gcx>,
99    /// Contract being analysed; `this.f()` and `super.f()` resolve against it, also inside
100    /// inlined helpers (runtime `this`).
101    enclosing_contract: Option<ContractId>,
102    call_stack: Vec<FunctionId>,
103    inline_cache: HelperAnalysisCache<InlineCallKey, Exits>,
104    /// Whether a helper can ever perform an external call; used where a recursive edge is cut.
105    external_call_reachability: HashMap<FunctionId, bool>,
106    /// Set when a reachability walk hit a recursive edge, so a negative result is inconclusive.
107    reachability_cut: bool,
108    emitted: HashSet<Span>,
109    /// Inside a helper entered with a clean state: the helper's own pass reports its emits.
110    suppress_inline_reports: bool,
111    /// Set by an inlined callee with no normal exit, so the enclosing statement aborts.
112    expr_aborted: bool,
113}
114
115impl<'ctx, 's, 'c, 'gcx> Analyzer<'ctx, 's, 'c, 'gcx> {
116    fn new(
117        ctx: &'ctx LintContext<'s, 'c>,
118        gcx: Gcx<'gcx>,
119        enclosing_contract: Option<ContractId>,
120    ) -> Self {
121        Self {
122            ctx,
123            gcx,
124            enclosing_contract,
125            call_stack: Vec::new(),
126            inline_cache: HelperAnalysisCache::new(DEFAULT_HELPER_ANALYSIS_CACHE_LIMIT),
127            external_call_reachability: HashMap::new(),
128            reachability_cut: false,
129            emitted: HashSet::new(),
130            suppress_inline_reports: false,
131            expr_aborted: false,
132        }
133    }
134
135    fn analyze_callable(
136        &mut self,
137        func: &'gcx Function<'gcx>,
138        body: Block<'gcx>,
139        entry: bool,
140    ) -> Exits {
141        self.analyze_modifier_chain(func.modifiers, 0, body, entry)
142    }
143
144    fn analyze_modifier_chain(
145        &mut self,
146        modifiers: &'gcx [hir::Modifier<'gcx>],
147        index: usize,
148        body: Block<'gcx>,
149        mut entry: bool,
150    ) -> Exits {
151        let Some(modifier) = modifiers.get(index) else {
152            return self.analyze_block(body, None, entry);
153        };
154        for arg in modifier.args.exprs() {
155            self.expr_aborted = false;
156            self.analyze_expr(arg, &mut entry);
157            // An aborting argument means the modifier, and so the body, is never entered.
158            if self.expr_aborted {
159                return Exits::default();
160            }
161        }
162        // A modifier may legitimately appear several times in the chain (`m(false) m(true)`),
163        // so duplicates are not skipped; `index` strictly increases, and recursion through
164        // internal calls is handled by `analyze_internal_call`.
165        let Some((modifier_id, modifier_body)) = self
166            .enclosing_contract
167            .map_or_else(
168                || modifier.id.as_function(),
169                |contract| self.gcx.resolve_modifier_target(contract, modifier),
170            )
171            .and_then(|id| Some((id, self.gcx.hir.function(id).body?)))
172        else {
173            return self.analyze_modifier_chain(modifiers, index + 1, body, entry);
174        };
175        self.call_stack.push(modifier_id);
176        let exits = self.analyze_block(modifier_body, Some((modifiers, index + 1, body)), entry);
177        self.call_stack.pop();
178        exits
179    }
180
181    /// Analyzes one loop iteration: the body, then the `for` update on the paths that complete it.
182    fn analyze_iteration(
183        &mut self,
184        block: Block<'gcx>,
185        source: LoopSource<'gcx>,
186        placeholder: Placeholder<'gcx>,
187        entry: bool,
188    ) -> Exits {
189        let mut exits = self.analyze_block(block, placeholder, entry);
190        if let (Some(update), Some(state)) = (loop_update(source), exits.fallthrough.take()) {
191            exits.merge(self.analyze_stmt(update, placeholder, state));
192        }
193        exits
194    }
195
196    fn analyze_block(
197        &mut self,
198        block: Block<'gcx>,
199        placeholder: Placeholder<'gcx>,
200        mut entry: bool,
201    ) -> Exits {
202        let mut exits = Exits::default();
203        for stmt in block.stmts {
204            let stmt_exits = self.analyze_stmt(stmt, placeholder, entry);
205            exits.merge(Exits { fallthrough: None, ..stmt_exits });
206            // Only the fallthrough state reaches the next statement; without it the rest is dead.
207            let Some(next) = stmt_exits.fallthrough else { return exits };
208            entry = next;
209        }
210        exits.fallthrough = Some(entry);
211        exits
212    }
213
214    fn analyze_stmt(
215        &mut self,
216        stmt: &'gcx Stmt<'gcx>,
217        placeholder: Placeholder<'gcx>,
218        mut entry: bool,
219    ) -> Exits {
220        self.expr_aborted = false;
221        match stmt.kind {
222            StmtKind::DeclSingle(var_id) => {
223                if let Some(init) = self.gcx.hir.variable(var_id).initializer {
224                    self.analyze_expr(init, &mut entry);
225                }
226                self.unless_aborted(Exits::fallthrough(entry))
227            }
228            StmtKind::DeclMulti(_, expr) | StmtKind::Expr(expr) => {
229                self.analyze_expr(expr, &mut entry);
230                if is_exit_call(self.gcx, expr) {
231                    return Exits::default();
232                }
233                self.unless_aborted(Exits::fallthrough(entry))
234            }
235            StmtKind::Block(block) | StmtKind::UncheckedBlock(block) => {
236                self.analyze_block(block, placeholder, entry)
237            }
238            StmtKind::Emit(expr) => {
239                // Event arguments are evaluated before emitting, so an external call in them
240                // taints this emit too, and an aborting argument makes it unreachable.
241                self.analyze_expr(expr, &mut entry);
242                if self.expr_aborted {
243                    return Exits::default();
244                }
245                if entry && !self.suppress_inline_reports && self.emitted.insert(stmt.span) {
246                    self.ctx.emit(&REENTRANCY_EVENTS, stmt.span);
247                }
248                Exits::fallthrough(entry)
249            }
250            StmtKind::Revert(expr) => {
251                self.analyze_expr(expr, &mut entry);
252                Exits::default()
253            }
254            StmtKind::Return(expr) => {
255                if let Some(expr) = expr {
256                    self.analyze_expr(expr, &mut entry);
257                }
258                self.unless_aborted(Exits::return_(entry))
259            }
260            StmtKind::Break => Exits::break_(entry),
261            StmtKind::Continue => Exits::continue_(entry),
262            StmtKind::Loop(block, source) => {
263                // Two passes suffice: the one-bit state can only go from clean to tainted around
264                // the back-edge, so a second pass from the merged entry catches emits tainted
265                // only on later iterations. `emitted` dedupes the diagnostics.
266                let first = self.analyze_iteration(block, source, placeholder, entry);
267                let back_edge =
268                    entry || first.fallthrough.unwrap_or(false) || first.continue_.unwrap_or(false);
269                let body = if back_edge == entry {
270                    first
271                } else {
272                    self.analyze_iteration(block, source, placeholder, back_edge)
273                };
274                // Zero iterations, the end of the body, `break` and `continue` all reach the exit.
275                let post = entry
276                    || body.fallthrough.unwrap_or(false)
277                    || body.break_.unwrap_or(false)
278                    || body.continue_.unwrap_or(false);
279                Exits { fallthrough: Some(post), return_: body.return_, ..Exits::default() }
280            }
281            StmtKind::If(cond, then_stmt, else_stmt) => {
282                self.analyze_expr(cond, &mut entry);
283                if self.expr_aborted {
284                    return Exits::default();
285                }
286                let mut exits = self.analyze_stmt(then_stmt, placeholder, entry);
287                exits.merge(match else_stmt {
288                    Some(else_stmt) => self.analyze_stmt(else_stmt, placeholder, entry),
289                    None => Exits::fallthrough(entry),
290                });
291                exits
292            }
293            StmtKind::Try(try_stmt) => {
294                self.analyze_expr(&try_stmt.expr, &mut entry);
295                if self.expr_aborted {
296                    return Exits::default();
297                }
298                let mut exits = Exits::default();
299                for clause in try_stmt.clauses {
300                    exits.merge(self.analyze_block(clause.block, placeholder, entry));
301                }
302                exits
303            }
304            StmtKind::Placeholder => match placeholder {
305                Some((modifiers, index, body)) => {
306                    self.analyze_modifier_chain(modifiers, index, body, entry)
307                }
308                None => Exits::fallthrough(entry),
309            },
310            // Inline assembly can call out and log; conservatively taint.
311            StmtKind::AssemblyBlock(_) | StmtKind::Switch(_) | StmtKind::Err(_) => {
312                Exits::fallthrough(true)
313            }
314        }
315    }
316
317    fn unless_aborted(&self, exits: Exits) -> Exits {
318        if self.expr_aborted { Exits::default() } else { exits }
319    }
320
321    fn analyze_expr(&mut self, expr: &'gcx Expr<'gcx>, tainted: &mut bool) {
322        match &expr.kind {
323            ExprKind::Call(callee, ..) => {
324                for_each_child(expr, &mut |child| self.analyze_expr(child, tainted));
325                if is_state_mutating_external_call(self.gcx, callee) {
326                    *tainted = true;
327                }
328                // Follow internal helpers and `super` dispatch so their external calls taint the
329                // caller too.
330                for func_id in self.callees(callee) {
331                    self.analyze_internal_call(func_id, tainted);
332                }
333            }
334            ExprKind::Binary(lhs, op, rhs) if matches!(op.kind, BinOpKind::And | BinOpKind::Or) => {
335                // The RHS is conditional: model it on a fork so its taint joins the result next
336                // to the short-circuit path, and an aborting RHS only drops the non-short-circuit
337                // path.
338                self.analyze_expr(lhs, tainted);
339                let lhs_aborted = std::mem::replace(&mut self.expr_aborted, false);
340                let mut rhs_tainted = *tainted;
341                self.analyze_expr(rhs, &mut rhs_tainted);
342                let rhs_aborted = self.expr_aborted;
343                self.expr_aborted = lhs_aborted;
344                if !lhs_aborted && !rhs_aborted {
345                    *tainted |= rhs_tainted;
346                }
347            }
348            ExprKind::Ternary(cond, then_expr, else_expr) => {
349                self.analyze_expr(cond, tainted);
350                let cond_aborted = std::mem::replace(&mut self.expr_aborted, false);
351                let mut then_tainted = *tainted;
352                self.analyze_expr(then_expr, &mut then_tainted);
353                let then_aborted = std::mem::replace(&mut self.expr_aborted, false);
354                let mut else_tainted = *tainted;
355                self.analyze_expr(else_expr, &mut else_tainted);
356                let else_aborted = self.expr_aborted;
357                // The ternary aborts iff the condition does or both branches do; aborting
358                // branches drop their state.
359                self.expr_aborted = cond_aborted || (then_aborted && else_aborted);
360                if !(then_aborted && else_aborted) {
361                    *tainted = (!then_aborted && then_tainted) || (!else_aborted && else_tainted);
362                }
363            }
364            _ => for_each_child(expr, &mut |child| self.analyze_expr(child, tainted)),
365        }
366    }
367
368    /// Internal functions and `super` targets a call through `callee` dispatches to.
369    fn callees(&self, callee: &'gcx Expr<'gcx>) -> Vec<FunctionId> {
370        self.enclosing_contract
371            .map_or_else(
372                || self.gcx.resolved_function(callee),
373                |contract| dispatched_function(self.gcx, contract, callee),
374            )
375            .into_iter()
376            .collect()
377    }
378
379    fn analyze_internal_call(&mut self, func_id: FunctionId, tainted: &mut bool) {
380        if self.call_stack.contains(&func_id) {
381            // Replace the cut recursive edge with the conservative "can this helper ever call
382            // out?" summary so inline summaries stay stack-insensitive.
383            *tainted |= self.helper_may_reach_external_call(func_id, &mut HashSet::new());
384            return;
385        }
386        let func = self.gcx.hir.function(func_id);
387        let Some(body) = func.body else { return };
388
389        // Diagnostics inside a helper entered clean are left to the helper's own pass, which
390        // avoids duplicate reports across callers.
391        let suppress = self.suppress_inline_reports || !*tainted;
392        let key = InlineCallKey {
393            func_id,
394            external_call_seen: *tainted,
395            suppress_inline_reports: suppress,
396        };
397        if self.inline_cache.is_in_progress(&key) {
398            return;
399        }
400        let summary = match self.inline_cache.get(&key) {
401            Some(summary) => *summary,
402            None => {
403                let prev_suppress = std::mem::replace(&mut self.suppress_inline_reports, suppress);
404                self.inline_cache.start(key);
405                self.call_stack.push(func_id);
406                let summary = self.analyze_callable(func, body, *tainted);
407                self.call_stack.pop();
408                self.inline_cache.finish(key, summary);
409                self.suppress_inline_reports = prev_suppress;
410                summary
411            }
412        };
413        // The caller continues in the state of the normally returning paths; a callee without
414        // any aborts the enclosing statement.
415        match summary.normal() {
416            Some(after) => *tainted = after,
417            None => self.expr_aborted = true,
418        }
419    }
420
421    /// Conservative summary of whether `func_id` can ever perform an external call.
422    fn helper_may_reach_external_call(
423        &mut self,
424        func_id: FunctionId,
425        seen: &mut HashSet<FunctionId>,
426    ) -> bool {
427        if let Some(&cached) = self.external_call_reachability.get(&func_id) {
428            return cached;
429        }
430        if !seen.insert(func_id) {
431            self.reachability_cut = true;
432            return false;
433        }
434        let outer_cut = std::mem::replace(&mut self.reachability_cut, false);
435        let func = self.gcx.hir.function(func_id);
436        let may_reach = func.modifiers.iter().any(|modifier| {
437            modifier.args.exprs().any(|arg| self.expr_may_reach_external_call(arg, seen))
438                || modifier
439                    .id
440                    .as_function()
441                    .is_some_and(|id| self.helper_may_reach_external_call(id, seen))
442        }) || func.body.is_some_and(|body| {
443            body.stmts.iter().any(|stmt| self.stmt_may_reach_external_call(stmt, seen))
444        });
445        seen.remove(&func_id);
446        // A negative answer that relied on a cut recursive edge is not conclusive.
447        if may_reach || !self.reachability_cut {
448            self.external_call_reachability.insert(func_id, may_reach);
449        }
450        self.reachability_cut |= outer_cut;
451        may_reach
452    }
453
454    fn stmt_may_reach_external_call(
455        &mut self,
456        stmt: &'gcx Stmt<'gcx>,
457        seen: &mut HashSet<FunctionId>,
458    ) -> bool {
459        match stmt.kind {
460            StmtKind::DeclSingle(var_id) => self
461                .gcx
462                .hir
463                .variable(var_id)
464                .initializer
465                .is_some_and(|init| self.expr_may_reach_external_call(init, seen)),
466            StmtKind::DeclMulti(_, expr)
467            | StmtKind::Expr(expr)
468            | StmtKind::Emit(expr)
469            | StmtKind::Revert(expr) => self.expr_may_reach_external_call(expr, seen),
470            StmtKind::Return(expr) => {
471                expr.is_some_and(|expr| self.expr_may_reach_external_call(expr, seen))
472            }
473            StmtKind::Block(block) | StmtKind::UncheckedBlock(block) => {
474                block.stmts.iter().any(|stmt| self.stmt_may_reach_external_call(stmt, seen))
475            }
476            StmtKind::Loop(block, source) => {
477                loop_stmts(block, source).any(|stmt| self.stmt_may_reach_external_call(stmt, seen))
478            }
479            StmtKind::If(cond, then_stmt, else_stmt) => {
480                self.expr_may_reach_external_call(cond, seen)
481                    || self.stmt_may_reach_external_call(then_stmt, seen)
482                    || else_stmt.is_some_and(|stmt| self.stmt_may_reach_external_call(stmt, seen))
483            }
484            StmtKind::Try(try_stmt) => {
485                self.expr_may_reach_external_call(&try_stmt.expr, seen)
486                    || try_stmt.clauses.iter().any(|clause| {
487                        clause
488                            .block
489                            .stmts
490                            .iter()
491                            .any(|stmt| self.stmt_may_reach_external_call(stmt, seen))
492                    })
493            }
494            StmtKind::AssemblyBlock(_) | StmtKind::Switch(_) => true,
495            StmtKind::Break | StmtKind::Continue | StmtKind::Placeholder | StmtKind::Err(_) => {
496                false
497            }
498        }
499    }
500
501    fn expr_may_reach_external_call(
502        &mut self,
503        expr: &'gcx Expr<'gcx>,
504        seen: &mut HashSet<FunctionId>,
505    ) -> bool {
506        let mut reached = false;
507        for_each_child(expr, &mut |child| {
508            reached = reached || self.expr_may_reach_external_call(child, seen);
509        });
510        if reached {
511            return true;
512        }
513        let ExprKind::Call(callee, ..) = &expr.kind else { return false };
514        is_state_mutating_external_call(self.gcx, callee)
515            || self
516                .callees(callee)
517                .into_iter()
518                .any(|id| self.helper_may_reach_external_call(id, seen))
519    }
520}