Skip to main content

r2smt_patch/
plan.rs

1//! Build a [`PatchPlan`] from r2SMT findings.
2//!
3//! Translates each actionable [`Finding`] into a concrete byte
4//! sequence the patcher will write. Strategy v0 supports
5//! `nop_jcc` and `replace_jcc_with_jmp` — operand-aware
6//! `setcc` / `cmovcc` synthesis stays deferred per `SPEC.md` §5.7.
7
8use r2smt_common::smt::SmtResult;
9use r2smt_common::{Address, Arch, Error, Result};
10use r2smt_core::{Confidence, Finding, FindingKind};
11use r2smt_ir::byte_patcher::BytePatcher;
12use r2smt_report::PatchStrategy;
13use tracing::{debug, warn};
14
15use crate::arm_encoding::{
16    ARM_INSTRUCTION_BYTES, THUMB_HALFWORD_BYTES, arm_nop_buffer, thumb_nop_buffer,
17};
18use crate::x86_encoding::{nop_buffer, patch_cmovcc_to_mov, patch_setcc};
19
20mod aarch64;
21use aarch64::{
22    classify_aarch64_mnemonic, plan_aarch64_cs_arith, plan_aarch64_csel, plan_aarch64_cset,
23};
24
25/// Single-byte x86 NOP opcode used by `nop_jcc` / `nop_padding`.
26const X86_NOP_BYTE: u8 = 0x90;
27
28/// A single ready-to-execute patch operation.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct PlanOperation {
31    /// Address of the patched instruction.
32    pub address: Address,
33    /// Strategy that produced this operation.
34    pub strategy: PatchStrategy,
35    /// Finding kind that motivated it.
36    pub kind: FindingKind,
37    /// Confidence forwarded from the finding.
38    pub confidence: Confidence,
39    /// Size, in bytes, of the original instruction.
40    pub size: usize,
41    /// Bytes to write at `address`.
42    pub new_bytes: Vec<u8>,
43    /// Human-readable rationale forwarded for the manifest.
44    pub rationale: String,
45}
46
47/// Ordered list of operations the patcher will execute.
48#[derive(Debug, Clone, Default, PartialEq, Eq)]
49pub struct PatchPlan {
50    /// Operations in execution order (preserving the order of the
51    /// findings they were built from).
52    pub operations: Vec<PlanOperation>,
53    /// Findings that were filtered out, paired with the human reason.
54    /// Surfaced so the CLI can explain what was skipped.
55    pub skipped: Vec<(Address, String)>,
56}
57
58/// Maximum number of bytes r2SMT will rewrite for a single
59/// instruction. Sized to fit a `jmp rel32` (5 bytes) plus a generous
60/// safety margin so `x86_64` encodings still fit.
61pub const MAX_INSTRUCTION_SIZE: usize = 16;
62
63/// Construct a [`PatchPlan`] from a slice of findings.
64///
65/// Each finding is gated by:
66///
67/// 1. `is_actionable()` — only opaque / dead / constant kinds.
68/// 2. `confidence <= min_confidence` (using the `Ord` semantics in
69///    `r2smt-core`, which place `High` lowest).
70/// 3. The presence of an instruction-size measurement (the patcher
71///    needs to know how many bytes to preserve).
72///
73/// `arch` selects the rewrite ISA: x86 / `x86_64` use the legacy
74/// `jcc` / `setcc` / `cmovcc` strategies; `AArch64` / `AArch32` use
75/// the ARM `b.<cond>` / `b<cond>` / `cbz` / `cbnz` / `tbz` / `tbnz`
76/// strategies (NOP-out for always-false, replace with `b <target>`
77/// for always-true). ARM `setcc` / `cmovcc` analogs (`cset` /
78/// `csel`) are deferred and surface as "no rewrite strategy" skips.
79///
80/// The function never writes to the binary. Callers use `apply_plan`
81/// to commit operations.
82///
83/// # Errors
84///
85/// Returns the first failure produced by the `BytePatcher` while
86/// measuring instruction size or assembling replacement bytes.
87pub fn build_plan(
88    findings: &[Finding],
89    min_confidence: Confidence,
90    arch: Arch,
91    patcher: &mut dyn BytePatcher,
92) -> Result<PatchPlan> {
93    let mut operations: Vec<PlanOperation> = Vec::new();
94    let mut skipped: Vec<(Address, String)> = Vec::new();
95
96    for finding in findings {
97        match consider_finding(finding, min_confidence, arch, patcher)? {
98            FindingDecision::Plan(op) => operations.push(op),
99            FindingDecision::Skip(reason) => skipped.push((finding.address, reason)),
100        }
101    }
102
103    Ok(PatchPlan {
104        operations,
105        skipped,
106    })
107}
108
109enum FindingDecision {
110    Plan(PlanOperation),
111    Skip(String),
112}
113
114fn consider_finding(
115    finding: &Finding,
116    min_confidence: Confidence,
117    arch: Arch,
118    patcher: &mut dyn BytePatcher,
119) -> Result<FindingDecision> {
120    if !finding.is_actionable() {
121        return Ok(FindingDecision::Skip(format!(
122            "kind {:?} is not actionable",
123            finding.kind
124        )));
125    }
126    if finding.confidence > min_confidence {
127        return Ok(FindingDecision::Skip(format!(
128            "confidence {:?} below threshold {:?}",
129            finding.confidence, min_confidence,
130        )));
131    }
132
133    let mnemonic = finding.mnemonic.to_ascii_lowercase();
134    let kind = classify_mnemonic(&mnemonic, arch);
135    if kind == MnemonicKind::Other {
136        return Ok(FindingDecision::Skip(format!(
137            "{mnemonic} not a recognised branch / setcc / cmovcc for {arch:?} — no rewrite strategy"
138        )));
139    }
140
141    // A finding we cannot size — no resolved fallthrough, an unmapped
142    // address, or a delta outside [1, MAX_INSTRUCTION_SIZE] — is skipped
143    // like every other rejection here, not propagated: one such finding
144    // must not discard the rest of the plan. `measure_instruction_size`
145    // guarantees the returned size is already in range.
146    let size = match measure_instruction_size(finding, patcher) {
147        Ok(size) => size,
148        Err(reason) => return Ok(FindingDecision::Skip(reason.to_string())),
149    };
150    if arch_is_arm(arch) && !finding.is_thumb && size % ARM_INSTRUCTION_BYTES != 0 {
151        return Ok(FindingDecision::Skip(format!(
152            "ARM instruction at {addr} has non-4-byte size {size} (Thumb mode?)",
153            addr = finding.address,
154        )));
155    }
156    if finding.is_thumb && size % THUMB_HALFWORD_BYTES != 0 {
157        return Ok(FindingDecision::Skip(format!(
158            "Thumb instruction at {addr} has odd size {size}",
159            addr = finding.address,
160        )));
161    }
162
163    match kind {
164        MnemonicKind::Jcc => plan_jcc(finding, size, arch, patcher),
165        MnemonicKind::SetCc => plan_setcc(finding, size, patcher),
166        MnemonicKind::CMovCc => plan_cmovcc(finding, size, patcher),
167        MnemonicKind::Cset { all_ones } => plan_aarch64_cset(finding, size, patcher, all_ones),
168        MnemonicKind::Csel => plan_aarch64_csel(finding, size, patcher),
169        MnemonicKind::CsArith { op, aliased } => {
170            plan_aarch64_cs_arith(finding, size, patcher, op, aliased)
171        }
172        MnemonicKind::Other => unreachable!(),
173    }
174}
175
176fn arch_is_arm(arch: Arch) -> bool {
177    matches!(arch, Arch::Aarch64 | Arch::Arm)
178}
179
180#[derive(Debug, Clone, Copy, PartialEq, Eq)]
181enum MnemonicKind {
182    Jcc,
183    SetCc,
184    CMovCc,
185    /// `AArch64` `cset` (`all_ones = false`) or `csetm` (`all_ones =
186    /// true`). One operand: destination GPR.
187    Cset {
188        all_ones: bool,
189    },
190    /// `AArch64` `csel` — three operands `Rd, Rn, Rm`.
191    Csel,
192    /// `AArch64` `csinc` / `csinv` / `csneg` (3-op) and their 2-op
193    /// aliases `cinc` / `cinv` / `cneg`. `aliased = true` means the
194    /// disassembler used the 2-op alias form; the rewrite recipe is
195    /// identical because Armv8 defines `cinc Rd, Rn, cond` ≡
196    /// `csinc Rd, Rn, Rn, !cond` (and analogously for `inv` / `neg`).
197    CsArith {
198        op: CsArithOp,
199        aliased: bool,
200    },
201    Other,
202}
203
204/// Which `AArch64` arithmetic cs-instruction the planner is rewriting.
205/// Selects the "false" arm of the rewrite (the true arm is always a
206/// `mov Rd, Rn`).
207#[derive(Debug, Clone, Copy, PartialEq, Eq)]
208enum CsArithOp {
209    /// `csinc` / `cinc` → `add Rd, Rm, #1` when the predicate is false.
210    Csinc,
211    /// `csinv` / `cinv` → `mvn Rd, Rm` when the predicate is false.
212    Csinv,
213    /// `csneg` / `cneg` → `neg Rd, Rm` when the predicate is false.
214    Csneg,
215}
216
217fn classify_mnemonic(mnemonic: &str, arch: Arch) -> MnemonicKind {
218    match arch {
219        Arch::X86 | Arch::X86_64 => classify_x86_mnemonic(mnemonic),
220        Arch::Aarch64 => classify_aarch64_mnemonic(mnemonic),
221        Arch::Arm => classify_aarch32_mnemonic(mnemonic),
222        _ => MnemonicKind::Other,
223    }
224}
225
226fn classify_x86_mnemonic(mnemonic: &str) -> MnemonicKind {
227    if mnemonic.starts_with("cmov") {
228        MnemonicKind::CMovCc
229    } else if mnemonic.starts_with("set") {
230        MnemonicKind::SetCc
231    } else if mnemonic.starts_with('j') && mnemonic != "jmp" {
232        MnemonicKind::Jcc
233    } else {
234        MnemonicKind::Other
235    }
236}
237
238fn classify_aarch32_mnemonic(mnemonic: &str) -> MnemonicKind {
239    // AArch32 conditional branches use the suffix form `b<cond>`.
240    // Exclude unconditional `b`, link forms `bl`/`blx`, and indirect
241    // `bx`. The valid suffixes are the standard AAPCS condition codes.
242    const COND_SUFFIXES: &[&str] = &[
243        "eq", "ne", "cs", "hs", "cc", "lo", "mi", "pl", "vs", "vc", "hi", "ls", "ge", "lt", "gt",
244        "le",
245    ];
246    if let Some(suffix) = mnemonic.strip_prefix('b')
247        && COND_SUFFIXES.contains(&suffix)
248    {
249        return MnemonicKind::Jcc;
250    }
251    MnemonicKind::Other
252}
253
254fn plan_jcc(
255    finding: &Finding,
256    size: usize,
257    arch: Arch,
258    patcher: &mut dyn BytePatcher,
259) -> Result<FindingDecision> {
260    let strategy = jcc_strategy(finding)?;
261    let new_bytes = match strategy {
262        PatchStrategy::NopJcc => nop_bytes_for(arch, size, finding.is_thumb)?,
263        PatchStrategy::ReplaceJccWithJmp => {
264            let Some(target) = finding.taken_target else {
265                return Ok(FindingDecision::Skip(
266                    "AlwaysTrue jcc has no resolved taken target".into(),
267                ));
268            };
269            let assembled =
270                patcher.assemble(finding.address, &unconditional_branch_asm(arch, target))?;
271            if assembled.len() > size {
272                warn!(
273                    target: "r2smt::patch",
274                    addr = %finding.address,
275                    asm_size = assembled.len(),
276                    original_size = size,
277                    "assembled branch larger than original — skipping"
278                );
279                return Ok(FindingDecision::Skip(format!(
280                    "assembled branch is {asm} bytes, original instruction is {orig}",
281                    asm = assembled.len(),
282                    orig = size,
283                )));
284            }
285            if arch_is_arm(arch) && assembled.len() != size {
286                // ARM instructions are fixed-width; a non-matching
287                // assembled length means we'd leave partial-instruction
288                // bytes in the patch slot. Refuse instead of padding
289                // with x86 NOPs that the ARM CPU would decode as
290                // garbage.
291                return Ok(FindingDecision::Skip(format!(
292                    "ARM assembled branch is {asm} bytes, original instruction is {orig} — refusing to pad",
293                    asm = assembled.len(),
294                    orig = size,
295                )));
296            }
297            pad_to_size(arch, assembled, size)?
298        }
299        _ => {
300            return Ok(FindingDecision::Skip(format!(
301                "{strategy:?} not applicable to jcc"
302            )));
303        }
304    };
305
306    debug!(
307        target: "r2smt::patch",
308        addr = %finding.address,
309        size,
310        strategy = strategy.as_str(),
311        "planned jcc operation"
312    );
313
314    Ok(FindingDecision::Plan(PlanOperation {
315        address: finding.address,
316        strategy,
317        kind: finding.kind,
318        confidence: finding.confidence,
319        size,
320        new_bytes,
321        rationale: rationale_for(finding, strategy),
322    }))
323}
324
325fn plan_setcc(
326    finding: &Finding,
327    size: usize,
328    patcher: &mut dyn BytePatcher,
329) -> Result<FindingDecision> {
330    let value = match finding.verdict {
331        SmtResult::AlwaysTrue => true,
332        SmtResult::AlwaysFalse => false,
333        _ => {
334            return Err(Error::parse(
335                "patch_plan",
336                format!(
337                    "{:?} verdict at {addr} cannot drive a setcc rewrite",
338                    finding.verdict,
339                    addr = finding.address,
340                ),
341            ));
342        }
343    };
344    let original = patcher.read_bytes(finding.address, size)?;
345    let new_bytes = match patch_setcc(&original, value) {
346        Ok(bytes) => bytes,
347        Err(err) => {
348            return Ok(FindingDecision::Skip(format!(
349                "setcc byte rewrite failed: {err}"
350            )));
351        }
352    };
353    let strategy = PatchStrategy::ReplaceSetCcWithMovConst;
354    debug!(
355        target: "r2smt::patch",
356        addr = %finding.address,
357        size,
358        strategy = strategy.as_str(),
359        value,
360        "planned setcc operation"
361    );
362    Ok(FindingDecision::Plan(PlanOperation {
363        address: finding.address,
364        strategy,
365        kind: finding.kind,
366        confidence: finding.confidence,
367        size,
368        new_bytes,
369        rationale: setcc_rationale(finding, value),
370    }))
371}
372
373fn plan_cmovcc(
374    finding: &Finding,
375    size: usize,
376    patcher: &mut dyn BytePatcher,
377) -> Result<FindingDecision> {
378    let always_true = match finding.verdict {
379        SmtResult::AlwaysTrue => true,
380        SmtResult::AlwaysFalse => false,
381        _ => {
382            return Err(Error::parse(
383                "patch_plan",
384                format!(
385                    "{:?} verdict at {addr} cannot drive a cmovcc rewrite",
386                    finding.verdict,
387                    addr = finding.address,
388                ),
389            ));
390        }
391    };
392
393    let new_bytes = if always_true {
394        let original = patcher.read_bytes(finding.address, size)?;
395        match patch_cmovcc_to_mov(&original) {
396            Ok(bytes) => bytes,
397            Err(err) => {
398                return Ok(FindingDecision::Skip(format!(
399                    "cmovcc byte rewrite failed: {err}"
400                )));
401            }
402        }
403    } else {
404        // Always-false: the conditional move never fires — NOP the
405        // whole instruction so the destination keeps its prior value.
406        nop_buffer(size)
407    };
408
409    let strategy = PatchStrategy::ReplaceCMovCcWithMovOrNop;
410    debug!(
411        target: "r2smt::patch",
412        addr = %finding.address,
413        size,
414        strategy = strategy.as_str(),
415        always_true,
416        "planned cmovcc operation"
417    );
418    Ok(FindingDecision::Plan(PlanOperation {
419        address: finding.address,
420        strategy,
421        kind: finding.kind,
422        confidence: finding.confidence,
423        size,
424        new_bytes,
425        rationale: cmovcc_rationale(finding, always_true),
426    }))
427}
428
429fn setcc_rationale(finding: &Finding, value: bool) -> String {
430    let target = i32::from(value);
431    format!(
432        "{mnem} at {addr} always sets its destination to {target} ({formula} is always {value})",
433        mnem = finding.mnemonic,
434        addr = finding.address,
435        formula = finding.formula,
436        value = if value { "true" } else { "false" },
437    )
438}
439
440fn cmovcc_rationale(finding: &Finding, always_true: bool) -> String {
441    if always_true {
442        format!(
443            "{mnem} at {addr} always moves ({formula} is always true) — rewritten as unconditional MOV",
444            mnem = finding.mnemonic,
445            addr = finding.address,
446            formula = finding.formula,
447        )
448    } else {
449        format!(
450            "{mnem} at {addr} never moves ({formula} is always false) — NOPed",
451            mnem = finding.mnemonic,
452            addr = finding.address,
453            formula = finding.formula,
454        )
455    }
456}
457
458fn jcc_strategy(finding: &Finding) -> Result<PatchStrategy> {
459    match finding.verdict {
460        SmtResult::AlwaysFalse => Ok(PatchStrategy::NopJcc),
461        SmtResult::AlwaysTrue => Ok(PatchStrategy::ReplaceJccWithJmp),
462        _ => Err(Error::parse(
463            "patch_plan",
464            format!(
465                "{:?} verdict at {addr} cannot drive a jcc rewrite",
466                finding.verdict,
467                addr = finding.address,
468            ),
469        )),
470    }
471}
472
473/// Return a `size`-byte NOP buffer encoded for `arch`.
474///
475/// On x86 the encoding is a string of single-byte `0x90`. On ARM
476/// (`AArch64` / `AArch32`) it tiles the architectural 4-byte NOP hint.
477/// `size` must be a multiple of 4 for ARM; non-ARM archs accept any
478/// size.
479fn nop_bytes_for(arch: Arch, size: usize, is_thumb: bool) -> Result<Vec<u8>> {
480    if is_thumb {
481        return thumb_nop_buffer(size);
482    }
483    if arch_is_arm(arch) {
484        arm_nop_buffer(arch, size)
485    } else {
486        Ok(vec![X86_NOP_BYTE; size])
487    }
488}
489
490/// Pad an assembled branch byte-string up to `size`, using NOP fill
491/// that's safe to execute under `arch`. On ARM the assembled length
492/// must already equal `size` (callers enforce this); the function
493/// becomes a no-op pass-through. On x86 it appends `0x90` until full.
494fn pad_to_size(arch: Arch, mut bytes: Vec<u8>, size: usize) -> Result<Vec<u8>> {
495    if arch_is_arm(arch) {
496        // ARM paths reject mismatched lengths upstream; if they get
497        // here something is wrong with the caller, not the encoding.
498        if bytes.len() != size {
499            return Err(Error::parse(
500                "patch_plan.pad",
501                format!(
502                    "ARM assembled length {} mismatched target size {}",
503                    bytes.len(),
504                    size
505                ),
506            ));
507        }
508        return Ok(bytes);
509    }
510    while bytes.len() < size {
511        bytes.push(X86_NOP_BYTE);
512    }
513    Ok(bytes)
514}
515
516fn unconditional_branch_asm(arch: Arch, target: Address) -> String {
517    if arch_is_arm(arch) {
518        format!("b {target}")
519    } else {
520        format!("jmp {target}")
521    }
522}
523
524fn rationale_for(finding: &Finding, strategy: PatchStrategy) -> String {
525    match strategy {
526        PatchStrategy::NopJcc => format!(
527            "{mnem} at {addr} is never taken ({formula} is always false)",
528            mnem = finding.mnemonic,
529            addr = finding.address,
530            formula = finding.formula,
531        ),
532        PatchStrategy::ReplaceJccWithJmp => format!(
533            "{mnem} at {addr} is always taken ({formula} is always true)",
534            mnem = finding.mnemonic,
535            addr = finding.address,
536            formula = finding.formula,
537        ),
538        _ => finding.formula.clone(),
539    }
540}
541
542fn measure_instruction_size(finding: &Finding, patcher: &mut dyn BytePatcher) -> Result<usize> {
543    // The patcher does not have direct access to instruction sizes;
544    // fall back to reading bytes until the next instruction. The
545    // simplest portable proxy: read up to `MAX_INSTRUCTION_SIZE` and
546    // then probe one byte at a time would require an instruction
547    // length decoder. Instead, we rely on the caller's knowledge of
548    // the instruction's footprint surfaced via `taken_target` and
549    // `fallthrough_target`: for a `jcc`, the fallthrough address sits
550    // immediately after the instruction's last byte, so
551    // `fallthrough - address` is the instruction size.
552    if let Some(ft) = finding.fallthrough_target {
553        let raw = ft.get().saturating_sub(finding.address.get());
554        if raw > 0
555            && let Ok(size) = usize::try_from(raw)
556            && size <= MAX_INSTRUCTION_SIZE
557        {
558            // Sanity: verify the patcher can actually read that
559            // many bytes — surfaces unmapped addresses up front.
560            let _ = patcher.read_bytes(finding.address, size)?;
561            return Ok(size);
562        }
563    }
564    Err(Error::parse(
565        "patch_plan",
566        format!(
567            "could not determine size of instruction at {addr}",
568            addr = finding.address,
569        ),
570    ))
571}
572
573#[cfg(test)]
574mod tests;