diff --git a/CLAUDE.md b/CLAUDE.md index ffd2b02d..0fadc2ef 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,7 +10,7 @@ Azoth is a research-grade toolchain for Ethereum smart-contract obfuscation. The 1. **Pre-processing/Analysis**: Isolate runtime bytecode and measure structure 2. **Obfuscation Passes**: Apply reversible transforms to raise analyst effort -3. **Re-assembly & Validation**: Splice segments back and validate equivalence +3. **Re-assembly & Validation**: Splice segments back, run structural checks, and use contract-specific differential EVM tests where available ## Architecture @@ -18,8 +18,8 @@ This is a Rust workspace with six main crates: - **`crates/core/`**: Core functionality including bytecode decoder, encoder, section detector, bytecode stripper, and CFG-IR generation. The detection module includes section isolation (`detection/sections.rs`) and dispatcher pattern detection (`detection/dispatcher.rs`) - **`crates/analysis/`**: Analysis utilities for measuring bytecode complexity and obfuscation quality through comprehensive metrics -- **`crates/transforms/`**: Obfuscation passes including opaque predicates, control flow shuffling, function dispatcher obfuscation, and jump address transformation. The `obfuscator.rs` module orchestrates all transforms -- **`crates/verification/`**: Formal verification engine providing mathematical proofs of semantic equivalence using Z3 SMT solver, plus practical testing with REVM. Supports multiple verification levels and generates cryptographic certificates +- **`crates/transforms/`**: Obfuscation passes and the unified pipeline in `obfuscator.rs`. The production default applies selector-only dispatcher rewriting when detected, then jump-trampoline topology diversification and cluster shuffling; literal synthesis and the other passes are opt-in experiments and string obfuscation is disabled +- **`crates/verification/`**: Experimental verification scaffolding. Contract-equivalence entry points fail closed as unsupported; the optional Z3 feature and proof data structures do not provide a mathematical equivalence guarantee - **`crates/utils/`**: Shared utilities including deterministic seed generation and error types - **`crates/cli/`**: Command-line interface (`azoth` binary) with subcommands for decode, strip, cfg, and obfuscate @@ -115,8 +115,8 @@ Write documentation in clear, professional prose rather than fragmented bullet p /// /// This function performs the core transformation from linear bytecode instructions /// into a structured graph representation that enables sophisticated analysis and -/// obfuscation transforms. The resulting CFG maintains semantic equivalence while -/// providing the structural information necessary for advanced code analysis. +/// obfuscation transforms. The resulting CFG represents the decoded control flow +/// and provides the structural information necessary for advanced code analysis. /// /// The construction process involves several phases: basic block identification /// through control flow analysis, edge creation based on jump target resolution, @@ -236,7 +236,7 @@ When creating GitHub issues, always assign appropriate labels to ensure proper c - `component:core` - Issues related to core bytecode processing - `component:transforms` - Obfuscation transform implementations - `component:analysis` - Metrics and analytical functionality -- `component:verification` - Formal verification and testing +- `component:verification` - Experimental verification scaffolding and testing - `component:utils` - Shared utilities and helpers - `component:cli` - Command-line interface and user experience - `component:tests` - Testing infrastructure and test cases diff --git a/Cargo.lock b/Cargo.lock index 95e23325..6647db1f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3428,10 +3428,12 @@ checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "js-sys" -version = "0.3.81" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] @@ -3929,9 +3931,9 @@ dependencies = [ [[package]] name = "owo-colors" -version = "4.2.2" +version = "4.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48dd4f4a2c8405440fd0462561f0e5806bd0f77e86f51c761481bdd4018b545e" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" [[package]] name = "p256" @@ -4431,9 +4433,9 @@ checksum = "d3edd4d5d42c92f0a659926464d4cce56b562761267ecf0f469d85b7de384175" [[package]] name = "redox_syscall" -version = "0.5.17" +version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ "bitflags", ] @@ -6091,9 +6093,9 @@ checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-truncate" @@ -6222,9 +6224,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.104" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" dependencies = [ "cfg-if", "once_cell", @@ -6233,38 +6235,21 @@ dependencies = [ "wasm-bindgen-shared", ] -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19" -dependencies = [ - "bumpalo", - "log", - "proc-macro2", - "quote", - "syn 2.0.106", - "wasm-bindgen-shared", -] - [[package]] name = "wasm-bindgen-futures" -version = "0.4.54" +version = "0.4.71" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e038d41e478cc73bae0ff9b36c60cff1c98b8f38f8d7e8061e79ee63608ac5c" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" dependencies = [ - "cfg-if", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.104" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -6272,22 +6257,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.104" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" dependencies = [ + "bumpalo", "proc-macro2", "quote", "syn 2.0.106", - "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.104" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" dependencies = [ "unicode-ident", ] @@ -6321,9 +6306,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.81" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9367c417a924a74cae129e6a2ae3b47fabb1f8995595ab474029da749a8be120" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" dependencies = [ "js-sys", "wasm-bindgen", diff --git a/README.md b/README.md index d91c3ca5..c5bebc8d 100644 --- a/README.md +++ b/README.md @@ -3,23 +3,27 @@ ## What is Azoth? -Azoth is a deterministic EVM bytecode obfuscator designed to make Mirage's execution contracts indistinguishable from ordinary, unverified deployments on Ethereum. The name "[Azoth](https://www.wikiwand.com/en/articles/Azoth)" derives from medieval alchemy, where it referred to the universal solvent: a hypothetical substance capable of dissolving any material and serving as the essential agent of transformation. +Azoth is a deterministic EVM bytecode obfuscator that varies deployment and runtime bytecode while preserving the behavior of supported contracts. Its research goal is to reduce static linkability and make transformed contracts resemble the broad population of unverified Ethereum deployments. Indistinguishability is an evaluation target, not a guarantee. The name "[Azoth](https://www.wikiwand.com/en/articles/Azoth)" derives from medieval alchemy, where it referred to the universal solvent: a hypothetical substance capable of dissolving any material and serving as the essential agent of transformation. ## How does it work? 1. Dissection: decode the contract’s init/runtime layout, resolve sections, and build a control-flow graph of block bodies and jump targets. -2. Transformation: apply deterministic transformations (e.g dispatcher transforms, block shuffling etc.) that changes the structure of the bytecode without blowing gas or size limits. +2. Transformation: apply seed-derived passes transactionally. The production default is selector-only `FunctionDispatcher` when a supported dispatcher is detected, followed by low-density `JumpTrampoline` topology diversification and LCS-bounded `ClusterShuffle` layout diversification. 3. Recovery: lower the rewritten runtime, patch init-code offsets, and mask any exact constructor-argument suffix so the final bytecode stays deployable without retaining an ABI-aligned plaintext tail. -Azoth also incorporates a formal verification system that provides mathematical guarantees of functional equivalence between original and obfuscated contracts. +The verification crate is experimental scaffolding. Contract-equivalence entry points currently return `Unsupported`; Azoth does not provide a formal proof or mathematical guarantee of semantic equivalence. Safety must instead be established with compiler-specific differential deployment and behavioral tests for the exact contract and pass set. Constructor-argument masking is an obfuscation boundary, not encryption: it defeats verbatim static suffix recovery, but public creation code can still be analyzed or executed to recover values. See the [constructor-argument security and benchmark report](docs/constructor-argument-obfuscation.md). ## Status -Azoth is under active development: the parsing pipeline, CFG builder, and several core transforms are in daily use, while additional passes, verification tooling, and resilience metrics are landing incrementally as we harden the stack for production-facing deployments. +Azoth is under active development. The current pipeline fails closed for unsupported self-code-layout and gas-observation patterns, including all `EXTCODE*` introspection because its target may alias the current contract, as well as ambiguous dispatcher selector uses, unsupported constructor layouts, and EVM size-limit violations. Those guards reduce known risks but do not establish equivalence for arbitrary bytecode. Treat generated deployments as experimental until they pass contract-specific differential tests. + +The optional JSON report records byte sizes and exact EVM code-deposit gas for the deployed runtime (`200 gas/byte`). It deliberately does not estimate creation calldata cost, EIP-3860 word cost, constructor execution, or runtime execution gas; measure those with an EVM harness. + +The current hardening results, benchmark methodology, red-team findings, and remaining limitations are documented in the [technical report](docs/AZOTH_TECHNICAL_REPORT.md) and [executive report](docs/AZOTH_EXECUTIVE_REPORT.md). ## Getting Started diff --git a/crates/analysis/README.md b/crates/analysis/README.md index 55163a00..2aac3c46 100644 --- a/crates/analysis/README.md +++ b/crates/analysis/README.md @@ -1,6 +1,6 @@ # Azoth Analysis -The `azoth-analysis` crate provides analytical metrics for evaluating EVM bytecode obfuscation transforms. This crate implements a minimal set of metrics to assess transform potency and gas efficiency. +The `azoth-analysis` crate provides analytical metrics for evaluating EVM bytecode obfuscation transforms. It measures structure, size, original-byte retention, and seed-to-seed diversity; it does not measure execution gas or prove semantic equivalence. ## Architecture @@ -10,7 +10,7 @@ The analysis crate focuses on quantifying bytecode complexity through: 2. **Stack Usage** - Maximum stack height measurements 3. **Dominator Analysis** - Control flow critical points using dominator/post-dominator overlap 4. **Size Metrics** - Bytecode length tracking -5. **Obfuscation Persistence** - Longest preserved byte sequences and n-gram diversity across randomized obfuscations +5. **Obfuscation Persistence** - Conservative ordered-byte retention, longest contiguous runs, aligned differences, and pairwise n-gram similarity across randomized obfuscations ## Key Components @@ -18,7 +18,7 @@ The analysis crate focuses on quantifying bytecode complexity through: Implements core metrics for evaluating bytecode complexity and transformation effectiveness: -- **Bytecode Size** (`byte_len`) - Size of cleaned runtime bytecode in bytes +- **Bytecode Size** (`byte_len`) - Encoded size of the current runtime CFG plus appended transform data - **Block Count** (`block_cnt`) - Number of basic blocks in the CFG (excluding Entry/Exit) - **Edge Count** (`edge_cnt`) - Number of edges in the CFG - **Maximum Stack Peak** (`max_stack_peak`) - Maximum stack height across all body blocks @@ -29,7 +29,8 @@ Implements core metrics for evaluating bytecode complexity and transformation ef The crate provides these primary functions: -- `collect_metrics(ir: &CfgIrBundle, report: &CleanReport) -> Result` - Collects all metrics from CFG and clean report +- `collect_metrics(ir: &CfgIrBundle) -> Result` - Collects metrics from the current CFG, including its current transformed byte length +- `current_byte_len(ir: &CfgIrBundle) -> usize` - Measures the instruction stream and appended transform data directly - `dominator_pairs(g: &DiGraph) -> (DominatorMap, DominatorMap)` - Computes dominator and post-dominator pairs - `dom_overlap(doms: &DominatorMap, pdoms: &DominatorMap) -> f64` - Calculates dominator overlap fraction - `compare(before: &Metrics, after: &Metrics) -> f64` - Compares metrics between transformations @@ -62,6 +63,9 @@ Runs multiple obfuscation attempts with randomized seeds and aggregates: - Summary statistics (average, median, percentiles, range, standard deviation) - Histogram distribution of preserved lengths - Top ten most frequent preserved sequences -- N-gram diversity (n = 2, 4, 8) across obfuscated outputs +- Conservative longest-common-subsequence retention and aligned original-to-output difference +- Pairwise aligned seed difference and pairwise n-gram set Jaccard similarity (n = 2, 4, 8) + +Pairwise n-gram Jaccard replaces the former pooled unique-window percentage, whose value decreased mechanically as more iterations were added. The report stores ratios in `0.0..=1.0` and renders them as percentages. Use `AnalysisConfig` to configure iterations, transform passes, and output path, then call `analyze_obfuscation(config)` to produce a markdown report. The CLI subcommand `azoth analyze` builds on this module. diff --git a/crates/analysis/src/lib.rs b/crates/analysis/src/lib.rs index 7468457d..6e7d7fed 100644 --- a/crates/analysis/src/lib.rs +++ b/crates/analysis/src/lib.rs @@ -1,16 +1,16 @@ //! Analytical utilities for assessing Azoth obfuscation results. The crate exposes: //! - Core metrics for bytecode size, control-flow structure, stack usage, and dominator overlap to //! estimate transform potency and gas impact. -//! - Comparison helpers that derive before/after deltas directly from a `CfgIrBundle` and -//! `CleanReport`. +//! - Comparison helpers that derive before/after deltas directly from the current `CfgIrBundle`. //! - An obfuscation study that repeatedly obfuscates bytecode with randomized seeds, //! aggregates longest preserved byte sequences, emits percentile summaries, tracks top repeated -//! motifs, and measures n-gram diversity for multiple n values before producing a Markdown -//! report. +//! motifs, and measures pairwise byte and n-gram similarity before producing a Markdown report. pub mod decompile_diff; pub mod metrics; -pub use metrics::{Metrics, collect_metrics, compare}; +pub use metrics::{Metrics, collect_metrics, compare, current_byte_len}; + +pub mod similarity; pub mod obfuscation; diff --git a/crates/analysis/src/metrics.rs b/crates/analysis/src/metrics.rs index 0f1fcf42..1444bcaf 100644 --- a/crates/analysis/src/metrics.rs +++ b/crates/analysis/src/metrics.rs @@ -1,6 +1,5 @@ use crate::{Error, Result}; use azoth_core::cfg_ir::{Block, BlockBody, CfgIrBundle, EdgeType}; -use azoth_core::strip::CleanReport; use petgraph::{ algo::dominators::simple_fast, graph::NodeIndex, @@ -18,7 +17,7 @@ use std::hash::Hash; /// states and guide transform selection. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Metrics { - /// Size of the cleaned runtime bytecode in bytes. + /// Encoded size of the current runtime instructions and appended transform data, in bytes. pub byte_len: usize, /// Number of basic blocks in the CFG (excluding Entry/Exit). pub block_cnt: usize, @@ -32,20 +31,42 @@ pub struct Metrics { pub potency: f64, } -/// Collects metrics from the cleaned runtime bytecode and CFG. +/// Return the encoded size of the current runtime CFG and its appended data section. +/// +/// This mirrors the obfuscator encoder's input: every body instruction is emitted once, followed +/// by arithmetic-chain data when present. Unlike `CleanReport::clean_len`, this value changes as +/// transforms add, remove, or widen instructions. +pub fn current_byte_len(ir: &CfgIrBundle) -> usize { + let instruction_bytes: usize = ir + .cfg + .node_indices() + .filter_map(|node| match &ir.cfg[node] { + Block::Body(body) => Some( + body.instructions + .iter() + .map(|instruction| instruction.byte_size()) + .sum::(), + ), + _ => None, + }) + .sum(); + instruction_bytes + ir.arithmetic_chain_data.as_ref().map_or(0, Vec::len) +} + +/// Collects metrics from the current runtime bytecode CFG. /// /// Computes bytecode size, block and edge counts, maximum stack height, dominator overlap, and -/// potency score. Uses the `CleanReport` for size and `CfgIrBundle` for control flow and stack -/// data. The potency score balances complexity (nodes, edges) against overlap to estimate analyst -/// effort, with adjustments for gas efficiency. +/// potency score. Byte size is derived from the current instructions and appended data, so a +/// post-transform call measures the transformed representation. The potency score balances +/// complexity (nodes, edges) against overlap to estimate analyst effort, with adjustments for gas +/// efficiency. /// /// # Arguments /// * `ir` - The CFG and IR bundle from `cfg_ir::build_cfg_ir`. -/// * `report` - The stripping report from `strip::strip_bytecode`. /// /// # Returns /// A `Metrics` struct with computed metrics, or an error if the CFG is invalid. -pub fn collect_metrics(ir: &CfgIrBundle, report: &CleanReport) -> Result { +pub fn collect_metrics(ir: &CfgIrBundle) -> Result { if ir.cfg.node_count() < 2 { return Err(Error::EmptyCfg); } @@ -65,7 +86,7 @@ pub fn collect_metrics(ir: &CfgIrBundle, report: &CleanReport) -> Result = HashMap, NodeIndex>; /// Computes dominator and post-dominator pairs for the CFG. /// /// Uses `petgraph`’s `simple_fast` algorithm to compute immediate dominators and post-dominators, -/// mapping nodes to their immediate dominator/post-dominator. The entry node (index 0) and exit -/// node (last index) are used as roots for the respective analyses. +/// mapping nodes to their immediate dominator/post-dominator. Roots are located by their +/// `Block::Entry` and `Block::Exit` variants; stable-graph node indices are not layout contracts. /// /// # Arguments /// * `g` - The CFG graph from `CfgIrBundle`. @@ -121,22 +142,29 @@ pub fn dominator_pairs( where Ix: IndexType, { - let entry = NodeIndex::::new(0); - let exit = NodeIndex::::new(g.node_count() - 1); - - let dominators_tree = simple_fast(g, entry); let mut dom_map = HashMap::new(); - for n in g.node_indices() { - if let Some(idom) = dominators_tree.immediate_dominator(n) { - dom_map.insert(n, idom); + if let Some(entry) = g + .node_indices() + .find(|node| matches!(g.node_weight(*node), Some(Block::Entry))) + { + let dominators_tree = simple_fast(g, entry); + for n in g.node_indices() { + if let Some(idom) = dominators_tree.immediate_dominator(n) { + dom_map.insert(n, idom); + } } } - let post_dominators_tree = simple_fast(Reversed(g), exit); let mut pdom_map = HashMap::new(); - for n in g.node_indices() { - if let Some(ipdom) = post_dominators_tree.immediate_dominator(n) { - pdom_map.insert(n, ipdom); + if let Some(exit) = g + .node_indices() + .find(|node| matches!(g.node_weight(*node), Some(Block::Exit))) + { + let post_dominators_tree = simple_fast(Reversed(g), exit); + for n in g.node_indices() { + if let Some(ipdom) = post_dominators_tree.immediate_dominator(n) { + pdom_map.insert(n, ipdom); + } } } @@ -203,3 +231,30 @@ fn score(overlap: f64, nodes: usize, edges: usize) -> f64 { pub fn compare(before: &Metrics, after: &Metrics) -> f64 { after.potency - before.potency - 0.25 * (after.byte_len as f64 - before.byte_len as f64) } + +#[cfg(test)] +mod tests { + use super::*; + use azoth_core::cfg_ir::BlockControl; + + #[test] + fn post_dominator_root_is_the_exit_variant_not_the_last_node() { + let mut graph = StableDiGraph::::new(); + let entry = graph.add_node(Block::Entry); + let exit = graph.add_node(Block::Exit); + // Exit is intentionally index 1 and this body is the last node. + let body = graph.add_node(Block::Body(BlockBody { + start_pc: 0, + instructions: Vec::new(), + max_stack: 0, + control: BlockControl::Terminal, + })); + graph.add_edge(entry, body, EdgeType::Fallthrough); + graph.add_edge(body, exit, EdgeType::Fallthrough); + + let (_, post_dominators) = dominator_pairs(&graph); + assert_eq!(post_dominators.get(&body), Some(&exit)); + assert_eq!(post_dominators.get(&entry), Some(&body)); + assert!(!post_dominators.contains_key(&exit)); + } +} diff --git a/crates/analysis/src/obfuscation.rs b/crates/analysis/src/obfuscation.rs index 9b449a53..218ecabf 100644 --- a/crates/analysis/src/obfuscation.rs +++ b/crates/analysis/src/obfuscation.rs @@ -1,7 +1,15 @@ +use crate::similarity::{ + DistributionSummary, aligned_byte_difference_ratio, conservative_lcs_retention, + longest_common_contiguous_slice, pairwise_aligned_byte_differences, pairwise_ngram_jaccard, + summarize_distribution, +}; use azoth_core::seed::Seed; use azoth_transform::{ Transform, + cluster_shuffle::ClusterShuffle, jump_address_transformer::JumpAddressTransformer, + jump_trampoline::JumpTrampoline, + literal_synthesis::LiteralSynthesis, obfuscator::{ObfuscationConfig, obfuscate_bytecode}, opaque_predicate::OpaquePredicate, shuffle::Shuffle, @@ -18,10 +26,9 @@ use thiserror::Error as ThisError; /// Default passes applied to each obfuscation run. /// -/// Leaving this empty means the analysis reuses the obfuscator's native defaults -/// (dispatcher when detected plus any user-specified transforms) instead of -/// forcing deprecated transforms such as Shuffle. -pub const DEFAULT_PASSES: &str = ""; +/// This mirrors the production CLI defaults so analysis experiments exercise the same transform +/// portfolio users receive. The dispatcher remains automatic when detected. +pub const DEFAULT_PASSES: &str = "jump_trampoline, cluster_shuffle"; /// Configuration for running an obfuscation analysis experiment. #[derive(Debug, Clone)] @@ -81,6 +88,32 @@ pub struct SequenceFrequency { pub sequence_hex: String, } +/// Byte-similarity distributions reported as ratios in the range `0.0..=1.0`. +#[derive(Debug, Clone, Serialize)] +pub struct SimilaritySummary { + /// Ordered original-byte retention for each output. Lower means more conservative change. + pub conservative_lcs_retention: DistributionSummary, + /// Position-aligned difference between the original and each output. Higher means more change. + pub aligned_original_difference: DistributionSummary, + /// Position-aligned difference for every unordered pair of generated outputs. + pub pairwise_aligned_difference: DistributionSummary, +} + +/// Deployment-bytecode size and growth distribution across generated variants. +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct SizeSummary { + pub count: usize, + pub mean_bytes: f64, + pub median_bytes: f64, + pub percentile_95_bytes: f64, + pub min_bytes: usize, + pub max_bytes: usize, + pub mean_ratio: f64, + pub median_ratio: f64, + pub percentile_95_ratio: f64, + pub max_ratio: f64, +} + /// Final report produced by the analysis. #[derive(Debug, Clone, Serialize)] pub struct AnalysisReport { @@ -91,10 +124,14 @@ pub struct AnalysisReport { pub seeds: Vec, pub unique_seed_count: usize, pub sequence_lengths: Vec, + pub output_lengths: Vec, pub top_sequences: Vec, pub summary: SummaryStats, + pub similarity: SimilaritySummary, + pub size: SizeSummary, pub histogram: Vec, - pub ngram_diversity: BTreeMap, + /// Pairwise set Jaccard by n-gram width. Lower similarity means more seed-to-seed diversity. + pub pairwise_ngram_jaccard: BTreeMap, pub markdown_path: PathBuf, } @@ -117,6 +154,25 @@ impl AnalysisReport { summarize_transforms(&self.transform_counts, self.iterations) )?; writeln!(out)?; + writeln!(out, "## Output Size Metrics")?; + writeln!(out)?; + writeln!( + out, + "- **Deployment bytes (mean / median / P95 / max):** {:.1} / {:.1} / {:.1} / {}", + self.size.mean_bytes, + self.size.median_bytes, + self.size.percentile_95_bytes, + self.size.max_bytes + )?; + writeln!( + out, + "- **Size ratio (mean / median / P95 / max):** {:.3}x / {:.3}x / {:.3}x / {:.3}x", + self.size.mean_ratio, + self.size.median_ratio, + self.size.percentile_95_ratio, + self.size.max_ratio + )?; + writeln!(out)?; writeln!(out, "## Summary Statistics")?; writeln!(out)?; writeln!( @@ -170,6 +226,43 @@ impl AnalysisReport { self.summary.percentile_95 )?; writeln!(out)?; + writeln!(out, "### Byte Similarity Metrics")?; + writeln!(out)?; + writeln!( + out, + "- **Conservative LCS retention (mean / median):** {:.2}% / {:.2}%", + self.similarity.conservative_lcs_retention.mean * 100.0, + self.similarity.conservative_lcs_retention.median * 100.0 + )?; + writeln!( + out, + "- **Conservative changed-order lower bound (1 - mean retention):** {:.2}%", + (1.0 - self.similarity.conservative_lcs_retention.mean) * 100.0 + )?; + writeln!( + out, + "- **Worst-sample changed-order lower bound (1 - maximum retention):** {:.2}%", + (1.0 - self.similarity.conservative_lcs_retention.max) * 100.0 + )?; + writeln!( + out, + "- **Aligned original-to-output difference (mean / median):** {:.2}% / {:.2}%", + self.similarity.aligned_original_difference.mean * 100.0, + self.similarity.aligned_original_difference.median * 100.0 + )?; + writeln!( + out, + "- **Pairwise seed-to-seed aligned difference (mean / median, {} pair{}):** {:.2}% / {:.2}%", + self.similarity.pairwise_aligned_difference.count, + if self.similarity.pairwise_aligned_difference.count == 1 { + "" + } else { + "s" + }, + self.similarity.pairwise_aligned_difference.mean * 100.0, + self.similarity.pairwise_aligned_difference.median * 100.0 + )?; + writeln!(out)?; writeln!(out, "## Top 10 Most Repeated Sequences")?; writeln!(out)?; writeln!( @@ -228,15 +321,23 @@ impl AnalysisReport { } } writeln!(out)?; - writeln!(out, "## N-gram Diversity Analysis")?; + writeln!(out, "## Pairwise N-gram Similarity")?; writeln!(out)?; writeln!( out, - "Percentage of unique n-byte sequences across all obfuscated outputs." + "Set Jaccard similarity for every unordered output pair. Lower values indicate greater seed-to-seed local-pattern diversity and do not mechanically shrink when more iterations are added." )?; writeln!(out)?; - for (n, value) in &self.ngram_diversity { - writeln!(out, "- **{}-byte sequences:** {:.2}% unique", n, value)?; + for (n, summary) in &self.pairwise_ngram_jaccard { + writeln!( + out, + "- **{}-byte sequences ({} pair{}):** {:.2}% mean / {:.2}% median similarity", + n, + summary.count, + if summary.count == 1 { "" } else { "s" }, + summary.mean * 100.0, + summary.median * 100.0 + )?; } writeln!(out)?; writeln!(out, "## Distribution Histogram")?; @@ -273,41 +374,55 @@ impl AnalysisReport { writeln!(out)?; writeln!( out, - "Average longest common sequence covers **{:.2}%** of the original bytecode.", + "Average longest common contiguous run covers **{:.2}%** of the original bytecode.", self.summary.preservation_ratio )?; - if self.summary.preservation_ratio < 10.0 { + writeln!( + out, + "Conservative ordered-byte retention averages **{:.2}%**; this is the safer byte-change baseline because insertions do not make surviving original order appear changed.", + self.similarity.conservative_lcs_retention.mean * 100.0 + )?; + if self.similarity.conservative_lcs_retention.mean > 0.75 { writeln!( out, - "This suggests strong obfuscation with minimal contiguous preservation." + "Most original byte order remains recoverable as a subsequence, even if contiguous runs are shorter." )?; - } else if self.summary.preservation_ratio < 25.0 { + } else if self.similarity.conservative_lcs_retention.mean > 0.5 { writeln!( out, - "This suggests moderate obfuscation with noticeable contiguous preservation." + "A majority of original byte order remains recoverable as a subsequence." )?; } else { writeln!( out, - "This suggests weaker obfuscation: significant contiguous blocks remain." + "Less than half of original byte order remains in the average output." )?; } writeln!(out)?; - let diversity = self.ngram_diversity.get(&8).copied().unwrap_or(0.0); - if diversity > 90.0 { + let ngram_similarity = self + .pairwise_ngram_jaccard + .get(&8) + .copied() + .unwrap_or_default(); + if ngram_similarity.count == 0 { + writeln!( + out, + "At least two generated outputs are required for pairwise seed-diversity metrics." + )?; + } else if ngram_similarity.median < 0.1 { writeln!( out, - "High 8-byte diversity indicates obfuscation yields highly varied byte patterns." + "Low median 8-byte Jaccard similarity indicates strongly varied local patterns across seeds." )?; - } else if diversity > 70.0 { + } else if ngram_similarity.median < 0.3 { writeln!( out, - "Moderate 8-byte diversity indicates reasonable variation across seeds." + "Median 8-byte Jaccard similarity indicates moderate local-pattern overlap across seeds." )?; } else { writeln!( out, - "Low 8-byte diversity indicates many recurring patterns across outputs." + "High median 8-byte Jaccard similarity indicates substantial recurring local patterns across seeds." )?; } writeln!(out)?; @@ -362,6 +477,9 @@ pub async fn analyze_obfuscation( let mut sequence_lengths = Vec::with_capacity(config.iterations); let mut sequence_counter: HashMap, usize> = HashMap::new(); let mut obfuscated_bytecodes: Vec> = Vec::with_capacity(config.iterations); + let mut output_lengths = Vec::with_capacity(config.iterations); + let mut conservative_retentions = Vec::with_capacity(config.iterations); + let mut aligned_original_differences = Vec::with_capacity(config.iterations); let mut seeds = Vec::with_capacity(config.iterations); let mut transform_counts: BTreeMap = BTreeMap::new(); transform_counts.insert("FunctionDispatcher".to_string(), 0); @@ -386,7 +504,8 @@ pub async fn analyze_obfuscation( Ok(result) => { let transforms_applied = result.metadata.transforms_applied.clone(); let obfuscated_bytes = hex_to_bytes(&result.obfuscated_bytecode)?; - let sequence = longest_common_substring(&original_bytes, &obfuscated_bytes); + let sequence = + longest_common_contiguous_slice(&original_bytes, &obfuscated_bytes); if !sequence.is_empty() { let entry = sequence_counter.entry(sequence.to_vec()).or_insert(0); *entry += 1; @@ -395,6 +514,15 @@ pub async fn analyze_obfuscation( *transform_counts.entry(name).or_insert(0) += 1; } sequence_lengths.push(sequence.len()); + conservative_retentions.push(conservative_lcs_retention( + &original_bytes, + &obfuscated_bytes, + )); + aligned_original_differences.push(aligned_byte_difference_ratio( + &original_bytes, + &obfuscated_bytes, + )); + output_lengths.push(obfuscated_bytes.len()); obfuscated_bytecodes.push(obfuscated_bytes); seeds.push(seed_hex); break; @@ -411,9 +539,23 @@ pub async fn analyze_obfuscation( } let summary = compute_summary_stats(&sequence_lengths, original_bytes.len()); + let size = compute_size_summary(&output_lengths, original_bytes.len()); let histogram = build_histogram(&sequence_lengths); let top_sequences = compute_top_sequences(sequence_counter); - let ngram_diversity = compute_ngram_diversity(&obfuscated_bytecodes, &[2, 4, 8]); + let similarity = SimilaritySummary { + conservative_lcs_retention: summarize_distribution(&conservative_retentions), + aligned_original_difference: summarize_distribution(&aligned_original_differences), + pairwise_aligned_difference: summarize_distribution(&pairwise_aligned_byte_differences( + &obfuscated_bytecodes, + )), + }; + let pairwise_ngram_jaccard = [2, 4, 8] + .into_iter() + .map(|n| { + let values = pairwise_ngram_jaccard(&obfuscated_bytecodes, n); + (n, summarize_distribution(&values)) + }) + .collect(); let unique_seed_count = seeds.iter().collect::>().len(); @@ -425,10 +567,13 @@ pub async fn analyze_obfuscation( seeds, unique_seed_count, sequence_lengths, + output_lengths, top_sequences, summary, + similarity, + size, histogram, - ngram_diversity, + pairwise_ngram_jaccard, markdown_path: config.report_path.clone(), }; @@ -438,6 +583,49 @@ pub async fn analyze_obfuscation( Ok(report) } +fn compute_size_summary(lengths: &[usize], original_len: usize) -> SizeSummary { + if lengths.is_empty() { + return SizeSummary { + count: 0, + mean_bytes: 0.0, + median_bytes: 0.0, + percentile_95_bytes: 0.0, + min_bytes: 0, + max_bytes: 0, + mean_ratio: 0.0, + median_ratio: 0.0, + percentile_95_ratio: 0.0, + max_ratio: 0.0, + }; + } + + let mut sorted = lengths.to_vec(); + sorted.sort_unstable(); + let mean_bytes = lengths.iter().sum::() as f64 / lengths.len() as f64; + let median_bytes = percentile(&sorted, 50.0); + let percentile_95_bytes = percentile(&sorted, 95.0); + let ratio = |bytes: f64| { + if original_len == 0 { + 0.0 + } else { + bytes / original_len as f64 + } + }; + + SizeSummary { + count: lengths.len(), + mean_bytes, + median_bytes, + percentile_95_bytes, + min_bytes: sorted[0], + max_bytes: *sorted.last().expect("non-empty lengths"), + mean_ratio: ratio(mean_bytes), + median_ratio: ratio(median_bytes), + percentile_95_ratio: ratio(percentile_95_bytes), + max_ratio: ratio(*sorted.last().expect("non-empty lengths") as f64), + } +} + fn write_report(path: &Path, contents: &str) -> Result<(), AnalysisError> { if let Some(parent) = path.parent() && !parent.as_os_str().is_empty() @@ -454,48 +642,6 @@ fn hex_to_bytes(input: &str) -> Result, FromHexError> { hex::decode(without_prefix) } -fn longest_common_substring<'a>(a: &'a [u8], b: &'a [u8]) -> &'a [u8] { - let mut low = 0usize; - let mut high = a.len().min(b.len()); - let mut best: &[u8] = &[]; - - while low <= high { - let mid = (low + high) / 2; - if let Some(candidate) = has_common_of_length(a, b, mid) { - if candidate.len() > best.len() { - best = candidate; - } - low = mid + 1; - } else { - if mid == 0 { - break; - } - high = mid - 1; - } - } - - best -} - -fn has_common_of_length<'a>(a: &'a [u8], b: &[u8], len: usize) -> Option<&'a [u8]> { - if len == 0 { - return Some(&[]); - } - if len > a.len() || len > b.len() { - return None; - } - let mut set: HashSet<&[u8]> = HashSet::with_capacity(a.len().saturating_sub(len) + 1); - for window in a.windows(len) { - set.insert(window); - } - for window in b.windows(len) { - if let Some(&candidate) = set.get(window) { - return Some(candidate); - } - } - None -} - fn compute_summary_stats(lengths: &[usize], original_len: usize) -> SummaryStats { let mut stats = SummaryStats { average_length: 0.0, @@ -612,34 +758,6 @@ fn compute_top_sequences(counter: HashMap, usize>) -> Vec], ns: &[usize]) -> BTreeMap { - let mut map = BTreeMap::new(); - for &n in ns { - if n == 0 { - map.insert(n, 0.0); - continue; - } - let mut total = 0usize; - let mut unique: HashSet> = HashSet::new(); - for code in bytecodes { - if code.len() < n { - continue; - } - total += code.len() - n + 1; - for window in code.windows(n) { - unique.insert(window.to_vec()); - } - } - let diversity = if total == 0 { - 0.0 - } else { - unique.len() as f64 / total as f64 * 100.0 - }; - map.insert(n, diversity); - } - map -} - fn sorted_transform_entries(counts: &BTreeMap) -> Vec<(String, usize)> { let mut entries: Vec<(String, usize)> = counts .iter() @@ -690,6 +808,9 @@ fn parse_passes(passes: &str) -> Result, AnalysisError> { "shuffle" => TransformSpec::Shuffle, "opaque_pred" | "opaque_predicate" => TransformSpec::OpaquePredicate, "jump_transform" | "jump_addr" => TransformSpec::JumpTransform, + "jump_trampoline" | "trampoline" => TransformSpec::JumpTrampoline, + "literal_synthesis" | "literal_synth" => TransformSpec::LiteralSynthesis, + "cluster_shuffle" => TransformSpec::ClusterShuffle, other => return Err(AnalysisError::InvalidPass(other.to_string())), }; specs.push(spec); @@ -701,6 +822,9 @@ enum TransformSpec { Shuffle, OpaquePredicate, JumpTransform, + JumpTrampoline, + LiteralSynthesis, + ClusterShuffle, } impl TransformSpec { @@ -709,6 +833,9 @@ impl TransformSpec { TransformSpec::Shuffle => Box::new(Shuffle), TransformSpec::OpaquePredicate => Box::new(OpaquePredicate::new()), TransformSpec::JumpTransform => Box::new(JumpAddressTransformer::new()), + TransformSpec::JumpTrampoline => Box::new(JumpTrampoline::new()), + TransformSpec::LiteralSynthesis => Box::new(LiteralSynthesis::new()), + TransformSpec::ClusterShuffle => Box::new(ClusterShuffle::new()), } } } @@ -718,10 +845,10 @@ mod tests { use super::*; #[test] - fn longest_common_substring_finds_match() { + fn longest_common_contiguous_slice_finds_match() { let a = b"abcdefg"; let b = b"xyzabcuvw"; - let result = longest_common_substring(a, b); + let result = longest_common_contiguous_slice(a, b); assert_eq!(result, b"abc"); } @@ -731,4 +858,24 @@ mod tests { assert_eq!(percentile(&values, 25.0), 17.5); assert_eq!(percentile(&values, 75.0), 32.5); } + + #[test] + fn analysis_defaults_match_the_production_transform_portfolio() { + let names: Vec<_> = parse_passes(DEFAULT_PASSES) + .expect("default passes parse") + .iter() + .map(|pass| pass.build().name()) + .collect(); + assert_eq!(names, ["JumpTrampoline", "ClusterShuffle"]); + } + + #[test] + fn size_summary_reports_growth_distribution() { + let summary = compute_size_summary(&[100, 110, 120, 200], 100); + assert_eq!(summary.count, 4); + assert_eq!(summary.median_bytes, 115.0); + assert_eq!(summary.max_bytes, 200); + assert_eq!(summary.median_ratio, 1.15); + assert_eq!(summary.max_ratio, 2.0); + } } diff --git a/crates/analysis/src/similarity.rs b/crates/analysis/src/similarity.rs new file mode 100644 index 00000000..153b09ba --- /dev/null +++ b/crates/analysis/src/similarity.rs @@ -0,0 +1,442 @@ +//! Byte-level similarity metrics for comparing original and transformed bytecode. +//! +//! The metrics in this module intentionally answer different questions: +//! - longest-common-subsequence retention is a conservative estimate of how much original byte +//! order survives, even when bytes are moved apart by insertions; +//! - longest common contiguous run finds large untouched motifs; +//! - aligned difference is a cheap position-by-position change measure; +//! - normalized Levenshtein distance accounts for insertions and deletions; and +//! - n-gram Jaccard compares the sets of local byte patterns without depending mechanically on +//! the number of samples in an experiment. + +use serde::Serialize; +use std::collections::{HashMap, HashSet}; + +/// A compact distribution summary used for per-sample and pairwise ratios. +#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize)] +pub struct DistributionSummary { + pub count: usize, + pub mean: f64, + pub median: f64, + pub min: f64, + pub max: f64, +} + +/// Summarize a set of finite metric values. +pub fn summarize_distribution(values: &[f64]) -> DistributionSummary { + if values.is_empty() { + return DistributionSummary::default(); + } + + debug_assert!(values.iter().all(|value| value.is_finite())); + let mut sorted = values.to_vec(); + sorted.sort_by(f64::total_cmp); + let count = sorted.len(); + let median = if count.is_multiple_of(2) { + (sorted[count / 2 - 1] + sorted[count / 2]) / 2.0 + } else { + sorted[count / 2] + }; + + DistributionSummary { + count, + mean: sorted.iter().sum::() / count as f64, + median, + min: sorted[0], + max: sorted[count - 1], + } +} + +/// Return the exact longest-common-subsequence length. +/// +/// This uses the bit-parallel LCS recurrence, which is exact for arbitrary bytes and reduces the +/// dynamic-programming work by roughly the machine word size. +pub fn longest_common_subsequence_len(a: &[u8], b: &[u8]) -> usize { + if a.is_empty() || b.is_empty() { + return 0; + } + + // Use the shorter input for the bit vector to minimize memory and work. + let (rows, columns) = if a.len() >= b.len() { (a, b) } else { (b, a) }; + let word_count = columns.len().div_ceil(u64::BITS as usize); + let mut matches = vec![vec![0u64; word_count]; 256]; + for (index, byte) in columns.iter().copied().enumerate() { + matches[byte as usize][index / 64] |= 1u64 << (index % 64); + } + + let mut state = vec![0u64; word_count]; + let mut shifted = vec![0u64; word_count]; + let mut difference = vec![0u64; word_count]; + + for byte in rows { + // y = (state << 1) | 1, as a little-endian multi-word bit vector. + let mut carry = 1u64; + for (source, target) in state.iter().copied().zip(&mut shifted) { + *target = (source << 1) | carry; + carry = source >> 63; + } + + // state = x & !(x - y), where x = matches[byte] | state. Subtraction must propagate + // borrow across words for the bit-parallel recurrence to remain exact. + let mut borrow = false; + for word in 0..word_count { + let x = matches[*byte as usize][word] | state[word]; + let (partial, first_borrow) = x.overflowing_sub(shifted[word]); + let (result, second_borrow) = partial.overflowing_sub(u64::from(borrow)); + difference[word] = result; + borrow = first_borrow || second_borrow; + state[word] = x & !difference[word]; + } + } + + state.iter().map(|word| word.count_ones() as usize).sum() +} + +/// Fraction of the original byte sequence retained as an ordered subsequence. +/// +/// This is conservative: insertions and moved-apart regions do not count as changed when their +/// original relative order survives. An empty original has no bytes to retain and returns `1.0`. +pub fn conservative_lcs_retention(original: &[u8], candidate: &[u8]) -> f64 { + if original.is_empty() { + return 1.0; + } + longest_common_subsequence_len(original, candidate) as f64 / original.len() as f64 +} + +#[derive(Clone, Default)] +struct SuffixState { + length: usize, + link: Option, + transitions: HashMap, + first_end: usize, +} + +/// Return an exact longest common contiguous slice, borrowing from `a`. +/// +/// A suffix automaton keeps this linear in the combined input lengths (expected, due to hash-map +/// transitions) rather than allocating a quadratic dynamic-programming table. +pub fn longest_common_contiguous_slice<'a>(a: &'a [u8], b: &[u8]) -> &'a [u8] { + if a.is_empty() || b.is_empty() { + return &[]; + } + + let mut states = vec![SuffixState::default()]; + let mut last = 0usize; + + for (position, byte) in a.iter().copied().enumerate() { + let current = states.len(); + states.push(SuffixState { + length: states[last].length + 1, + link: None, + transitions: HashMap::new(), + first_end: position, + }); + + let mut cursor = Some(last); + while let Some(state_index) = cursor { + if states[state_index].transitions.contains_key(&byte) { + break; + } + states[state_index].transitions.insert(byte, current); + cursor = states[state_index].link; + } + + if let Some(parent) = cursor { + let target = states[parent].transitions[&byte]; + if states[parent].length + 1 == states[target].length { + states[current].link = Some(target); + } else { + let clone_index = states.len(); + let mut clone = states[target].clone(); + clone.length = states[parent].length + 1; + states.push(clone); + + let mut ancestor = Some(parent); + while let Some(state_index) = ancestor { + if states[state_index].transitions.get(&byte).copied() != Some(target) { + break; + } + states[state_index].transitions.insert(byte, clone_index); + ancestor = states[state_index].link; + } + states[target].link = Some(clone_index); + states[current].link = Some(clone_index); + } + } else { + states[current].link = Some(0); + } + + last = current; + } + + let mut state = 0usize; + let mut current_length = 0usize; + let mut best_length = 0usize; + let mut best_end = 0usize; + + for &byte in b { + while state != 0 && !states[state].transitions.contains_key(&byte) { + state = states[state] + .link + .expect("non-root suffix state has a link"); + current_length = current_length.min(states[state].length); + } + + if let Some(next) = states[state].transitions.get(&byte).copied() { + state = next; + current_length += 1; + if current_length > best_length { + best_length = current_length; + best_end = states[state].first_end; + } + } else { + current_length = 0; + } + } + + if best_length == 0 { + &[] + } else { + &a[best_end + 1 - best_length..=best_end] + } +} + +/// Length of the longest exact contiguous byte run shared by both inputs. +pub fn longest_common_contiguous_run(a: &[u8], b: &[u8]) -> usize { + longest_common_contiguous_slice(a, b).len() +} + +/// Position-aligned byte difference normalized to the longer input length. +/// +/// Bytes beyond the shorter input count as differences. This is cheap and useful for same-layout +/// seed comparisons, but unlike Levenshtein distance it intentionally does not realign insertions. +pub fn aligned_byte_difference_ratio(a: &[u8], b: &[u8]) -> f64 { + let denominator = a.len().max(b.len()); + if denominator == 0 { + return 0.0; + } + let mismatches = a + .iter() + .zip(b) + .filter(|(left, right)| left != right) + .count() + + a.len().abs_diff(b.len()); + mismatches as f64 / denominator as f64 +} + +/// Exact Levenshtein edit distance normalized to the longer input length. +/// +/// The implementation uses `O(min(a.len(), b.len()))` memory and quadratic time. Prefer the +/// aligned metric for large, same-layout seed corpora where insertion realignment is unnecessary. +pub fn normalized_levenshtein_distance(a: &[u8], b: &[u8]) -> f64 { + let denominator = a.len().max(b.len()); + if denominator == 0 { + return 0.0; + } + + let (rows, columns) = if a.len() >= b.len() { (a, b) } else { (b, a) }; + let mut costs: Vec = (0..=columns.len()).collect(); + for (row_index, row_byte) in rows.iter().enumerate() { + let mut diagonal = costs[0]; + costs[0] = row_index + 1; + for (column_index, column_byte) in columns.iter().enumerate() { + let above = costs[column_index + 1]; + costs[column_index + 1] = if row_byte == column_byte { + diagonal + } else { + 1 + diagonal.min(above).min(costs[column_index]) + }; + diagonal = above; + } + } + costs[columns.len()] as f64 / denominator as f64 +} + +/// Jaccard similarity between the sets of `n`-byte windows in two byte sequences. +/// +/// `1.0` means identical n-gram sets and `0.0` means disjoint sets. When neither input contains +/// an n-gram (including `n == 0`), the two empty sets are treated as identical. +pub fn ngram_jaccard(a: &[u8], b: &[u8], n: usize) -> f64 { + if n == 0 { + return 1.0; + } + let left: HashSet<&[u8]> = a.windows(n).collect(); + let right: HashSet<&[u8]> = b.windows(n).collect(); + let union = left.union(&right).count(); + if union == 0 { + return 1.0; + } + left.intersection(&right).count() as f64 / union as f64 +} + +/// Compute aligned normalized differences for every unordered pair of samples. +pub fn pairwise_aligned_byte_differences(samples: &[Vec]) -> Vec { + pairwise(samples, aligned_byte_difference_ratio) +} + +/// Compute n-gram Jaccard similarity for every unordered pair of samples. +pub fn pairwise_ngram_jaccard(samples: &[Vec], n: usize) -> Vec { + pairwise(samples, |left, right| ngram_jaccard(left, right, n)) +} + +fn pairwise(samples: &[Vec], metric: impl Fn(&[u8], &[u8]) -> f64) -> Vec { + let pair_count = samples + .len() + .saturating_mul(samples.len().saturating_sub(1)) + / 2; + let mut values = Vec::with_capacity(pair_count); + for left in 0..samples.len() { + for right in left + 1..samples.len() { + values.push(metric(&samples[left], &samples[right])); + } + } + values +} + +#[cfg(test)] +mod tests { + use super::*; + + fn reference_lcs(a: &[u8], b: &[u8]) -> usize { + let mut row = vec![0usize; b.len() + 1]; + for left in a { + let mut diagonal = 0; + for (index, right) in b.iter().enumerate() { + let above = row[index + 1]; + row[index + 1] = if left == right { + diagonal + 1 + } else { + row[index].max(above) + }; + diagonal = above; + } + } + row[b.len()] + } + + fn reference_contiguous_run(a: &[u8], b: &[u8]) -> usize { + let mut previous = vec![0usize; b.len() + 1]; + let mut best = 0usize; + for left in a { + let mut current = vec![0usize; b.len() + 1]; + for (index, right) in b.iter().enumerate() { + if left == right { + current[index + 1] = previous[index] + 1; + best = best.max(current[index + 1]); + } + } + previous = current; + } + best + } + + fn words(alphabet: &[u8], max_len: usize) -> Vec> { + let mut result = vec![Vec::new()]; + for _ in 0..max_len { + let existing = result.clone(); + for prefix in existing { + if prefix.len() == result.last().map_or(0, Vec::len) { + for byte in alphabet { + let mut word = prefix.clone(); + word.push(*byte); + result.push(word); + } + } + } + } + result.retain(|word| word.len() <= max_len); + result + } + + #[test] + fn bit_parallel_lcs_matches_reference_exhaustively() { + let corpus = words(b"ab", 4); + for left in &corpus { + for right in &corpus { + assert_eq!( + longest_common_subsequence_len(left, right), + reference_lcs(left, right), + "left={left:?}, right={right:?}" + ); + } + } + } + + #[test] + fn bit_parallel_lcs_propagates_across_machine_words() { + let left: Vec = (0..150).map(|index| (index % 11) as u8).collect(); + let mut right = left[7..].to_vec(); + right.splice(63..63, [42, 43, 44]); + right.drain(101..109); + assert_eq!( + longest_common_subsequence_len(&left, &right), + reference_lcs(&left, &right) + ); + } + + #[test] + fn conservative_retention_counts_ordered_bytes() { + assert_eq!(conservative_lcs_retention(b"abcdef", b"aXbcYdef"), 1.0); + assert_eq!(conservative_lcs_retention(b"abcdef", b"ace"), 0.5); + } + + #[test] + fn contiguous_match_is_exact_and_borrowed_from_first_input() { + let left = b"xxabcdeyy"; + let matched = longest_common_contiguous_slice(left, b"zzabcdeqq"); + assert_eq!(matched, b"abcde"); + assert_eq!(longest_common_contiguous_run(b"abc", b"xyz"), 0); + } + + #[test] + fn suffix_automaton_matches_reference_exhaustively() { + let corpus = words(b"ab", 4); + for left in &corpus { + for right in &corpus { + assert_eq!( + longest_common_contiguous_run(left, right), + reference_contiguous_run(left, right), + "left={left:?}, right={right:?}" + ); + } + } + } + + #[test] + fn normalized_difference_metrics_handle_alignment_and_insertions() { + assert_eq!(aligned_byte_difference_ratio(b"abc", b"abc"), 0.0); + assert_eq!(aligned_byte_difference_ratio(b"abc", b"axc"), 1.0 / 3.0); + assert_eq!(normalized_levenshtein_distance(b"abc", b"zabc"), 0.25); + assert_eq!( + normalized_levenshtein_distance(b"kitten", b"sitting"), + 3.0 / 7.0 + ); + } + + #[test] + fn ngram_jaccard_uses_sets_not_pooled_occurrence_counts() { + assert_eq!(ngram_jaccard(b"abcd", b"abcd", 2), 1.0); + assert_eq!(ngram_jaccard(b"abcd", b"wxyz", 2), 0.0); + assert_eq!(ngram_jaccard(b"a", b"b", 2), 1.0); + } + + #[test] + fn pairwise_helpers_return_one_value_per_unordered_pair() { + let samples = vec![b"abc".to_vec(), b"axc".to_vec(), b"ayc".to_vec()]; + let edit = pairwise_aligned_byte_differences(&samples); + assert_eq!(edit.len(), 3); + assert!(edit.iter().all(|value| *value > 0.0)); + let ngrams = pairwise_ngram_jaccard(&samples, 2); + assert_eq!(ngrams.len(), 3); + } + + #[test] + fn distribution_summary_reports_median_and_bounds() { + let summary = summarize_distribution(&[0.4, 0.1, 0.3, 0.2]); + assert_eq!(summary.count, 4); + assert_eq!(summary.mean, 0.25); + assert_eq!(summary.median, 0.25); + assert_eq!(summary.min, 0.1); + assert_eq!(summary.max, 0.4); + } +} diff --git a/crates/cli/README.md b/crates/cli/README.md index a7dfd2af..64eca571 100644 --- a/crates/cli/README.md +++ b/crates/cli/README.md @@ -60,7 +60,7 @@ Applies obfuscation transformations to bytecode. ```bash azoth obfuscate -D -R azoth obfuscate --deployment 0x6080... --runtime 0x6080... --seed 12345 -azoth obfuscate -D path/to/deployment.hex -R path/to/runtime.hex --passes shuffle +azoth obfuscate -D path/to/deployment.hex -R path/to/runtime.hex --passes jump_trampoline,cluster_shuffle azoth obfuscate -D path/to/deployment.hex -R path/to/runtime.hex --constructor-args 0x... ``` @@ -69,12 +69,14 @@ Options: - `-R, --runtime ` - Input runtime bytecode (required) - `--constructor-args ` - ABI-encoded constructor suffix to append and obfuscate; omit when `-D` already contains it - `--seed ` - Cryptographic seed for deterministic obfuscation -- `--passes ` - Comma-separated list of transforms (default: shuffle) -- `--emit ` - Path to write gas/size report as JSON +- `--passes ` - Comma-separated runtime transforms (default: `jump_trampoline,cluster_shuffle`) +- `--emit ` - Write byte sizes and exact deployed-runtime code-deposit gas as JSON - `--emit-debug ` - Path to emit detailed CFG trace debug report as JSON - `--tui` - Launch TUI to view debug trace after obfuscation -Note: `function_dispatcher` is always applied automatically. +When a supported Solidity-style dispatcher is detected, selector-only `function_dispatcher` runs before the listed passes and the result includes the selector-token mapping callers must use. It is skipped when no dispatcher is detected. Supplying `--passes` replaces the two listed default passes; it does not disable dispatcher detection. + +The `--emit` gas fields cover only the EVM's `200 gas/byte` code-deposit charge. Creation calldata, EIP-3860 word cost, constructor execution, and runtime execution require measurement in an EVM and are intentionally not estimated. The runtime is used as an exact, authoritative deployment boundary. A supplied runtime that is missing or occurs more than once is rejected. Constructor masking does not parse the ABI and is not cryptographic confidentiality; it removes the stable plaintext suffix while preserving constructor behavior. @@ -93,7 +95,7 @@ Options: - `--output ` - Where to write the markdown report (default: ./obfuscation_analysis_report.md) - `--max-attempts ` - Retry budget per iteration when a seed fails (default: 5) -The analysis runs with the dispatcher when detected and otherwise mirrors the obfuscator's default transform selection (no extra passes are forced). The summary printed to stdout mirrors the generated report and includes average/percentile longest preserved block sizes plus n-gram diversity metrics. +The analysis runs with the dispatcher when detected and otherwise mirrors the obfuscator's default transform selection (no extra passes are forced). The stdout summary mirrors the generated report and includes longest contiguous runs, conservative ordered-byte retention, aligned original and seed-to-seed differences, and pairwise n-gram Jaccard similarity. ## Input Formats diff --git a/crates/cli/src/commands/analyze.rs b/crates/cli/src/commands/analyze.rs index 82ff8e03..db285cb2 100644 --- a/crates/cli/src/commands/analyze.rs +++ b/crates/cli/src/commands/analyze.rs @@ -91,9 +91,46 @@ impl super::Command for AnalyzeArgs { report.unique_seed_count ); println!("Transforms observed: {}", report.transform_summary()); + println!( + "Deployment size ratio: {:>6.3}x mean / {:>6.3}x P95 / {:>6.3}x max", + report.size.mean_ratio, report.size.percentile_95_ratio, report.size.max_ratio + ); + println!( + "Conservative LCS retention: {:>6.2}% mean / {:>6.2}% median", + report.similarity.conservative_lcs_retention.mean * 100.0, + report.similarity.conservative_lcs_retention.median * 100.0 + ); + println!( + "Conservative change bound: {:>6.2}% mean / {:>6.2}% worst sample", + (1.0 - report.similarity.conservative_lcs_retention.mean) * 100.0, + (1.0 - report.similarity.conservative_lcs_retention.max) * 100.0 + ); + println!( + "Aligned original difference: {:>6.2}% mean / {:>6.2}% median", + report.similarity.aligned_original_difference.mean * 100.0, + report.similarity.aligned_original_difference.median * 100.0 + ); + println!( + "Pairwise seed difference: {:>6.2}% mean / {:>6.2}% median ({} pair{})", + report.similarity.pairwise_aligned_difference.mean * 100.0, + report.similarity.pairwise_aligned_difference.median * 100.0, + report.similarity.pairwise_aligned_difference.count, + if report.similarity.pairwise_aligned_difference.count == 1 { + "" + } else { + "s" + } + ); println!(); - for (n, value) in &report.ngram_diversity { - println!("{:>2}-byte n-gram diversity: {:>6.2}%", n, value); + for (n, summary) in &report.pairwise_ngram_jaccard { + println!( + "{:>2}-byte pairwise Jaccard: {:>6.2}% mean / {:>6.2}% median ({} pair{})", + n, + summary.mean * 100.0, + summary.median * 100.0, + summary.count, + if summary.count == 1 { "" } else { "s" } + ); } println!("============================================================"); println!( diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index b7d5f5a9..d8f74825 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -13,7 +13,7 @@ pub mod tui; use thiserror::Error; -pub const DEFAULT_PASSES: &str = "arithmetic_chain, push_split, slot_shuffle, string_obfuscate"; +pub const DEFAULT_PASSES: &str = "jump_trampoline, cluster_shuffle"; /// Errors that can occur during obfuscation. #[derive(Debug, Error)] diff --git a/crates/cli/src/commands/obfuscate.rs b/crates/cli/src/commands/obfuscate.rs index 4681c35d..48978bc0 100644 --- a/crates/cli/src/commands/obfuscate.rs +++ b/crates/cli/src/commands/obfuscate.rs @@ -172,6 +172,9 @@ pub(crate) fn build_passes(list: &str) -> Result>, Box Ok(Box::new( azoth_transform::jump_address_transformer::JumpAddressTransformer::new(), ) as Box), + "jump_trampoline" | "trampoline" => Ok(Box::new( + azoth_transform::jump_trampoline::JumpTrampoline::new(), + ) as Box), "arithmetic_chain" => Ok(Box::new( azoth_transform::arithmetic_chain::ArithmeticChain::new(), ) as Box), @@ -192,6 +195,9 @@ pub(crate) fn build_passes(list: &str) -> Result>, Box, ), + "literal_synthesis" | "literal_synth" => Ok(Box::new( + azoth_transform::literal_synthesis::LiteralSynthesis::new(), + ) as Box), "splice" => Ok(Box::new(azoth_transform::splice::Splice::new()) as Box), _ => Err(ObfuscateError::InvalidPass(name.to_string()).into()), }) diff --git a/crates/core/src/cfg_ir/mod.rs b/crates/core/src/cfg_ir/mod.rs index 4348761d..56285cec 100644 --- a/crates/core/src/cfg_ir/mod.rs +++ b/crates/core/src/cfg_ir/mod.rs @@ -12,7 +12,7 @@ use petgraph::graph::NodeIndex; use petgraph::stable_graph::StableDiGraph; use petgraph::visit::{EdgeRef, IntoNodeReferences}; use serde::{Deserialize, Serialize}; -use std::collections::{HashMap, HashSet}; +use std::collections::{HashMap, HashSet, VecDeque}; mod trace; @@ -29,6 +29,43 @@ type PcRemap = HashMap; type RuntimeBounds = Option<(usize, usize)>; type ReindexOutcome = (PcRemap, RuntimeBounds); +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct PushOrigin { + node: NodeIndex, + instruction: usize, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct AbstractStackValue { + /// Candidate PUSH instructions whose value is carried by this stack item. + origins: HashSet, + /// Exact values are retained only through PUSH/DUP/SWAP. Any arithmetic or + /// environmental operation makes the value unknown. + constants: Option>, + /// The value may originate from PUSH0. Unlike PUSH1 0x00, PUSH0 has no + /// immediate bytes that can be rewritten if runtime-relative PC zero moves. + push0: bool, +} + +/// Evidence produced by the stack-provenance relocation analysis. +#[derive(Debug, Clone, Default)] +pub struct JumpAddressProof { + pub pushes: Vec<(NodeIndex, usize, usize)>, + pub uses_push0_target: bool, +} + +const PUSH_USED_AS_JUMP: u8 = 1; +const PUSH_USED_AS_DATA: u8 = 2; +const MAX_PROVENANCE_PATH_STATES: usize = 32_768; +const MAX_PROVENANCE_STACK_CELLS: usize = 1_000_000; +const MAX_PROVENANCE_FACT_GROWTH: usize = 1_250_000; +const MAX_PROVENANCE_TRANSFER_FACTS: usize = 1_250_000; +const MAX_PROVENANCE_ITERATIONS: usize = 250_000; + +type ProvenanceShape = Vec; +type ProvenanceStates = HashMap>>; +type ProvenanceKey = (NodeIndex, ProvenanceShape); + /// CFG node representation. #[derive(Debug, Clone)] pub enum Block { @@ -759,240 +796,352 @@ impl CfgIrBundle { Ok(()) } - /// Remap return-address PUSH instructions in Solidity internal function call patterns. + /// Proves which literal PUSH instructions are used as jump destinations. /// - /// After `reindex_pcs` shifts PCs, `write_symbolic_immediates` and `patch_jump_immediates` - /// update PUSH values that feed directly into JUMP/JUMPI. However, return addresses pushed - /// earlier in a block (the `PUSH ret_addr` in `PUSH ret_addr; PUSH func_entry; JUMP`) are - /// not part of any recognized jump pattern and become stale. This pass finds those specific - /// return-address PUSHes and remaps them. - pub fn remap_orphan_jump_pushes( - &mut self, - pc_mapping: &HashMap, - old_runtime_bounds: Option<(usize, usize)>, - ) -> Result<(), Error> { - let old_runtime_start = old_runtime_bounds.map(|(s, _)| s); - let new_runtime_start = self.runtime_bounds.map(|(s, _)| s); + /// Solidity carries internal-function return addresses across basic blocks. + /// A previous implementation guessed that every stack-carried literal equal + /// to a `JUMPDEST` was a return address. That silently rewrote ordinary data + /// constants on numeric collisions. This analysis instead propagates exact + /// PUSH provenance through every reachable CFG path. A literal is eligible + /// for relocation only when it is consumed as a JUMP/JUMPI target and never + /// as data. Unknown dynamic control flow and mixed address/data uses fail + /// closed. + pub fn prove_jump_address_pushes(&self) -> Result { + let bounds = self.runtime_bounds; + let runtime_start = bounds.map(|(start, _)| start).unwrap_or(0); + let mut runtime_nodes: Vec<_> = self + .cfg + .node_indices() + .filter_map(|node| match &self.cfg[node] { + Block::Body(body) + if bounds.is_none_or(|(start, end)| { + body.start_pc >= start && body.start_pc < end + }) => + { + Some((body.start_pc, node)) + } + _ => None, + }) + .collect(); + runtime_nodes.sort_by_key(|(pc, _)| *pc); + let Some((_, entry)) = runtime_nodes.first().copied() else { + return Ok(JumpAddressProof::default()); + }; - // Build set of old JUMPDEST PCs so we can verify candidates are real jump targets. - let inverse: HashMap = - pc_mapping.iter().map(|(&old, &new)| (new, old)).collect(); - let mut old_jumpdest_pcs: HashSet = HashSet::new(); - for node in self.cfg.node_indices() { - if let Some(Block::Body(body)) = self.cfg.node_weight(node) { - for instr in &body.instructions { - if matches!(instr.op, Opcode::JUMPDEST) - && let Some(&old_pc) = inverse.get(&instr.pc) - { - old_jumpdest_pcs.insert(old_pc); - } + let physical_next: HashMap<_, _> = runtime_nodes + .windows(2) + .map(|pair| (pair[0].1, pair[1].1)) + .collect(); + let mut jumpdest_by_value = HashMap::new(); + for (_, node) in &runtime_nodes { + let Block::Body(body) = &self.cfg[*node] else { + continue; + }; + for instruction in &body.instructions { + if matches!(instruction.op, Opcode::JUMPDEST) { + let value = instruction.pc.checked_sub(runtime_start).ok_or_else(|| { + Error::InvalidBlockStructure( + "runtime JUMPDEST precedes runtime start".into(), + ) + })?; + jumpdest_by_value.insert(value, *node); } } } - if old_jumpdest_pcs.is_empty() { - return Ok(()); - } - - // First pass: collect (node, instruction_index) pairs that need remapping. - // We look for the internal call pattern: PUSH ret_addr; PUSH func_entry; JUMP - // The return address PUSH is at push_idx - 1 in a Direct pattern. - let nodes: Vec<_> = self.cfg.node_indices().collect(); - let mut edits: Vec<(NodeIndex, usize, usize)> = Vec::new(); // (node, instr_idx, new_value) - - for &node in &nodes { - let Some(Block::Body(body)) = self.cfg.node_weight(node) else { + // Keep one conservative abstract stack per node, stack height, and + // slot-wise value class (unknown, known non-target, or JUMPDEST + // candidate). At compatible joins, union PUSH origins and candidate + // JUMPDEST values slot-wise. This can over-approximate an impossible + // origin/target pairing, but never drops a real use: spurious data uses + // merely make relocation fail closed, and spurious dynamic targets are + // still restricted to actual JUMPDESTs. Separating the three value + // classes prevents an unknown/non-target path from being hidden by a + // target-bearing path. Retaining every exact call path instead makes + // normal Solidity internal-call graphs grow exponentially. + let mut seen = ProvenanceStates::new(); + seen.insert(entry, HashMap::from([(Vec::new(), Vec::new())])); + let entry_key = (entry, Vec::new()); + let mut queue = VecDeque::from([entry_key.clone()]); + let mut queued = HashSet::from([entry_key]); + let mut uses: HashMap = HashMap::new(); + let mut origin_values: HashMap = HashMap::new(); + let mut resources = ProvenanceResources { + path_states: 1, + ..ProvenanceResources::default() + }; + let mut transfer_fact_count = 0usize; + let mut iterations = 0usize; + let mut uses_push0_target = false; + + while let Some((node, shape)) = queue.pop_front() { + queued.remove(&(node, shape.clone())); + let Some(mut stack) = seen + .get(&node) + .and_then(|states| states.get(&shape)) + .cloned() + else { continue; }; - - let in_runtime = body.is_runtime(self.runtime_bounds); - - // compute the remapped value for a PUSH instruction - let try_remap = |push_value: usize| -> Option { - let old_pc_abs = if in_runtime { - old_runtime_start.unwrap_or(0).saturating_add(push_value) - } else { - push_value - }; - if !old_jumpdest_pcs.contains(&old_pc_abs) { - return None; - } - let &new_pc_abs = pc_mapping.get(&old_pc_abs)?; - let new_value = if in_runtime { - new_runtime_start - .map(|s| new_pc_abs.saturating_sub(s)) - .unwrap_or(new_pc_abs) - } else { - new_pc_abs - }; - if push_value != new_value { - Some(new_value) - } else { - None - } + iterations += 1; + if iterations > MAX_PROVENANCE_ITERATIONS { + return Err(Error::ObfuscationFailed( + "jump-address provenance did not converge".into(), + )); + } + transfer_fact_count = transfer_fact_count + .checked_add(abstract_stack_fact_count(&stack)) + .ok_or_else(|| { + Error::ObfuscationFailed( + "jump-address provenance transfer work overflowed".into(), + ) + })?; + if transfer_fact_count > MAX_PROVENANCE_TRANSFER_FACTS { + return Err(Error::ObfuscationFailed(format!( + "jump-address provenance exceeded its transfer-work budget ({transfer_fact_count} abstract facts)" + ))); + } + let Block::Body(body) = &self.cfg[node] else { + continue; }; - - // Find the terminal jump pattern - let pattern = detect_jump_pattern(&body.instructions); - - if let Some(ref pat) = pattern { - // check the PUSH immediately before the jump pattern - let pattern_first_push_idx = match pat { - JumpPattern::Direct { push_idx } => *push_idx, - JumpPattern::SplitAdd { push_a_idx, .. } => *push_a_idx, - JumpPattern::PcRelative { push_idx, .. } => *push_idx, - }; - - if pattern_first_push_idx > 0 { - let ret_idx = pattern_first_push_idx - 1; - let ret_instr = &body.instructions[ret_idx]; - if matches!(ret_instr.op, Opcode::PUSH(_)) - && let Some(imm) = &ret_instr.imm - && let Ok(push_value) = usize::from_str_radix(imm, 16) - && let Some(new_value) = try_remap(push_value) - { - let old_pc_abs = if in_runtime { - old_runtime_start.unwrap_or(0).saturating_add(push_value) + let control = body.control.clone(); + let instructions = body.instructions.clone(); + let mut successors = Vec::new(); + + for (index, instruction) in instructions.iter().enumerate() { + match instruction.op { + Opcode::PUSH(_) => { + let immediate = instruction.imm.as_deref().ok_or_else(|| { + Error::InvalidImmediate(format!( + "PUSH at pc 0x{:x} has no immediate", + instruction.pc + )) + })?; + // Values wider than the host address space cannot be a + // legacy runtime PC (EIP-170 is far below usize::MAX), + // so retain them as unknown non-PC data rather than + // rejecting ordinary PUSH32 masks and constants. + let value = usize::from_str_radix(immediate, 16).ok(); + let origin = PushOrigin { + node, + instruction: index, + }; + let mut origins = HashSet::new(); + let mut constants = HashSet::new(); + if let Some(value) = value + && jumpdest_by_value.contains_key(&value) + { + origins.insert(origin); + constants.insert(value); + origin_values.insert(origin, value); + } + stack.push(AbstractStackValue { + origins, + // Only a literal that names an actual JUMPDEST can + // influence this relocation proof. Collapsing all + // other known literals to the same empty set keeps + // ordinary data from multiplying path states while + // an empty set still fails closed if later consumed + // as a dynamic jump target. + constants: Some(constants), + push0: false, + }); + } + Opcode::PUSH0 => { + let constants = if jumpdest_by_value.contains_key(&0) { + HashSet::from([0]) } else { - push_value + HashSet::new() }; - let new_pc_abs = pc_mapping.get(&old_pc_abs).copied().unwrap_or(0); - tracing::debug!( - "remap_orphan_jump_pushes: block {} instr {} at pc=0x{:x}: \ - 0x{:x} -> 0x{:x} (abs: 0x{:x} -> 0x{:x})", - node.index(), - ret_idx, - ret_instr.pc, - push_value, - new_value, - old_pc_abs, - new_pc_abs, - ); - edits.push((node, ret_idx, new_value)); + stack.push(AbstractStackValue { + origins: HashSet::new(), + constants: Some(constants), + push0: true, + }); + } + Opcode::DUP(depth) => { + let depth = depth as usize; + if depth == 0 || depth > stack.len() { + return Err(Error::InvalidBlockStructure(format!( + "DUP{depth} underflow during jump-address provenance" + ))); + } + stack.push(stack[stack.len() - depth].clone()); + } + Opcode::SWAP(depth) => { + let depth = depth as usize; + if depth == 0 || depth >= stack.len() { + return Err(Error::InvalidBlockStructure(format!( + "SWAP{depth} underflow during jump-address provenance" + ))); + } + let top = stack.len() - 1; + stack.swap(top, top - depth); + } + Opcode::POP => { + pop_abstract(&mut stack, instruction.pc)?; + } + Opcode::JUMP => { + let target = pop_abstract(&mut stack, instruction.pc)?; + uses_push0_target |= target.push0; + mark_origins(&mut uses, &target.origins, PUSH_USED_AS_JUMP); + successors = jump_successors(&control, &target, &jumpdest_by_value, false)?; + break; + } + Opcode::JUMPI => { + let target = pop_abstract(&mut stack, instruction.pc)?; + let condition = pop_abstract(&mut stack, instruction.pc)?; + uses_push0_target |= target.push0; + mark_origins(&mut uses, &target.origins, PUSH_USED_AS_JUMP); + mark_origins(&mut uses, &condition.origins, PUSH_USED_AS_DATA); + successors = jump_successors(&control, &target, &jumpdest_by_value, true)?; + if let Some(next) = physical_next.get(&node).copied() + && !successors.contains(&next) + { + successors.push(next); + } + break; + } + Opcode::STOP | Opcode::INVALID | Opcode::SELFDESTRUCT => { + if matches!(instruction.op, Opcode::SELFDESTRUCT) { + let value = pop_abstract(&mut stack, instruction.pc)?; + mark_origins(&mut uses, &value.origins, PUSH_USED_AS_DATA); + } + break; + } + Opcode::UNKNOWN(_) + | Opcode::RJUMP + | Opcode::RJUMPI + | Opcode::RJUMPV + | Opcode::CALLF + | Opcode::RETF + | Opcode::JUMPF + | Opcode::DUPN + | Opcode::SWAPN + | Opcode::EXCHANGE => { + return Err(Error::UnsupportedOpcode(format!( + "{} in jump-address provenance", + instruction.op + ))); + } + _ => { + let info = + instruction.op.as_opcode().info().ok_or_else(|| { + Error::UnsupportedOpcode(instruction.op.to_string()) + })?; + let mut consumed = Vec::with_capacity(info.inputs as usize); + for _ in 0..info.inputs { + consumed.push(pop_abstract(&mut stack, instruction.pc)?); + } + for value in &consumed { + mark_origins(&mut uses, &value.origins, PUSH_USED_AS_DATA); + } + for _ in 0..info.outputs { + stack.push(AbstractStackValue::default()); + } + if info.terminates { + break; + } } } - } - // Extended scan: scan ALL PUSH2+ instructions in EVERY body block - // for values matching old JUMPDEST PCs. Solidity's internal - // function call convention routinely pushes a return address in - // one block and consumes it from a JUMP in a *later* block - // (stack-carried), e.g. - // - // Block A: - // PUSH2 ret_addr ← return address - // SLOAD - // ... ← no JUMP here, block ends by falling - // through at a JUMPDEST - // Block B (starts at the JUMPDEST): - // ... address mask ... - // SWAP1 - // JUMP ← consumes the ret_addr pushed in A - // - // If we only scanned blocks that end with JUMP/JUMPI, the - // return-address PUSH in Block A would never be visited and - // would stay stale after PC-shifting transforms (PushSplit, - // ArithmeticChain). That produced the InvalidJump regression - // documented in tests/src/e2e/collect_proof.rs. - // - // Constraining to PUSH2+ avoids false positives on small - // literals that happen to coincide with early JUMPDEST PCs - // (slot indices, loop bounds, etc.) — Solidity would use PUSH2 - // for any jump target in a contract whose code is >256 bytes. - let pattern_indices: HashSet = match &pattern { - Some(JumpPattern::Direct { push_idx }) => { - [*push_idx, push_idx.wrapping_sub(1)].into_iter().collect() - } - Some(JumpPattern::SplitAdd { - push_a_idx, - push_b_idx, - }) => [*push_a_idx, *push_b_idx, push_a_idx.wrapping_sub(1)] - .into_iter() - .collect(), - Some(JumpPattern::PcRelative { push_idx, .. }) => { - [*push_idx, push_idx.wrapping_sub(1)].into_iter().collect() + if stack.len() > 1024 { + return Err(Error::InvalidBlockStructure( + "abstract stack exceeds the EVM 1024-item limit".into(), + )); } - None => HashSet::new(), - }; - for (idx, instr) in body.instructions.iter().enumerate() { - if pattern_indices.contains(&idx) { - continue; - } - let push_width = match instr.op { - Opcode::PUSH(w) if w >= 2 => w, - _ => continue, - }; - let _ = push_width; - let Some(imm) = &instr.imm else { - continue; - }; - let Ok(push_value) = usize::from_str_radix(imm, 16) else { - continue; - }; - if let Some(new_value) = try_remap(push_value) { - // Narrow false positives: only remap if the value - // actually flows into a JUMP/JUMPI. Without this check - // a PUSH2 whose 16-bit value numerically matches a - // JUMPDEST PC but is really a bit mask, deadline - // constant, or other non-target literal would be - // silently rewritten (PUSH2 values span `0..=0xffff` - // which heavily overlaps the PC range of sub-64KB - // runtimes). `push_reaches_jump` forward-walks from - // the PUSH within its block and returns `false` only - // when the value is unambiguously consumed by a - // non-jump op; stack-carried values (survive to - // block end or flow through a JUMP to a callee) and - // within-block JUMP targets still return `true`, so - // return addresses remain detectable. - if !push_reaches_jump(&body.instructions, idx) { - tracing::debug!( - "remap_orphan_jump_pushes: skipping block {} instr {} at pc=0x{:x} \ - (value 0x{:x} matches JUMPDEST PC but is consumed by non-jump op)", - node.index(), - idx, - instr.pc, - push_value - ); - continue; - } - let old_pc_abs = if in_runtime { - old_runtime_start.unwrap_or(0).saturating_add(push_value) - } else { - push_value - }; - let new_pc_abs = pc_mapping.get(&old_pc_abs).copied().unwrap_or(0); - tracing::debug!( - "remap_orphan_jump_pushes: block {} instr {} at pc=0x{:x}: \ - 0x{:x} -> 0x{:x} (abs: 0x{:x} -> 0x{:x}) [extended scan]", - node.index(), - idx, - instr.pc, - push_value, - new_value, - old_pc_abs, - new_pc_abs, - ); - edits.push((node, idx, new_value)); + if index + 1 == instructions.len() + && let Some(next) = physical_next.get(&node).copied() + { + successors.push(next); } } - } - // Second pass: apply the edits - let total_remapped = edits.len(); - for (node, instr_idx, new_value) in edits { - if let Some(Block::Body(body)) = self.cfg.node_weight_mut(node) { - apply_immediate(&mut body.instructions[instr_idx], new_value)?; + if instructions.is_empty() + && let Some(next) = physical_next.get(&node).copied() + { + successors.push(next); + } + + for successor in successors { + enqueue_abstract_path( + &mut seen, + &mut queue, + &mut queued, + successor, + &stack, + &mut resources, + )?; + } + } + + let mut proven = Vec::new(); + for (origin, value) in origin_values { + match uses.get(&origin).copied().unwrap_or(0) { + PUSH_USED_AS_JUMP => proven.push((origin.node, origin.instruction, value)), + PUSH_USED_AS_DATA | 0 => {} + flags if flags == PUSH_USED_AS_JUMP | PUSH_USED_AS_DATA => { + return Err(Error::ObfuscationFailed(format!( + "PUSH at block {} instruction {} is used as both a jump address and data", + origin.node.index(), + origin.instruction + ))); + } + _ => unreachable!("only address/data use bits are assigned"), } } + proven.sort_by_key(|(node, index, _)| (node.index(), *index)); + Ok(JumpAddressProof { + pushes: proven, + uses_push0_target, + }) + } - if total_remapped > 0 { - tracing::debug!( - "remap_orphan_jump_pushes: remapped {} internal-call return address PUSHes", - total_remapped - ); + /// Relocate only PUSH instructions proven by [`Self::prove_jump_address_pushes`]. + pub fn remap_proven_jump_pushes( + &mut self, + proven: &[(NodeIndex, usize, usize)], + pc_mapping: &HashMap, + old_runtime_bounds: Option<(usize, usize)>, + ) -> Result<(), Error> { + let old_runtime_start = old_runtime_bounds.map(|(start, _)| start).unwrap_or(0); + let new_runtime_start = self.runtime_bounds.map(|(start, _)| start).unwrap_or(0); + let mut remapped = 0usize; + + for &(node, instruction_index, old_value) in proven { + let old_target = old_runtime_start + .checked_add(old_value) + .ok_or_else(|| Error::InvalidImmediate("jump target address overflowed".into()))?; + let new_target = pc_mapping.get(&old_target).copied().ok_or_else(|| { + Error::InvalidBlockStructure(format!( + "proven jump target 0x{old_target:x} has no PC relocation" + )) + })?; + let new_value = new_target.checked_sub(new_runtime_start).ok_or_else(|| { + Error::InvalidImmediate("relocated jump precedes runtime start".into()) + })?; + let Block::Body(body) = self.cfg.node_weight_mut(node).ok_or_else(|| { + Error::InvalidBlockStructure("proven jump PUSH block disappeared".into()) + })? + else { + return Err(Error::InvalidBlockStructure( + "proven jump PUSH is not in a body block".into(), + )); + }; + let instruction = body + .instructions + .get_mut(instruction_index) + .ok_or_else(|| { + Error::InvalidBlockStructure("proven jump PUSH instruction disappeared".into()) + })?; + if new_value != old_value { + apply_immediate(instruction, new_value)?; + remapped += 1; + } } + tracing::debug!(remapped, "remapped proven jump-address PUSH instructions"); Ok(()) } @@ -2048,6 +2197,204 @@ fn absolute_target_from_value( // the terminal check in `ensure_jump_pattern`. // Keeping them together looks slightly repetitive, but they feed different workflows +fn pop_abstract( + stack: &mut Vec, + pc: usize, +) -> Result { + stack.pop().ok_or_else(|| { + Error::InvalidBlockStructure(format!( + "stack underflow at pc 0x{pc:x} during jump-address provenance" + )) + }) +} + +fn mark_origins(uses: &mut HashMap, origins: &HashSet, flag: u8) { + for origin in origins { + *uses.entry(*origin).or_default() |= flag; + } +} + +fn jump_successors( + control: &BlockControl, + target: &AbstractStackValue, + jumpdest_by_value: &HashMap, + conditional: bool, +) -> Result, Error> { + let typed = match (conditional, control) { + ( + false, + BlockControl::Jump { + target: JumpTarget::Block { node, .. }, + }, + ) => Some(*node), + ( + true, + BlockControl::Branch { + true_target: JumpTarget::Block { node, .. }, + .. + }, + ) => Some(*node), + _ => None, + }; + if let Some(node) = typed { + return Ok(vec![node]); + } + + let constants = target.constants.as_ref().ok_or_else(|| { + Error::ObfuscationFailed( + "unresolved dynamic JUMP/JUMPI prevents sound PC relocation".into(), + ) + })?; + if constants.is_empty() { + return Err(Error::ObfuscationFailed( + "empty dynamic jump target set prevents sound PC relocation".into(), + )); + } + let mut successors = Vec::with_capacity(constants.len()); + for value in constants { + let node = jumpdest_by_value.get(value).copied().ok_or_else(|| { + Error::ObfuscationFailed(format!( + "dynamic jump target 0x{value:x} is not a proven JUMPDEST" + )) + })?; + if !successors.contains(&node) { + successors.push(node); + } + } + Ok(successors) +} + +fn enqueue_abstract_path( + seen: &mut ProvenanceStates, + queue: &mut VecDeque, + queued: &mut HashSet, + node: NodeIndex, + incoming: &[AbstractStackValue], + resources: &mut ProvenanceResources, +) -> Result<(), Error> { + if incoming.len() > 1024 { + return Err(Error::InvalidBlockStructure( + "abstract stack exceeds the EVM 1024-item limit".into(), + )); + } + let paths = seen.entry(node).or_default(); + let shape = provenance_shape(incoming); + let mut candidate = incoming.to_vec(); + let mut is_new_shape = true; + let mut fact_growth = 0usize; + if let Some(existing) = paths.get_mut(&shape) { + let mut changed = false; + for (current, next) in existing.iter_mut().zip(incoming) { + let old_origin_count = current.origins.len(); + current.origins.extend(next.origins.iter().copied()); + let added_origins = current.origins.len() - old_origin_count; + fact_growth = fact_growth.saturating_add(added_origins); + changed |= added_origins != 0; + + match (&mut current.constants, &next.constants) { + (Some(current), Some(next)) => { + let old_constant_count = current.len(); + current.extend(next.iter().copied()); + let added_constants = current.len() - old_constant_count; + fact_growth = fact_growth.saturating_add(added_constants); + changed |= added_constants != 0; + } + (slot @ Some(_), None) => { + *slot = None; + fact_growth = fact_growth.saturating_add(1); + changed = true; + } + (None, Some(_) | None) => {} + } + if next.push0 && !current.push0 { + current.push0 = true; + fact_growth = fact_growth.saturating_add(1); + changed = true; + } + } + if !changed { + return Ok(()); + } + candidate = existing.clone(); + is_new_shape = false; + } else { + fact_growth = abstract_stack_fact_count(incoming); + } + + let next_fact_growth = resources + .fact_growth + .checked_add(fact_growth) + .ok_or_else(|| { + Error::ObfuscationFailed("jump-address provenance fact growth overflowed".into()) + })?; + if next_fact_growth > MAX_PROVENANCE_FACT_GROWTH { + return Err(Error::ObfuscationFailed(format!( + "jump-address provenance exceeded its fact-growth budget ({next_fact_growth} abstract facts)" + ))); + } + + if is_new_shape { + let next_path_count = resources.path_states.checked_add(1).ok_or_else(|| { + Error::ObfuscationFailed("jump-address provenance path count overflowed".into()) + })?; + let next_stack_cells = resources + .stack_cells + .checked_add(incoming.len()) + .ok_or_else(|| { + Error::ObfuscationFailed( + "jump-address provenance stack-cell count overflowed".into(), + ) + })?; + if next_path_count > MAX_PROVENANCE_PATH_STATES + || next_stack_cells > MAX_PROVENANCE_STACK_CELLS + { + return Err(Error::ObfuscationFailed(format!( + "jump-address provenance exceeded its resource budget ({next_path_count} path states, {next_stack_cells} stack cells)" + ))); + } + paths.insert(shape.clone(), candidate); + resources.path_states = next_path_count; + resources.stack_cells = next_stack_cells; + } + resources.fact_growth = next_fact_growth; + + // Queue the state key at most once. Consumers always read the latest joined + // state from `seen`, so incremental fan-in cannot create a backlog of stale + // full-stack snapshots. + let key = (node, shape); + if queued.insert(key.clone()) { + queue.push_back(key); + } + Ok(()) +} + +fn abstract_stack_fact_count(stack: &[AbstractStackValue]) -> usize { + stack.iter().fold(stack.len(), |count, value| { + count + .saturating_add(value.origins.len()) + .saturating_add(value.constants.as_ref().map_or(0, HashSet::len)) + .saturating_add(usize::from(value.push0)) + }) +} + +#[derive(Default)] +struct ProvenanceResources { + path_states: usize, + stack_cells: usize, + fact_growth: usize, +} + +fn provenance_shape(stack: &[AbstractStackValue]) -> Vec { + stack + .iter() + .map(|value| match &value.constants { + None => 0, + Some(constants) if constants.is_empty() => 1, + Some(_) => 2, + }) + .collect() +} + enum JumpPattern { /// `PUSH ; JUMP/JUMPI` Direct { push_idx: usize }, @@ -2793,4 +3140,46 @@ mod tests { let expected = format!("{:04x}", max_block_start.saturating_sub(start)); assert_eq!(push_imm.as_deref(), Some(expected.as_str())); } + + #[test] + fn provenance_path_state_budget_fails_closed() { + let mut seen = HashMap::new(); + let mut queue = VecDeque::new(); + let mut queued = HashSet::new(); + let mut resources = ProvenanceResources::default(); + + for value in 0..MAX_PROVENANCE_PATH_STATES { + let node = NodeIndex::new(value); + let incoming = [AbstractStackValue { + origins: HashSet::new(), + constants: Some(HashSet::from([value])), + push0: false, + }]; + enqueue_abstract_path( + &mut seen, + &mut queue, + &mut queued, + node, + &incoming, + &mut resources, + ) + .expect("states inside the budget should enqueue"); + } + + let over_budget = [AbstractStackValue { + origins: HashSet::new(), + constants: Some(HashSet::from([MAX_PROVENANCE_PATH_STATES])), + push0: false, + }]; + let error = enqueue_abstract_path( + &mut seen, + &mut queue, + &mut queued, + NodeIndex::new(MAX_PROVENANCE_PATH_STATES), + &over_budget, + &mut resources, + ) + .expect_err("one additional unique path must fail closed"); + assert!(error.to_string().contains("resource budget")); + } } diff --git a/crates/core/src/encoder.rs b/crates/core/src/encoder.rs index 934046ed..f4bad0b2 100644 --- a/crates/core/src/encoder.rs +++ b/crates/core/src/encoder.rs @@ -39,45 +39,36 @@ pub fn encode(instructions: &[Instruction], bytecode: &[u8]) -> Result, ins.imm ); - // Handle INVALID opcodes by attempting to preserve the original byte. + // Handle INVALID opcodes by preserving the exact original byte. // // Note: INVALID here is often a placeholder from the decoder, not the actual 0xFE opcode. // When heimdall outputs "unknown" without a hex byte, the decoder uses INVALID as a marker. - // We recover the actual byte value from the original bytecode using PC, or skip if unavailable. + // We recover the actual byte value from the original bytecode using PC and + // fail closed if recovery is impossible. if matches!(ins.op, Opcode::INVALID) { - unknown_count += 1; - tracing::warn!("Encoding INVALID opcode at pc={}", ins.pc); - - // First try immediate data (might contain the original byte value) - if let Some(immediate) = &ins.imm - && let Ok(byte_val) = u8::from_str_radix(immediate, 16) - { - bytes.push(byte_val); - tracing::debug!( - "Preserved INVALID opcode from immediate as byte 0x{:02x}", - byte_val - ); - continue; - } - - // Then try bytecode lookup - if ins.pc < bytecode.len() { - let byte_val = bytecode[ins.pc]; - bytes.push(byte_val); - tracing::debug!( - "Preserved INVALID opcode from bytecode as byte 0x{:02x} at pc={}", - byte_val, - ins.pc + let recovered = ins + .imm + .as_deref() + .and_then(|immediate| u8::from_str_radix(immediate, 16).ok()) + .or_else(|| bytecode.get(ins.pc).copied()) + .ok_or_else(|| { + Error::UnsupportedOpcode(format!( + "cannot recover INVALID/raw byte at pc 0x{:x}", + ins.pc + )) + })?; + bytes.push(recovered); + if recovered == Opcode::INVALID.to_byte() { + tracing::debug!(pc = ins.pc, "Encoded explicit EVM INVALID (0xfe)"); + } else { + unknown_count += 1; + tracing::warn!( + pc = ins.pc, + byte = recovered, + "Preserved decoder-unknown opcode as its exact raw byte" ); - continue; } - - // Last resort: SKIP the instruction (cannot determine byte value) - tracing::error!( - "Cannot determine byte value for INVALID opcode at pc={}, skipping (this may break functionality)", - ins.pc - ); - continue; // Skip this instruction instead of encoding 0xFE + continue; } let opcode = ins.op; @@ -138,7 +129,7 @@ pub fn encode(instructions: &[Instruction], bytecode: &[u8]) -> Result, if unknown_count > 0 { tracing::warn!( - "Encoded {} unknown opcodes as raw bytes. The resulting bytecode preserves the original bytes but these may represent invalid EVM instructions.", + "Encoded {} decoder-unknown opcode(s) as exact raw bytes.", unknown_count ); } @@ -222,6 +213,21 @@ mod tests { assert_eq!(bytes, vec![reference[2]]); } + #[test] + fn errors_when_invalid_raw_byte_cannot_be_recovered() { + let instructions = vec![Instruction { + pc: 7, + op: Opcode::INVALID, + imm: None, + }]; + + let err = encode(&instructions, &[]).unwrap_err(); + assert!( + matches!(err, Error::UnsupportedOpcode(_)), + "unexpected error: {err:?}" + ); + } + #[test] fn errors_on_missing_push_immediate() { let instructions = vec![Instruction { diff --git a/crates/core/src/seed.rs b/crates/core/src/seed.rs index b1cc5747..550b2e63 100644 --- a/crates/core/src/seed.rs +++ b/crates/core/src/seed.rs @@ -56,6 +56,21 @@ impl Seed { StdRng::seed_from_u64(rng_seed) } + /// Create a deterministic RNG isolated to one named pipeline domain. + /// + /// Unlike the legacy helper, this consumes all 256 derived bits. Giving each + /// transform its own domain also means inserting a no-op pass cannot perturb + /// the random stream of every later pass. + pub fn create_domain_rng(&self, domain: &[u8]) -> StdRng { + let mut hasher = Sha3_256::new(); + hasher.update(b"AZOTH_BYTECODE_OBFUSCATION_DOMAIN_V1"); + hasher.update((domain.len() as u64).to_be_bytes()); + hasher.update(domain); + hasher.update(self.inner); + let seed: [u8; 32] = hasher.finalize().into(); + StdRng::from_seed(seed) + } + /// Get a hash of this seed for integrity/identification purposes pub fn hash(&self) -> [u8; 32] { let mut hasher = Sha3_256::new(); @@ -126,4 +141,15 @@ mod tests { assert_eq!(rng.next_u64(), manual_rng.next_u64()); } } + + #[test] + fn domain_rng_is_deterministic_and_isolated() { + let seed = Seed::from_hex(SAMPLE_HEX).expect("valid sample seed"); + let mut first = seed.create_domain_rng(b"LiteralSynthesis:0"); + let mut repeated = seed.create_domain_rng(b"LiteralSynthesis:0"); + let mut other = seed.create_domain_rng(b"ClusterShuffle:0"); + + assert_eq!(first.next_u64(), repeated.next_u64()); + assert_ne!(first.next_u64(), other.next_u64()); + } } diff --git a/crates/core/src/strip.rs b/crates/core/src/strip.rs index 82cb115e..fc3481cf 100644 --- a/crates/core/src/strip.rs +++ b/crates/core/src/strip.rs @@ -9,6 +9,7 @@ use hex::encode; use revm::primitives::{B256, Bytes}; use serde::{Deserialize, Serialize}; use sha3::{Digest, Keccak256}; +use std::collections::HashSet; /// Represents a runtime section with its original offset and length. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -134,6 +135,55 @@ struct PushInfo { value: usize, } +fn collect_previous_pushes(bytes: &[u8], start: usize, max: usize) -> Vec { + let mut pushes = Vec::new(); + let mut pc = 0usize; + while pc < start && pc < bytes.len() { + let opcode = bytes[pc]; + if (0x60..=0x7f).contains(&opcode) { + let width = (opcode - 0x5f) as usize; + let end = pc + 1 + width; + if end > start || end > bytes.len() { + break; + } + let value = bytes[pc + 1..end] + .iter() + .fold(0usize, |acc, &byte| (acc << 8) | byte as usize); + pushes.push(PushInfo { + pos: pc, + width, + value, + }); + pc = end; + } else { + pc += 1; + } + } + pushes.into_iter().rev().take(max).collect() +} + +fn immutable_placeholder_offsets(runtime: &[u8]) -> HashSet { + let mut offsets = HashSet::new(); + let mut pc = 0usize; + while pc < runtime.len() { + let opcode = runtime[pc]; + if (0x60..=0x7f).contains(&opcode) { + let width = (opcode - 0x5f) as usize; + let end = pc + 1 + width; + if end > runtime.len() { + break; + } + if width == 32 && runtime[pc + 1..end].iter().all(|byte| *byte == 0) { + offsets.insert(pc + 1); + } + pc = end; + } else { + pc += 1; + } + } + offsets +} + fn opcode_positions(bytes: &[u8], target: u8) -> Vec { let mut positions = Vec::new(); let mut pc = 0usize; @@ -151,6 +201,236 @@ fn opcode_positions(bytes: &[u8], target: u8) -> Vec { positions } +#[derive(Clone, Debug, PartialEq, Eq)] +enum InitSymbol { + Unknown(usize), + Constant { + value: usize, + origin: usize, + }, + RuntimeBase, + RuntimeAddress { + offset: usize, + push_pos: usize, + width: usize, + add_pos: usize, + }, +} + +#[derive(Clone, Debug)] +struct ProvenImmutableWrite { + push_pos: usize, + width: usize, + offset: usize, +} + +#[derive(Clone, Debug)] +struct ImmutablePatchRegion { + codecopy: usize, + return_position: usize, + writes: Vec, +} + +fn pop_init_symbol(stack: &mut Vec, pc: usize) -> Result { + stack + .pop() + .ok_or_else(|| format!("init stack underflow at 0x{pc:x} during immutable proof")) +} + +fn init_constant(value: &InitSymbol) -> Option { + match value { + InitSymbol::Constant { value, .. } => Some(*value), + _ => None, + } +} + +/// Symbolically proves the straight-line solc 0.8.30 runtime-copy/immutable-write region. +/// +/// The proof is deliberately provenance-sensitive. Equal numeric constants are not aliases: +/// only the exact value copied (via DUP/SWAP) into CODECOPY's destination becomes +/// `RuntimeBase`. This prevents unrelated `PUSH base; PUSH offset; ADD; MSTORE` arithmetic from +/// being mistaken for a runtime-placeholder write. +fn prove_immutable_patch_region( + init: &[u8], + target_codecopy: usize, + runtime_start: usize, + runtime_len: usize, +) -> Result, String> { + let block_start = opcode_positions(init, 0x5b) + .into_iter() + .filter(|position| *position < target_codecopy) + .max() + .map_or(0, |position| position + 1); + let mut stack = Vec::::new(); + let mut writes = Vec::new(); + let mut matched_copy = false; + let mut pc = block_start; + + while pc < init.len() { + let opcode = init[pc]; + match opcode { + 0x5f => { + stack.push(InitSymbol::Constant { + value: 0, + origin: pc, + }); + pc += 1; + } + 0x60..=0x7f => { + let width = (opcode - 0x5f) as usize; + let end = pc + 1 + width; + let Some(immediate) = init.get(pc + 1..end) else { + return Ok(None); + }; + let value = immediate.iter().try_fold(0usize, |value, byte| { + value.checked_mul(256)?.checked_add(*byte as usize) + }); + let Some(value) = value else { + // A value wider than usize cannot be a runtime offset or length. + stack.push(InitSymbol::Unknown(pc)); + pc = end; + continue; + }; + stack.push(InitSymbol::Constant { value, origin: pc }); + pc = end; + } + 0x80..=0x8f => { + let depth = (opcode - 0x7f) as usize; + if depth > stack.len() { + return Ok(None); + } + stack.push(stack[stack.len() - depth].clone()); + pc += 1; + } + 0x90..=0x9f => { + let depth = (opcode - 0x8f) as usize; + if depth >= stack.len() { + return Ok(None); + } + let top = stack.len() - 1; + stack.swap(top, top - depth); + pc += 1; + } + 0x50 => { + pop_init_symbol(&mut stack, pc)?; + pc += 1; + } + 0x51 => { + pop_init_symbol(&mut stack, pc)?; + stack.push(InitSymbol::Unknown(pc)); + pc += 1; + } + 0x01 => { + let first = pop_init_symbol(&mut stack, pc)?; + let second = pop_init_symbol(&mut stack, pc)?; + let address = match (&first, &second) { + ( + InitSymbol::RuntimeBase, + InitSymbol::Constant { + value, + origin: push_pos, + }, + ) + | ( + InitSymbol::Constant { + value, + origin: push_pos, + }, + InitSymbol::RuntimeBase, + ) => { + let width = (init[*push_pos] - 0x5f) as usize; + InitSymbol::RuntimeAddress { + offset: *value, + push_pos: *push_pos, + width, + add_pos: pc, + } + } + _ => InitSymbol::Unknown(pc), + }; + stack.push(address); + pc += 1; + } + 0x52 => { + let address = pop_init_symbol(&mut stack, pc)?; + pop_init_symbol(&mut stack, pc)?; + if let InitSymbol::RuntimeAddress { + offset, + push_pos, + width, + add_pos, + } = address + { + if add_pos + 1 != pc { + return Err(format!( + "runtime-relative immutable address at 0x{add_pos:x} is not consumed by the immediately following MSTORE" + )); + } + writes.push(ProvenImmutableWrite { + push_pos, + width, + offset, + }); + } + pc += 1; + } + 0x39 => { + let destination = pop_init_symbol(&mut stack, pc)?; + let source = pop_init_symbol(&mut stack, pc)?; + let size = pop_init_symbol(&mut stack, pc)?; + if pc == target_codecopy { + if init_constant(&source) != Some(runtime_start) + || init_constant(&size) != Some(runtime_len) + { + return Ok(None); + } + matched_copy = true; + for value in &mut stack { + if *value == destination { + *value = InitSymbol::RuntimeBase; + } + } + } + pc += 1; + } + 0xf3 => { + if !matched_copy { + return Ok(None); + } + let offset = pop_init_symbol(&mut stack, pc)?; + let size = pop_init_symbol(&mut stack, pc)?; + if !matches!(offset, InitSymbol::RuntimeBase) + || init_constant(&size) != Some(runtime_len) + { + return Err(format!( + "runtime RETURN at 0x{pc:x} does not reuse the proven CODECOPY base and length" + )); + } + return Ok(Some(ImmutablePatchRegion { + codecopy: target_codecopy, + return_position: pc, + writes, + })); + } + 0x5b => pc += 1, + _ => { + if matched_copy { + return Err(format!( + "unsupported opcode 0x{opcode:02x} at 0x{pc:x} in Solidity immutable patch region" + )); + } + return Ok(None); + } + } + } + + if matched_copy { + Err("proven runtime CODECOPY has no following RETURN".to_string()) + } else { + Ok(None) + } +} + fn patch_constructor_arg_base( bytes: &mut [u8], old_value: usize, @@ -276,36 +556,6 @@ impl CleanReport { ); } - fn collect_previous_pushes(bytes: &[u8], start: usize, max: usize) -> Vec { - let mut pushes = Vec::new(); - let mut idx = start; - while idx > 0 && pushes.len() < max { - idx -= 1; - let opcode = bytes[idx]; - if !(0x60..=0x7f).contains(&opcode) { - continue; - } - let width = (opcode - 0x60 + 1) as usize; - if idx + 1 + width > bytes.len() || idx + 1 + width > start { - continue; - } - let mut value = 0usize; - for &byte in &bytes[idx + 1..idx + 1 + width] { - value = (value << 8) | byte as usize; - } - pushes.push(PushInfo { - pos: idx, - width, - value, - }); - if idx < width + 1 { - break; - } - idx = idx.saturating_sub(width); - } - pushes - } - fn write_push_value( bytes: &mut [u8], info: &PushInfo, @@ -443,19 +693,20 @@ impl CleanReport { Ok(()) } - /// Patch immutable reference offsets in the init code. + /// Patch proven Solidity immutable-reference offsets in init code. /// - /// The Solidity compiler's init code writes immutable variable values into the - /// runtime bytecode at hardcoded byte offsets. When obfuscation transforms change - /// the runtime layout (e.g., PushSplit growing blocks), these offsets become stale. - /// This method detects the pattern `PUSH2 ; ... ADD` in the init code and - /// updates each offset using the supplied byte-offset remapping closure. - /// - /// The `remap` closure takes an old byte offset within the runtime and returns the - /// new byte offset, or `None` if no mapping is available. + /// Solidity 0.8.30 copies the runtime into memory, then writes constructor + /// values into zero-filled `PUSH32` immediate placeholders with the exact + /// sequence `PUSH ; ADD; MSTORE`. Numeric equality with a + /// relocated runtime PC is not evidence: constructor arithmetic can contain + /// the same literal. We therefore require all of the compiler-shaped evidence: + /// a zero `PUSH32` placeholder, an exact ADD/MSTORE address sequence, and a + /// site between the matching runtime CODECOPY and its RETURN. Anything that + /// looks like an immutable write but cannot be relocated exactly fails closed. pub fn patch_init_immutable_refs( &mut self, remap: &dyn Fn(usize) -> Option, + original_runtime: &[u8], ) -> Result<(), String> { let runtime_start = self .runtime_layout @@ -463,7 +714,34 @@ impl CleanReport { .map(|span| span.offset) .min() .ok_or("No runtime layout found")?; - let runtime_end = runtime_start + self.clean_len; + let deployed_suffix_len: usize = self + .removed + .iter() + .filter(|removed| { + removed.offset >= runtime_start + && !matches!(removed.kind, SectionKind::ConstructorArgs) + }) + .map(|removed| removed.data.len()) + .sum(); + let original_deployed_runtime_len = self.clean_len + deployed_suffix_len; + let allowed_offsets = immutable_placeholder_offsets(original_runtime); + if allowed_offsets.is_empty() { + return Ok(()); + } + let mut required_offsets = HashSet::new(); + for offset in &allowed_offsets { + let relocated = remap(*offset).ok_or_else(|| { + format!( + "zero PUSH32 placeholder at runtime offset 0x{offset:x} has no proven relocation" + ) + })?; + if relocated != *offset { + required_offsets.insert(*offset); + } + } + if required_offsets.is_empty() { + return Ok(()); + } let init_section = self .removed @@ -472,73 +750,93 @@ impl CleanReport { .ok_or("No Init section found")?; let mut init_bytes = init_section.data.to_vec(); - let mut patched = 0usize; - let mut idx = 0usize; - - while idx < init_bytes.len() { - let opcode = init_bytes[idx]; - if !(0x60..=0x7f).contains(&opcode) { - idx += 1; - continue; + let mut regions = Vec::new(); + for codecopy in opcode_positions(&init_bytes, 0x39) { + if let Some(region) = prove_immutable_patch_region( + &init_bytes, + codecopy, + runtime_start, + original_deployed_runtime_len, + )? { + regions.push(region); } - - let width = (opcode - 0x60 + 1) as usize; - if idx + 1 + width > init_bytes.len() { - idx += 1; - continue; + } + if regions.len() != 1 { + return Err(format!( + "immutable references require exactly one proven Solidity runtime CODECOPY/RETURN region; found {}", + regions.len() + )); + } + let region = regions.pop().expect("region count checked"); + + // A base-relative constructor write to any other moved runtime byte is + // another relocation obligation that this compiler-specific lowering + // does not understand. Reject it instead of silently moving only the + // zero-PUSH32 subset. + for write in ®ion.writes { + if !allowed_offsets.contains(&write.offset) + && remap(write.offset).is_some_and(|new| new != write.offset) + { + return Err(format!( + "unrecognized runtime-relative constructor write at offset 0x{:x}", + write.offset + )); } + } - let mut value = 0usize; - for &byte in &init_bytes[idx + 1..idx + 1 + width] { - value = (value << 8) | byte as usize; + let mut candidates = Vec::<(usize, usize, usize)>::new(); + for offset in &required_offsets { + let matching: Vec<_> = region + .writes + .iter() + .filter(|write| write.offset == *offset) + .collect(); + if matching.len() != 1 { + return Err(format!( + "relocated zero PUSH32 placeholder 0x{offset:x} requires exactly one proven Solidity immutable write; found {}", + matching.len() + )); } + let write = matching[0]; + candidates.push((write.push_pos, write.width, write.offset)); + } - // Check if the next non-stack-manipulation opcode is ADD (0x01). - // The pattern is: PUSH2 ; (DUP/SWAP ops); ADD - let after = idx + 1 + width; - let is_add_target = if after < init_bytes.len() { - init_bytes[after] == 0x01 // ADD immediately follows + let mut patched = 0usize; + for (position, width, value) in candidates { + if position <= region.codecopy || position >= region.return_position { + return Err(format!( + "immutable-like reference at init offset 0x{position:x} is outside the proven runtime patch region" + )); + } + let new_value = remap(value).ok_or_else(|| { + format!( + "immutable reference at init offset 0x{position:x} has no proven relocation for runtime offset 0x{value:x}" + ) + })?; + let max = if width >= std::mem::size_of::() { + usize::MAX } else { - false + (1usize << (width * 8)) - 1 }; - - // Only remap values that look like runtime offsets followed by ADD - if is_add_target - && value >= 1 - && value < runtime_end.saturating_sub(runtime_start) - && let Some(new_value) = remap(value) - && new_value != value - { - // Check that new value fits in the same width - let max = if width >= std::mem::size_of::() { - usize::MAX - } else { - (1usize << (width * 8)) - 1 - }; - if new_value > max { - tracing::warn!( - "Immutable ref at init offset 0x{:x}: new value 0x{:x} exceeds \ - PUSH{} capacity", - idx, - new_value, - width - ); - } else { - for j in 0..width { - let shift = (width - 1 - j) * 8; - init_bytes[idx + 1 + j] = ((new_value >> shift) & 0xff) as u8; - } - tracing::debug!( - "Patched immutable ref at init offset 0x{:x}: 0x{:x} -> 0x{:x}", - idx, - value, - new_value - ); - patched += 1; - } + if new_value > max { + return Err(format!( + "immutable reference at init offset 0x{position:x}: relocated value 0x{new_value:x} exceeds PUSH{width} capacity" + )); } - - idx += 1 + width; + if new_value == value { + continue; + } + for byte_index in 0..width { + let shift = (width - 1 - byte_index) * 8; + init_bytes[position + 1 + byte_index] = ((new_value >> shift) & 0xff) as u8; + } + tracing::debug!( + "Patched proven immutable ref at init offset 0x{:x}: 0x{:x} -> 0x{:x}", + position, + value, + new_value + ); + patched += 1; } if patched > 0 { diff --git a/crates/transforms/README.md b/crates/transforms/README.md index d0bea4aa..0bd42d73 100644 --- a/crates/transforms/README.md +++ b/crates/transforms/README.md @@ -1,224 +1,47 @@ # Azoth Transforms -The `azoth-transform` crate implements the obfuscation transformations that enhance bytecode complexity and resistance to analysis. This crate provides a pluggable architecture for applying various obfuscation techniques while maintaining semantic equivalence. +`azoth-transform` applies deterministic, seed-derived rewrites to Azoth's CFG/IR. A pass runs against a cloned bundle and is committed only when it succeeds and reports a change; errors abort the pipeline and unsuccessful attempts cannot leave partial mutations behind. This transaction boundary is useful defensive behavior, but it is not a proof that a rewrite preserves every EVM observation. -## Architecture +## Production default -The transforms crate implements a pass-based architecture where each transformation operates on the CFG/IR representation: +The unified obfuscator applies passes in this order: -1. **Pass Interface** - Standardized transformation interface for modularity -2. **Transformation Passes** - Individual obfuscation techniques -3. **Metrics Integration** - Continuous evaluation during transformation -4. **Rollback Support** - Automatic rejection of ineffective passes +1. `FunctionDispatcher`, only when a supported Solidity-style dispatcher is detected. +2. `JumpTrampoline`. +3. `ClusterShuffle`. -## Current Transforms +`FunctionDispatcher` replaces dispatcher selector literals with deterministic four-byte tokens and returns the original-selector-to-token mapping required by callers. The default selector-only mode does not add controller, decoy, or storage-dependent blocks. It rejects a selector that also appears outside the recognized dispatcher because Azoth cannot yet prove whether that occurrence participates in a self-call or interface data flow. Token collisions with every original selector are excluded. -### Constructor arguments (`constructor_args.rs`) +`JumpTrampoline` reroutes a small seed-derived sample of existing symbolic jumps through ordinary forwarding blocks. It changes CFG topology without dead branches, storage reads, or environment-dependent predicates and adds at most three five-byte blocks. -When the deployment payload contains bytes after the exact caller-supplied runtime, the pipeline masks every byte of that suffix and injects a seed-varied init-code decoder. Detection uses the complete runtime as an authoritative boundary and does not inspect the ABI, source, address shapes, or zero padding. Unsupported or ambiguous constructor copy layouts fail closed instead of returning plaintext arguments. This pass is automatic and is reported as `ConstructorArgs` in result metadata. +`ClusterShuffle` moves maximal clusters connected only by explicit jumps. It anchors the entry cluster, keeps fallthrough/false-branch sequences adjacent, and searches seed-derived safe layouts until clean-runtime LCS retention is at most 40% when possible. It then recalculates program counters and runtime bounds. -The bytecode contains everything required to reverse the mask, so this is obfuscation against literal/static recovery rather than encryption. Trampoline form, chunk order, arithmetic mask synthesis, and constants vary with the seed; no marker or fixed decoder byte string is emitted. +Recognized Solidity IPFS or Swarm metadata digests are also diversified automatically after runtime encoding. The CBOR envelope and compiler version remain intact, but the derived digest is intentionally not a valid source-verification pointer. -### Shuffle (`shuffle.rs`) +Constructor-argument masking is automatic when the deployment payload contains a supported argument suffix. See [constructor-argument obfuscation](../../docs/constructor-argument-obfuscation.md) for its public-data security boundary. -Reorders basic blocks within the CFG while updating jump targets to maintain correctness. Simple block-level randomization that changes program layout without affecting execution. +## Experimental and legacy passes -Example +The other exported passes are opt-in and are not part of the production default. They need contract-specific differential validation before use. In particular: -```assembly -Original -> 0x60015b6002 -Shuffled -> 0x5b60026001 -``` - -### Opaque Predicate (`opaque_predicate.rs`) - -Injects always-true (or always-false) predicates built from cheap arithmetic or constant-equality (e.g., XOR + ISZERO or EQ on identical constants). Adds dummy control-flow that never influences observable behavior but explodes CFG shape. - -Example - -Original bytecode: 0x6001600260016003 (8 bytes, 4 instructions, 1 block) - -```assembly -PUSH1 0x01 -PUSH1 0x02 -PUSH1 0x01 -PUSH1 0x03 -``` - -After OpaquePredicate: (~80–100 bytes, ~12 instructions, 3 blocks; seed-dependent) - -```assembly -// Original block (now with predicate appended) -PUSH1 0x01 -PUSH1 0x02 -PUSH1 0x01 -PUSH1 0x03 -PUSH32 C // Random 32-byte constant -PUSH32 C // Same constant -XOR // 0 -ISZERO // -> 1 (true) -PUSH2 true_pc -JUMPI -JUMPDEST // Join point (false path) -JUMP false_pc - -// New true_label block -JUMPDEST // True branch target (always taken) -// execution continues to original fallthrough - -// New false_label block -JUMPDEST // False branch target (never reached) -PUSH1 0x00 -JUMP // Dead code path -``` - -Changes: +80-100 bytes, splits 1 block into 3, adds always-true branching that never affects execution but complicates CFG analysis. - -### Jump Address Transformer (`jump_address_transformer.rs`) - -Splits jump targets into arithmetic operations. Replaces PUSH1 0x42 JUMP with PUSH1 0x20 PUSH1 0x22 ADD JUMP where the values sum to the original target. - -Example - -Original bytecode: 0x60085760015b00 (7 bytes, 5 instructions, 3 blocks) - -```assembly -PUSH1 0x08 // Direct jump target -JUMPI // Conditional jump to 0x08 -PUSH1 0x01 // Fallthrough path -JUMPDEST // Jump destination at 0x08 -STOP -``` - -After JumpAddressTransformer: 0x60046004015760015b00 (10 bytes, 7 instructions, 3 blocks) - -```assembly -PUSH1 0x04 // First part of split target -PUSH1 0x04 // Second part (0x04 + 0x04 = 0x08) -ADD // Compute original target at runtime -JUMPI // Conditional jump to computed value -PUSH1 0x01 // Fallthrough path unchanged -JUMPDEST // Same jump destination -STOP -``` - -Changes: +3 bytes, +2 instructions; replaces direct 0x08 with 0x04 + 0x04 via ADD. Net +6 gas (2 extra PUSH1s + ADD – original single PUSH1). - -### Function Dispatcher (`function_dispatcher.rs`) +- The legacy multi-tier dispatcher is available only through constructors named `experimental_multi_tier`; it adds controller, decoy, and storage-dependent blocks and is excluded from normal orchestration. +- `StringObfuscate` is disabled and returns an error because scrambling `Error(string)` data changes observable revert bytes. Use `LiteralSynthesis` for exact constant rewrites. +- `LiteralSynthesis` exactly reconstructs selected constants but is not a default because red-team testing found its repeated algebraic templates were an easy family classifier. +- `ArithmeticChain`, `Shuffle`, `OpaquePredicate`, `JumpAddressTransformer`, `PushSplit`, `StorageGates`, `SlotShuffle`, and `Splice` remain available for explicit experiments; they are not safety-certified defaults. -Replaces Solidity-style dispatchers with a cryptographically hardened version that is resistant to selector fingerprinting and pattern-based detection. +## Unsupported runtime observations -#### Key Features +Layout-changing orchestration currently rejects runtimes that use `PC`, `CODESIZE`, or `CODECOPY`, because Azoth does not yet have typed relocation records for embedded code/data references. It conservatively rejects every `EXTCODESIZE`, `EXTCODECOPY`, and `EXTCODEHASH` use as well: without sound stack and address-alias analysis, their target may be the current contract and therefore expose transformed code size, bytes, or hash. Passes that add executed instructions, including `JumpTrampoline` and experimental `LiteralSynthesis`, also skip or reject every `GAS`-observing runtime; even the standard `GAS; CALL` sequence can expose added overhead through EIP-150 forwarding. Unsupported or ambiguous inputs fail closed rather than being transformed speculatively. -* **Token Derivation**: Uses cryptographic `keccak256(secret || selector)[:4]` to generate fixed 4-byte tokens -* **Disguised Extraction**: Replaces obvious calldata patterns with obfuscated arithmetic (e.g., `XOR`, `SUB`, `MOD`, `MSTORE/MLOAD`) -* **SHR-Based Extraction**: Extracts tokens using right-shift operations that work correctly even when calldata includes function arguments -* **Comparison Replacement**: Replaces all `PUSH4 ` checks with `PUSH4 ` -* **Shuffled Order**: Randomizes comparison order to prevent pattern recognition -* **Internal Call Updates**: Updates all internal `PUSH4 selector; CALL` instructions to use `PUSH4 token; CALL` +These checks do not replace semantic testing. Before deployment, compare original and transformed contracts in an EVM across successful calls, reverts and returndata, logs, storage, balances, and relevant environment inputs. -#### Token Extraction Method - -The dispatcher uses a right-shift (SHR) operation to extract the 4-byte token from calldata: - -1. **CALLDATALOAD(0)** loads the first 32 bytes with the selector left-aligned -2. **SHR 224** shifts right by 28 bytes (224 bits), moving the token to the low position -3. The shift automatically discards any function arguments, leaving only the 4-byte token - -This approach works correctly even when calldata includes arguments: - -``` -Calldata for bond(1000): - 0x500b6840 00000000000000000000000000000000000000000000000000000000000003e8 - │────────│ ──────────────────────────────────────────────────────────────│ - 4-byte 32-byte argument (uint256 = 1000) - token - -CALLDATALOAD(0): 0x500b684000000000000000000000000000000000000000000000000000000000 -SHR 224: 0x00000000000000000000000000000000000000000000000000000000500b6840 - └──────┘ - 4-byte token -``` - -Since we use **fixed 4-byte tokens**, the SHR operation alone is sufficient. The right-shift automatically removes all argument bytes, leaving only the token for comparison. No additional masking is needed. - -#### Example Transformation - -**Original dispatcher:** - -```assembly -PUSH1 0x00 ; offset 0 -CALLDATALOAD ; load calldata[0..31] -PUSH1 0xe0 ; 224 bits -SHR ; extract selector -DUP1 -PUSH4 0x7ff36ab5 ; selector for balanceOf -EQ -PUSH1 0x1a -JUMPI -DUP1 -PUSH4 0xa9059cbb ; selector for transfer -EQ -PUSH1 0x21 -JUMPI -``` - -**Obfuscated dispatcher:** - -```assembly -PUSH1 0x39 -PUSH1 0x39 -XOR ; disguised 0x00 (random arithmetic) -CALLDATALOAD ; load calldata[0..31] -PUSH1 0xe0 ; 224 bits -SHR ; extract 4-byte token from left -DUP1 -PUSH4 0x1278bea5 ; cryptographic token for 0xa9059cbb (transfer) -EQ -PUSH1 0x30 -JUMPI -DUP1 -PUSH4 0xa93a3604 ; cryptographic token for 0x7ff36ab5 (balanceOf) -EQ -PUSH1 0x29 -JUMPI -PUSH1 0x00 -DUP1 -REVERT ; default case -``` - -#### Changes - -* **Original selectors completely replaced** with cryptographically-derived tokens -* **Calldata offset computation disguised** using random arithmetic operations -* **Comparison order randomized** to prevent sequential pattern matching -* **Function bodies unchanged** - they still read arguments from offset 4+ as normal -* **Jump targets automatically updated** to maintain semantic equivalence - -This makes function selectors unrecognizable to static analysis tools and completely eliminates selector-based fingerprinting, while maintaining full compatibility with standard ABI encoding for function arguments. - -### Transform Interface +## Transform interface ```rust -#[async_trait] pub trait Transform: Send + Sync { fn name(&self) -> &'static str; - async fn apply(&self, ir: &mut CfgIrBundle, rng: &mut StdRng) -> Result; -} -``` - -### Pass Execution - -Consumers typically orchestrate transforms themselves. A minimal driver looks like: - -```rust -let mut rng = seed.create_deterministic_rng(); -for transform in transforms { - if transform.apply(&mut cfg_ir, &mut rng)? { - // optionally recompute metrics, log deltas, etc. - } + fn apply(&self, ir: &mut CfgIrBundle, rng: &mut StdRng) -> Result; } ``` -The transforms operate on `CfgIrBundle` structures and can be combined with -metrics from `azoth-analysis` to evaluate effectiveness. +Use `ObfuscationConfig::default()` or `ObfuscationConfig::with_seed(...)` for the production pass set. Supplying `config.transforms` explicitly replaces `JumpTrampoline` and `ClusterShuffle`; dispatcher detection still runs first. The full obfuscator should be preferred over invoking passes directly because it provides transactional application, domain-separated randomness, reindexing, relocation patches, size checks, and result metadata. diff --git a/crates/transforms/src/cluster_shuffle.rs b/crates/transforms/src/cluster_shuffle.rs index 7e95611a..2faf2c87 100644 --- a/crates/transforms/src/cluster_shuffle.rs +++ b/crates/transforms/src/cluster_shuffle.rs @@ -1,23 +1,30 @@ //! Cluster-aware CFG shuffler. //! -//! Instead of shuffling individual blocks, this transform will shuffle block -//! clusters (dispatcher tiers, stub+decoy pairs, storage gates) to preserve -//! logical adjacency while changing layout. +//! Instead of shuffling individual blocks, this transform shuffles maximal +//! physical-fallthrough clusters. Concrete terminators, rather than potentially +//! incomplete CFG edges, decide which blocks must remain adjacent. Clusters +//! connected only by explicit jumps may move independently. This preserves EVM +//! fallthrough semantics without injecting a recognizable trampoline after every +//! block. //! -//! Assembly example: -//! ```assembly -//! // Blocks before -//! [dispatcher_tier_0][controller_real][decoy_stub][storage_gate] -//! -//! // After cluster shuffle (one variant) -//! [storage_gate][dispatcher_tier_0][decoy_stub][controller_real] -//! ``` +//! To avoid weak seeds which accidentally leave most clusters in source order, +//! the selected permutation must retain at most half of the movable cluster +//! order. Selection is still seed-derived and deterministic. -use crate::{Result, Transform}; -use azoth_core::cfg_ir::CfgIrBundle; +use crate::{has_self_code_layout_semantics, Error, Result, Transform}; +use azoth_core::cfg_ir::{Block, BlockBody, CfgIrBundle}; +use azoth_core::{is_terminal_opcode, Opcode}; use rand::rngs::StdRng; +use rand::seq::SliceRandom; +use std::collections::HashMap; use tracing::debug; +const MAX_PERMUTATION_TRIALS: usize = 64; +// One bit-parallel LCS trial performs roughly `n * ceil(n / 64)` word +// operations. Bound aggregate search work so a near-limit runtime cannot turn +// obfuscation into an accidental CPU denial of service. +const LCS_WORD_OPERATION_BUDGET: usize = 32_000_000; + /// Cluster-level shuffle wrapper. #[derive(Default)] pub struct ClusterShuffle; @@ -33,8 +40,595 @@ impl Transform for ClusterShuffle { "ClusterShuffle" } - fn apply(&self, _ir: &mut CfgIrBundle, _rng: &mut StdRng) -> Result { - debug!("ClusterShuffle: placeholder apply (no-op)"); - Ok(false) + fn supports_gas_observation(&self) -> bool { + true + } + + fn apply(&self, ir: &mut CfgIrBundle, rng: &mut StdRng) -> Result { + if has_self_code_layout_semantics(ir) { + return Err(Error::Generic( + "cluster shuffle requires typed relocation support for PC/CODESIZE/CODECOPY".into(), + )); + } + let bounds = ir.runtime_bounds; + let mut runtime_nodes: Vec<_> = ir + .cfg + .node_indices() + .filter_map(|node| match &ir.cfg[node] { + Block::Body(body) + if bounds.is_none_or(|(start, end)| { + body.start_pc >= start && body.start_pc < end + }) => + { + Some((body.start_pc, node)) + } + _ => None, + }) + .collect(); + runtime_nodes.sort_by_key(|(pc, _)| *pc); + if runtime_nodes.len() < 3 { + return Ok(false); + } + + let final_runtime_falls_off = runtime_nodes + .last() + .and_then(|(_, node)| match &ir.cfg[*node] { + Block::Body(body) => Some(requires_physical_successor(body)), + Block::Entry | Block::Exit => None, + }) + .unwrap_or(false); + + // Form maximal sequences whose physical adjacency is semantically required. + // CFG metadata can be Unknown for computed JUMPI targets, but the false + // path still always continues at the following byte. + let mut clusters: Vec> = Vec::new(); + for (_, node) in runtime_nodes { + let append_to_previous = clusters.last().is_some_and(|cluster| { + let previous = *cluster.last().expect("non-empty cluster"); + matches!(&ir.cfg[previous], Block::Body(body) if requires_physical_successor(body)) + }); + if append_to_previous { + clusters.last_mut().expect("cluster exists").push(node); + } else { + clusters.push(vec![node]); + } + } + + // Execution starts at runtime byte zero, so the cluster containing the entry + // block is anchored. Only explicitly reached clusters may move around it. + if clusters.len() < 3 { + return Ok(false); + } + let entry_cluster = clusters.remove(0); + // Falling off the original runtime executes the EVM's implicit STOP. If + // that cluster moved ahead of another cluster, the old falloff would + // instead execute those bytes. Keep the complete tail cluster last. + let tail_cluster = final_runtime_falls_off.then(|| { + clusters + .pop() + .expect("the original runtime tail belongs to a cluster") + }); + if clusters.len() < 2 { + return Ok(false); + } + let original_movable = clusters.clone(); + clusters = choose_strong_permutation( + ir, + &entry_cluster, + tail_cluster.as_deref().unwrap_or(&[]), + &original_movable, + rng, + ); + if clusters == original_movable { + return Ok(false); + } + + let runtime_start = bounds.map(|(start, _)| start).unwrap_or(0); + let mut cursor = runtime_start; + for node in entry_cluster + .into_iter() + .chain(clusters.into_iter().flatten()) + .chain(tail_cluster.into_iter().flatten()) + { + let Some(Block::Body(body)) = ir.cfg.node_weight_mut(node) else { + continue; + }; + body.start_pc = cursor; + cursor += body + .instructions + .iter() + .map(azoth_core::decoder::Instruction::byte_size) + .sum::(); + } + if bounds.is_some() { + // Literal synthesis may have grown the runtime before this pass. + // Keeping the original end would make reindexing misclassify every + // shuffled block placed beyond that stale boundary as init/data. + ir.runtime_bounds = Some((runtime_start, cursor)); + } + + debug!("ClusterShuffle: safely reordered fallthrough-preserving clusters"); + Ok(true) + } +} + +/// Whether execution can reach the byte immediately following this concrete +/// block. JUMPI always has a physical false path, even when its true target is +/// unresolved and the high-level control descriptor is therefore `Unknown`. +fn requires_physical_successor(body: &BlockBody) -> bool { + body.instructions.last().is_none_or(|instruction| { + matches!(instruction.op, Opcode::JUMPI) + || (!matches!(instruction.op, Opcode::JUMP) && !is_terminal_opcode(instruction.op)) + }) +} + +/// Pick a deterministic random permutation whose surviving source-order +/// subsequence is no larger than half of the movable clusters. With very small +/// cluster sets, reversal is the exact strongest fallback. +fn choose_strong_permutation( + ir: &CfgIrBundle, + entry: &[petgraph::graph::NodeIndex], + suffix: &[petgraph::graph::NodeIndex], + original: &[Vec], + rng: &mut StdRng, +) -> Vec> { + debug_assert!(original.len() >= 2); + let target = (original.len() / 2).max(1); + let mut best = original.to_vec(); + let original_runtime = original_clean_runtime(ir); + let byte_target = original_runtime + .as_deref() + .map_or(usize::MAX, |bytes| bytes.len() * 2 / 5); + let mut best_score = usize::MAX; + + let trial_count = original_runtime + .as_deref() + .map_or(MAX_PERMUTATION_TRIALS, |bytes| { + candidate_trial_budget(bytes.len()) + }); + for _ in 0..trial_count { + let mut candidate = original.to_vec(); + candidate.shuffle(rng); + let retained = retained_order_len(original, &candidate); + if candidate == original || retained > target { + continue; + } + + let byte_score = original_runtime + .as_deref() + .and_then(|source| { + encode_cluster_order(ir, entry, &candidate, suffix).map(|output| (source, output)) + }) + .map_or(retained, |(source, output)| lcs_len(source, &output)); + if byte_score < best_score { + best = candidate.clone(); + best_score = byte_score; + } + // Accept the first sufficiently strong seed-derived permutation instead + // of canonicalizing every output to the single global minimum. + if byte_score <= byte_target { + return candidate; + } + } + + if best != original { + return best; + } + original.iter().rev().cloned().collect() +} + +/// Recover the immutable clean-runtime source from the strip report. Runtime +/// bounds are mutable transform state, so using them after a size-changing pass +/// can accidentally score against auxdata or a constructor suffix. +fn original_clean_runtime(ir: &CfgIrBundle) -> Option> { + let mut bytes = Vec::with_capacity(ir.clean_report.clean_len); + for span in &ir.clean_report.runtime_layout { + let end = span.offset.checked_add(span.len)?; + bytes.extend_from_slice(ir.original_bytecode.get(span.offset..end)?); + } + (bytes.len() == ir.clean_report.clean_len).then_some(bytes) +} + +fn encode_cluster_order( + ir: &CfgIrBundle, + entry: &[petgraph::graph::NodeIndex], + clusters: &[Vec], + suffix: &[petgraph::graph::NodeIndex], +) -> Option> { + let mut bytes = Vec::new(); + for node in entry.iter().chain(clusters.iter().flatten()).chain(suffix) { + let Block::Body(body) = &ir.cfg[*node] else { + continue; + }; + for instruction in &body.instructions { + if matches!(instruction.op, Opcode::INVALID) { + if let Some(immediate) = &instruction.imm { + if let Ok(byte) = u8::from_str_radix(immediate, 16) { + bytes.push(byte); + continue; + } + } + bytes.push(*ir.original_bytecode.get(instruction.pc)?); + continue; + } + bytes.push(instruction.op.to_byte()); + if let Opcode::PUSH(width) = instruction.op { + let immediate = hex::decode(instruction.imm.as_deref()?).ok()?; + if immediate.len() != width as usize { + return None; + } + bytes.extend_from_slice(&immediate); + } + } + } + Some(bytes) +} + +fn lcs_len(left: &[u8], right: &[u8]) -> usize { + if left.is_empty() || right.is_empty() { + return 0; + } + + // Exact bit-parallel LCS. Candidate count is size-budgeted above, and the + // ordinary quadratic table would still be prohibitively slow near EIP-170. + let (rows, columns) = if left.len() >= right.len() { + (left, right) + } else { + (right, left) + }; + let word_count = columns.len().div_ceil(u64::BITS as usize); + let mut matches = vec![vec![0u64; word_count]; 256]; + for (index, byte) in columns.iter().copied().enumerate() { + matches[byte as usize][index / 64] |= 1u64 << (index % 64); + } + + let mut state = vec![0u64; word_count]; + let mut shifted = vec![0u64; word_count]; + for byte in rows { + let mut carry = 1u64; + for (source, target) in state.iter().copied().zip(&mut shifted) { + *target = (source << 1) | carry; + carry = source >> 63; + } + + let mut borrow = false; + for word in 0..word_count { + let x = matches[*byte as usize][word] | state[word]; + let (partial, first_borrow) = x.overflowing_sub(shifted[word]); + let (difference, second_borrow) = partial.overflowing_sub(u64::from(borrow)); + borrow = first_borrow || second_borrow; + state[word] = x & !difference; + } + } + + state.iter().map(|word| word.count_ones() as usize).sum() +} + +fn candidate_trial_budget(byte_len: usize) -> usize { + let words = byte_len.div_ceil(u64::BITS as usize); + let per_trial = byte_len.saturating_mul(words).max(1); + (LCS_WORD_OPERATION_BUDGET / per_trial).clamp(4, MAX_PERMUTATION_TRIALS) +} + +/// LCS length for two permutations of the same unique clusters. +/// +/// Map each cluster to its source position, then compute the longest increasing +/// subsequence in `O(k log k)`. The first node is a stable identity because CFG +/// nodes occur in exactly one non-empty cluster. +fn retained_order_len( + original: &[Vec], + candidate: &[Vec], +) -> usize { + let positions: HashMap<_, _> = original + .iter() + .enumerate() + .map(|(position, cluster)| (*cluster.first().expect("clusters are non-empty"), position)) + .collect(); + let mut tails = Vec::::with_capacity(candidate.len()); + for cluster in candidate { + let position = positions[cluster.first().expect("clusters are non-empty")]; + let insertion = tails.partition_point(|tail| *tail < position); + if insertion == tails.len() { + tails.push(position); + } else { + tails[insertion] = position; + } + } + tails.len() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::literal_synthesis::LiteralSynthesis; + use azoth_core::cfg_ir::{BlockControl, EdgeType}; + use azoth_core::decoder::Instruction; + use azoth_core::process_bytecode_to_cfg; + use azoth_core::seed::Seed; + use petgraph::visit::{EdgeRef, IntoEdgeReferences}; + + const FIXED_SEED: &str = "0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + const COUNTER_DEPLOYMENT: &str = + include_str!("../../../tests/bytecode/counter/counter_deployment.hex"); + const COUNTER_RUNTIME: &str = + include_str!("../../../tests/bytecode/counter/counter_runtime.hex"); + const TAIL_FALLOFF_DEPLOYMENT: &str = "0x600b600a5f39600b5ff3610008565bfe5bfe5b6002"; + const TAIL_FALLOFF_RUNTIME: &str = "0x610008565bfe5bfe5b6002"; + const UNRESOLVED_JUMPI_DEPLOYMENT: &str = + "0x6011600a5f3960115ff36000600f805057602a5f5260205ff35bfe"; + const UNRESOLVED_JUMPI_RUNTIME: &str = "0x6000600f805057602a5f5260205ff35bfe"; + + #[tokio::test] + async fn preserves_entry_and_every_physical_fallthrough_pair() { + // Solidity-style branch diamonds with three independently jumped-to tails. + let bytecode = "0x600b576005565b600e565b6011565b005b005b00"; + let (mut ir, _, _, _) = process_bytecode_to_cfg(bytecode, false, bytecode, false) + .await + .unwrap(); + let original_entry = ir + .cfg + .node_indices() + .filter_map(|node| match &ir.cfg[node] { + Block::Body(body) => Some((body.start_pc, node)), + _ => None, + }) + .min_by_key(|(pc, _)| *pc) + .unwrap() + .1; + let required_pairs: Vec<_> = ir + .cfg + .edge_references() + .filter(|edge| { + matches!(edge.weight(), EdgeType::Fallthrough | EdgeType::BranchFalse) + && matches!(ir.cfg[edge.source()], Block::Body(_)) + && matches!(ir.cfg[edge.target()], Block::Body(_)) + }) + .map(|edge| (edge.source(), edge.target())) + .collect(); + + let seed = Seed::from_hex(FIXED_SEED).unwrap(); + let mut rng = seed.create_deterministic_rng(); + let _ = ClusterShuffle::new().apply(&mut ir, &mut rng).unwrap(); + + let mut ordered: Vec<_> = ir + .cfg + .node_indices() + .filter_map(|node| match &ir.cfg[node] { + Block::Body(body) => Some((body.start_pc, node)), + _ => None, + }) + .collect(); + ordered.sort_by_key(|(pc, _)| *pc); + assert_eq!(ordered.first().unwrap().1, original_entry); + let positions: std::collections::HashMap<_, _> = ordered + .iter() + .enumerate() + .map(|(index, (_, node))| (*node, index)) + .collect(); + for (source, target) in required_pairs { + assert_eq!(positions[&target], positions[&source] + 1); + } + } + + #[tokio::test] + async fn refreshes_runtime_bounds_after_literal_growth() { + let (mut ir, _, _, _) = process_bytecode_to_cfg( + COUNTER_DEPLOYMENT.trim(), + false, + COUNTER_RUNTIME.trim(), + false, + ) + .await + .expect("counter bytecode should produce a CFG"); + let (runtime_start, stale_runtime_end) = ir.runtime_bounds.expect("runtime bounds"); + let seed = Seed::from_hex(FIXED_SEED).expect("valid fixed seed"); + let mut rng = seed.create_deterministic_rng(); + + assert!(LiteralSynthesis::new() + .apply(&mut ir, &mut rng) + .expect("literal synthesis should succeed")); + let grown_runtime_len: usize = ir + .cfg + .node_indices() + .filter_map(|node| match &ir.cfg[node] { + Block::Body(body) + if body.start_pc >= runtime_start && body.start_pc < stale_runtime_end => + { + Some( + body.instructions + .iter() + .map(azoth_core::decoder::Instruction::byte_size) + .sum::(), + ) + } + _ => None, + }) + .sum(); + let expected_runtime_end = runtime_start + grown_runtime_len; + assert!( + expected_runtime_end > stale_runtime_end, + "fixture must make the pre-shuffle runtime bound stale" + ); + + assert!(ClusterShuffle::new() + .apply(&mut ir, &mut rng) + .expect("cluster shuffle should succeed")); + assert_eq!( + ir.runtime_bounds, + Some((runtime_start, expected_runtime_end)), + "all shuffled blocks, including the grown tail, must remain classified as runtime" + ); + assert!(ir.cfg.node_indices().all(|node| match &ir.cfg[node] { + Block::Body(body) => { + body.start_pc >= runtime_start && body.start_pc < expected_runtime_end + } + Block::Entry | Block::Exit => true, + })); + + let (_, old_bounds) = ir.reindex_pcs().expect("reindexing should succeed"); + assert_eq!(old_bounds, Some((runtime_start, expected_runtime_end))); + assert_eq!( + ir.runtime_bounds, + Some((0, grown_runtime_len)), + "reindexing must retain the complete grown runtime" + ); + } + + #[tokio::test] + async fn pins_an_original_falloff_tail_after_all_shuffled_clusters() { + let (mut ir, _, _, _) = + process_bytecode_to_cfg(TAIL_FALLOFF_DEPLOYMENT, false, TAIL_FALLOFF_RUNTIME, false) + .await + .expect("tail fixture should produce a CFG"); + let original_tail = ir + .cfg + .node_indices() + .filter_map(|node| match &ir.cfg[node] { + Block::Body(body) => Some((body.start_pc, node)), + Block::Entry | Block::Exit => None, + }) + .max_by_key(|(pc, _)| *pc) + .expect("runtime tail") + .1; + let Block::Body(tail_body) = &ir.cfg[original_tail] else { + unreachable!() + }; + assert!(requires_physical_successor(tail_body)); + + let seed = Seed::from_hex(FIXED_SEED).expect("fixed seed"); + let mut rng = seed.create_deterministic_rng(); + assert!(ClusterShuffle::new() + .apply(&mut ir, &mut rng) + .expect("shuffle should succeed")); + + let final_tail = ir + .cfg + .node_indices() + .filter_map(|node| match &ir.cfg[node] { + Block::Body(body) => Some((body.start_pc, node)), + Block::Entry | Block::Exit => None, + }) + .max_by_key(|(pc, _)| *pc) + .expect("runtime tail") + .1; + assert_eq!(final_tail, original_tail, "falloff must still reach EOF"); + } + + #[tokio::test] + async fn unresolved_jumpi_keeps_its_concrete_false_path_adjacent() { + let (mut ir, _, _, _) = process_bytecode_to_cfg( + UNRESOLVED_JUMPI_DEPLOYMENT, + false, + UNRESOLVED_JUMPI_RUNTIME, + false, + ) + .await + .expect("unresolved JUMPI fixture should produce a CFG"); + let mut ordered: Vec<_> = ir + .cfg + .node_indices() + .filter_map(|node| match &ir.cfg[node] { + Block::Body(body) => Some((body.start_pc, node)), + Block::Entry | Block::Exit => None, + }) + .collect(); + ordered.sort_by_key(|(pc, _)| *pc); + let jumpi = ordered[0].1; + let false_path = ordered[1].1; + let Block::Body(jumpi_body) = &ir.cfg[jumpi] else { + unreachable!() + }; + assert!(matches!(jumpi_body.control, BlockControl::Unknown)); + assert!(matches!( + jumpi_body.instructions.last(), + Some(Instruction { + op: Opcode::JUMPI, + .. + }) + )); + assert!(requires_physical_successor(jumpi_body)); + + let seed = Seed::from_hex(FIXED_SEED).expect("fixed seed"); + let mut rng = seed.create_deterministic_rng(); + let _ = ClusterShuffle::new() + .apply(&mut ir, &mut rng) + .expect("shuffle should not fail"); + let jumpi_pc = match &ir.cfg[jumpi] { + Block::Body(body) => body.start_pc, + Block::Entry | Block::Exit => unreachable!(), + }; + let jumpi_len = match &ir.cfg[jumpi] { + Block::Body(body) => body + .instructions + .iter() + .map(Instruction::byte_size) + .sum::(), + Block::Entry | Block::Exit => unreachable!(), + }; + let false_pc = match &ir.cfg[false_path] { + Block::Body(body) => body.start_pc, + Block::Entry | Block::Exit => unreachable!(), + }; + assert_eq!(false_pc, jumpi_pc + jumpi_len); + } + + #[tokio::test] + async fn strong_permutation_limits_surviving_source_order() { + let original: Vec> = (0..12) + .map(|value| vec![petgraph::graph::NodeIndex::new(value)]) + .collect(); + let seed = Seed::from_hex(FIXED_SEED).unwrap(); + let mut first_rng = seed.create_deterministic_rng(); + let mut second_rng = seed.create_deterministic_rng(); + let (ir, _, _, _) = process_bytecode_to_cfg( + COUNTER_DEPLOYMENT.trim(), + false, + COUNTER_RUNTIME.trim(), + false, + ) + .await + .unwrap(); + let entry = Vec::new(); + let first = choose_strong_permutation(&ir, &entry, &[], &original, &mut first_rng); + let second = choose_strong_permutation(&ir, &entry, &[], &original, &mut second_rng); + + assert_eq!(first, second, "same seed must select the same permutation"); + assert_ne!(first, original); + assert!(retained_order_len(&original, &first) <= original.len() / 2); + } + + #[test] + fn bit_parallel_lcs_is_exact_on_repeated_bytes() { + assert_eq!(lcs_len(b"abcabcaa", b"acbacba"), 5); + assert_eq!(lcs_len(b"", b"anything"), 0); + assert_eq!(lcs_len(b"same", b"same"), 4); + } + + #[test] + fn candidate_search_is_bounded_for_near_limit_runtime() { + assert_eq!(candidate_trial_budget(509), MAX_PERMUTATION_TRIALS); + assert_eq!(candidate_trial_budget(24_576), 4); + assert!((4..=MAX_PERMUTATION_TRIALS).contains(&candidate_trial_budget(8_192))); + } + + #[tokio::test] + async fn scoring_source_ignores_mutated_runtime_bounds() { + let (mut ir, _, _, _) = process_bytecode_to_cfg( + COUNTER_DEPLOYMENT.trim(), + false, + COUNTER_RUNTIME.trim(), + false, + ) + .await + .expect("counter bytecode should produce a CFG"); + let original = original_clean_runtime(&ir).expect("original clean runtime"); + assert_eq!(original.len(), ir.clean_report.clean_len); + + let (start, end) = ir.runtime_bounds.expect("runtime bounds"); + ir.runtime_bounds = Some((start, end + 17)); + assert_eq!( + original_clean_runtime(&ir).expect("source remains recoverable"), + original + ); } } diff --git a/crates/transforms/src/function_dispatcher/mod.rs b/crates/transforms/src/function_dispatcher/mod.rs index 27f8f30f..c445018b 100644 --- a/crates/transforms/src/function_dispatcher/mod.rs +++ b/crates/transforms/src/function_dispatcher/mod.rs @@ -12,20 +12,38 @@ use azoth_core::seed::Seed; use azoth_core::Opcode; use petgraph::graph::NodeIndex; use rand::rngs::StdRng; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use tracing::debug; +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +enum DispatcherStyle { + /// Replace selector literals without adding new control-flow or storage dependencies. + #[default] + SelectorOnly, + /// Retain the legacy multi-tier controller layout for explicit experimentation only. + ExperimentalMultiTier, +} + #[derive(Default)] pub struct FunctionDispatcher { cached_dispatcher: Option, seed: Option, + style: DispatcherStyle, } impl FunctionDispatcher { pub fn new() -> Self { + Self::default() + } + + /// Constructs the legacy multi-tier dispatcher transform. + /// + /// This mode adds controller, decoy, and storage-dependent blocks and is intentionally + /// excluded from the default path. It is retained only for explicit experimentation. + pub fn experimental_multi_tier() -> Self { Self { - cached_dispatcher: None, - seed: None, + style: DispatcherStyle::ExperimentalMultiTier, + ..Self::default() } } @@ -33,6 +51,22 @@ impl FunctionDispatcher { Self { cached_dispatcher: Some(dispatcher_info), seed: Some(seed), + ..Self::default() + } + } + + /// Constructs the legacy multi-tier dispatcher transform with precomputed detection data. + /// + /// Unlike [`Self::with_dispatcher_info_and_seed`], this opts into controller, decoy, and + /// storage-dependent blocks and should be used only for experiments. + pub fn experimental_multi_tier_with_dispatcher_info_and_seed( + dispatcher_info: DispatcherInfo, + seed: Seed, + ) -> Self { + Self { + cached_dispatcher: Some(dispatcher_info), + seed: Some(seed), + style: DispatcherStyle::ExperimentalMultiTier, } } @@ -77,6 +111,45 @@ impl FunctionDispatcher { } } + fn reject_ambiguous_selector_uses( + &self, + runtime: &[Instruction], + info: &DispatcherInfo, + ) -> Result<()> { + let dispatcher_pcs: HashSet<_> = info + .selectors + .iter() + .filter_map(|selector| runtime.get(selector.instruction_index)) + .map(|instruction| instruction.pc) + .collect(); + let selectors: HashSet<_> = info + .selectors + .iter() + .map(|selector| selector.selector) + .collect(); + + for instruction in runtime { + if dispatcher_pcs.contains(&instruction.pc) + || !matches!(instruction.op, Opcode::PUSH(4)) + { + continue; + } + let Some(immediate) = instruction.imm.as_deref() else { + continue; + }; + let Ok(value) = u32::from_str_radix(immediate, 16) else { + continue; + }; + if selectors.contains(&value) { + return Err(Error::Generic(format!( + "dispatcher: selector 0x{value:08x} is also used outside the dispatcher at pc 0x{:x}; self-call/interface dataflow is ambiguous", + instruction.pc + ))); + } + } + Ok(()) + } + pub(crate) fn apply_instruction_replacements( &self, ir: &mut CfgIrBundle, @@ -342,7 +415,7 @@ impl FunctionDispatcher { return Err(Error::Generic(format!( "dispatcher: instruction at pc {} not found in CFG", instruction.pc - ))) + ))); } }; @@ -378,6 +451,12 @@ impl Transform for FunctionDispatcher { "FunctionDispatcher" } + fn supports_gas_observation(&self) -> bool { + // SelectorOnly replaces PUSH4 immediates in place and leaves the exact + // executed opcode sequence unchanged. Multi-tier adds executed blocks. + self.style == DispatcherStyle::SelectorOnly + } + fn apply(&self, ir: &mut CfgIrBundle, rng: &mut StdRng) -> Result { let (runtime_instructions, index_by_pc) = self.collect_runtime_instructions(ir); if runtime_instructions.is_empty() { @@ -398,23 +477,12 @@ impl Transform for FunctionDispatcher { return Ok(false); } - let runtime_len = if let Some((start, end)) = ir.runtime_bounds { - end.saturating_sub(start) - } else { - runtime_instructions - .last() - .map(|instr| instr.pc + instr.byte_size()) - .unwrap_or(0) - }; - let selector_count = dispatcher_info.selectors.len(); - let lightweight_dispatcher = selector_count <= 2 || runtime_len <= 96; - - if lightweight_dispatcher { + if self.style == DispatcherStyle::SelectorOnly { debug!( - runtime_len, - selectors = selector_count, - "Using lightweight dispatcher obfuscation path" + selectors = dispatcher_info.selectors.len(), + "Using selector-only dispatcher obfuscation path" ); + self.reject_ambiguous_selector_uses(&runtime_instructions, &dispatcher_info)?; let preserve_bytes = HashMap::new(); let seed = self.seed.as_ref().ok_or_else(|| { Error::Generic("dispatcher: seed required for token mapping".into()) @@ -481,3 +549,83 @@ impl Transform for FunctionDispatcher { } } } + +#[cfg(test)] +mod tests { + use super::*; + use azoth_core::process_bytecode_to_cfg; + + const COUNTER_DEPLOYMENT: &str = + include_str!("../../../../tests/bytecode/counter/counter_deployment.hex"); + const COUNTER_RUNTIME: &str = + include_str!("../../../../tests/bytecode/counter/counter_runtime.hex"); + const FIXED_SEED: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + + #[test] + fn dispatcher_styles_require_explicit_multi_tier_opt_in() { + assert_eq!( + FunctionDispatcher::new().style, + DispatcherStyle::SelectorOnly + ); + assert_eq!( + FunctionDispatcher::experimental_multi_tier().style, + DispatcherStyle::ExperimentalMultiTier + ); + + let info = DispatcherInfo { + start_offset: 0, + end_offset: 0, + selectors: Vec::new(), + extraction_pattern: azoth_core::detection::ExtractionPattern::Standard, + }; + let seed = Seed::from_hex(FIXED_SEED).expect("valid fixed seed"); + assert_eq!( + FunctionDispatcher::with_dispatcher_info_and_seed(info.clone(), seed.clone()).style, + DispatcherStyle::SelectorOnly + ); + assert_eq!( + FunctionDispatcher::experimental_multi_tier_with_dispatcher_info_and_seed(info, seed) + .style, + DispatcherStyle::ExperimentalMultiTier + ); + } + + #[tokio::test] + async fn default_dispatcher_only_replaces_selectors_on_large_dispatcher() { + let deployment = COUNTER_DEPLOYMENT.trim(); + let runtime = COUNTER_RUNTIME.trim(); + let (mut ir, _, _, _) = process_bytecode_to_cfg(deployment, false, runtime, false) + .await + .expect("counter bytecode should produce a CFG"); + + let detector = FunctionDispatcher::new(); + let (runtime_instructions, _) = detector.collect_runtime_instructions(&ir); + let info = detect_function_dispatcher(&runtime_instructions) + .expect("counter runtime should contain a dispatcher"); + assert!( + info.selectors.len() > 2, + "fixture must exercise the former multi-tier heuristic" + ); + + let original_node_count = ir.cfg.node_count(); + let original_runtime_bounds = ir.runtime_bounds; + let seed = Seed::from_hex(FIXED_SEED).expect("valid fixed seed"); + let mut rng = seed.create_deterministic_rng(); + let dispatcher = FunctionDispatcher::with_dispatcher_info_and_seed(info.clone(), seed); + + assert!(dispatcher + .apply(&mut ir, &mut rng) + .expect("selector-only dispatcher transform should succeed")); + assert_eq!(ir.cfg.node_count(), original_node_count); + assert_eq!(ir.runtime_bounds, original_runtime_bounds); + assert!(ir.dispatcher_controller_pcs.is_none()); + assert!(ir.dispatcher_patches.is_none()); + assert!(ir.stub_patches.is_none()); + assert!(ir.decoy_patches.is_none()); + assert!(ir.controller_patches.is_none()); + assert_eq!( + ir.selector_mapping.as_ref().map(HashMap::len), + Some(info.selectors.len()) + ); + } +} diff --git a/crates/transforms/src/function_dispatcher/token.rs b/crates/transforms/src/function_dispatcher/token.rs index 5d10cca6..58feb915 100644 --- a/crates/transforms/src/function_dispatcher/token.rs +++ b/crates/transforms/src/function_dispatcher/token.rs @@ -30,7 +30,11 @@ pub fn generate_selector_token_mapping( let secret: [u8; 32] = hasher.finalize().into(); let mut mapping = HashMap::with_capacity(selectors.len()); - let mut used_tokens = HashSet::with_capacity(selectors.len()); + // Reserve the entire original selector set before deriving any token. Otherwise a token for + // one function can equal another function's original selector and make legacy calldata route + // to the wrong implementation. + let mut used_tokens: HashSet = + selectors.iter().map(|selector| selector.selector).collect(); // First validate all byte indices for (selector_val, &byte_index) in preserve_bytes.iter() { @@ -237,7 +241,9 @@ mod tests { let unique_tokens: HashSet<_> = token_values.iter().collect(); assert_eq!(unique_tokens.len(), token_values.len()); - // Check no token matches its original selector + // Check no token matches any original selector + let original_selectors: HashSet<_> = + selectors.iter().map(|selector| selector.selector).collect(); for (selector, token_bytes) in &mapping { let token = u32::from_be_bytes([ token_bytes[0], @@ -245,10 +251,69 @@ mod tests { token_bytes[2], token_bytes[3], ]); - assert_ne!(token, *selector); + assert!( + !original_selectors.contains(&token), + "Token 0x{token:08x} for selector 0x{selector:08x} collides with an original selector" + ); } } + #[test] + fn test_tokens_reserve_all_original_selectors() { + let seed = Seed::from_bytes([0x42; 32]); + let first_selector = FunctionSelector { + selector: 0xa9059cbb, + instruction_index: 0, + target_address: 0x100, + }; + let preserve_bytes = HashMap::new(); + + // Discover the first selector's otherwise-valid token, then include that value as a + // second original selector. Generation must retry rather than reuse the reserved value. + let initial = generate_selector_token_mapping( + std::slice::from_ref(&first_selector), + &seed, + &preserve_bytes, + ) + .expect("initial token generation should succeed"); + let initial_token = initial[&first_selector.selector].as_slice(); + let colliding_selector = u32::from_be_bytes( + initial_token + .try_into() + .expect("selector tokens are exactly four bytes"), + ); + let selectors = vec![ + first_selector, + FunctionSelector { + selector: colliding_selector, + instruction_index: 10, + target_address: 0x200, + }, + ]; + + let mapping = generate_selector_token_mapping(&selectors, &seed, &preserve_bytes) + .expect("generation should retry reserved-selector collisions"); + let originals: HashSet<_> = selectors.iter().map(|selector| selector.selector).collect(); + + for token in mapping.values() { + let value = u32::from_be_bytes( + token + .as_slice() + .try_into() + .expect("selector tokens are exactly four bytes"), + ); + assert!( + !originals.contains(&value), + "generated token 0x{value:08x} must not equal any original selector" + ); + } + assert_ne!( + mapping[&selectors[0].selector], + colliding_selector.to_be_bytes(), + "the first candidate was deliberately reserved by the second selector" + ); + } + #[test] fn test_empty_selectors() { let selectors = vec![]; diff --git a/crates/transforms/src/jump_trampoline.rs b/crates/transforms/src/jump_trampoline.rs new file mode 100644 index 00000000..1569a4b5 --- /dev/null +++ b/crates/transforms/src/jump_trampoline.rs @@ -0,0 +1,490 @@ +//! Low-density, compiler-shaped control-flow topology diversification. +//! +//! This pass reroutes a small seed-derived sample of existing symbolic runtime +//! jumps through ordinary `JUMPDEST; PUSH2; JUMP` forwarding blocks. The new +//! nodes change CFG topology without introducing dead branches, storage reads, +//! or environment-dependent predicates. The shape is deliberately one Solidity +//! and Yul already use for internal-function routing. + +use crate::{ + collect_protected_nodes, has_gas_observation, has_self_code_layout_semantics, Error, Result, + Transform, +}; +use azoth_core::cfg_ir::{Block, BlockBody, BlockControl, CfgIrBundle, JumpTarget}; +use azoth_core::decoder::Instruction; +use azoth_core::{is_terminal_opcode, Opcode}; +use petgraph::graph::NodeIndex; +use rand::rngs::StdRng; +use rand::seq::SliceRandom; +use rand::Rng; +use tracing::debug; + +/// Adds a small number of forwarding nodes to existing direct jump edges. +#[derive(Debug, Default)] +pub struct JumpTrampoline; + +impl JumpTrampoline { + #[must_use] + pub fn new() -> Self { + Self + } +} + +#[derive(Clone, Copy)] +struct Candidate { + source: NodeIndex, + target: NodeIndex, +} + +impl Transform for JumpTrampoline { + fn name(&self) -> &'static str { + "JumpTrampoline" + } + + fn supports_gas_observation(&self) -> bool { + // apply() detects any GAS opcode and returns no-change. + true + } + + fn apply(&self, ir: &mut CfgIrBundle, rng: &mut StdRng) -> Result { + if has_self_code_layout_semantics(ir) { + return Err(Error::Generic( + "jump trampolines do not support PC/CODESIZE/CODECOPY or EXTCODE* introspection" + .into(), + )); + } + if has_gas_observation(ir) { + debug!("JumpTrampoline skipped because runtime observes GAS"); + return Ok(false); + } + + let protected = collect_protected_nodes(ir); + let bounds = ir.runtime_bounds; + let mut candidates: Vec<_> = ir + .cfg + .node_indices() + .filter_map(|source| { + if protected.contains(&source) || ir.dispatcher_blocks.contains(&source.index()) { + return None; + } + let Block::Body(body) = &ir.cfg[source] else { + return None; + }; + if !bounds.is_none_or(|(start, end)| body.start_pc >= start && body.start_pc < end) + || !ends_in_wide_direct_jump(body) + { + return None; + } + let BlockControl::Jump { + target: JumpTarget::Block { node: target, .. }, + } = body.control + else { + return None; + }; + Some(Candidate { source, target }) + }) + .collect(); + + if candidates.is_empty() { + return Ok(false); + } + if runtime_tail_falls_off(ir, bounds) + && bounds.is_some_and(|(_, runtime_end)| runtime_end < ir.original_bytecode.len()) + { + // Runtime fallthrough continues into deployed metadata/padding. An + // inserted STOP would change that suffix's behavior, while appending + // a trampoline directly would execute it. Leave this pass unchanged; + // ClusterShuffle can still preserve the original suffix fallthrough. + debug!("JumpTrampoline skipped because runtime falls into a deployed suffix"); + return Ok(false); + } + candidates.shuffle(rng); + + // Keep the pass low-density: one forwarding node for small contracts, + // and at most three even for large contracts. Varying the count prevents + // a fixed per-contract cardinality signature. + let maximum = (1 + candidates.len() / 32).min(3); + let count = rng.random_range(1..=maximum.min(candidates.len())); + let selected = &candidates[..count]; + + let mut cursor = next_runtime_pc(ir, bounds); + // EOF behaves as an implicit STOP. Appending forwarding blocks would + // otherwise turn a formerly harmless falloff (including a JUMPI false + // path) into execution of the first trampoline. + let stop_barrier_added = append_end_of_code_stop_barrier(ir, bounds, &mut cursor)?; + for candidate in selected { + let trampoline_value = runtime_target_value(cursor, bounds); + let target_pc = match &ir.cfg[candidate.target] { + Block::Body(body) => body.start_pc, + Block::Entry | Block::Exit => continue, + }; + let target_value = runtime_target_value(target_pc, bounds); + if trampoline_value > u16::MAX as usize || target_value > u16::MAX as usize { + return Err(Error::SizeLimitExceeded); + } + + let trampoline = ir.add_block(Block::Body(BlockBody { + start_pc: cursor, + instructions: vec![ + Instruction { + pc: cursor, + op: Opcode::JUMPDEST, + imm: None, + }, + Instruction { + pc: cursor + 1, + op: Opcode::PUSH(2), + imm: Some(format!("{target_value:04x}")), + }, + Instruction { + pc: cursor + 4, + op: Opcode::JUMP, + imm: None, + }, + ], + max_stack: 1, + control: BlockControl::Unknown, + })); + // `add_block` intentionally does not infer a PC mapping. Registering + // the fresh label lets the symbolic CFG helper resolve the redirect. + ir.pc_to_block.insert(cursor, trampoline); + cursor += 5; + if let Some((start, _)) = bounds { + ir.runtime_bounds = Some((start, cursor)); + } + + // Both rewrites are symbolic and therefore participate in the normal + // post-layout jump relocation instead of embedding stale raw PCs. + ir.set_unconditional_jump(trampoline, candidate.target) + .map_err(|error| Error::CoreError(error.to_string()))?; + write_direct_jump_immediate(ir, candidate.source, trampoline_value)?; + ir.set_unconditional_jump(candidate.source, trampoline) + .map_err(|error| Error::CoreError(error.to_string()))?; + } + + debug!( + count, + stop_barrier_added, "JumpTrampoline added forwarding CFG nodes" + ); + Ok(true) + } +} + +fn append_end_of_code_stop_barrier( + ir: &mut CfgIrBundle, + bounds: Option<(usize, usize)>, + cursor: &mut usize, +) -> Result { + let tail = runtime_tail(ir, bounds); + let Some(tail) = tail else { + return Ok(false); + }; + if !runtime_tail_falls_off(ir, bounds) { + return Ok(false); + } + + let stop_pc = *cursor; + let barrier = ir.add_block(Block::Body(BlockBody { + start_pc: stop_pc, + instructions: vec![Instruction { + pc: stop_pc, + op: Opcode::STOP, + imm: None, + }], + max_stack: 0, + control: BlockControl::Terminal, + })); + ir.pc_to_block.insert(stop_pc, barrier); + *cursor += 1; + if let Some((start, _)) = bounds { + ir.runtime_bounds = Some((start, *cursor)); + } + // Rebuild after registering the barrier so a concrete fallthrough or a + // JUMPI false edge points to it. Keeping JUMPI as the tail's final + // instruction is essential for subsequent symbolic relocation. + ir.rebuild_edges_for_block(tail) + .map_err(|error| Error::CoreError(error.to_string()))?; + ir.rebuild_edges_for_block(barrier) + .map_err(|error| Error::CoreError(error.to_string()))?; + Ok(true) +} + +fn runtime_tail(ir: &CfgIrBundle, bounds: Option<(usize, usize)>) -> Option { + ir.cfg + .node_indices() + .filter_map(|node| match &ir.cfg[node] { + Block::Body(body) + if bounds + .is_none_or(|(start, end)| body.start_pc >= start && body.start_pc < end) => + { + Some((body.start_pc, node)) + } + Block::Entry | Block::Exit | Block::Body(_) => None, + }) + .max_by_key(|(start_pc, _)| *start_pc) + .map(|(_, node)| node) +} + +fn runtime_tail_falls_off(ir: &CfgIrBundle, bounds: Option<(usize, usize)>) -> bool { + runtime_tail(ir, bounds).is_some_and(|tail| matches!(&ir.cfg[tail], Block::Body(body) if body.instructions.last().is_none_or(|instruction| { + matches!(instruction.op, Opcode::JUMPI) + || (!matches!(instruction.op, Opcode::JUMP) + && !is_terminal_opcode(instruction.op)) + }))) +} + +fn runtime_target_value(pc: usize, bounds: Option<(usize, usize)>) -> usize { + bounds.map_or(pc, |(start, _)| pc.saturating_sub(start)) +} + +fn write_direct_jump_immediate( + ir: &mut CfgIrBundle, + source: NodeIndex, + value: usize, +) -> Result<()> { + let Some(Block::Body(body)) = ir.cfg.node_weight_mut(source) else { + return Err(Error::Generic("jump source is not a body block".into())); + }; + let Some(instruction) = body.instructions.iter_mut().rev().nth(1) else { + return Err(Error::Generic( + "direct jump source has no target PUSH".into(), + )); + }; + let Opcode::PUSH(width) = instruction.op else { + return Err(Error::Generic( + "direct jump source target is not a PUSH".into(), + )); + }; + let digits = width as usize * 2; + if digits < std::mem::size_of::() * 2 && value >= (1usize << (digits * 4)) { + return Err(Error::SizeLimitExceeded); + } + instruction.imm = Some(format!("{value:0digits$x}")); + Ok(()) +} + +fn ends_in_wide_direct_jump(body: &BlockBody) -> bool { + matches!( + body.instructions.as_slice(), + [.., Instruction { op: Opcode::PUSH(width), .. }, Instruction { op: Opcode::JUMP, .. }] + if *width >= 2 + ) +} + +fn next_runtime_pc(ir: &CfgIrBundle, bounds: Option<(usize, usize)>) -> usize { + let runtime_start = bounds.map(|(start, _)| start).unwrap_or(0); + let runtime_len: usize = ir + .cfg + .node_indices() + .filter_map(|node| match &ir.cfg[node] { + Block::Body(body) + if bounds + .is_none_or(|(start, end)| body.start_pc >= start && body.start_pc < end) => + { + Some( + body.instructions + .iter() + .map(Instruction::byte_size) + .sum::(), + ) + } + _ => None, + }) + .sum(); + runtime_start + runtime_len +} + +#[cfg(test)] +mod tests { + use super::*; + use azoth_core::process_bytecode_to_cfg; + use azoth_core::seed::Seed; + use petgraph::visit::EdgeRef; + + const COUNTER_DEPLOYMENT: &str = + include_str!("../../../tests/bytecode/counter/counter_deployment.hex"); + const COUNTER_RUNTIME: &str = + include_str!("../../../tests/bytecode/counter/counter_runtime.hex"); + const TAIL_FALLOFF_DEPLOYMENT: &str = "0x600b600a5f39600b5ff3610008565bfe5bfe5b6002"; + const TAIL_FALLOFF_RUNTIME: &str = "0x610008565bfe5bfe5b6002"; + const JUMPI_TAIL_DEPLOYMENT: &str = "0x600c600a5f39600c5ff3610006565b005b6000600657"; + const JUMPI_TAIL_RUNTIME: &str = "0x610006565b005b6000600657"; + const SUFFIX_FALLOFF_DEPLOYMENT: &str = "0x600e600a5f39600e5ff3610008565bfe5bfe5b6002fe0001"; + const SUFFIX_FALLOFF_RUNTIME: &str = "0x610008565bfe5bfe5b6002fe0001"; + const GAS_DEPLOYMENT: &str = "0x6008600a5f3960085ff3610004565b5a5000"; + const GAS_RUNTIME: &str = "0x610004565b5a5000"; + + #[tokio::test] + async fn adds_real_forwarding_nodes_and_edges() { + let (mut ir, _, _, _) = process_bytecode_to_cfg( + COUNTER_DEPLOYMENT.trim(), + false, + COUNTER_RUNTIME.trim(), + false, + ) + .await + .unwrap(); + let original_nodes = ir.cfg.node_count(); + let seed = Seed::from_bytes([0x42; 32]); + let mut rng = seed.create_deterministic_rng(); + + assert!(JumpTrampoline::new().apply(&mut ir, &mut rng).unwrap()); + assert!(ir.cfg.node_count() > original_nodes); + assert!(ir.cfg.node_indices().any(|node| { + let Block::Body(body) = &ir.cfg[node] else { + return false; + }; + matches!( + body.instructions.as_slice(), + [ + Instruction { + op: Opcode::JUMPDEST, + .. + }, + Instruction { + op: Opcode::PUSH(2), + .. + }, + Instruction { + op: Opcode::JUMP, + .. + } + ] + ) && ir.cfg.edges(node).any(|edge| edge.target() != node) + })); + } + + #[tokio::test] + async fn inserts_stop_before_trampolines_when_the_original_tail_falls_off() { + let (mut ir, _, _, _) = + process_bytecode_to_cfg(TAIL_FALLOFF_DEPLOYMENT, false, TAIL_FALLOFF_RUNTIME, false) + .await + .expect("tail fixture should produce a CFG"); + let (_, original_runtime_end) = ir.runtime_bounds.expect("runtime bounds"); + let original_tail = ir + .cfg + .node_indices() + .filter_map(|node| match &ir.cfg[node] { + Block::Body(body) => Some((body.start_pc, node)), + Block::Entry | Block::Exit => None, + }) + .max_by_key(|(pc, _)| *pc) + .expect("runtime tail") + .1; + let seed = Seed::from_bytes([0x42; 32]); + let mut rng = seed.create_deterministic_rng(); + + assert!(JumpTrampoline::new().apply(&mut ir, &mut rng).unwrap()); + let Block::Body(tail) = &ir.cfg[original_tail] else { + unreachable!() + }; + assert!(matches!( + tail.instructions.last(), + Some(Instruction { + op: Opcode::PUSH(1), + .. + }) + )); + assert!(ir.cfg.node_indices().any(|node| { + matches!(&ir.cfg[node], Block::Body(body) + if body.start_pc == original_runtime_end + && matches!(body.instructions.as_slice(), [Instruction { pc, op: Opcode::STOP, .. }] if *pc == original_runtime_end)) + })); + assert!(ir.cfg.node_indices().any(|node| { + matches!(&ir.cfg[node], Block::Body(body) + if body.start_pc == original_runtime_end + 1 + && matches!(body.instructions.first(), Some(Instruction { op: Opcode::JUMPDEST, .. }))) + })); + assert_eq!( + ir.runtime_bounds, + Some(( + ir.runtime_bounds.expect("runtime bounds").0, + original_runtime_end + 6 + )) + ); + } + + #[tokio::test] + async fn inserts_stop_for_a_jumpi_false_path_at_original_eof() { + let (mut ir, _, _, _) = + process_bytecode_to_cfg(JUMPI_TAIL_DEPLOYMENT, false, JUMPI_TAIL_RUNTIME, false) + .await + .expect("JUMPI-tail fixture should produce a CFG"); + let (_, original_runtime_end) = ir.runtime_bounds.expect("runtime bounds"); + let tail = ir + .cfg + .node_indices() + .filter_map(|node| match &ir.cfg[node] { + Block::Body(body) => Some((body.start_pc, node)), + Block::Entry | Block::Exit => None, + }) + .max_by_key(|(pc, _)| *pc) + .expect("runtime tail") + .1; + assert!(matches!( + &ir.cfg[tail], + Block::Body(body) + if matches!(body.instructions.last(), Some(Instruction { op: Opcode::JUMPI, .. })) + )); + let seed = Seed::from_bytes([0x24; 32]); + let mut rng = seed.create_deterministic_rng(); + + assert!(JumpTrampoline::new().apply(&mut ir, &mut rng).unwrap()); + let Block::Body(body) = &ir.cfg[tail] else { + unreachable!() + }; + assert!(matches!( + body.instructions.last(), + Some(Instruction { + op: Opcode::JUMPI, + .. + }) + )); + let barrier = ir + .cfg + .node_indices() + .find(|node| { + matches!(&ir.cfg[*node], Block::Body(barrier) + if barrier.start_pc == original_runtime_end + && matches!(barrier.instructions.as_slice(), [Instruction { op: Opcode::STOP, .. }])) + }) + .expect("explicit EOF STOP barrier"); + assert!(ir.cfg.edges(tail).any(|edge| { + edge.target() == barrier + && matches!(edge.weight(), azoth_core::cfg_ir::EdgeType::BranchFalse) + })); + } + + #[tokio::test] + async fn skips_when_tail_fallthrough_executes_a_deployed_suffix() { + let (mut ir, _, _, _) = process_bytecode_to_cfg( + SUFFIX_FALLOFF_DEPLOYMENT, + false, + SUFFIX_FALLOFF_RUNTIME, + false, + ) + .await + .expect("suffix fixture should produce a CFG"); + let before = ir.clone(); + let seed = Seed::from_bytes([0x42; 32]); + let mut rng = seed.create_deterministic_rng(); + + assert!(!JumpTrampoline::new().apply(&mut ir, &mut rng).unwrap()); + assert_eq!(ir.cfg.node_count(), before.cfg.node_count()); + assert_eq!(ir.runtime_bounds, before.runtime_bounds); + } + + #[tokio::test] + async fn skips_every_runtime_that_observes_gas() { + let (mut ir, _, _, _) = process_bytecode_to_cfg(GAS_DEPLOYMENT, false, GAS_RUNTIME, false) + .await + .expect("GAS fixture should produce a CFG"); + let before = ir.clone(); + let seed = Seed::from_bytes([0x42; 32]); + let mut rng = seed.create_deterministic_rng(); + + assert!(!JumpTrampoline::new().apply(&mut ir, &mut rng).unwrap()); + assert_eq!(ir.cfg.node_count(), before.cfg.node_count()); + assert_eq!(ir.runtime_bounds, before.runtime_bounds); + } +} diff --git a/crates/transforms/src/lib.rs b/crates/transforms/src/lib.rs index bba9e70f..6c30a706 100644 --- a/crates/transforms/src/lib.rs +++ b/crates/transforms/src/lib.rs @@ -3,6 +3,9 @@ pub mod cluster_shuffle; pub mod constructor_args; pub mod function_dispatcher; pub mod jump_address_transformer; +pub mod jump_trampoline; +pub mod literal_synthesis; +pub mod metadata; pub mod obfuscator; pub mod opaque_predicate; pub mod push_split; @@ -50,10 +53,67 @@ pub type Result = std::result::Result; pub trait Transform: Send + Sync { /// Returns the transform's name for logging and identification. fn name(&self) -> &'static str; + /// Whether applying this pass to a runtime containing `GAS` is sound. + /// + /// The default is fail-closed. A pass may return true when it is exactly + /// runtime-gas neutral or when it detects `GAS` and commits no mutation. + fn supports_gas_observation(&self) -> bool { + false + } /// Applies the transform to the CFG IR, returning whether changes were made. fn apply(&self, ir: &mut CfgIrBundle, rng: &mut StdRng) -> Result; } +/// Whether the runtime may observe its physical code layout. +/// +/// Azoth does not yet have typed relocation records for embedded runtime data. +/// Layout-changing transforms must therefore fail closed on these opcodes rather +/// than guessing which numeric literals are code/data offsets. External-code +/// introspection is also rejected conservatively: an `EXTCODE*` target may be +/// `ADDRESS`, an alias of the current contract, or otherwise resolve to it through +/// stack computation. +pub fn has_self_code_layout_semantics(ir: &CfgIrBundle) -> bool { + let bounds = ir.runtime_bounds; + ir.cfg.node_indices().any(|node| match &ir.cfg[node] { + azoth_core::cfg_ir::Block::Body(body) + if bounds.is_none_or(|(start, end)| body.start_pc >= start && body.start_pc < end) => + { + body.instructions.iter().any(|instruction| { + matches!( + instruction.op, + Opcode::PC + | Opcode::CODESIZE + | Opcode::CODECOPY + | Opcode::EXTCODESIZE + | Opcode::EXTCODECOPY + | Opcode::EXTCODEHASH + ) + }) + } + _ => false, + }) +} + +/// Whether the runtime reads remaining gas. +/// +/// Even the compiler-standard `GAS; CALL` sequence is observable: adding a +/// trampoline before it changes the amount forwarded after EIP-150 rounding and +/// can change callee behavior. Passes that add executed instructions must skip +/// such runtimes unless they can prove an exact gas relation. +pub fn has_gas_observation(ir: &CfgIrBundle) -> bool { + let bounds = ir.runtime_bounds; + ir.cfg.node_indices().any(|node| match &ir.cfg[node] { + azoth_core::cfg_ir::Block::Body(body) + if bounds.is_none_or(|(start, end)| body.start_pc >= start && body.start_pc < end) => + { + body.instructions + .iter() + .any(|instruction| matches!(instruction.op, Opcode::GAS)) + } + _ => false, + }) +} + /// Parses a PUSH opcode string and returns the corresponding Opcode enum and immediate size. /// /// This helper function centralizes the parsing of PUSH opcodes (PUSH1-PUSH32) and eliminates diff --git a/crates/transforms/src/literal_synthesis.rs b/crates/transforms/src/literal_synthesis.rs new file mode 100644 index 00000000..c9009155 --- /dev/null +++ b/crates/transforms/src/literal_synthesis.rs @@ -0,0 +1,507 @@ +//! Seed-varied, semantics-preserving synthesis of literal constants. +//! +//! Solidity bytecode contains a large amount of semantic information in `PUSH` +//! immediates: addresses, event topics, masks, bounds, and revert constants. This +//! pass replaces a budgeted subset of those literals with one of several exact +//! stack expressions. Unlike the historical string pass, every emitted expression +//! evaluates to the original 256-bit value and therefore preserves returndata and +//! revert bytes. +//! +//! The pass deliberately excludes dispatcher metadata, recognized jump addresses, +//! blocks which introspect their own code layout, and wide all-zero placeholders +//! used by Solidity immutables. These exclusions are conservative: an unchanged +//! literal is preferable to corrupting a relocation that Azoth cannot prove safe. + +use crate::{ + collect_protected_nodes, collect_protected_pcs, has_gas_observation, + has_self_code_layout_semantics, Error, Result, Transform, +}; +use azoth_core::cfg_ir::{Block, CfgIrBundle}; +use azoth_core::decoder::Instruction; +use azoth_core::Opcode; +use petgraph::graph::NodeIndex; +use rand::rngs::StdRng; +use rand::seq::SliceRandom; +use rand::{Rng, RngCore}; +use std::collections::{HashMap, HashSet}; +use tracing::debug; + +/// Maximum code growth attributable to literal synthesis. +const MAX_GROWTH_RATIO: f64 = 0.45; + +/// Probability that an otherwise eligible literal enters the candidate set. +const TRANSFORM_PROBABILITY: f64 = 0.20; + +/// Rewrites literal pushes as exact, seed-varied stack expressions. +#[derive(Debug, Default)] +pub struct LiteralSynthesis; + +impl LiteralSynthesis { + /// Creates the literal-synthesis pass with the production growth budget. + #[must_use] + pub fn new() -> Self { + Self + } +} + +#[derive(Clone, Copy, Debug)] +enum Variant { + Xor2, + Xor3, + ShiftOr, +} + +#[derive(Clone, Debug)] +struct Candidate { + node: NodeIndex, + index: usize, + width: u8, + value: Vec, +} + +impl Transform for LiteralSynthesis { + fn name(&self) -> &'static str { + "LiteralSynthesis" + } + + fn apply(&self, ir: &mut CfgIrBundle, rng: &mut StdRng) -> Result { + if has_self_code_layout_semantics(ir) { + return Err(Error::Generic( + "literal synthesis does not support PC/CODESIZE/CODECOPY or EXTCODE* introspection" + .into(), + )); + } + if has_gas_observation(ir) { + return Err(Error::Generic( + "literal synthesis does not support runtimes that observe GAS".into(), + )); + } + let protected_pcs = collect_protected_pcs(ir); + let protected_nodes = collect_protected_nodes(ir); + let jump_values = collect_jump_values(ir); + let runtime_bounds = ir.runtime_bounds; + + let mut nodes: Vec<_> = ir.cfg.node_indices().collect(); + nodes.sort_by_key(|node| match &ir.cfg[*node] { + Block::Body(body) => body.start_pc, + Block::Entry | Block::Exit => usize::MAX, + }); + + let runtime_size: usize = nodes + .iter() + .filter_map(|node| match &ir.cfg[*node] { + Block::Body(body) if in_runtime(body.start_pc, runtime_bounds) => Some( + body.instructions + .iter() + .map(Instruction::byte_size) + .sum::(), + ), + _ => None, + }) + .sum(); + let growth_budget = ((runtime_size as f64) * MAX_GROWTH_RATIO).floor() as usize; + if growth_budget == 0 { + return Ok(false); + } + + let mut candidates = Vec::new(); + for node in nodes { + if protected_nodes.contains(&node) || ir.dispatcher_blocks.contains(&node.index()) { + continue; + } + let Some(Block::Body(body)) = ir.cfg.node_weight(node) else { + continue; + }; + if !in_runtime(body.start_pc, runtime_bounds) + || code_layout_sensitive(body) + || body.max_stack >= 1024 + { + continue; + } + + for (index, instruction) in body.instructions.iter().enumerate() { + let Opcode::PUSH(width) = instruction.op else { + continue; + }; + if protected_pcs.contains(&instruction.pc) + || is_jump_operand(&body.instructions, index) + || rng.random_bool(1.0 - TRANSFORM_PROBABILITY) + { + continue; + } + let Some(value) = decode_immediate(instruction, width) else { + continue; + }; + if jump_values.contains(&value_to_usize(&value)) { + continue; + } + // Solidity immutable placeholders are commonly emitted as a wide + // all-zero PUSH whose bytes are overwritten by init code. + if width >= 20 && value.iter().all(|byte| *byte == 0) { + continue; + } + candidates.push(Candidate { + node, + index, + width, + value, + }); + } + } + + if candidates.is_empty() { + return Ok(false); + } + candidates.shuffle(rng); + + let mut fresh_pc = ir + .cfg + .node_indices() + .filter_map(|node| match &ir.cfg[node] { + Block::Body(body) => body + .instructions + .last() + .map(|instruction| instruction.pc + instruction.byte_size()), + Block::Entry | Block::Exit => None, + }) + .max() + .unwrap_or(0) + .saturating_add(1); + + let mut selected: HashMap>> = HashMap::new(); + let mut growth = 0usize; + for candidate in candidates { + let variant = choose_variant(candidate.width, rng); + let synthesis_width = choose_synthesis_width(candidate.width, variant, rng); + let estimated_growth = variant_growth(candidate.width, synthesis_width, variant); + if growth + estimated_growth > growth_budget { + continue; + } + let original_pc = match &ir.cfg[candidate.node] { + Block::Body(body) => body.instructions[candidate.index].pc, + Block::Entry | Block::Exit => continue, + }; + let replacement = synthesize( + &candidate.value, + candidate.width, + synthesis_width, + variant, + original_pc, + &mut fresh_pc, + rng, + ); + growth += replacement + .iter() + .map(Instruction::byte_size) + .sum::() + .saturating_sub(candidate.width as usize + 1); + selected + .entry(candidate.node) + .or_default() + .insert(candidate.index, replacement); + } + + if selected.is_empty() { + return Ok(false); + } + + let mut changed = false; + for (node, replacements) in selected { + let Some(Block::Body(body)) = ir.cfg.node_weight(node) else { + continue; + }; + let mut rewritten = + Vec::with_capacity(body.instructions.len() + replacements.len() * 3); + for (index, instruction) in body.instructions.iter().enumerate() { + if let Some(replacement) = replacements.get(&index) { + rewritten.extend(replacement.iter().cloned()); + } else { + rewritten.push(instruction.clone()); + } + } + let mut new_body = body.clone(); + new_body.instructions = rewritten; + // XOR/SHL/OR synthesis temporarily needs at most one more stack slot + // than the literal it replaces. + new_body.max_stack = new_body.max_stack.saturating_add(1); + ir.overwrite_block(node, new_body) + .map_err(|error| Error::CoreError(error.to_string()))?; + changed = true; + } + + debug!(growth, growth_budget, "LiteralSynthesis applied"); + Ok(changed) + } +} + +fn in_runtime(pc: usize, bounds: Option<(usize, usize)>) -> bool { + bounds.is_none_or(|(start, end)| pc >= start && pc < end) +} + +fn code_layout_sensitive(body: &azoth_core::cfg_ir::BlockBody) -> bool { + body.instructions.iter().any(|instruction| { + matches!( + instruction.op, + Opcode::PC | Opcode::CODESIZE | Opcode::CODECOPY | Opcode::EXTCODECOPY + ) + }) +} + +fn collect_jump_values(ir: &CfgIrBundle) -> HashSet> { + let runtime_start = ir.runtime_bounds.map(|(start, _)| start).unwrap_or(0); + let mut values = HashSet::new(); + for node in ir.cfg.node_indices() { + let Some(Block::Body(body)) = ir.cfg.node_weight(node) else { + continue; + }; + if body + .instructions + .first() + .is_some_and(|instruction| matches!(instruction.op, Opcode::JUMPDEST)) + { + values.insert(Some(body.start_pc)); + values.insert(Some(body.start_pc.saturating_sub(runtime_start))); + } + } + values +} + +fn is_jump_operand(instructions: &[Instruction], index: usize) -> bool { + let end = (index + 6).min(instructions.len()); + instructions[index + 1..end] + .iter() + .any(|instruction| matches!(instruction.op, Opcode::JUMP | Opcode::JUMPI)) +} + +fn decode_immediate(instruction: &Instruction, width: u8) -> Option> { + let mut bytes = hex::decode(instruction.imm.as_deref()?).ok()?; + let width = width as usize; + if bytes.len() > width { + return None; + } + if bytes.len() < width { + let mut padded = vec![0; width - bytes.len()]; + padded.append(&mut bytes); + return Some(padded); + } + Some(bytes) +} + +fn value_to_usize(bytes: &[u8]) -> Option { + if bytes.len() > std::mem::size_of::() { + let prefix = &bytes[..bytes.len() - std::mem::size_of::()]; + if prefix.iter().any(|byte| *byte != 0) { + return None; + } + } + Some( + bytes[bytes.len().saturating_sub(std::mem::size_of::())..] + .iter() + .fold(0usize, |value, byte| (value << 8) | *byte as usize), + ) +} + +fn choose_variant(width: u8, rng: &mut StdRng) -> Variant { + if width >= 2 { + match rng.random_range(0..3) { + 0 => Variant::Xor2, + 1 => Variant::Xor3, + _ => Variant::ShiftOr, + } + } else if rng.random_bool(0.65) { + Variant::Xor2 + } else { + Variant::Xor3 + } +} + +/// XOR operands may be wider than the source PUSH. Their randomized high bytes +/// cancel exactly, while changing both the literal bytes and the PUSH opcode +/// family. This avoids leaving a strong compiler-shaped opcode skeleton behind. +fn choose_synthesis_width(width: u8, variant: Variant, rng: &mut StdRng) -> u8 { + if matches!(variant, Variant::ShiftOr) || width == 32 { + return width; + } + rng.random_range(width + 1..=width.saturating_add(4).min(32)) +} + +fn variant_growth(original_width: u8, synthesis_width: u8, variant: Variant) -> usize { + match variant { + Variant::Xor2 => synthesis_width as usize * 2 - original_width as usize + 2, + Variant::Xor3 => synthesis_width as usize * 3 - original_width as usize + 4, + Variant::ShiftOr => 6, + } +} + +fn synthesize( + value: &[u8], + original_width: u8, + synthesis_width: u8, + variant: Variant, + original_pc: usize, + fresh_pc: &mut usize, + rng: &mut StdRng, +) -> Vec { + debug_assert!(synthesis_width >= original_width); + let mut target = vec![0; synthesis_width as usize - value.len()]; + target.extend_from_slice(value); + match variant { + Variant::Xor2 => { + let first = random_part(&target, rng); + let second: Vec<_> = first + .iter() + .zip(&target) + .map(|(left, right)| left ^ right) + .collect(); + vec![ + push(synthesis_width, first, original_pc), + push(synthesis_width, second, take_pc(fresh_pc)), + op(Opcode::XOR, take_pc(fresh_pc)), + ] + } + Variant::Xor3 => { + let first = random_part(&target, rng); + let second = random_part(&target, rng); + let third: Vec<_> = first + .iter() + .zip(&second) + .zip(&target) + .map(|((a, b), target)| a ^ b ^ target) + .collect(); + vec![ + push(synthesis_width, first, original_pc), + push(synthesis_width, second, take_pc(fresh_pc)), + op(Opcode::XOR, take_pc(fresh_pc)), + push(synthesis_width, third, take_pc(fresh_pc)), + op(Opcode::XOR, take_pc(fresh_pc)), + ] + } + Variant::ShiftOr => { + let split = rng.random_range(1..original_width as usize); + let high = value[..split].to_vec(); + let low = value[split..].to_vec(); + let shift = ((original_width as usize - split) * 8) as u8; + vec![ + push(split as u8, high, original_pc), + push(1, vec![shift], take_pc(fresh_pc)), + op(Opcode::SHL, take_pc(fresh_pc)), + push( + (original_width as usize - split) as u8, + low, + take_pc(fresh_pc), + ), + op(Opcode::OR, take_pc(fresh_pc)), + ] + } + } +} + +fn random_part(value: &[u8], rng: &mut StdRng) -> Vec { + let mut part = vec![0u8; value.len()]; + for _ in 0..8 { + rng.fill_bytes(&mut part); + if part.first().copied().unwrap_or(0) != 0 + && part + .first() + .zip(value.first()) + .is_some_and(|(left, right)| left ^ right != 0) + { + break; + } + } + part +} + +fn take_pc(next: &mut usize) -> usize { + let pc = *next; + *next = next.saturating_add(1); + pc +} + +fn push(width: u8, bytes: Vec, pc: usize) -> Instruction { + Instruction { + pc, + op: Opcode::PUSH(width), + imm: Some(hex::encode(bytes)), + } +} + +fn op(opcode: Opcode, pc: usize) -> Instruction { + Instruction { + pc, + op: opcode, + imm: None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use azoth_core::seed::Seed; + + const SEED: &str = "0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + + fn evaluate(sequence: &[Instruction]) -> Vec { + let mut stack: Vec<[u8; 32]> = Vec::new(); + for instruction in sequence { + match instruction.op { + Opcode::PUSH(width) => { + let bytes = hex::decode(instruction.imm.as_deref().unwrap()).unwrap(); + let mut word = [0u8; 32]; + word[32 - width as usize..].copy_from_slice(&bytes); + stack.push(word); + } + Opcode::XOR | Opcode::OR => { + let right = stack.pop().unwrap(); + let left = stack.pop().unwrap(); + let mut word = [0u8; 32]; + for i in 0..32 { + word[i] = if matches!(instruction.op, Opcode::XOR) { + left[i] ^ right[i] + } else { + left[i] | right[i] + }; + } + stack.push(word); + } + Opcode::SHL => { + let shift = stack.pop().unwrap()[31] as usize; + let value = stack.pop().unwrap(); + let mut word = [0u8; 32]; + let byte_shift = shift / 8; + let retained = 32usize.saturating_sub(byte_shift); + word[..retained].copy_from_slice(&value[byte_shift..byte_shift + retained]); + stack.push(word); + } + _ => panic!("unexpected opcode"), + } + } + stack.pop().unwrap().to_vec() + } + + #[test] + fn every_variant_reconstructs_the_exact_word() { + let value = hex::decode("1122334455667788").unwrap(); + let seed = Seed::from_hex(SEED).unwrap(); + let mut rng = seed.create_deterministic_rng(); + for variant in [Variant::Xor2, Variant::Xor3, Variant::ShiftOr] { + let mut fresh = 1_000; + let synthesis_width = if matches!(variant, Variant::ShiftOr) { + 8 + } else { + 12 + }; + let sequence = synthesize( + &value, + 8, + synthesis_width, + variant, + 10, + &mut fresh, + &mut rng, + ); + assert_eq!(&evaluate(&sequence)[24..], value); + assert!(evaluate(&sequence)[..24].iter().all(|byte| *byte == 0)); + } + } +} diff --git a/crates/transforms/src/metadata.rs b/crates/transforms/src/metadata.rs new file mode 100644 index 00000000..fa009dfb --- /dev/null +++ b/crates/transforms/src/metadata.rs @@ -0,0 +1,99 @@ +//! Solidity metadata diversification. +//! +//! Reusing the compiler-emitted IPFS/Swarm digest across every seed creates a +//! perfect family-level linking tag. This helper deterministically replaces only +//! the content digest while retaining the original CBOR envelope and compiler +//! version. The result remains structurally ordinary Solidity metadata, but its +//! content address is intentionally non-resolving and must not be presented as a +//! source-verification pointer. + +use azoth_core::detection::SectionKind; +use azoth_core::strip::CleanReport; +use sha3::{Digest, Keccak256}; + +/// Diversifies recognized metadata content hashes in-place. +/// +/// Returns the number of 32-byte digests replaced. Unknown metadata layouts are +/// left untouched so malformed CBOR is never emitted. +pub fn diversify_metadata(report: &mut CleanReport, seed: &[u8; 32]) -> usize { + let mut changed = 0usize; + for removed in &mut report.removed { + if !matches!(removed.kind, SectionKind::Auxdata) { + continue; + } + let mut data = removed.data.to_vec(); + let original = data.clone(); + + let digest_start = find_ipfs_digest(&data).or_else(|| find_swarm_digest(&data)); + let Some(start) = digest_start else { + continue; + }; + if start + 32 > data.len().saturating_sub(2) { + continue; + } + + let mut hasher = Keccak256::new(); + hasher.update(b"AZOTH_METADATA_CONTENT_DIGEST_V1"); + hasher.update(seed); + hasher.update(&original); + let digest: [u8; 32] = hasher.finalize().into(); + data[start..start + 32].copy_from_slice(&digest); + removed.data = data.into(); + changed += 1; + } + changed +} + +fn find_ipfs_digest(data: &[u8]) -> Option { + // Solidity encodes an IPFS CIDv0 multihash as bytes(34): 0x12 0x20 . + data.windows(2) + .position(|window| window == [0x12, 0x20]) + .map(|index| index + 2) +} + +fn find_swarm_digest(data: &[u8]) -> Option { + let marker = data.windows(4).position(|window| window == b"bzzr")?; + data[marker + 4..] + .windows(2) + .position(|window| window == [0x58, 0x20]) + .map(|relative| marker + 4 + relative + 2) +} + +#[cfg(test)] +mod tests { + use super::*; + use azoth_core::strip::{CleanReport, Removed}; + use revm::primitives::{Bytes, B256}; + + fn report(auxdata: Vec) -> CleanReport { + CleanReport { + runtime_layout: Vec::new(), + removed: vec![Removed { + offset: 0, + kind: SectionKind::Auxdata, + data: Bytes::from(auxdata), + }], + swarm_hash: None, + bytes_saved: 0, + clean_len: 1, + clean_keccak: B256::ZERO, + program_counter_mapping: Vec::new(), + } + } + + #[test] + fn changes_only_ipfs_digest_and_is_deterministic() { + let mut aux = vec![0xa2, 0x64, b'i', b'p', b'f', b's', 0x58, 0x22, 0x12, 0x20]; + aux.extend([0x44; 32]); + aux.extend([0x64, b's', b'o', b'l', b'c', 0x43, 0, 8, 30, 0, 51]); + let mut first = report(aux.clone()); + let mut second = report(aux.clone()); + let seed = [7u8; 32]; + assert_eq!(diversify_metadata(&mut first, &seed), 1); + assert_eq!(diversify_metadata(&mut second, &seed), 1); + assert_eq!(first.removed[0].data, second.removed[0].data); + assert_eq!(&first.removed[0].data[..10], &aux[..10]); + assert_eq!(&first.removed[0].data[42..], &aux[42..]); + assert_ne!(&first.removed[0].data[10..42], &aux[10..42]); + } +} diff --git a/crates/transforms/src/obfuscator.rs b/crates/transforms/src/obfuscator.rs index e363c518..cecae444 100644 --- a/crates/transforms/src/obfuscator.rs +++ b/crates/transforms/src/obfuscator.rs @@ -1,10 +1,9 @@ -use crate::arithmetic_chain::ArithmeticChain; +use crate::cluster_shuffle::ClusterShuffle; use crate::constructor_args::obfuscate_constructor_args; use crate::function_dispatcher::FunctionDispatcher; -use crate::push_split::PushSplit; -use crate::slot_shuffle::SlotShuffle; -use crate::string_obfuscate::StringObfuscate; -use crate::Transform; +use crate::jump_trampoline::JumpTrampoline; +use crate::metadata::diversify_metadata; +use crate::{has_gas_observation, has_self_code_layout_semantics, Transform}; use azoth_core::seed::Seed; use azoth_core::{ cfg_ir::{self, snapshot_bundle_with_runtime, Block, CfgIrDiff, OperationKind, TraceEvent}, @@ -58,7 +57,7 @@ impl ObfuscationConfig { pub fn with_seed(seed: Seed) -> Self { Self { seed, - transforms: Vec::new(), + transforms: production_transforms(), preserve_unknown_opcodes: true, } } @@ -68,17 +67,19 @@ impl Default for ObfuscationConfig { fn default() -> Self { Self { seed: Seed::generate(), - transforms: vec![ - Box::new(ArithmeticChain::new()), - Box::new(PushSplit::new()), - Box::new(SlotShuffle::new()), - Box::new(StringObfuscate::new()), - ], + transforms: production_transforms(), preserve_unknown_opcodes: true, } } } +fn production_transforms() -> Vec> { + vec![ + Box::new(JumpTrampoline::new()), + Box::new(ClusterShuffle::new()), + ] +} + impl std::fmt::Debug for ObfuscationConfig { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("ObfuscationConfig") @@ -102,6 +103,12 @@ pub struct ObfuscationResult { pub original_size: usize, /// Obfuscated bytecode size in bytes pub obfuscated_size: usize, + /// Original deployed runtime size, including compiler auxdata and padding. + #[serde(default)] + pub original_runtime_size: usize, + /// Obfuscated deployed runtime size, including compiler auxdata and padding. + #[serde(default)] + pub obfuscated_runtime_size: usize, /// Size increase as percentage pub size_increase_percentage: f64, /// Number of unknown opcodes preserved @@ -125,8 +132,17 @@ pub struct ObfuscationResult { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ObfuscationMetadata { + /// Exact generation seed required to reproduce this variant. + #[serde(default)] + pub generation_seed: String, /// Names of transforms that were applied pub transforms_applied: Vec, + /// Every pass that was attempted, including passes which safely made no change. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub transforms_attempted: Vec, + /// Transactional outcome and committed structural delta for each attempted pass. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub transform_outcomes: Vec, /// Whether the size limit was exceeded pub size_limit_exceeded: bool, /// Whether unknown opcodes were preserved @@ -140,6 +156,22 @@ pub struct ObfuscationMetadata { /// Number of seed-varied decoder bytes inserted into init code. #[serde(default)] pub constructor_decoder_bytes: usize, + /// Number of compiler metadata content digests diversified for this seed. + #[serde(default)] + pub metadata_digests_diversified: usize, +} + +/// Truthful result of one transactionally executed transform. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct TransformOutcome { + /// Transform name. + pub name: String, + /// `applied` or `no_change`. + pub status: String, + /// Committed body-block count delta. + pub blocks_delta: i32, + /// Committed instruction count delta. + pub instructions_delta: i32, } /// Main obfuscation pipeline @@ -164,9 +196,32 @@ pub async fn obfuscate_bytecode( tracing::debug!(" Input size: {} bytes", original_size); // Step 2: Analyze instructions for unknown opcodes - let (total_instructions, unknown_count, unknown_types) = analyze_instructions(&instructions); + let executable_instructions: Vec<_> = instructions + .iter() + .filter(|instruction| { + sections.iter().any(|section| { + matches!( + section.kind, + detection::SectionKind::Init | detection::SectionKind::Runtime + ) && instruction.pc >= section.offset + && instruction.pc < section.offset + section.len + }) + }) + .cloned() + .collect(); + let (total_instructions, unknown_count, unknown_types) = + analyze_instructions(&executable_instructions, &bytes); tracing::debug!(" Total instructions: {}", total_instructions); tracing::debug!(" Unknown opcodes: {}", unknown_count); + if unknown_count > 0 && !config.preserve_unknown_opcodes { + return Err(ObfuscationError::from_err( + format!( + "input contains {unknown_count} decoder-unknown opcode(s) ({}) but preserve_unknown_opcodes is disabled", + unknown_types.join(", ") + ), + &cfg_ir.trace, + )); + } // Log section info tracing::debug!( @@ -181,6 +236,18 @@ pub async fn obfuscate_bytecode( " Bytes saved by stripping: {}", cfg_ir.clean_report.bytes_saved ); + let original_runtime_size: usize = sections + .iter() + .filter(|section| { + matches!( + section.kind, + detection::SectionKind::Runtime + | detection::SectionKind::Auxdata + | detection::SectionKind::Padding + ) + }) + .map(|section| section.len) + .sum(); // Track initial metrics let original_block_count = cfg_ir.cfg.node_count(); @@ -219,8 +286,6 @@ pub async fn obfuscate_bytecode( detection::detect_function_dispatcher(&instructions) }; - let has_dispatcher = dispatcher_info.is_some(); - // Store dispatcher info in bundle for snapshot/TUI visualization cfg_ir.dispatcher_info = dispatcher_info.clone(); @@ -239,34 +304,51 @@ pub async fn obfuscate_bytecode( ); } - let user_transform_names: Vec = config - .transforms - .iter() - .map(|t| t.name().to_string()) - .collect(); - // Add user-specified transforms (this moves config.transforms) all_transforms.extend(config.transforms); - // Track which transforms were applied (including the mandatory ones if dispatcher exists) - let mut transforms_applied: Vec = Vec::new(); - if has_dispatcher { - transforms_applied.push("FunctionDispatcher".to_string()); + // Runtime code-layout introspection needs typed data/code relocation and alias + // analysis which this IR cannot yet prove. EXTCODE* is conservatively included + // because its target may be self. + let self_code_sensitive = has_self_code_layout_semantics(&cfg_ir); + if self_code_sensitive && !all_transforms.is_empty() { + return Err(ObfuscationError::from_err( + "runtime uses PC/CODESIZE/CODECOPY/EXTCODESIZE/EXTCODECOPY/EXTCODEHASH; typed relocation or self-address alias analysis is not supported", + &cfg_ir.trace, + )); + } + if let Some(transform) = has_gas_observation(&cfg_ir) + .then(|| { + all_transforms + .iter() + .find(|transform| !transform.supports_gas_observation()) + }) + .flatten() + { + return Err(ObfuscationError::from_err( + format!( + "transform {} may add executed gas before a GAS observation; only gas-neutral selector replacement/layout shuffling and the self-skipping JumpTrampoline are supported", + transform.name() + ), + &cfg_ir.trace, + )); } - transforms_applied.extend(user_transform_names); + + // Track only transforms whose changes were transactionally committed. + let mut transforms_applied: Vec = Vec::new(); + let mut transforms_attempted: Vec = Vec::new(); + let mut transform_outcomes: Vec = Vec::new(); // Track individual transform effects let mut transform_change_log = Vec::new(); let mut any_transform_changed = false; if !all_transforms.is_empty() { - // Create deterministic RNG from cryptographic seed - let mut shared_rng = config.seed.create_deterministic_rng(); - tracing::debug!("Applying {} transforms", all_transforms.len(),); for (i, transform) in all_transforms.iter().enumerate() { let transform_name = transform.name(); + transforms_attempted.push(transform_name.to_string()); let pre_instruction_count = count_instructions_in_cfg(&cfg_ir); let pre_block_count = cfg_ir.cfg.node_count(); @@ -278,29 +360,47 @@ pub async fn obfuscate_bytecode( pre_instruction_count ); - // Record transform start for trace grouping - cfg_ir.record_transform_start(transform_name); - - // Apply transform with deterministic RNG - let transform_changed = match transform.apply(&mut cfg_ir, &mut shared_rng) { - Ok(changed) => { - tracing::debug!(" Result: changed={}", changed); - changed - } - Err(e) => { - tracing::error!(" Transform {} failed: {}", transform_name, e); - false - } + // Execute against a clone. Errors abort the whole pipeline and a + // false/no-change result cannot leak partial mutations. + let mut candidate = cfg_ir.clone(); + candidate.record_transform_start(transform_name); + let domain = format!("{transform_name}:{i}"); + let mut transform_rng = config.seed.create_domain_rng(domain.as_bytes()); + let transform_changed = transform + .apply(&mut candidate, &mut transform_rng) + .map_err(|error| { + ObfuscationError::from_err( + format!("transform {transform_name} failed: {error}"), + &candidate.trace, + ) + })?; + candidate.record_transform_end(transform_name); + + let (post_instruction_count, post_block_count) = if transform_changed { + let counts = ( + count_instructions_in_cfg(&candidate), + candidate.cfg.node_count(), + ); + cfg_ir = candidate; + transforms_applied.push(transform_name.to_string()); + counts + } else { + (pre_instruction_count, pre_block_count) }; - - // Record transform end for trace grouping - cfg_ir.record_transform_end(transform_name); - - let post_instruction_count = count_instructions_in_cfg(&cfg_ir); - let post_block_count = cfg_ir.cfg.node_count(); let instructions_delta = post_instruction_count as i32 - pre_instruction_count as i32; let blocks_delta = post_block_count as i32 - pre_block_count as i32; + transform_outcomes.push(TransformOutcome { + name: transform_name.to_string(), + status: if transform_changed { + "applied".to_string() + } else { + "no_change".to_string() + }, + blocks_delta, + instructions_delta, + }); + transform_change_log.push(format!( "{transform_name}: changed={transform_changed}, blocks_delta={blocks_delta:+}, instructions_delta={instructions_delta:+}", )); @@ -342,6 +442,14 @@ pub async fn obfuscate_bytecode( tracing::debug!(" {}", log_entry); } + // Build typed relocation evidence while instruction PCs still refer to the + // original layout. This follows stack-carried Solidity return addresses + // across CFG edges and rejects unresolved dynamic jumps or mixed data/address + // uses instead of guessing from numeric equality with a JUMPDEST. + let proven_jump_pushes = cfg_ir + .prove_jump_address_pushes() + .map_err(|error| ObfuscationError::from_err(error, &cfg_ir.trace))?; + // Capture old instruction layout before reindexing (needed for immutable ref patching). // For each runtime instruction, record (old_pc, byte_size) so we can build a byte-level // displacement map after reindex_pcs remaps instruction PCs. @@ -373,16 +481,35 @@ pub async fn obfuscate_bytecode( .map_err(|e| ObfuscationError::from_err(e, &cfg_ir.trace))?; tracing::debug!(" PC reindexing complete: {} mappings", pc_mapping.len()); + // PUSH0 can encode only runtime-relative target zero. It is safe only + // while the original runtime entry remains the transformed runtime entry; + // unlike PUSH1 0x00, there is no immediate to rewrite. + if proven_jump_pushes.uses_push0_target { + let old_runtime_start = old_runtime_bounds.map(|(start, _)| start).unwrap_or(0); + let new_runtime_start = cfg_ir.runtime_bounds.map(|(start, _)| start).unwrap_or(0); + let relocated_entry = pc_mapping.get(&old_runtime_start).copied().ok_or_else(|| { + ObfuscationError::from_err( + "PUSH0 jump target has no runtime-entry relocation", + &cfg_ir.trace, + ) + })?; + if relocated_entry != new_runtime_start { + return Err(ObfuscationError::from_err( + "PUSH0 jump target cannot be relocated away from runtime-relative PC zero", + &cfg_ir.trace, + )); + } + } + // Patch jump immediates using the PC mapping cfg_ir .patch_jump_immediates(&pc_mapping, old_runtime_bounds) .map_err(|e| ObfuscationError::from_err(e, &cfg_ir.trace))?; tracing::debug!(" Patched jump immediates after PC reindexing"); - // Remap orphan jump-address PUSHes (e.g. return addresses for internal function calls) - // that are not part of any recognized jump pattern. + // Remap only PUSHes proven to flow exclusively into JUMP/JUMPI targets. cfg_ir - .remap_orphan_jump_pushes(&pc_mapping, old_runtime_bounds) + .remap_proven_jump_pushes(&proven_jump_pushes.pushes, &pc_mapping, old_runtime_bounds) .map_err(|e| ObfuscationError::from_err(e, &cfg_ir.trace))?; // Re-apply dispatcher jump target patches with OLD controller PCs (before updating) @@ -633,11 +760,29 @@ pub async fn obfuscate_bytecode( ); } - // Step 7b: Patch immutable reference offsets in init code. - // When transforms grow the runtime (e.g., PushSplit), the init code's hardcoded byte - // offsets for writing immutable variables become stale. Build a byte-level displacement - // map from the old instruction layout and pc_mapping, then patch the init code. + // Step 7b: Patch compiler-proven immutable reference offsets in init code. + // Build a byte-level displacement map, but only apply it to exact Solidity + // PUSH32-placeholder/ADD/MSTORE sites in the runtime CODECOPY/RETURN region. { + let mut original_clean_runtime = Vec::with_capacity(cfg_ir.clean_report.clean_len); + for span in &cfg_ir.clean_report.runtime_layout { + let end = span.offset.checked_add(span.len).ok_or_else(|| { + ObfuscationError::from_err("original runtime span overflowed", &cfg_ir.trace) + })?; + let source = bytes.get(span.offset..end).ok_or_else(|| { + ObfuscationError::from_err( + "original runtime span is outside deployment bytecode", + &cfg_ir.trace, + ) + })?; + original_clean_runtime.extend_from_slice(source); + } + if original_clean_runtime.len() != cfg_ir.clean_report.clean_len { + return Err(ObfuscationError::from_err( + "original clean-runtime length disagrees with strip report", + &cfg_ir.trace, + )); + } let new_runtime_start = cfg_ir.runtime_bounds.map(|(s, _)| s).unwrap_or(0); // Build byte-level remap: for each byte in the old runtime, compute where it lands // in the new runtime. We build a sorted list of (old_rel_offset, new_rel_offset) for @@ -674,14 +819,16 @@ pub async fn obfuscate_bytecode( } }; - if let Err(e) = cfg_ir.clean_report.patch_init_immutable_refs(&remap) { - tracing::warn!("Failed to patch init immutable refs: {}", e); - } + cfg_ir + .clean_report + .patch_init_immutable_refs(&remap, &original_clean_runtime) + .map_err(|error| ObfuscationError::from_err(error, &cfg_ir.trace))?; } // Step 7c: Mask an exact constructor-argument suffix and inject a seed-varied decoder. // This runs after init immutable patching so its insertion can remap all existing init jumps // once. It fails closed when arguments exist but their copy site is unsupported. + transforms_attempted.push("ConstructorArgs".to_string()); let constructor_args = obfuscate_constructor_args(&mut cfg_ir.clean_report, config.seed.as_bytes()) .map_err(|e| ObfuscationError::from_err(e, &cfg_ir.trace))?; @@ -693,6 +840,39 @@ pub async fn obfuscate_bytecode( constructor_args.decoder_bytes ); } + transform_outcomes.push(TransformOutcome { + name: "ConstructorArgs".to_string(), + status: if constructor_args.applied { + "applied".to_string() + } else { + "no_change".to_string() + }, + blocks_delta: 0, + instructions_delta: 0, + }); + + // Preserve the ordinary Solidity CBOR envelope and compiler marker while + // removing the source-linked digest shared by every seed. Self-code-aware + // runtimes were rejected above, so this cannot affect runtime CODECOPY data. + transforms_attempted.push("MetadataDigest".to_string()); + let metadata_digests_diversified = if self_code_sensitive { + 0 + } else { + diversify_metadata(&mut cfg_ir.clean_report, config.seed.as_bytes()) + }; + if metadata_digests_diversified > 0 { + transforms_applied.push("MetadataDigest".to_string()); + } + transform_outcomes.push(TransformOutcome { + name: "MetadataDigest".to_string(), + status: if metadata_digests_diversified > 0 { + "applied".to_string() + } else { + "no_change".to_string() + }, + blocks_delta: 0, + instructions_delta: 0, + }); // Step 8: Reassemble final bytecode (init + runtime with data section + auxdata) let final_bytecode = cfg_ir @@ -716,13 +896,15 @@ pub async fn obfuscate_bytecode( tracing::warn!(" Transform change flags: {:?}", transform_change_log); } - // Step 9: Detailed gas analysis + // Step 9: Account only for the byte-dependent portion of transaction calldata. + // This is not a deployment-gas estimate: the transaction base charge, create + // charge, init execution, code deposit, and EIP-3860 are separate. let original_zero_bytes = bytes.iter().filter(|&&b| b == 0).count(); let original_nonzero_bytes = bytes.len() - original_zero_bytes; let obfuscated_zero_bytes = final_bytecode.iter().filter(|&&b| b == 0).count(); let obfuscated_nonzero_bytes = final_bytecode.len() - obfuscated_zero_bytes; - tracing::debug!("Gas analysis breakdown:"); + tracing::debug!("Creation-input calldata byte-gas breakdown:"); tracing::debug!( " Original: {} zeros, {} non-zeros", original_zero_bytes, @@ -734,15 +916,25 @@ pub async fn obfuscate_bytecode( obfuscated_nonzero_bytes ); - let original_gas = - 21_000 + (original_zero_bytes as u64 * 4) + (original_nonzero_bytes as u64 * 16); - let obfuscated_gas = - 21_000 + (obfuscated_zero_bytes as u64 * 4) + (obfuscated_nonzero_bytes as u64 * 16); - let gas_delta = obfuscated_gas as i64 - original_gas as i64; + let original_calldata_byte_gas = + (original_zero_bytes as u64 * 4) + (original_nonzero_bytes as u64 * 16); + let obfuscated_calldata_byte_gas = + (obfuscated_zero_bytes as u64 * 4) + (obfuscated_nonzero_bytes as u64 * 16); + let calldata_byte_gas_delta = + obfuscated_calldata_byte_gas as i64 - original_calldata_byte_gas as i64; - tracing::debug!(" Original gas: {}", original_gas); - tracing::debug!(" Obfuscated gas: {}", obfuscated_gas); - tracing::debug!(" Gas delta: {:+}", gas_delta); + tracing::debug!( + " Original creation-input calldata byte gas: {}", + original_calldata_byte_gas + ); + tracing::debug!( + " Obfuscated creation-input calldata byte gas: {}", + obfuscated_calldata_byte_gas + ); + tracing::debug!( + " Creation-input calldata byte-gas delta: {:+}", + calldata_byte_gas_delta + ); // Step 10: Enforce protocol size limits. Constructor arguments are part of the creation // transaction's initcode for EIP-3860 accounting, while compiler auxdata is part of the @@ -802,6 +994,8 @@ pub async fn obfuscate_bytecode( obfuscated_runtime: format!("0x{}", hex::encode(&obfuscated_bytes)), original_size, obfuscated_size, + original_runtime_size, + obfuscated_runtime_size: deployed_runtime_size, size_increase_percentage, unknown_opcodes_count: unknown_count, unknown_opcode_types: unknown_types, @@ -809,12 +1003,16 @@ pub async fn obfuscate_bytecode( instructions_added, total_instructions, metadata: ObfuscationMetadata { + generation_seed: config.seed.to_hex(), transforms_applied, + transforms_attempted, + transform_outcomes, size_limit_exceeded, unknown_opcodes_preserved: config.preserve_unknown_opcodes, constructor_args_obfuscated: constructor_args.applied, constructor_argument_bytes: constructor_args.argument_bytes, constructor_decoder_bytes: constructor_args.decoder_bytes, + metadata_digests_diversified, }, selector_mapping: cfg_ir.selector_mapping, trace, @@ -822,23 +1020,31 @@ pub async fn obfuscate_bytecode( } /// Analyzes instructions to count unknown opcodes and provide feedback. -fn analyze_instructions(instructions: &[decoder::Instruction]) -> (usize, usize, Vec) { +fn analyze_instructions( + instructions: &[decoder::Instruction], + original_bytes: &[u8], +) -> (usize, usize, Vec) { let total_count = instructions.len(); let mut unknown_count = 0; let mut unknown_types = HashSet::new(); for instruction in instructions { - if matches!(instruction.op, Opcode::INVALID | Opcode::UNKNOWN(_)) { + // INVALID (0xfe) is a real Solidity opcode used for unreachable data and + // must not be reported as an unknown instruction. The decoder also uses + // INVALID as a placeholder for unrecognized disassembler output; compare + // the source byte to distinguish that marker from genuine 0xfe. + let is_unknown = matches!(instruction.op, Opcode::UNKNOWN(_)) + || (matches!(instruction.op, Opcode::INVALID) + && original_bytes.get(instruction.pc).copied() != Some(0xfe)); + if is_unknown { unknown_count += 1; unknown_types.insert(format!("{}", instruction.op)); } } - ( - total_count, - unknown_count, - unknown_types.into_iter().collect(), - ) + let mut unknown_types: Vec<_> = unknown_types.into_iter().collect(); + unknown_types.sort(); + (total_count, unknown_count, unknown_types) } /// Count instructions in CFG @@ -951,15 +1157,17 @@ pub fn print_obfuscation_analysis(result: &ObfuscationResult) { /// Creates a gas report from obfuscation results pub fn create_gas_report(result: &ObfuscationResult) -> serde_json::Value { - let gas = |bytes| 32_000 + 200 * bytes as u64; + let code_deposit_gas = |bytes| 200 * bytes as u64; json!({ "original_bytes": result.original_size, "obfuscated_bytes": result.obfuscated_size, "size_delta_bytes": (result.obfuscated_size as i64 - result.original_size as i64), - "original_deploy_gas": gas(result.original_size), - "obfuscated_deploy_gas": gas(result.obfuscated_size), - "gas_delta": (gas(result.obfuscated_size) as i64 - gas(result.original_size) as i64), + "original_runtime_bytes": result.original_runtime_size, + "obfuscated_runtime_bytes": result.obfuscated_runtime_size, + "original_code_deposit_gas": code_deposit_gas(result.original_runtime_size), + "obfuscated_code_deposit_gas": code_deposit_gas(result.obfuscated_runtime_size), + "code_deposit_gas_delta": (code_deposit_gas(result.obfuscated_runtime_size) as i64 - code_deposit_gas(result.original_runtime_size) as i64), "percent_size": result.size_increase_percentage, "unknown_opcodes_preserved": result.unknown_opcodes_count, "blocks_created": result.blocks_created, @@ -968,10 +1176,174 @@ pub fn create_gas_report(result: &ObfuscationResult) -> serde_json::Value { "constructor_args_obfuscated": result.metadata.constructor_args_obfuscated, "constructor_argument_bytes": result.metadata.constructor_argument_bytes, "constructor_decoder_bytes": result.metadata.constructor_decoder_bytes, - "notes": if result.unknown_opcodes_count > 0 { - "Unknown opcodes were preserved as raw bytes to maintain functionality" - } else { - "All opcodes were standard and successfully obfuscated" - } + "notes": "Code-deposit gas is exact for deployed bytes. Creation transaction calldata, EIP-3860 word cost, and init/runtime execution gas require an EVM measurement and are intentionally not fabricated here." }) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Error, Result}; + use rand::rngs::StdRng; + + const COUNTER_DEPLOYMENT: &str = + include_str!("../../../tests/bytecode/counter/counter_deployment.hex"); + const COUNTER_RUNTIME: &str = + include_str!("../../../tests/bytecode/counter/counter_runtime.hex"); + + struct MutateThenFalse; + + impl Transform for MutateThenFalse { + fn name(&self) -> &'static str { + "MutateThenFalse" + } + + fn apply(&self, ir: &mut cfg_ir::CfgIrBundle, _rng: &mut StdRng) -> Result { + for node in ir.cfg.node_indices().collect::>() { + if let Block::Body(body) = &mut ir.cfg[node] { + if let Some(instruction) = body.instructions.first_mut() { + instruction.op = Opcode::INVALID; + break; + } + } + } + Ok(false) + } + } + + struct MutateThenError; + + impl Transform for MutateThenError { + fn name(&self) -> &'static str { + "MutateThenError" + } + + fn apply(&self, ir: &mut cfg_ir::CfgIrBundle, _rng: &mut StdRng) -> Result { + for node in ir.cfg.node_indices().collect::>() { + if let Block::Body(body) = &mut ir.cfg[node] { + if let Some(instruction) = body.instructions.first_mut() { + instruction.op = Opcode::INVALID; + break; + } + } + } + Err(Error::Generic("intentional failure".into())) + } + } + + #[tokio::test] + async fn no_change_transform_cannot_leak_partial_mutation() { + let seed = Seed::from_bytes([0x42; 32]); + let mut baseline_config = ObfuscationConfig::with_seed(seed.clone()); + baseline_config.transforms.clear(); + let baseline = obfuscate_bytecode(COUNTER_DEPLOYMENT, COUNTER_RUNTIME, baseline_config) + .await + .unwrap(); + + let mut candidate_config = ObfuscationConfig::with_seed(seed); + candidate_config.transforms = vec![Box::new(MutateThenFalse)]; + let candidate = obfuscate_bytecode(COUNTER_DEPLOYMENT, COUNTER_RUNTIME, candidate_config) + .await + .unwrap(); + + assert_eq!(candidate.obfuscated_bytecode, baseline.obfuscated_bytecode); + assert!(!candidate + .metadata + .transforms_applied + .contains(&"MutateThenFalse".to_string())); + assert!(candidate + .metadata + .transform_outcomes + .iter() + .any(|outcome| { outcome.name == "MutateThenFalse" && outcome.status == "no_change" })); + } + + #[tokio::test] + async fn transform_error_aborts_the_pipeline() { + let mut config = ObfuscationConfig::with_seed(Seed::from_bytes([0x24; 32])); + config.transforms = vec![Box::new(MutateThenError)]; + let error = obfuscate_bytecode(COUNTER_DEPLOYMENT, COUNTER_RUNTIME, config) + .await + .unwrap_err(); + assert!(error.message.contains("MutateThenError")); + assert!(error.message.contains("intentional failure")); + } + + #[tokio::test] + async fn default_pipeline_rejects_external_code_introspection_that_may_target_self() { + // The runtime jumps to ADDRESS; EXTCODESIZE and returns the observed size. + // A layout-changing transform would change that result, so the production + // pipeline must reject the input before applying any transform. + let fixtures = [ + ( + "EXTCODESIZE", + "0x6011600a5f3960115ff361000856000000005b303b5f5260205ff3", + "0x61000856000000005b303b5f5260205ff3", + ), + ( + "EXTCODECOPY", + "0x6011600a5f3960115ff361000856000000005b303c5f5260205ff3", + "0x61000856000000005b303c5f5260205ff3", + ), + ( + "EXTCODEHASH", + "0x6011600a5f3960115ff361000856000000005b303f5f5260205ff3", + "0x61000856000000005b303f5f5260205ff3", + ), + ]; + + for (opcode, deployment, runtime) in fixtures { + let config = ObfuscationConfig::with_seed(Seed::from_bytes([0x73; 32])); + let error = obfuscate_bytecode(deployment, runtime, config) + .await + .expect_err("external-code introspection must fail closed"); + + assert!(error.message.contains(opcode), "{}", error.message); + assert!(error.message.contains("not supported"), "{}", error.message); + } + } + + #[test] + fn genuine_invalid_is_not_reported_as_an_unknown_opcode() { + let instructions = vec![ + decoder::Instruction { + pc: 0, + op: Opcode::INVALID, + imm: None, + }, + decoder::Instruction { + pc: 1, + op: Opcode::INVALID, + imm: None, + }, + decoder::Instruction { + pc: 2, + op: Opcode::UNKNOWN(0xaa), + imm: None, + }, + ]; + let (total, unknown, kinds) = analyze_instructions(&instructions, &[0xfe, 0xaa, 0xaa]); + + assert_eq!(total, 3); + assert_eq!(unknown, 2); + assert!(!kinds.is_empty()); + } + + #[tokio::test] + async fn preserve_unknown_opcodes_false_rejects_decoder_unknown_input() { + // Twelve-byte init wrapper returning the single raw runtime byte 0xaa. + let deployment = "0x6001600c60003960016000f3aa"; + let runtime = "0xaa"; + let mut config = ObfuscationConfig::with_seed(Seed::from_bytes([0x9a; 32])); + config.transforms.clear(); + config.preserve_unknown_opcodes = false; + + let error = obfuscate_bytecode(deployment, runtime, config) + .await + .expect_err("disabled unknown-opcode preservation must fail closed"); + assert!(error.message.contains("decoder-unknown opcode")); + assert!(error + .message + .contains("preserve_unknown_opcodes is disabled")); + } +} diff --git a/crates/transforms/src/string_obfuscate.rs b/crates/transforms/src/string_obfuscate.rs index d0fa5cba..8fb08a7e 100644 --- a/crates/transforms/src/string_obfuscate.rs +++ b/crates/transforms/src/string_obfuscate.rs @@ -1,4 +1,4 @@ -//! Obfuscate Error(string) revert literals by rewriting string data PUSH immediates. +//! Legacy Error(string) detector. //! //! This pass uses structural detection for ABI-encoded Error(string). //! It handles two selector patterns: @@ -9,16 +9,19 @@ //! 1. Absolute: `PUSH value ; PUSH offset ; MSTORE` //! 2. Relative: `PUSH value ; PUSH offset ; DUP3 ; ADD ; MSTORE` (base pointer on stack) -use crate::{collect_protected_pcs, Error, Result, Transform}; -use azoth_core::cfg_ir::{Block, CfgIrBundle}; +use crate::{Error, Result, Transform}; +use azoth_core::cfg_ir::CfgIrBundle; +#[cfg(test)] use azoth_core::decoder::Instruction; +#[cfg(test)] use azoth_core::Opcode; use rand::rngs::StdRng; -use rand::RngCore; +#[cfg(test)] use std::collections::HashMap; +#[cfg(test)] use tracing::debug; -/// Obfuscate Error(string) literals by scrambling the encoded string data. +/// Disabled legacy pass retained so old configurations fail explicitly. #[derive(Default)] pub struct StringObfuscate; @@ -33,65 +36,15 @@ impl Transform for StringObfuscate { "StringObfuscate" } - fn apply(&self, ir: &mut CfgIrBundle, rng: &mut StdRng) -> Result { - debug!("StringObfuscate: scanning for Error(string) literals"); - - let protected_pcs = collect_protected_pcs(ir); - let nodes: Vec<_> = ir.cfg.node_indices().collect(); - let mut changed = false; - - for node in nodes { - let Some(Block::Body(body)) = ir.cfg.node_weight(node) else { - continue; - }; - - let mut rewritten = body.instructions.clone(); - let mut block_changed = false; - - let data_push_indices = collect_error_string_data_pushes(&rewritten); - if data_push_indices.is_empty() { - continue; - } - - for idx in data_push_indices { - let instr = &rewritten[idx]; - if protected_pcs.contains(&instr.pc) { - continue; - } - let (width, mut bytes) = match parse_push_immediate(instr) { - Some(value) => value, - None => continue, - }; - if width == 0 { - continue; - } - - // Scramble the literal bytes in-place with a random mask. - for byte in &mut bytes { - *byte ^= (rng.next_u32() & 0xff) as u8; - } - rewritten[idx].imm = Some(hex::encode(&bytes)); - block_changed = true; - } - - if block_changed { - let mut new_body = body.clone(); - new_body.instructions = rewritten; - ir.overwrite_block(node, new_body) - .map_err(|e| Error::CoreError(e.to_string()))?; - changed = true; - } - } - - if changed { - debug!("StringObfuscate: obfuscated Error(string) literals"); - } else { - debug!("StringObfuscate: no eligible Error(string) literals found"); - } - Ok(changed) + fn apply(&self, _ir: &mut CfgIrBundle, _rng: &mut StdRng) -> Result { + Err(Error::Generic( + "StringObfuscate is disabled because scrambling Error(string) bytes changes revert semantics; use LiteralSynthesis" + .into(), + )) } } +#[cfg(test)] fn collect_error_string_data_pushes(instructions: &[Instruction]) -> Vec { // Try structural detection first if let Some(indices) = try_structural_detection(instructions) { @@ -117,6 +70,7 @@ fn collect_error_string_data_pushes(instructions: &[Instruction]) -> Vec } /// Structural detection: look for full Error(string) ABI pattern in block. +#[cfg(test)] fn try_structural_detection(instructions: &[Instruction]) -> Option> { // Find if this block has an Error(string) selector pattern. let _selector_idx = find_error_selector_index(instructions)?; @@ -172,6 +126,7 @@ fn try_structural_detection(instructions: &[Instruction]) -> Option> /// Heuristic detection: find PUSH instructions with high ASCII content. /// Used as fallback when structural detection fails (e.g., after block splitting). +#[cfg(test)] fn collect_ascii_string_pushes(instructions: &[Instruction]) -> Vec { let mut indices = Vec::new(); @@ -203,6 +158,7 @@ fn collect_ascii_string_pushes(instructions: &[Instruction]) -> Vec { /// Check if bytes look like ASCII string data. /// Returns true if the majority of non-null bytes are printable ASCII. +#[cfg(test)] fn is_likely_ascii_string(bytes: &[u8]) -> bool { // Find where content ends (before null padding) let content_end = bytes @@ -230,6 +186,7 @@ fn is_likely_ascii_string(bytes: &[u8]) -> bool { /// Find the index of an Error(string) selector in the instruction stream. /// Returns Some(idx) if found, None otherwise. +#[cfg(test)] fn find_error_selector_index(instructions: &[Instruction]) -> Option { for (idx, instr) in instructions.iter().enumerate() { // Pattern 1: Direct PUSH4 0x08c379a0 @@ -270,6 +227,7 @@ fn find_error_selector_index(instructions: &[Instruction]) -> Option { /// Collect MSTORE writes, handling both absolute and relative addressing. /// Returns a map of relative_offset -> (value_push_index, value_bytes). +#[cfg(test)] fn collect_mstore_writes(instructions: &[Instruction]) -> HashMap)> { let mut writes: HashMap)> = HashMap::new(); @@ -290,6 +248,7 @@ fn collect_mstore_writes(instructions: &[Instruction]) -> HashMap Option<(usize, Vec)> { match instr.op { Opcode::PUSH(width) => { @@ -374,6 +334,7 @@ fn parse_push_immediate(instr: &Instruction) -> Option<(usize, Vec)> { } } +#[cfg(test)] fn normalize_immediate(imm: &str, width: usize) -> Option> { let mut hex = imm.to_ascii_lowercase(); if !hex.len().is_multiple_of(2) { @@ -391,6 +352,7 @@ fn normalize_immediate(imm: &str, width: usize) -> Option> { Some(bytes) } +#[cfg(test)] fn parse_usize_be(bytes: &[u8]) -> Option { if bytes.is_empty() { return None; @@ -411,6 +373,7 @@ fn parse_usize_be(bytes: &[u8]) -> Option { Some(value) } +#[cfg(test)] fn is_error_selector(bytes: &[u8]) -> bool { const SELECTOR: [u8; 4] = [0x08, 0xc3, 0x79, 0xa0]; if bytes.len() < 4 { diff --git a/crates/verification/Cargo.toml b/crates/verification/Cargo.toml index e4b3bac0..f12154ab 100644 --- a/crates/verification/Cargo.toml +++ b/crates/verification/Cargo.toml @@ -3,6 +3,10 @@ name = "azoth-verification" version.workspace = true edition.workspace = true +[features] +default = [] +z3 = ["dep:z3"] + [dependencies] azoth-core.workspace = true serde.workspace = true @@ -13,7 +17,7 @@ tokio.workspace = true tracing.workspace = true hex.workspace = true sha3.workspace = true -z3.workspace = true +z3 = { workspace = true, optional = true } num-bigint.workspace = true indexmap.workspace = true petgraph.workspace = true diff --git a/crates/verification/README.md b/crates/verification/README.md index 7bcbc8e6..9fc47878 100644 --- a/crates/verification/README.md +++ b/crates/verification/README.md @@ -1,41 +1,29 @@ ## azoth-verification -This crate provides mathematical guarantees that obfuscated smart contracts behave identically to their original versions. When we obfuscate bytecode, we fundamentally alter its structure while preserving functionality. Formal verification uses mathematical proofs to ensure this preservation is complete and correct. +This crate is an experimental foundation for future equivalence verification. It does **not** currently provide a mathematical guarantee that original and obfuscated contracts behave identically. -Traditional testing can only verify specific cases, but smart contracts must handle infinite input combinations. A single undetected difference between original and obfuscated contracts could compromise security or functionality. Formal verification provides mathematical certainty that the contracts are equivalent for **all possible inputs**, not just tested ones. +## Current safety behavior -We use SMT-LIB (Satisfiability Modulo Theories) as our mathematical language to express contract properties, and Z3 theorem prover to automatically verify these properties. This serves as link between low-level bytecode and high-level mathematical reasoning. +- `FormalVerifier::prove_equivalence` returns `Error::Unsupported` until complete proof obligations and a sound EVM semantics exist. +- An empty set of proof statements is invalid. +- Unsupported or malformed SMT syntax is rejected rather than approximated as `true`. +- A solver `unknown` result is an error, never evidence of equivalence. +- Callers must not present `FormalProof` data structures as proof of equivalence unless they were produced by a future sound verifier. -Now our verification establishes four key equivalence properties: +## Optional Z3 backend -- Bisimulation -```smt -(assert (forall ((state State) (input Input)) - (= (execute-original state input) - (execute-obfuscated state input)))) -``` -For EVERY input and state, both contracts produce the same execution trace. +Z3 is optional and disabled by default, allowing the workspace to build without system Z3 headers or libraries. Enable the prototype backend explicitly: -- State Equivalence -```smt -(assert (forall ((initial-state State) (transaction Tx)) - (= (final-state (execute-original initial-state transaction)) - (final-state (execute-obfuscated initial-state transaction))))) +```bash +cargo test -p azoth-verification --features z3 ``` -After ANY transaction, the storage, balances, and contract state are identical between original and obfuscated versions. -- Property Preservation -```smt -(assert (forall ((s State)) - (and (access-control-original s) - (access-control-obfuscated s)))) -``` -ALL security properties satisfied by original are satisfied by obfuscated. +The backend currently accepts only a deliberately small assertion subset. Formula generation remains scaffolding and is not connected to a production equivalence claim. -- Gas Bounds -```smt -(assert (forall ((tx Transaction)) - (<= (gas-used (execute-obfuscated tx)) - (* 1.15 (gas-used (execute-original tx)))))) -``` -For ANY transaction, obfuscated version uses at most 15% more gas. +## Required work before verification can be enabled + +- A complete 256-bit EVM execution model with calls, logs, reverts, storage, balances, environmental inputs, and gas semantics. +- Relational handling for intentional selector and storage-layout mappings. +- Proofs formulated as the absence of a counterexample, with `unsat` required for success. +- Independent known-equivalent and known-inequivalent fixtures, including expected counterexamples. +- Resource limits and explicit failure on timeout or indeterminate solver status. diff --git a/crates/verification/src/lib.rs b/crates/verification/src/lib.rs index e0772301..414bc12e 100644 --- a/crates/verification/src/lib.rs +++ b/crates/verification/src/lib.rs @@ -1,7 +1,7 @@ -//! Azoth's Formal Verification Engine +//! Azoth's experimental formal verification API. //! -//! This crate provides formal guarantees that obfuscated contracts are functionally -//! equivalent to their original versions through formal verification using SMT solvers. +//! No production equivalence proof is implemented yet. Public verification entry points +//! fail closed with [`Error::Unsupported`] instead of returning placeholder success. pub mod proofs; pub mod properties; @@ -13,8 +13,6 @@ pub use proofs::{FormalProof, ProofStatement, ProofType}; pub use properties::{ArithmeticOperation, SecurityProperty}; pub use result::{Error, Result}; -use std::time::Instant; - /// Result type for verification operations (alias for backward compatibility) pub type VerificationResult = Result; @@ -36,188 +34,14 @@ impl FormalVerifier { /// Main entry point: prove that two contracts are equivalent pub async fn prove_equivalence( &mut self, - original_bytecode: &[u8], - original_runtime: &[u8], - obfuscated_bytecode: &[u8], - obfuscated_runtime: &[u8], - security_properties: &[SecurityProperty], + _original_bytecode: &[u8], + _original_runtime: &[u8], + _obfuscated_bytecode: &[u8], + _obfuscated_runtime: &[u8], + _security_properties: &[SecurityProperty], ) -> VerificationResult { - let start_time = Instant::now(); - - tracing::info!("Starting formal verification of contract equivalence"); - - // Parse both contracts into semantic representations - let original_semantics = - semantics::extract_semantics_from_bytecode(original_bytecode, original_runtime).await?; - let obfuscated_semantics = - semantics::extract_semantics_from_bytecode(obfuscated_bytecode, obfuscated_runtime) - .await?; - - tracing::debug!("Extracted semantics for both contracts"); - - // Generate proof statements - let mut statements = Vec::new(); - - // 1. Prove bisimulation (step-by-step equivalence) - if let Ok(bisim_statement) = self - .prove_bisimulation(&original_semantics, &obfuscated_semantics) - .await - { - statements.push(bisim_statement); - } - - // 2. Prove state equivalence - if let Ok(state_statement) = self - .prove_state_equivalence(&original_semantics, &obfuscated_semantics) - .await - { - statements.push(state_statement); - } - - // 3. Prove property preservation - for property in security_properties { - if let Ok(prop_statement) = self - .prove_property_preservation(&original_semantics, &obfuscated_semantics, property) - .await - { - statements.push(prop_statement); - } - } - - // 4. Prove gas bounds - if let Ok(gas_statement) = self - .prove_gas_bounds(&original_semantics, &obfuscated_semantics) - .await - { - statements.push(gas_statement); - } - - let proof_time = start_time.elapsed(); - let _statements_clone = statements.clone(); // Clone for hash computation - - let proof = FormalProof::new( - ProofType::Combined(vec![ - ProofType::Bisimulation, - ProofType::StateEquivalence, - ProofType::PropertyPreservation, - ProofType::GasBounds, - ]), - statements, - proof_time, - ); - - tracing::info!( - "Formal verification completed in {:.2}s, valid: {}", - proof_time.as_secs_f64(), - proof.valid - ); - - Ok(proof) - } - - /// Prove bisimulation: every execution step is equivalent - async fn prove_bisimulation( - &mut self, - _original: &semantics::ContractSemantics, - _obfuscated: &semantics::ContractSemantics, - ) -> VerificationResult { - let start_time = Instant::now(); - - tracing::debug!("Proving bisimulation between contracts"); - - // Create bisimulation assertion - let bisim_formula = "(assert (forall ((state State) (input Input)) - (= (execute-original state input) - (execute-obfuscated state input))))" - .to_string(); - - // TODO: Implement actual SMT verification - let proven = true; // Placeholder - let proof_time = start_time.elapsed(); - - Ok(ProofStatement::new( - "Bisimulation: Every execution step produces identical results".to_string(), - bisim_formula, - proven, - proof_time, - )) - } - - /// Prove state equivalence: final states are identical - async fn prove_state_equivalence( - &mut self, - _original: &semantics::ContractSemantics, - _obfuscated: &semantics::ContractSemantics, - ) -> VerificationResult { - let start_time = Instant::now(); - - tracing::debug!("Proving state equivalence between contracts"); - - let state_equiv_formula = "(assert (forall ((initial-state State) (transaction Tx)) - (= (final-state (execute-original initial-state transaction)) - (final-state (execute-obfuscated initial-state transaction)))))" - .to_string(); - - // TODO: Implement actual SMT verification - let proven = true; - let proof_time = start_time.elapsed(); - - Ok(ProofStatement::new( - "State Equivalence: Final contract states are identical".to_string(), - state_equiv_formula, - proven, - proof_time, - )) - } - - /// Prove that security properties are preserved - async fn prove_property_preservation( - &mut self, - _original: &semantics::ContractSemantics, - _obfuscated: &semantics::ContractSemantics, - property: &SecurityProperty, - ) -> VerificationResult { - let start_time = Instant::now(); - - let description = property.description(); - let formal_statement = property.to_smt_formula(); - - // TODO: Implement actual property verification - let proven = true; - let proof_time = start_time.elapsed(); - - Ok(ProofStatement::new( - description, - formal_statement, - proven, - proof_time, - )) - } - - /// Prove gas consumption bounds - async fn prove_gas_bounds( - &mut self, - _original: &semantics::ContractSemantics, - _obfuscated: &semantics::ContractSemantics, - ) -> VerificationResult { - let start_time = Instant::now(); - - tracing::debug!("Proving gas consumption bounds"); - - let gas_bound_formula = "(assert (forall ((input Input)) - (<= (gas-consumed (execute-obfuscated input)) - (* 1.15 (gas-consumed (execute-original input))))))" - .to_string(); - - // TODO: Implement actual gas bounds verification - let proven = true; - let proof_time = start_time.elapsed(); - - Ok(ProofStatement::new( - "Gas Bounds: Obfuscated contract uses at most 15% more gas".to_string(), - gas_bound_formula, - proven, - proof_time, + Err(Error::Unsupported( + "contract equivalence proofs are not implemented".to_string(), )) } } @@ -246,9 +70,18 @@ mod tests { #[tokio::test] async fn test_formal_verifier_creation() { let verifier = FormalVerifier::new(); + assert!(verifier.is_ok()); + } + + #[tokio::test] + async fn equivalence_verification_fails_closed_as_unsupported() { + let mut verifier = FormalVerifier::new().unwrap(); + let error = verifier + .prove_equivalence(&[], &[], &[], &[], &[]) + .await + .unwrap_err(); - // Should create successfully (even if SMT solver not available) - assert!(verifier.is_ok() || matches!(verifier.unwrap_err(), Error::SmtSolver(_))); + assert!(matches!(error, Error::Unsupported(_))); } #[test] diff --git a/crates/verification/src/proofs.rs b/crates/verification/src/proofs.rs index 4d3297a6..6b612717 100644 --- a/crates/verification/src/proofs.rs +++ b/crates/verification/src/proofs.rs @@ -4,7 +4,9 @@ use serde::{Deserialize, Serialize}; use sha3::{Digest, Sha3_256}; use std::time::Duration; -/// A formal mathematical proof of contract equivalence +/// A container for formal proof statements. +/// +/// Construction alone does not establish that the statements came from a sound verifier. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FormalProof { /// Type of proof generated @@ -13,13 +15,17 @@ pub struct FormalProof { pub statements: Vec, /// Time taken to generate the proof pub proof_time: Duration, - /// Whether the proof is valid + /// Whether the proof is valid. + /// + /// This field is always reset to `false` during deserialization. Serialized proof + /// metadata is not trusted as verification evidence. + #[serde(default, skip_deserializing)] pub valid: bool, /// Hash of the proof for integrity verification pub proof_hash: String, } -/// Types of formal proofs we can generate +/// Proof categories represented by verification metadata. #[derive(Debug, Clone, Serialize, Deserialize)] pub enum ProofType { /// Bisimulation proof showing step-by-step equivalence @@ -34,14 +40,14 @@ pub enum ProofType { Combined(Vec), } -/// A mathematical statement that has been proven +/// A candidate proof statement and its claimed result. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProofStatement { /// Human-readable description of what was proven pub description: String, /// Formal mathematical statement (in SMT-LIB format) pub formal_statement: String, - /// Whether this statement was successfully proven + /// Whether this statement was marked as successfully proven pub proven: bool, /// Time taken to prove this statement pub proof_time: Duration, @@ -54,7 +60,9 @@ impl FormalProof { statements: Vec, proof_time: Duration, ) -> Self { - let valid = statements.iter().all(|s| s.proven); + // `Iterator::all` is vacuously true for an empty iterator. A proof with no + // obligations is not evidence, so require at least one proven statement. + let valid = !statements.is_empty() && statements.iter().all(|s| s.proven); let proof_hash = Self::compute_hash(&statements); Self { @@ -100,14 +108,18 @@ impl FormalProof { let mut all_statements = Vec::new(); let mut total_time = Duration::default(); let mut proof_types = Vec::new(); + let mut all_inputs_valid = !proofs.is_empty(); for proof in proofs { + all_inputs_valid &= proof.valid; all_statements.extend(proof.statements); total_time += proof.proof_time; proof_types.push(proof.proof_type); } - Self::new(ProofType::Combined(proof_types), all_statements, total_time) + let mut combined = Self::new(ProofType::Combined(proof_types), all_statements, total_time); + combined.valid &= all_inputs_valid; + combined } } @@ -127,12 +139,12 @@ impl ProofStatement { } } - /// Create a successful proof statement + /// Create a proof statement marked as successful pub fn proven(description: String, formal_statement: String, proof_time: Duration) -> Self { Self::new(description, formal_statement, true, proof_time) } - /// Create a failed proof statement + /// Create a proof statement marked as failed pub fn failed(description: String, formal_statement: String, proof_time: Duration) -> Self { Self::new(description, formal_statement, false, proof_time) } @@ -161,6 +173,65 @@ mod tests { assert_eq!(proof.success_rate(), 1.0); } + #[test] + fn empty_proof_is_invalid() { + let proof = FormalProof::new( + ProofType::Bisimulation, + Vec::new(), + Duration::from_millis(0), + ); + + assert!(!proof.valid); + assert_eq!(proof.total_statements_count(), 0); + assert_eq!(proof.success_rate(), 0.0); + } + + #[test] + fn combining_no_proofs_is_invalid() { + let proof = FormalProof::combine(Vec::new()); + + assert!(!proof.valid); + assert_eq!(proof.total_statements_count(), 0); + } + + #[test] + fn combining_with_an_empty_proof_cannot_launder_it_as_valid() { + let valid = FormalProof::new( + ProofType::Bisimulation, + vec![ProofStatement::proven( + "Test statement".to_string(), + "(assert true)".to_string(), + Duration::from_millis(1), + )], + Duration::from_millis(1), + ); + let empty = FormalProof::new( + ProofType::StateEquivalence, + Vec::new(), + Duration::from_millis(0), + ); + + let combined = FormalProof::combine(vec![valid, empty]); + + assert!(!combined.valid); + } + + #[test] + fn deserialized_valid_flag_is_not_trusted() { + let proof = FormalProof::new( + ProofType::Bisimulation, + Vec::new(), + Duration::from_millis(0), + ); + let mut serialized = serde_json::to_value(proof).unwrap(); + serialized["valid"] = serde_json::Value::Bool(true); + + let decoded: FormalProof = serde_json::from_value(serialized).unwrap(); + + assert!(!decoded.valid); + assert!(decoded.statements.is_empty()); + } + #[test] fn test_proof_combination() { let proof1 = FormalProof::new( diff --git a/crates/verification/src/result.rs b/crates/verification/src/result.rs index e175ea11..35a398b4 100644 --- a/crates/verification/src/result.rs +++ b/crates/verification/src/result.rs @@ -5,6 +5,8 @@ use thiserror::Error; /// Main error type for verification operations #[derive(Error, Debug)] pub enum Error { + #[error("Unsupported verification operation: {0}")] + Unsupported(String), #[error("SMT solver error: {0}")] SmtSolver(String), #[error("Verification timeout after {seconds} seconds")] diff --git a/crates/verification/src/smt.rs b/crates/verification/src/smt.rs index 85da2cd4..d79a78dc 100644 --- a/crates/verification/src/smt.rs +++ b/crates/verification/src/smt.rs @@ -7,14 +7,16 @@ use crate::semantics::{ContractSemantics, FunctionSemantics, ModificationType, S use crate::{Error, VerificationResult}; use serde::{Deserialize, Serialize}; use std::time::Duration; +#[cfg(feature = "z3")] use z3::{ ast::{self, Ast}, - Config, Context, Solver, + Config, Context, SatResult, Solver, }; /// SMT solver for formal verification #[derive(Debug)] pub struct SmtSolver { + #[cfg(feature = "z3")] z3_context: Context, } @@ -74,6 +76,7 @@ impl SmtFormula { impl SmtSolver { /// Create new SMT solver instance + #[cfg(feature = "z3")] pub fn new() -> VerificationResult { let z3_config = Config::new(); let z3_context = Context::new(&z3_config); @@ -81,7 +84,29 @@ impl SmtSolver { Ok(Self { z3_context }) } - /// Check satisfiability of SMT formulas + /// Create a verifier without an SMT backend. + #[cfg(not(feature = "z3"))] + pub fn new() -> VerificationResult { + Ok(Self {}) + } + + /// Check satisfiability of SMT formulas. + /// + /// The optional Z3 backend is deliberately disabled by default. When enabled, + /// unsupported syntax and an indeterminate solver result are errors rather than + /// optimistic approximations. + #[cfg(not(feature = "z3"))] + pub async fn check_satisfiability( + &self, + _formulas: &[String], + ) -> VerificationResult { + Err(Error::Unsupported( + "SMT solving requires the `z3` crate feature".to_string(), + )) + } + + /// Check satisfiability of SMT formulas. + #[cfg(feature = "z3")] pub async fn check_satisfiability(&self, formulas: &[String]) -> VerificationResult { let start_time = std::time::Instant::now(); let solver = Solver::new(&self.z3_context); @@ -92,8 +117,7 @@ impl SmtSolver { } // Check satisfiability - let result = solver.check(); - let satisfiable = matches!(result, z3::SatResult::Sat); + let satisfiable = Self::satisfiable_from_status(solver.check())?; // Get model if satisfiable let model = if satisfiable { @@ -111,26 +135,58 @@ impl SmtSolver { }) } + #[cfg(feature = "z3")] + fn satisfiable_from_status(status: SatResult) -> VerificationResult { + match status { + SatResult::Sat => Ok(true), + SatResult::Unsat => Ok(false), + SatResult::Unknown => Err(Error::SmtSolver( + "Z3 returned unknown; no conclusion can be drawn".to_string(), + )), + } + } + /// Parse and add SMT formula to solver + #[cfg(feature = "z3")] fn parse_and_add_formula(&self, solver: &z3::Solver, formula: &str) -> VerificationResult<()> { - if formula.trim().starts_with("(assert") { + let trimmed = formula.trim(); + if !Self::has_balanced_parentheses(trimmed) { + return Err(Error::SmtSolver(format!( + "Unbalanced parentheses in SMT formula: {formula}" + ))); + } + + if trimmed.starts_with("(assert") { let content = self.extract_assertion_content(formula)?; let ast = self.parse_assertion_content(&content)?; solver.assert(&ast); Ok(()) } else { - // Handle declarations - if formula.trim().starts_with("(declare-") { - // For now, skip declarations as they're handled by our type system - Ok(()) - } else { - Err(Error::SmtSolver(format!( - "Unsupported formula format: {formula}", - ))) + Err(Error::SmtSolver(format!( + "Unsupported formula format: {formula}", + ))) + } + } + + #[cfg(feature = "z3")] + fn has_balanced_parentheses(formula: &str) -> bool { + let mut depth = 0usize; + for character in formula.chars() { + match character { + '(' => depth += 1, + ')' => { + let Some(next_depth) = depth.checked_sub(1) else { + return false; + }; + depth = next_depth; + } + _ => {} } } + depth == 0 } + #[cfg(feature = "z3")] fn extract_assertion_content(&self, formula: &str) -> VerificationResult { let trimmed = formula.trim(); if trimmed.starts_with("(assert") && trimmed.ends_with(')') { @@ -141,6 +197,7 @@ impl SmtSolver { } } + #[cfg(feature = "z3")] fn parse_assertion_content(&self, content: &str) -> VerificationResult> { let content = content.trim(); @@ -151,34 +208,27 @@ impl SmtSolver { Ok(ast::Bool::from_bool(&self.z3_context, false)) } else if content.starts_with("(=") { self.parse_equality(content) - } else if content.starts_with("(>") { - self.parse_comparison(content, ">") } else if content.starts_with("(>=") { self.parse_comparison(content, ">=") - } else if content.starts_with("(<") { - self.parse_comparison(content, "<") + } else if content.starts_with("(>") { + self.parse_comparison(content, ">") } else if content.starts_with("(<=") { self.parse_comparison(content, "<=") - } else if content.starts_with("(and") { - self.parse_and(content) - } else if content.starts_with("(or") { - self.parse_or(content) + } else if content.starts_with("(<") { + self.parse_comparison(content, "<") } else if content.starts_with("(not") { self.parse_not(content) - } else if content.starts_with("(forall") { - self.parse_forall(content) - } else if content.starts_with("(=>") { - self.parse_implies(content) } else { - // For now, treat unknown formulas as true to avoid failures - tracing::warn!("Unknown SMT formula pattern: {content}, treating as true"); - Ok(ast::Bool::from_bool(&self.z3_context, true)) + Err(Error::SmtSolver(format!( + "Unsupported SMT assertion: {content}" + ))) } } + #[cfg(feature = "z3")] fn parse_equality(&self, content: &str) -> VerificationResult> { // Simple equality parsing: (= a b) - if content.len() > 4 { + if content.len() > 4 && content.ends_with(')') { let inner = &content[2..content.len() - 1].trim(); let parts: Vec<&str> = inner.split_whitespace().collect(); if parts.len() == 2 { @@ -186,16 +236,21 @@ impl SmtSolver { let right = self.parse_term(parts[1])?; Ok(left._eq(&right)) } else { - Ok(ast::Bool::from_bool(&self.z3_context, true)) + Err(Error::SmtSolver(format!( + "Malformed equality assertion: {content}" + ))) } } else { - Ok(ast::Bool::from_bool(&self.z3_context, true)) + Err(Error::SmtSolver(format!( + "Malformed equality assertion: {content}" + ))) } } + #[cfg(feature = "z3")] fn parse_comparison(&self, content: &str, op: &str) -> VerificationResult> { let op_len = op.len() + 1; // +1 for opening paren - if content.len() > op_len + 1 { + if content.len() > op_len + 1 && content.ends_with(')') { let inner = &content[op_len..content.len() - 1].trim(); let parts: Vec<&str> = inner.split_whitespace().collect(); if parts.len() == 2 { @@ -206,72 +261,36 @@ impl SmtSolver { ">=" => Ok(left.ge(&right)), "<" => Ok(left.lt(&right)), "<=" => Ok(left.le(&right)), - _ => Ok(ast::Bool::from_bool(&self.z3_context, true)), + _ => Err(Error::SmtSolver(format!( + "Unsupported comparison operator: {op}" + ))), } } else { - Ok(ast::Bool::from_bool(&self.z3_context, true)) + Err(Error::SmtSolver(format!( + "Malformed comparison assertion: {content}" + ))) } } else { - Ok(ast::Bool::from_bool(&self.z3_context, true)) + Err(Error::SmtSolver(format!( + "Malformed comparison assertion: {content}" + ))) } } - fn parse_and(&self, _content: &str) -> VerificationResult> { - // For now, simplified and parsing - Ok(ast::Bool::from_bool(&self.z3_context, true)) - } - - fn parse_or(&self, _content: &str) -> VerificationResult> { - // For now, simplified or parsing - Ok(ast::Bool::from_bool(&self.z3_context, true)) - } - + #[cfg(feature = "z3")] fn parse_not(&self, content: &str) -> VerificationResult> { - if content.len() > 5 { + if content.len() > 5 && content.ends_with(')') { let inner = &content[4..content.len() - 1].trim(); let inner_ast = self.parse_assertion_content(inner)?; Ok(inner_ast.not()) } else { - Ok(ast::Bool::from_bool(&self.z3_context, true)) - } - } - - fn parse_forall(&self, content: &str) -> VerificationResult> { - let content = content.trim(); - if !content.starts_with("(forall") { - return Ok(ast::Bool::from_bool(&self.z3_context, true)); - } - - // Extract the body part after variable declarations - // For now, we'll parse basic forall patterns - if let Some(body_start) = content.find(")) ") { - let body = &content[body_start + 3..]; - let body = if let Some(stripped) = body.strip_suffix(')') { - stripped - } else { - body - }; - - // Parse the body formula - self.parse_assertion_content(body) - } else { - // Fallback for complex quantifiers - tracing::warn!("Complex quantifier pattern, approximating as true"); - Ok(ast::Bool::from_bool(&self.z3_context, true)) - } - } - - fn parse_implies(&self, content: &str) -> VerificationResult> { - // Implication parsing: (=> a b) - if content.len() > 4 { - let _inner = &content[3..content.len() - 1].trim(); - // For now, simplified parsing - Ok(ast::Bool::from_bool(&self.z3_context, true)) - } else { - Ok(ast::Bool::from_bool(&self.z3_context, true)) + Err(Error::SmtSolver(format!( + "Malformed negation assertion: {content}" + ))) } } + #[cfg(feature = "z3")] fn parse_term(&self, term: &str) -> VerificationResult> { // Try to parse as integer first if let Ok(value) = term.parse::() { @@ -280,8 +299,9 @@ impl SmtSolver { if let Ok(value) = i64::from_str_radix(stripped, 16) { Ok(ast::Int::from_i64(&self.z3_context, value).into()) } else { - // Create integer variable (Z3 automatically infers sort) - Ok(ast::Int::new_const(&self.z3_context, term).into()) + Err(Error::SmtSolver(format!( + "Integer literal is outside the supported i64 range: {term}" + ))) } } else { // Variable name - create integer constant @@ -289,6 +309,7 @@ impl SmtSolver { } } + #[cfg(feature = "z3")] fn parse_int_term(&self, term: &str) -> VerificationResult> { if let Ok(value) = term.parse::() { Ok(ast::Int::from_i64(&self.z3_context, value)) @@ -296,7 +317,9 @@ impl SmtSolver { if let Ok(value) = i64::from_str_radix(stripped, 16) { Ok(ast::Int::from_i64(&self.z3_context, value)) } else { - Ok(ast::Int::new_const(&self.z3_context, term)) + Err(Error::SmtSolver(format!( + "Integer literal is outside the supported i64 range: {term}" + ))) } } else { Ok(ast::Int::new_const(&self.z3_context, term)) @@ -848,16 +871,12 @@ impl SmtSolver { /// Prove that two contracts are equivalent pub async fn prove_equivalence( &self, - original: &ContractSemantics, - obfuscated: &ContractSemantics, + _original: &ContractSemantics, + _obfuscated: &ContractSemantics, ) -> VerificationResult { - let equivalence_formula = self.generate_equivalence_formula(original, obfuscated)?; - - // Check satisfiability (if unsatisfiable, then equivalence holds) - let result = self.check_satisfiability(&[equivalence_formula]).await?; - - // For equivalence proofs, we want UNSAT (meaning the negation is unsatisfiable) - Ok(!result.satisfiable) + Err(Error::Unsupported( + "SMT contract equivalence proofs are not implemented".to_string(), + )) } } @@ -871,8 +890,21 @@ mod tests { assert!(solver.is_ok()); } + #[cfg(not(feature = "z3"))] + #[tokio::test] + async fn disabled_backend_is_explicitly_unsupported() { + let solver = SmtSolver::new().unwrap(); + let error = solver + .check_satisfiability(&["(assert true)".to_string()]) + .await + .unwrap_err(); + + assert!(matches!(error, Error::Unsupported(_))); + } + + #[cfg(feature = "z3")] #[tokio::test] - async fn test_basic_formula_parsing() { + async fn contradictory_formulas_are_unsatisfiable() { let solver = SmtSolver::new().unwrap(); let formulas = vec![ @@ -881,8 +913,37 @@ mod tests { "(assert (= x 42))".to_string(), ]; - let result = solver.check_satisfiability(&formulas).await; - assert!(result.is_ok()); + let result = solver.check_satisfiability(&formulas).await.unwrap(); + assert!(!result.satisfiable); + assert!(result.model.is_none()); + } + + #[cfg(feature = "z3")] + #[tokio::test] + async fn unsupported_or_malformed_formulas_are_rejected() { + let solver = SmtSolver::new().unwrap(); + let invalid_formulas = [ + "(assert (and true true))", + "(assert (= x))", + "(assert (>= x))", + "(assert (forall ((x Int)) (= x x)))", + "(declare-fun x () Int)", + "(assert (= x #xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff))", + "(assert (= x 1)", + "(assert (= x 1)))", + ]; + + for formula in invalid_formulas { + let result = solver.check_satisfiability(&[formula.to_string()]).await; + assert!(result.is_err(), "formula must fail closed: {formula}"); + } + } + + #[cfg(feature = "z3")] + #[test] + fn unknown_solver_status_is_an_error() { + let error = SmtSolver::satisfiable_from_status(SatResult::Unknown).unwrap_err(); + assert!(matches!(error, Error::SmtSolver(_))); } #[test] diff --git a/docs/AZOTH_EXECUTIVE_REPORT.md b/docs/AZOTH_EXECUTIVE_REPORT.md new file mode 100644 index 00000000..6dfefa75 --- /dev/null +++ b/docs/AZOTH_EXECUTIVE_REPORT.md @@ -0,0 +1,54 @@ +# Azoth obfuscation hardening executive report + +Date: 2026-08-18 +Baseline: `6634a2707c82` + +## Bottom line + +Azoth's production pipeline is materially stronger and safer. On the 100-seed Counter benchmark, it changed at least **56.78%** of deployment bytes by a conservative LCS measure, versus **3.97%** before: a minimum **14.30x improvement**. All 100 outputs had unique deployment bytecode, runtime, opcode skeleton, selectors, and compiler-metadata digest, while output size grew only from 509 to 514 bytes (1.0098x). + +That result satisfies the requested 10x/50% target for the benchmark, not universally. Small contracts and contracts whose semantics expose gas or code layout may transform less or be rejected. The product should enforce a per-artifact strength gate instead of making a blanket claim. + +## What changed + +- Added low-density jump trampolines that create real forwarding nodes and CFG edges. +- Rebuilt block shuffling to preserve fallthrough semantics and search for layouts with low original-byte retention. +- Hardened selector replacement, kept it compiler-shaped, and returned an unambiguous caller mapping. +- Diversified recognized compiler metadata digests without changing their CBOR/compiler envelope. +- Kept automatic constructor-argument masking and made init/immutable relocation require symbolic evidence. +- Made passes transactional and randomness domain-separated, improving failure safety and repeat determinism. +- Added exact byte/opcode retention, contiguous-run, pairwise diversity, and corrected CFG/size metrics. + +## Critical safety work + +The audit found and fixed or disabled several high-risk paths: false formal-verification success, observable revert-string mutation, selector collisions, runtime and init literals mistaken for jump/immutable addresses, broken fallthrough/end-of-code layouts, unsafe unknown-opcode encoding, self-code and gas observations, partial pass mutations, misleading gas/size reporting, and exponential CFG-provenance analysis. + +Unsupported or ambiguous contracts now fail closed. Formal equivalence is explicitly `Unsupported`; it is no longer represented as a proof. Safety is established for tested contracts through differential execution, not a mathematical guarantee. + +## Validation + +- 248 workspace tests passed; 9 optional/benchmark tests were ignored; none failed. +- 18/18 tests passed with the optional Z3 feature. +- All-feature Clippy and formatting checks passed. +- Fuzzing requested 200 successes and completed 204 concurrent iterations with 200 successes, zero errors, zero deployment mismatches, and zero saved crashes. +- Dedicated differential tests compare deployment, calls, successes/reverts, exact output, logs, storage, balances, malformed calldata, nonpayable behavior, and overflow behavior. + +## Red-team assessment + +Shallow exact matching is much weaker: bytes, selectors, metadata digests, opcode layouts, and raw CFGs vary by seed. Expert normalization remains a real weakness. The 100 outputs had 14 raw CFG shapes, but contracting simple forwarding nodes reduced all of them to the original normalized shape. Experimental literal synthesis was trivially detected in 100/100 samples and was therefore excluded from the default pipeline. Non-resolving metadata can also be tested actively. + +Blink reported 97,430,622 unverified contracts out of 104,876,220 on 2026-08-18, and 42,785 unverified Solidity 0.8.30 contracts in the sampled metadata table. That is a useful candidate corpus, not proof that all those contracts belong to Azoth's effective anonymity set. See [Blink](https://blink.mirageprivacy.com/) and its [SQL interface](https://blink.mirageprivacy.com/docs.html#sql). + +## Recommendation + +Proceed with Azoth as an experimental, fail-closed obfuscation pipeline and add a release gate requiring: + +- 100% contract-specific differential agreement and repeat determinism; +- at least 50% conservative deployment-byte change for artifacts sold at this strength; +- bounded size and measured gas overhead; +- low longest-contiguous-source retention and strong seed-to-seed diversity; and +- held-out classifier performance near chance at operationally low false-positive rates. + +The next priority is not more obvious algebraic noise. It is transformations that survive CFG normalization, a representative Solidity 0.8.30 corpus, full generated calldata adapters beyond selector replacement, active metadata-resolution testing, and a low-false-positive classifier benchmark. + +Full details, findings, benchmark definitions, release metrics, and limitations are in [the technical report](AZOTH_TECHNICAL_REPORT.md). diff --git a/docs/AZOTH_TECHNICAL_REPORT.md b/docs/AZOTH_TECHNICAL_REPORT.md new file mode 100644 index 00000000..a597de9c --- /dev/null +++ b/docs/AZOTH_TECHNICAL_REPORT.md @@ -0,0 +1,239 @@ +# Azoth obfuscation hardening technical report + +Date: 2026-08-18 +Baseline: `6634a2707c82` (`feat: mask constructor arguments during obfuscation (#150)`) +Scope: Solidity 0.8.30-style legacy EVM deployment/runtime bytecode + +## Result + +The production pipeline is substantially stronger and safer, but the result needs a precise qualification. + +On the 509-byte Counter deployment used for the before/after study, 100 deterministic seeds produced a **minimum 56.7780% conservative byte change** and a mean of 58.9568%. The previous pipeline changed 3.97% by the same rounded baseline measure. The minimum improvement is therefore **14.30x**, and all 100 deployment bytecodes, runtimes, opcode skeletons, selector sets, and metadata digests were unique. Output size was 514 bytes, or 1.0098x the input. + +This establishes the requested 10x and 50% thresholds for that benchmark, not for every supported contract. A small runtime with a `GAS` observation, for example, can safely receive only selector replacement and changed about 11% in a regression fixture. Azoth must therefore measure and reject weak artifacts rather than advertise a universal 50% guarantee. + +The most important outcome is the new fail-closed foundation. Several pre-existing paths could silently change semantics, report unsupported verification as success, or consume excessive CPU on adversarial CFGs. Those paths are now rejected, bounded, or covered by differential tests. + +## Threat model and anonymity-set evidence + +Azoth assumes the observer can obtain creation input, deployed code, calldata, receipts, traces, state, and historical chain data. Obfuscation can raise static and dynamic analysis cost; it cannot make public EVM data confidential. Constructor masking in particular removes a plaintext ABI tail but remains reversible by executing or analyzing init code. + +The intended product direction matches Mirage's stated focus on making deployments harder to link while preserving behavior: [Azoth introduction](https://docs.mirageprivacy.com/azoth/introduction/), [technical model](https://docs.mirageprivacy.com/understanding-mirage/technical-model/), and [undetectability challenge](https://docs.mirageprivacy.com/understanding-mirage/the-undetectability-challenge/). Those documents describe a goal; they are not evidence that the current implementation is indistinguishable. + +Blink's public SQL interface was usable for a first-order anonymity-set sample. The following queries were run on 2026-08-18 through [Blink](https://blink.mirageprivacy.com/) and its [SQL documentation](https://blink.mirageprivacy.com/docs.html#sql): + +```sql +SELECT COUNT(*) AS total_contracts, + SUM(CASE WHEN is_verified THEN 1 ELSE 0 END) AS verified_contracts, + SUM(CASE WHEN NOT is_verified THEN 1 ELSE 0 END) AS unverified_contracts, + ROUND(100.0 * AVG(CASE WHEN is_verified THEN 1 ELSE 0 END), 2) AS verified_pct +FROM contract_metadata; +``` + +Result: 104,876,220 contracts, 7,445,598 verified, 97,430,622 unverified, and 7.10% verified. + +```sql +SELECT COUNT(*) AS unverified_solc_0830, + ROUND(AVG(n_code_bytes), 1) AS mean_bytes, + quantile_cont(n_code_bytes, 0.5) AS median_bytes, + quantile_cont(n_code_bytes, 0.95) AS p95_bytes, + ROUND(100.0 * AVG(CASE WHEN uses_push0 THEN 1 ELSE 0 END), 2) AS push0_pct, + ROUND(100.0 * AVG(CASE WHEN has_source_hash THEN 1 ELSE 0 END), 2) AS metadata_pct +FROM contract_metadata +WHERE NOT is_verified + AND compiler_version = '0.8.30' + AND n_code_bytes > 0; +``` + +Result: 42,785 contracts; mean 2,644.7 bytes; median 438 bytes; p95 8,043 bytes; 87.83% use `PUSH0`; 96.02% have source-hash metadata. + +This is useful calibration, not an indistinguishability result. The query does not measure opcode motifs, normalized CFGs, metadata resolution, source family, optimizer settings, proxy patterns, deployment factories, or classifier performance. A representative corpus must be sampled and analyzed before claiming the 97.4-million-contract set as Azoth's effective anonymity set. + +## Production transformation portfolio + +The unified obfuscator now applies the following deterministic, seed-derived operations. + +### Selector-only function dispatcher + +For a recognized Solidity dispatcher, each four-byte selector literal is replaced with a unique seed-derived four-byte token. Azoth returns an original-selector-to-token map so callers can rebuild calldata. Tokens cannot collide with one another or with any original selector. Ambiguous duplicate selectors and selector literals found outside the recognized dispatcher fail closed because they could participate in self-calls or other interface data flows. + +The production mode does not add controllers, storage gates, decoy branches, or environment-dependent behavior. The former multi-tier design remains explicit experimental code. + +Only the first four calldata bytes are changed. ABI argument offsets, widths, and encodings are not yet diversified. + +### Jump trampoline + +This new pass reroutes a seed-derived sample of existing symbolic jump edges through one to three ordinary `JUMPDEST; PUSH2; JUMP` forwarding blocks. It introduces genuine CFG nodes and edges without opaque predicates, storage reads, or dead branches. A `STOP` barrier preserves the EVM's implicit end-of-code behavior when appending nodes would otherwise make a former fallthrough execute new code. Runtimes that fall into an existing deployed suffix are skipped. + +### Cluster shuffle + +Cluster shuffling was redesigned around semantic layout constraints and an exact LCS objective. It groups blocks connected by mandatory physical fallthrough, anchors the runtime entry, preserves `JUMPI` false-path adjacency and end-of-code behavior, then searches a bounded set of seed-derived layouts. When possible, it accepts a layout whose clean-runtime byte LCS retention is at most 40%. + +The exact LCS implementation is bit-parallel rather than quadratic, and candidate trials shrink near the EIP-170 limit. A 23,996-byte synthetic runtime with 4,798 forwarding blocks completed all ten tested transformations in a mean 76.4 ms (p95 77.3 ms, maximum 77.3 ms). + +### Compiler metadata digest diversification + +Recognized Solidity IPFS or Swarm digests are changed deterministically while retaining the CBOR envelope and compiler-version fields. This avoids a constant original source digest across variants and matches the high observed prevalence of metadata in the sampled Solidity 0.8.30 population. + +The generated digest intentionally does not resolve to source content. Active source-resolution failure can itself be a classifier feature, so this is an interim measure rather than a solved fingerprint problem. Metadata is not rewritten when self-code-sensitive behavior makes byte changes unsafe. + +### Constructor-argument masking and init-code relocation + +When an exact constructor-argument suffix is detected, Azoth masks it and inserts a deterministic decoder so the deployed runtime is unchanged without retaining a plaintext ABI-aligned tail. The output reports whether masking occurred and the argument/decoder byte counts. + +Init-code patching now requires symbolic evidence. A relocatable Solidity immutable write must derive its destination from the exact base retained from the deployment `CODECOPY`, and `RETURN` must reuse that same base and length. Each moved runtime placeholder must be patched exactly once. Coincidental arithmetic literals that happen to equal a runtime PC are rejected rather than rewritten. + +## Safety and correctness findings + +| Finding | Risk before this work | Resolution | +|---|---|---| +| Equivalence API returned proof-shaped success without a complete EVM model | False assurance about arbitrary contracts | `prove_equivalence` now returns `Unsupported`; empty/vacuous proofs, malformed formulas, solver `unknown`, and unsupported syntax fail closed. Z3 remains optional scaffolding. | +| String pass scrambled `Error(string)` bytes | Observable revert-data semantics changed | Production string transformation is disabled and returns an error. | +| Passes could mutate the shared IR before reporting failure/no-change | Partial corruption could leak into later passes | Every pass runs on a clone and commits transactionally only after success and a reported change. | +| One sequential RNG coupled unrelated passes | Adding/skipping a pass changed all later choices | Each pass receives a domain-separated RNG derived from the root seed and pass identity. | +| Hash-map iteration affected mapping order | Same seed could vary across processes/platforms | Inputs are sorted before seeded decisions; determinism regressions cover mappings and output. | +| Dispatcher tokens could collide with original selectors or ambiguous duplicates | Wrong function dispatch or aliasing | Reserve all original selectors, require unique tokens, reject ambiguous occurrences, and return the complete mapping. | +| Numeric literals equal to a `JUMPDEST` PC were treated as jump addresses | Storage/data/constants could be silently relocated | Runtime-wide abstract stack provenance now proves which PUSH origins reach jump targets; mixed address/data use fails closed. `PUSH0` targets are tracked explicitly. | +| Exact-path provenance could grow exponentially | Adversarial bytecode caused approximately 19 seconds of analysis for a 1,529-byte fixture | Compatible states are joined, pending keys are deduplicated, and path-state, stack-cell, fact-growth, transfer-work, and iteration budgets fail closed. The same adversary now aborts in about 1.43 seconds in a debug build while the large real escrow fixture completes. | +| Init reassembly patched PUSH values based on numeric coincidence | An unrelated `ADD; MSTORE` sequence could corrupt the deployed runtime | Relocation requires an exact symbolic `CODECOPY` base/length proof and exact placeholder coverage. A minimized corruption reproducer is now a fail-closed regression. | +| Cluster movement broke fallthrough and unresolved `JUMPI` false edges | Different control flow or execution into the wrong block | Mandatory physical adjacency is modeled as a cluster constraint; unresolved dynamic jumps fail closed. | +| Appended trampolines changed implicit EOF `STOP` behavior | Formerly terminating code could execute the trampoline region | Add a barrier where safe; skip runtimes that fall through into a deployed suffix. | +| Layout changes ignored `PC`, `CODESIZE`, `CODECOPY`, and self-targeting `EXTCODE*` observations | A contract could observe changed offsets, bytes, length, or hash | Production layout changes reject these patterns. All `EXTCODESIZE`, `EXTCODECOPY`, and `EXTCODEHASH` are conservatively rejected until address-alias analysis exists. | +| Added executed instructions changed `GAS` observations and EIP-150 forwarding | Calls or branches could change despite identical logical code | Overhead-adding passes skip/reject every runtime containing `GAS`. | +| Decoder placeholders could turn unknown bytes into `INVALID` on encode | Future or unsupported opcodes could be corrupted | Original raw opcode bytes are preserved exactly when possible; unsafe recovery fails closed. | +| Runtime/init sizes and gas fields were incomplete or misleading | Invalid artifacts or fabricated cost estimates | Enforce 24,576-byte runtime and 49,152-byte initcode limits. Report exact runtime code-deposit gas only; runtime execution and full creation gas require measurement. | +| Analysis used weak/unstable metrics and incorrect post-dominator/size inputs | Scores could improve without real diversification | Added exact LCS, longest contiguous match, aligned difference, pairwise output metrics, n-gram Jaccard, current encoded size, and corrected exit-root post-dominators. | + +These guards intentionally reduce coverage. A rejected contract is safer than a transformed contract whose semantics depend on unmodeled code layout, gas, or dynamic control flow. + +## Obfuscation and fingerprint benchmark + +### Method + +The benchmark uses the checked-in Solidity Counter creation bytecode across seeds 0 through 99. Its original artifact is 509 bytes: 28 bytes of init code, 428 bytes of clean runtime, and 53 bytes of compiler metadata, containing 267 decoded runtime opcodes. Each output used the production default and was compared both to the original and to every other seed. + +The primary conservative change measure is: + +```text +1 - LCS(original deployment bytes, output deployment bytes) / original deployment bytes +``` + +Unlike position-by-position difference, insertions do not make every following byte look changed. Longest common contiguous retention measures the largest surviving source slice. Opcode-LCS removes immediate-byte noise. Pairwise measures test whether different seeds create meaningfully different variants rather than merely changing a seed tag. + +### Results over 100 seeds + +| Measure | Minimum | Mean | Median | p95 | Maximum | +|---|---:|---:|---:|---:|---:| +| Conservative changed bytes | 56.7780% | 58.9568% | 58.9391% | 60.1277% | 60.7073% | +| Aligned byte difference | 85.6031% | 88.5311% | 88.6187% | 90.0875% | 90.6615% | +| Longest original contiguous run retained | 9.0373% | 9.2927% | 9.2338% | 9.6267% | 10.6090% | +| Pairwise aligned difference | 63.4241% | 82.1214% | 83.0739% | 85.4086% | 87.1595% | +| Original-to-output opcode-LCS retention | 46.8165% | 50.5506% | 50.5618% | 52.8090% | 53.9326% | +| Pairwise opcode-LCS retention | 46.6667% | 57.1000% | 57.0370% | 62.9630% | 70.0000% | + +Additional results: + +- Worst conservative seed: 50, with an LCS of 220/509 bytes. +- Old rounded baseline: 3.97% conservative change and approximately 27.8% size growth. +- Improvement over that baseline: minimum 14.3018x and mean 14.8506x. +- Output size: 514 bytes, 1.009823x the original. +- Uniqueness: 100/100 deployment bytecodes, runtimes, opcode skeletons, and metadata digests; 300/300 selector tokens. +- Source-matched generation latency: mean 13.1 ms and p95 18.4 ms. +- Reproducibility: the concatenated 100-seed corpus SHA-256 is `7405f44b3f1607645932ed8aff07c780794649d14fae9c6aaef921c4d8133472`; seed 0 is `30c8603df21db7a816e0dbf33e0382539af5d1d22f83eaafb76f91d1b124b3cd`, and worst seed 50 is `f3b83bcde4e0b097c47570e4823f8a72075e40e514576d6ba32440ed93a2f317`. + +The conservative measure is the release criterion. The aligned measure is diagnostic because layout insertion can inflate it. Opcode retention and longest-run retention expose variants that change immediates while leaving most structure untouched. + +### Red-team conclusions + +The output family is harder to link by exact bytes, selectors, metadata, and raw block order. It does not yet defeat expert normalization. + +- The 100 outputs had 14 raw CFG shapes. Contracting simple forwarding nodes reduced every output and the original to one normalized CFG shape. Jump trampolines therefore add analyst work but not durable structural ambiguity against a normalizer. +- Experimental `LiteralSynthesis` was detected in 100/100 transformed samples and 0/4 native compiler controls using a simple algebraic-template heuristic. It created 3-11 recognizable patterns per sample (mean 7.08), covering about 9.34% of opcodes and 22.45% of bytes. It was rejected from the default portfolio. +- A non-resolving metadata digest is structurally compiler-like but can be detected by an active resolver. +- Selector replacement changes the public interface and may be visible in transaction-flow analysis even though the tokens themselves are not a fixed Azoth signature. +- Low-density forwarding blocks resemble shapes used by Solidity/Yul, but a classifier trained on local motifs and their placement still needs to be measured. +- No classifier AUC or low-false-positive-rate result exists yet. Consequently, the effective anonymity set is unknown and should not be equated with all unverified contracts. + +The honest assessment is that Azoth now defeats much more shallow static matching and raises the cost of manual recovery, but an AI-assisted expert can still normalize away its principal CFG addition and follow the returned selector interface through observed transactions. + +## Validation performed + +Final source gates: + +```text +cargo fmt --all -- --check + passed + +Z3_SYS_Z3_HEADER=/opt/homebrew/include/z3.h \ +LIBRARY_PATH=/opt/homebrew/lib \ +cargo clippy --workspace --all-targets --all-features -- -D warnings + passed + +cargo test --workspace --all-targets + 248 passed; 0 failed; 9 ignored + +Z3_SYS_Z3_HEADER=/opt/homebrew/include/z3.h \ +LIBRARY_PATH=/opt/homebrew/lib \ +cargo test -p azoth-verification --features z3 + 18 passed; 0 failed + +cargo run -q -p azoth-cli -- fuzz -j4 -i200 \ + --check-deploy --crash-dir /tmp/azoth-fuzz-final2 + 204 iterations; 200 successes; 0 errors; + 0 deployment mismatches; 0 saved crashes +``` + +The fuzz harness exercises escrow and Counter fixtures with no transform, jump trampolines, and cluster shuffling, then deploys the outputs in REVM. The dedicated Counter differential test runs three fixed seeds through deployment and 16 logical calls, comparing success/revert class, exact return/revert bytes, logs, account/storage state, balances, nonpayable behavior, malformed/unknown calldata, and overflow behavior. It also checks repeat determinism, selector uniqueness, and EVM/relative size ceilings. + +Additional regressions cover real constructor-initialized runtime/immutable data, ERC-20 proof collection, unresolved jumps, false fallthrough, EOF, init/runtime literal-PC collisions, unknown opcodes, self-code inspection, `GAS`, transactional failures, and near-limit candidate-search bounds. + +Passing this corpus is evidence for the covered contracts and states, not a proof for arbitrary EVM programs. + +The final independent safety audit found no P0/P1 blocker in the documented Solidity 0.8.30/default path. The bounded 1,529-byte provenance adversary still takes about 1.4 seconds in a debug build before failing closed, so an outer service CPU/time limit remains an operational P2 requirement. + +## Release metrics and proposed gates + +Metrics must be computed on creation bytes, clean runtime, decoded opcode stream, normalized CFG, execution transcripts, and a representative chain corpus. One aggregate potency score is not sufficient. + +| Dimension | Metric | Initial release gate | +|---|---|---| +| Semantics | Differential agreement over deployment, returndata/revertdata, logs, storage, balances, selfdestruct/calls, and relevant environments | 100% for every artifact in its contract-specific corpus; otherwise reject | +| Determinism | Exact deployment/runtime/mapping equality across repeat runs, processes, and supported platforms | 100% | +| Original retention | `1 - byte_LCS/original_len` on the complete deployment | At least 50% for an artifact marketed at the requested strength; otherwise reject or label weak | +| Surviving chunks | Longest common contiguous original slice | At most 12% of input and at most 64 bytes, with an explicit exception policy for very small inputs | +| Structural retention | Opcode LCS and normalized CFG graph-edit/similarity | Track per size/source stratum; reject unexplained regressions | +| Seed diversity | Pairwise byte/opcode LCS, aligned difference, n-gram Jaccard, uniqueness | 100% exact uniqueness; median pairwise aligned difference at least 30% | +| Size | Runtime/init growth distributions plus protocol limits | p95 at most 1.25x, maximum at most 1.50x, and always below EIP-170/EIP-3860 limits | +| Gas | Measured deploy and runtime gas distributions for workloads | p95 at most 1.25x and maximum at most 1.50x; never infer runtime gas from byte size | +| Detectability | Cross-validated classifier on held-out source families and compiler settings | ROC-AUC at most 0.60 and true-positive rate at most 5% at 0.1% false-positive rate | +| Metadata | Envelope distribution, compiler fields, and active digest resolution | Match the target corpus; do not ship one universal resolution-failure pattern | +| Robustness | Fuzz/differential failures, timeouts, crashes, nondeterminism | Zero; all analysis budgets fail closed | + +The classifier gate is the closest operational measure of the desired anonymity set. Evaluation must split by source/project family so near-duplicate contracts cannot leak across train and test sets. Results should also be stratified by code size, compiler version, optimizer settings, proxy/factory family, and `PUSH0`/metadata presence. + +## Attempts, decisions, and rejected directions + +| Attempt | Result | Decision | +|---|---|---| +| Legacy random block shuffling | Low conservative change, large growth, and unsafe physical-fallthrough assumptions | Replaced by cluster-constrained, LCS-scored layouts | +| Multi-tier dispatcher/controller/decoy design | More visible synthetic machinery and greater semantic surface | Keep explicit experimental only; production uses selector literals only | +| String scrambling | Changed observable revert data | Disable | +| Literal algebraic synthesis | Exact in isolation but trivially classified in 100/100 samples | Keep opt-in experimental; redesign with compiler-corpus-derived, heterogeneous lowering before reconsideration | +| Metadata digest replacement | Removes cross-seed digest equality but creates non-resolution risk | Keep as an interim diversified field and add active-resolution corpus testing | +| Exact path-sensitive jump provenance | Sounder-looking but exponential on ordinary internal-call fan-in | Use conservative joined states with explicit work budgets | +| Numeric immutable/PC heuristics | Fast but produced concrete semantic corruption reproducers | Replace with symbolic provenance and fail closed when proof is incomplete | +| Formal-equivalence claim on partial SMT scaffolding | Unsound product claim | Return `Unsupported` until a complete relational EVM model exists | + +## Remaining limitations and next work + +1. Build a ≥50-contract Solidity 0.8.30 differential corpus spanning escrow, ERC-20/721, proxies, factories, access control, custom errors, immutables, external calls, CREATE/CREATE2, logs, and adversarial fallback/receive paths. Run hundreds of seeds per stratum. +2. Make the ≥50% conservative-change threshold an opt-in/output policy enforced by the CLI and API. Small, `GAS`-observing, or weakly transformable contracts must be rejected rather than silently accepted at low potency. +3. Add transformations that survive forwarding-node contraction: compiler-corpus-derived equivalent dispatcher trees, safe basic-block splitting/merging, and typed data/code relocation. Each needs differential and gas proofs for its supported subset. +4. Diversify full calldata argument encoding only behind generated client adapters and explicit mappings. Current Azoth changes selectors only. +5. Train the low-FPR family classifier against Blink-derived samples and publish confidence intervals, feature ablations, and source-family-held-out results. +6. Replace non-resolving metadata digests with a policy that matches the selected anonymity stratum, or allow an explicitly measured metadata-free stratum. +7. Extend code-layout and address-alias analysis so safe `PC`/`CODECOPY`/self-`EXTCODE*` cases can be relocated rather than categorically rejected. +8. Measure runtime gas with stateful workloads. The current report correctly limits itself to exact byte counts and code-deposit gas. +9. Treat formal equivalence as future work. A sound implementation needs full 256-bit EVM semantics, calls, reverts, logs, storage, balances, gas, environmental inputs, intentional selector mappings, and counterexample-oriented proof obligations. + +Until those gates exist, generated contracts should remain experimental and contract-specific differential validation is mandatory. diff --git a/docs/constructor-argument-obfuscation.md b/docs/constructor-argument-obfuscation.md index e61fb647..d0f00297 100644 --- a/docs/constructor-argument-obfuscation.md +++ b/docs/constructor-argument-obfuscation.md @@ -30,7 +30,9 @@ The fix has four cooperating parts: The transform is automatically applied when an argument suffix exists. Callers may pass a full creation payload to `-D`, or pass compiler creation bytecode plus `--constructor-args `. Result metadata exposes `constructor_args_obfuscated`, `constructor_argument_bytes`, and `constructor_decoder_bytes` so release tooling can enforce that expected sensitive inputs were actually handled. -### Soundness and adversarial verification +### Validation performed for this change + +The following results are a historical validation snapshot for constructor masking, not a formal equivalence proof or a guarantee for arbitrary contracts and pass combinations. | Check | Scope | Result | |---|---:|---:| @@ -45,7 +47,7 @@ The transform is automatically applied when an argument suffix exists. Callers m The built-in campaign randomly varied seeds, constructor recipients and amounts, and transform selections. Its existing REVM oracle required every transformed payload whose original deployed successfully to deploy successfully as well. The focused differential test supplied the stronger byte-for-byte deployed-runtime comparison. Unsupported and ambiguous copy layouts have explicit rejection tests. -The full workspace build reaches the external Z3-backed verification crate but cannot compile it in the current environment because the system `z3.h` header is not installed. This is an environment prerequisite, not a failure in the changed core/transform/CLI crates; those crates compile and test cleanly. +The verification crate is now optional and builds without Z3 by default. Its contract-equivalence API returns `Unsupported`; enabling the prototype `z3` feature does not turn these constructor tests into a mathematical equivalence proof. ### Benchmark @@ -65,4 +67,4 @@ Across 100 seeds, decoder size averaged 569.7 bytes (433 minimum, 711 maximum), This mitigation closes the report's direct plaintext-suffix extraction path. It does not encrypt transaction calldata, hide values after the EVM decodes them, suppress storage/log/call/proof disclosures, remove Solidity CBOR metadata, or prevent dynamic/symbolic recovery. Teams requiring confidentiality from validators, archive nodes, or skilled reverse engineers need a cryptographic protocol change, which was explicitly outside this work. -For rollout, require `constructor_args_obfuscated: true` whenever an expected deployment has constructor inputs, retain differential deployment testing for each production compiler/version, and treat a fail-closed unsupported-layout error as a release blocker. Re-run the benchmark on the exact production constructor payload because decoder overhead is argument-length dependent. +For rollout experiments, require `constructor_args_obfuscated: true` whenever an expected deployment has constructor inputs, retain differential deployment and behavior testing for each contract/compiler/pass set, and treat a fail-closed unsupported-layout error as a release blocker. Re-run the benchmark on the exact payload because decoder overhead is argument-length dependent. diff --git a/tests/src/analysis/metrics.rs b/tests/src/analysis/metrics.rs index 2be4c0a7..34b3aaa2 100644 --- a/tests/src/analysis/metrics.rs +++ b/tests/src/analysis/metrics.rs @@ -8,11 +8,11 @@ use petgraph::graph::NodeIndex; /// Tests metrics computation for a simple bytecode with linear control flow. #[tokio::test] async fn test_collect_metrics_simple() { - tracing_subscriber::fmt() + let _ = tracing_subscriber::fmt() .with_max_level(tracing::Level::DEBUG) .with_ansi(false) .without_time() - .init(); + .try_init(); let bytecode = "0x600160015601"; // PUSH1 0x01, PUSH1 0x01, ADD let (instructions, _, _, bytes) = decoder::decode_bytecode(bytecode, false).await.unwrap(); @@ -20,7 +20,7 @@ async fn test_collect_metrics_simple() { let (_clean_runtime, report) = strip::strip_bytecode(&bytes, §ions).unwrap(); let cfg_ir = cfg_ir::build_cfg_ir(&instructions, §ions, report.clone(), &bytes).unwrap(); - let metrics = collect_metrics(&cfg_ir, &report).expect("Metrics computation failed"); + let metrics = collect_metrics(&cfg_ir).expect("Metrics computation failed"); assert_eq!(metrics.byte_len, 6, "Byte length mismatch"); assert_eq!(metrics.block_cnt, 2, "Block count mismatch"); assert!( @@ -37,11 +37,11 @@ async fn test_collect_metrics_simple() { /// Tests metrics computation for a single-block bytecode. #[tokio::test] async fn test_collect_metrics_single_block() { - tracing_subscriber::fmt() + let _ = tracing_subscriber::fmt() .with_max_level(tracing::Level::DEBUG) .with_ansi(false) .without_time() - .init(); + .try_init(); let bytecode = "0x600050"; // PUSH1 0x00, STOP let (instructions, _, _, bytes) = decoder::decode_bytecode(bytecode, false).await.unwrap(); @@ -49,7 +49,7 @@ async fn test_collect_metrics_single_block() { let (_clean_runtime, report) = strip::strip_bytecode(&bytes, §ions).unwrap(); let cfg_ir = cfg_ir::build_cfg_ir(&instructions, §ions, report.clone(), &bytes).unwrap(); - let metrics = collect_metrics(&cfg_ir, &report).expect("Metrics computation failed"); + let metrics = collect_metrics(&cfg_ir).expect("Metrics computation failed"); assert_eq!(metrics.byte_len, 3, "Byte length mismatch"); assert_eq!(metrics.block_cnt, 1, "Block count mismatch"); assert_eq!(metrics.edge_cnt, 2, "Edge count mismatch"); @@ -63,11 +63,11 @@ async fn test_collect_metrics_single_block() { /// Tests metrics computation for a bytecode with conditional branching. #[tokio::test] async fn test_collect_metrics_branching() { - tracing_subscriber::fmt() + let _ = tracing_subscriber::fmt() .with_max_level(tracing::Level::DEBUG) .with_ansi(false) .without_time() - .init(); + .try_init(); let bytecode = "0x6000600157600256"; // PUSH1 0x00, JUMPI, JUMPDEST, STOP let (instructions, _, _, bytes) = decoder::decode_bytecode(bytecode, false).await.unwrap(); @@ -75,7 +75,7 @@ async fn test_collect_metrics_branching() { let (_clean_runtime, report) = strip::strip_bytecode(&bytes, §ions).unwrap(); let cfg_ir = cfg_ir::build_cfg_ir(&instructions, §ions, report.clone(), &bytes).unwrap(); - let metrics = collect_metrics(&cfg_ir, &report).expect("Metrics computation failed"); + let metrics = collect_metrics(&cfg_ir).expect("Metrics computation failed"); assert_eq!(metrics.byte_len, 8, "Byte length mismatch"); assert_eq!(metrics.block_cnt, 2, "Block count mismatch"); assert_eq!(metrics.edge_cnt, 2, "Edge count mismatch"); @@ -92,11 +92,11 @@ async fn test_collect_metrics_branching() { /// Tests that decoding an empty bytecode fails with a parse error. #[tokio::test] async fn test_collect_metrics_empty_input() { - tracing_subscriber::fmt() + let _ = tracing_subscriber::fmt() .with_max_level(tracing::Level::DEBUG) .with_ansi(false) .without_time() - .init(); + .try_init(); let err = decoder::decode_bytecode("0x", false) .await .expect_err("empty blob must fail to decode"); @@ -106,11 +106,11 @@ async fn test_collect_metrics_empty_input() { /// Tests metrics computation for a CFG with no body blocks. #[tokio::test] async fn test_collect_metrics_no_body_blocks() { - tracing_subscriber::fmt() + let _ = tracing_subscriber::fmt() .with_max_level(tracing::Level::DEBUG) .with_ansi(false) .without_time() - .init(); + .try_init(); let bytecode = "0x00"; // STOP let (instructions, _, _, bytes) = decoder::decode_bytecode(bytecode, false).await.unwrap(); @@ -119,18 +119,18 @@ async fn test_collect_metrics_no_body_blocks() { let (_clean_runtime, report) = strip::strip_bytecode(&bytes, §ions).unwrap(); let cfg_ir = cfg_ir::build_cfg_ir(&instructions, §ions, report.clone(), &bytes).unwrap(); - let m = collect_metrics(&cfg_ir, &report).expect("single STOP is still code"); + let m = collect_metrics(&cfg_ir).expect("single STOP is still code"); assert_eq!(m.block_cnt, 1, "Single STOP should form one body block"); } /// Tests the compare function for metrics. #[tokio::test] async fn test_compare_metrics() { - tracing_subscriber::fmt() + let _ = tracing_subscriber::fmt() .with_max_level(tracing::Level::DEBUG) .with_ansi(false) .without_time() - .init(); + .try_init(); let bytecode_before = "0x600050"; // PUSH1 0x00, STOP let (instructions, _, _, bytes) = decoder::decode_bytecode(bytecode_before, false) @@ -140,7 +140,7 @@ async fn test_compare_metrics() { let sections = detection::locate_sections(&bytes, &instructions, &[]).unwrap(); let (_clean_runtime, report) = strip::strip_bytecode(&bytes, §ions).unwrap(); let cfg_ir = cfg_ir::build_cfg_ir(&instructions, §ions, report.clone(), &bytes).unwrap(); - let metrics_before = collect_metrics(&cfg_ir, &report).unwrap(); + let metrics_before = collect_metrics(&cfg_ir).unwrap(); let bytecode_after = "0x600160015601"; // PUSH1 0x01, PUSH1 0x01, ADD let (instructions, _, _, bytes) = decoder::decode_bytecode(bytecode_after, false) @@ -150,7 +150,7 @@ async fn test_compare_metrics() { let sections = detection::locate_sections(&bytes, &instructions, &[]).unwrap(); let (_clean_runtime, report) = strip::strip_bytecode(&bytes, §ions).unwrap(); let cfg_ir = cfg_ir::build_cfg_ir(&instructions, §ions, report.clone(), &bytes).unwrap(); - let metrics_after = collect_metrics(&cfg_ir, &report).unwrap(); + let metrics_after = collect_metrics(&cfg_ir).unwrap(); let score = compare(&metrics_before, &metrics_after); assert!(score > 0.0, "Transform should increase potency"); @@ -159,11 +159,11 @@ async fn test_compare_metrics() { /// Tests invariant: potency score increases with more edges. #[tokio::test] async fn test_potency_edge_increase() { - tracing_subscriber::fmt() + let _ = tracing_subscriber::fmt() .with_max_level(tracing::Level::DEBUG) .with_ansi(false) .without_time() - .init(); + .try_init(); let bytecode_simple = "0x600050"; // PUSH1 0x00, STOP let (instructions, _, _, bytes) = decoder::decode_bytecode(bytecode_simple, false) .await @@ -171,7 +171,7 @@ async fn test_potency_edge_increase() { let sections = detection::locate_sections(&bytes, &instructions, &[]).unwrap(); let (_clean_runtime, report) = strip::strip_bytecode(&bytes, §ions).unwrap(); let cfg_ir = cfg_ir::build_cfg_ir(&instructions, §ions, report.clone(), &bytes).unwrap(); - let metrics_simple = collect_metrics(&cfg_ir, &report).unwrap(); + let metrics_simple = collect_metrics(&cfg_ir).unwrap(); let bytecode_complex = "0x6000600157600256"; // PUSH1 0x00, JUMPI, JUMPDEST, STOP let (instructions, _, _, bytes) = decoder::decode_bytecode(bytecode_complex, false) @@ -180,7 +180,7 @@ async fn test_potency_edge_increase() { let sections = detection::locate_sections(&bytes, &instructions, &[]).unwrap(); let (_clean_runtime, report) = strip::strip_bytecode(&bytes, §ions).unwrap(); let cfg_ir = cfg_ir::build_cfg_ir(&instructions, §ions, report.clone(), &bytes).unwrap(); - let metrics_complex = collect_metrics(&cfg_ir, &report).unwrap(); + let metrics_complex = collect_metrics(&cfg_ir).unwrap(); assert!( metrics_complex.potency > metrics_simple.potency, @@ -191,11 +191,11 @@ async fn test_potency_edge_increase() { /// Tests dominator and post-dominator computation for a branching CFG. #[tokio::test] async fn test_dominator_computation() { - tracing_subscriber::fmt() + let _ = tracing_subscriber::fmt() .with_max_level(tracing::Level::DEBUG) .with_ansi(false) .without_time() - .init(); + .try_init(); let bytecode = "0x6000600157600256"; // PUSH1 0x00, PUSH1 0x01, JUMPI, PUSH1 0x02, JUMP let (cfg_ir, _, _, _) = azoth_core::process_bytecode_to_cfg(bytecode, false, bytecode, false) .await @@ -218,20 +218,19 @@ async fn test_dominator_computation() { ); // Verify post-dominators - let _exit = NodeIndex::::new(1); // Exit is at index 1 + let exit = NodeIndex::::new(1); // Exit is at index 1 let second_body = NodeIndex::::new(3); // Second body block is at index 3 assert!( - post_dominators.contains_key(&first_body), - "First body block should have a post-dominator" + !post_dominators.contains_key(&first_body), + "First body block cannot reach Exit in this looping graph" ); assert!( !post_dominators.contains_key(&second_body), - "Second body block should have no post-dominator due to potential loop" + "Second body block cannot reach Exit in this looping graph" ); - assert_eq!( - post_dominators.get(&first_body).copied(), - Some(second_body), - "In this graph every path from first body block goes through second body block, not Exit" + assert!( + !post_dominators.contains_key(&exit), + "The actual Exit root has no immediate post-dominator" ); // Verify overlap bounds @@ -241,7 +240,7 @@ async fn test_dominator_computation() { ); // Verify metrics integration - let metrics = collect_metrics(&cfg_ir, &cfg_ir.clean_report).unwrap(); + let metrics = collect_metrics(&cfg_ir).unwrap(); assert_eq!( metrics.dom_overlap, overlap, "Metrics overlap should match computed overlap" diff --git a/tests/src/core/cfg_ir.rs b/tests/src/core/cfg_ir.rs index f033cf0f..a0b575e5 100644 --- a/tests/src/core/cfg_ir.rs +++ b/tests/src/core/cfg_ir.rs @@ -195,10 +195,11 @@ async fn test_storage_cfg_trace_progression() { // ----------------------------------------------------------------------- // push_reaches_jump unit tests // -// `push_reaches_jump` gates `remap_orphan_jump_pushes`'s extended scan so -// only PUSH literals whose value is *plausibly* a branch target get -// remapped after a PC-shifting transform. The critical invariants these -// tests pin down are: +// `push_reaches_jump` is retained as a conservative, local diagnostic for +// callers inspecting whether one literal could reach a branch. Production +// relocation uses `CfgIrBundle::prove_jump_address_pushes`, whose whole-CFG, +// path-sensitive provenance proof also rejects mixed code/data use. The local +// helper's critical invariants are: // // * direct JUMP/JUMPI targets -> true // * values consumed by non-jump ops (arithmetic, MSTORE/SSTORE, @@ -209,9 +210,8 @@ async fn test_storage_cfg_trace_progression() { // where the return address sits beneath the JUMP's target) -> // true, which is the reason the entire extended scan exists // -// Together these ensure the post-reindex remap cannot silently corrupt -// a PUSH2 whose 16-bit literal numerically coincides with a JUMPDEST -// PC but semantically is not a branch target. +// Together these keep the diagnostic conservative; they are not the production +// relocation safety argument. // ----------------------------------------------------------------------- fn prj_instr(pc: usize, op: Opcode, imm: Option<&str>) -> Instruction { diff --git a/tests/src/core/detection/dispatcher.rs b/tests/src/core/detection/dispatcher.rs index 8d8730d1..3009538b 100644 --- a/tests/src/core/detection/dispatcher.rs +++ b/tests/src/core/detection/dispatcher.rs @@ -5,11 +5,11 @@ use azoth_core::{ #[tokio::test] async fn test_dispatcher_detection() { - tracing_subscriber::fmt() + let _ = tracing_subscriber::fmt() .with_max_level(tracing::Level::DEBUG) .with_ansi(false) .without_time() - .init(); + .try_init(); let bytecode = "0x60c060405234801561000f575f5ffd5b5060405161162b38038061162b833981810160405281019061003191906100fd565b8073ffffffffffffffffffffffffffffffffffffffff1660a08173ffffffffffffffffffffffffffffffffffffffff16815250503373ffffffffffffffffffffffffffffffffffffffff1660808173ffffffffffffffffffffffffffffffffffffffff168152505050610128565b5f5ffd5b5f73ffffffffffffffffffffffffffffffffffffffff82169050919050565b5f6100cc826100a3565b9050919050565b6100dc816100c2565b81146100e6575f5ffd5b50565b5f815190506100f7816100d3565b92915050565b5f602082840312156101125761011161009f565b5b5f61011f848285016100e9565b91505092915050565b60805160a0516114b86101735f395f818161052d015281816107c8015281816109b60152610c4801525f8181610290015281816103c5015281816105d701526108c401526114b85ff3fe608060405234801561000f575f5ffd5b50600436106100fe575f3560e01c80638bd03d0a11610095578063d415b3f911610064578063d415b3f91461022a578063e522538114610248578063f3a504f214610252578063fe03a46014610270576100fe565b80638bd03d0a146101b65780639940686e146101d4578063a65e2cfd146101f0578063cb766a561461020c576100fe565b80633ccfd60b116100d15780003ccfd60b146101665780635a4fd6451461017057806380f323a71461018e57806381972d00146101ac576100fe565b8063046f7da2146101025780631aa7c0ec1461010c578063308657d71461012a57806333ee5f3514610148575b5f5ffd5b"; diff --git a/tests/src/core/detection/sections.rs b/tests/src/core/detection/sections.rs index 45301bae..92e5a475 100644 --- a/tests/src/core/detection/sections.rs +++ b/tests/src/core/detection/sections.rs @@ -7,11 +7,11 @@ const STORAGE_BYTECODE: &str = include_str!("../../../bytecode/storage.hex"); #[tokio::test] async fn test_full_deploy_payload_properties() { - tracing_subscriber::fmt() + let _ = tracing_subscriber::fmt() .with_max_level(tracing::Level::DEBUG) .with_ansi(false) .without_time() - .init(); + .try_init(); let (instructions, info, _, bytes) = decode_bytecode(STORAGE_BYTECODE, false).await.unwrap(); diff --git a/tests/src/core/encoder.rs b/tests/src/core/encoder.rs index ff1fbd4b..dff81ef3 100644 --- a/tests/src/core/encoder.rs +++ b/tests/src/core/encoder.rs @@ -71,9 +71,8 @@ fn encode_invalid_opcode_without_original_data() { op: Opcode::INVALID, imm: None, }; - // PC 42 is beyond original bytecode, so it gets skipped + // PC 42 is beyond original bytecode, so exact preservation is impossible. let original = vec![0x60, 0x01]; // Only 2 bytes, PC 42 doesn't exist let result = encode(&[ins], &original); - assert!(result.is_ok()); - assert_eq!(result.unwrap(), Vec::::new()); // Empty - skipped! + assert!(result.is_err(), "unrecoverable raw bytes must fail closed"); } diff --git a/tests/src/core/strip.rs b/tests/src/core/strip.rs index 1d99427a..e2ef17cc 100644 --- a/tests/src/core/strip.rs +++ b/tests/src/core/strip.rs @@ -9,11 +9,11 @@ const COUNTER_RUNTIME_BYTECODE: &str = include_str!("../../bytecode/counter/coun #[tokio::test] async fn test_round_trip() { - tracing_subscriber::fmt() + let _ = tracing_subscriber::fmt() .with_max_level(tracing::Level::DEBUG) .with_ansi(false) .without_time() - .init(); + .try_init(); let (instructions, _, _, bytecode) = decode_bytecode(COUNTER_DEPLOYMENT_BYTECODE, false) .await @@ -33,11 +33,11 @@ async fn test_round_trip() { #[tokio::test] async fn test_runtime_only() { - tracing_subscriber::fmt() + let _ = tracing_subscriber::fmt() .with_max_level(tracing::Level::DEBUG) .with_ansi(false) .without_time() - .init(); + .try_init(); let (instructions, _, _, bytecode) = decode_bytecode(COUNTER_RUNTIME_BYTECODE, false) .await diff --git a/tests/src/e2e/collect_proof.rs b/tests/src/e2e/collect_proof.rs index 58794a6f..430188e4 100644 --- a/tests/src/e2e/collect_proof.rs +++ b/tests/src/e2e/collect_proof.rs @@ -16,6 +16,8 @@ use super::{ }; use azoth_core::seed::Seed; use azoth_transform::arithmetic_chain::ArithmeticChain; +use azoth_transform::cluster_shuffle::ClusterShuffle; +use azoth_transform::jump_trampoline::JumpTrampoline; use azoth_transform::obfuscator::{obfuscate_bytecode, ObfuscationConfig}; use azoth_transform::push_split::PushSplit; use azoth_transform::slot_shuffle::SlotShuffle; @@ -611,13 +613,16 @@ async fn test_collect_with_erc20_proof_dispatcher_only_succeeds() -> Result<()> /// coincidental `PUSH1 0x20; PUSH; PUSH1 0x00; CODECOPY` sequences /// the Solidity compiler emits for unrelated code copies. #[tokio::test] -async fn test_collect_with_erc20_proof_dispatcher_plus_arithmetic_chain_succeeds() -> Result<()> { - let label = "dispatcher_plus_arithmetic_chain"; - let (deployment_bytecode, bond_calldata, collect_selector) = - build_obfuscated_flow_inputs(label, vec![Box::new(ArithmeticChain::new())]).await?; - let outcome = - execute_collect_proof_flow(deployment_bytecode, bond_calldata, collect_selector, label)?; - assert_collect_flow_success(label, outcome) +async fn test_collect_with_erc20_proof_arithmetic_chain_fails_closed_on_gas() -> Result<()> { + let error = build_obfuscated_flow_inputs( + "dispatcher_plus_arithmetic_chain", + vec![Box::new(ArithmeticChain::new())], + ) + .await + .expect_err("a gas-changing pass must be rejected when runtime observes GAS"); + assert!(error.to_string().contains("ArithmeticChain")); + assert!(error.to_string().contains("GAS observation")); + Ok(()) } /// Regression probe for two PushSplit bugs that both made `collect()` @@ -634,24 +639,22 @@ async fn test_collect_with_erc20_proof_dispatcher_plus_arithmetic_chain_succeeds /// was to prepend a `PUSH0` before the chain so the first op always /// starts from zero. /// -/// 2. `cfg_ir::remap_orphan_jump_pushes` only scanned blocks ending with -/// `JUMP`/`JUMPI`. Solidity's inherited-function-call convention -/// (EscrowERC20 → EscrowBase) pushes the return address in one block -/// and consumes it from a `JUMP` in a later block, with a `JUMPDEST` -/// separating them. After PushSplit grew some blocks, those return -/// addresses were stale but invisible to the extended scan. The fix -/// drops the JUMP-ending filter and walks every body block, scoped to -/// `PUSH2+` to avoid false positives on small literals that coincide -/// with early `JUMPDEST` PCs. This test caught it as a runtime JUMP -/// at PC `0x07be` consuming a stale PUSH2 from `0x07a6`. +/// 2. Relocating a stack-carried return address requires whole-CFG provenance, +/// because Solidity may push it in one block and consume it at a `JUMP` in +/// another. Production relocation now proves code-address use path by path +/// and rejects unresolved or mixed code/data use rather than relying on a +/// numeric-literal heuristic. #[tokio::test] -async fn test_collect_with_erc20_proof_dispatcher_plus_push_split_succeeds() -> Result<()> { - let label = "dispatcher_plus_push_split"; - let (deployment_bytecode, bond_calldata, collect_selector) = - build_obfuscated_flow_inputs(label, vec![Box::new(PushSplit::new())]).await?; - let outcome = - execute_collect_proof_flow(deployment_bytecode, bond_calldata, collect_selector, label)?; - assert_collect_flow_success(label, outcome) +async fn test_collect_with_erc20_proof_push_split_fails_closed_on_gas() -> Result<()> { + let error = build_obfuscated_flow_inputs( + "dispatcher_plus_push_split", + vec![Box::new(PushSplit::new())], + ) + .await + .expect_err("a gas-changing pass must be rejected when runtime observes GAS"); + assert!(error.to_string().contains("PushSplit")); + assert!(error.to_string().contains("GAS observation")); + Ok(()) } /// Regression probe for two SlotShuffle bugs that made `bond()` revert @@ -681,23 +684,32 @@ async fn test_collect_with_erc20_proof_dispatcher_plus_push_split_succeeds() -> /// pair, and excludes those slot literals from the shuffle mapping so /// init-touched slots stay at their original indices. #[tokio::test] -async fn test_collect_with_erc20_proof_dispatcher_plus_slot_shuffle_succeeds() -> Result<()> { - let label = "dispatcher_plus_slot_shuffle"; - let (deployment_bytecode, bond_calldata, collect_selector) = - build_obfuscated_flow_inputs(label, vec![Box::new(SlotShuffle::new())]).await?; - let outcome = - execute_collect_proof_flow(deployment_bytecode, bond_calldata, collect_selector, label)?; - assert_collect_flow_success(label, outcome) +async fn test_collect_with_erc20_proof_slot_shuffle_fails_closed_on_gas() -> Result<()> { + let error = build_obfuscated_flow_inputs( + "dispatcher_plus_slot_shuffle", + vec![Box::new(SlotShuffle::new())], + ) + .await + .expect_err("a gas-changing pass must be rejected when runtime observes GAS"); + assert!(error.to_string().contains("SlotShuffle")); + assert!(error.to_string().contains("GAS observation")); + Ok(()) } #[tokio::test] -async fn test_collect_with_erc20_proof_dispatcher_plus_string_obfuscate_succeeds() -> Result<()> { - let label = "dispatcher_plus_string_obfuscate"; - let (deployment_bytecode, bond_calldata, collect_selector) = - build_obfuscated_flow_inputs(label, vec![Box::new(StringObfuscate::new())]).await?; - let outcome = - execute_collect_proof_flow(deployment_bytecode, bond_calldata, collect_selector, label)?; - assert_collect_flow_success(label, outcome) +async fn test_collect_with_erc20_proof_string_obfuscate_fails_closed() -> Result<()> { + let error = build_obfuscated_flow_inputs( + "dispatcher_plus_string_obfuscate", + vec![Box::new(StringObfuscate::new())], + ) + .await + .expect_err("unsafe string rewriting must be rejected"); + assert!(error.to_string().contains("StringObfuscate")); + assert!( + error.to_string().contains("GAS observation") + || error.to_string().contains("StringObfuscate is disabled") + ); + Ok(()) } #[tokio::test] @@ -706,10 +718,8 @@ async fn test_collect_with_erc20_proof_default_pipeline_succeeds() -> Result<()> let (deployment_bytecode, bond_calldata, collect_selector) = build_obfuscated_flow_inputs( label, vec![ - Box::new(ArithmeticChain::new()), - Box::new(PushSplit::new()), - Box::new(SlotShuffle::new()), - Box::new(StringObfuscate::new()), + Box::new(JumpTrampoline::new()), + Box::new(ClusterShuffle::new()), ], ) .await?; @@ -732,10 +742,8 @@ async fn test_collect_with_erc20_proof_failing_seed_default_pipeline() -> Result build_obfuscated_flow_inputs_with_seed( label, vec![ - Box::new(ArithmeticChain::new()), - Box::new(PushSplit::new()), - Box::new(SlotShuffle::new()), - Box::new(StringObfuscate::new()), + Box::new(JumpTrampoline::new()), + Box::new(ClusterShuffle::new()), ], seed, ) @@ -809,13 +817,16 @@ async fn bisect_failing_seed_transform_subsets() -> Result<()> { /// Same failing seed, ArithmeticChain only, to localise the corruption to a /// single transform if the full-pipeline test fails. #[tokio::test] -async fn test_collect_with_erc20_proof_failing_seed_arithmetic_chain_only() -> Result<()> { - let label = "failing_seed_arithmetic_chain_only"; +async fn test_collect_with_erc20_proof_failing_seed_arithmetic_chain_fails_closed() -> Result<()> { let seed = Seed::from_hex(MAINNET_FAILING_SEED).expect("valid seed"); - let (deployment_bytecode, bond_calldata, collect_selector) = - build_obfuscated_flow_inputs_with_seed(label, vec![Box::new(ArithmeticChain::new())], seed) - .await?; - let outcome = - execute_collect_proof_flow(deployment_bytecode, bond_calldata, collect_selector, label)?; - assert_collect_flow_success(label, outcome) + let error = build_obfuscated_flow_inputs_with_seed( + "failing_seed_arithmetic_chain_only", + vec![Box::new(ArithmeticChain::new())], + seed, + ) + .await + .expect_err("a gas-changing pass must be rejected when runtime observes GAS"); + assert!(error.to_string().contains("ArithmeticChain")); + assert!(error.to_string().contains("GAS observation")); + Ok(()) } diff --git a/tests/src/e2e/counter_differential.rs b/tests/src/e2e/counter_differential.rs new file mode 100644 index 00000000..77e3bdbd --- /dev/null +++ b/tests/src/e2e/counter_differential.rs @@ -0,0 +1,738 @@ +use azoth_core::seed::Seed; +use azoth_transform::obfuscator::{obfuscate_bytecode, ObfuscationConfig}; +use color_eyre::eyre::eyre; +use color_eyre::Result; +use revm::context::result::{ExecutionResult, Output}; +use revm::context::{ContextTr, TxEnv}; +use revm::database::InMemoryDB; +use revm::primitives::{Address, Bytes, Log, TxKind, U256}; +use revm::state::AccountInfo; +use revm::{Context, DatabaseCommit, ExecuteEvm, MainBuilder, MainContext}; +use std::collections::{BTreeMap, HashMap, HashSet}; + +const COUNTER_DEPLOYMENT_BYTECODE: &str = + include_str!("../../bytecode/counter/counter_deployment.hex"); +const COUNTER_RUNTIME_BYTECODE: &str = include_str!("../../bytecode/counter/counter_runtime.hex"); + +const SET_NUMBER_SELECTOR: u32 = 0x3fb5c1cb; +const NUMBER_SELECTOR: u32 = 0x8381f58a; +const INCREMENT_SELECTOR: u32 = 0xd09de08a; + +const DEPLOY_GAS_LIMIT: u64 = 20_000_000; +const CALL_GAS_LIMIT: u64 = 5_000_000; +const MAX_INITCODE_SIZE: usize = 49_152; +const MAX_RUNTIME_SIZE: usize = 24_576; +const MAX_RELATIVE_SIZE_MULTIPLIER: usize = 2; +const INITIAL_BALANCE: u128 = 1_000_000_000_000_000_000; +const DEPLOYER: Address = Address::new([0x45; 20]); + +const REGRESSION_SEEDS: [[u8; 32]; 3] = [[0x00; 32], [0x42; 32], [0xff; 32]]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +enum CounterFunction { + SetNumber, + Number, + Increment, +} + +#[derive(Debug, Clone, Copy)] +struct CounterSelectors { + set_number: [u8; 4], + number: [u8; 4], + increment: [u8; 4], +} + +impl CounterSelectors { + fn original() -> Self { + Self { + set_number: SET_NUMBER_SELECTOR.to_be_bytes(), + number: NUMBER_SELECTOR.to_be_bytes(), + increment: INCREMENT_SELECTOR.to_be_bytes(), + } + } + + fn from_mapping(mapping: &HashMap>) -> Result { + Ok(Self { + set_number: mapped_selector(mapping, SET_NUMBER_SELECTOR)?, + number: mapped_selector(mapping, NUMBER_SELECTOR)?, + increment: mapped_selector(mapping, INCREMENT_SELECTOR)?, + }) + } + + fn get(self, function: CounterFunction) -> [u8; 4] { + match function { + CounterFunction::SetNumber => self.set_number, + CounterFunction::Number => self.number, + CounterFunction::Increment => self.increment, + } + } + + fn all(self) -> [[u8; 4]; 3] { + [self.set_number, self.number, self.increment] + } +} + +fn mapped_selector(mapping: &HashMap>, selector: u32) -> Result<[u8; 4]> { + let token = mapping + .get(&selector) + .ok_or_else(|| eyre!("missing Counter selector mapping for 0x{selector:08x}"))?; + token.as_slice().try_into().map_err(|_| { + eyre!( + "Counter selector 0x{selector:08x} mapped to {} bytes instead of 4", + token.len() + ) + }) +} + +#[derive(Debug, Clone)] +enum CalldataSpec { + Function { + function: CounterFunction, + arguments: Vec, + }, + Raw(Vec), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum OutcomeClass { + Success, + Revert, + Halt, +} + +#[derive(Debug, Clone)] +struct LogicalCall { + label: &'static str, + calldata: CalldataSpec, + value: U256, + expected_class: OutcomeClass, + expected_output: Bytes, + expected_storage: U256, +} + +impl LogicalCall { + fn encoded_calldata(&self, selectors: CounterSelectors) -> Bytes { + match &self.calldata { + CalldataSpec::Function { + function, + arguments, + } => { + let mut data = selectors.get(*function).to_vec(); + data.extend_from_slice(arguments); + Bytes::from(data) + } + CalldataSpec::Raw(data) => Bytes::copy_from_slice(data), + } + } +} + +fn word(value: U256) -> Vec { + value.to_be_bytes::<32>().to_vec() +} + +fn encoded_word(value: U256) -> Bytes { + Bytes::copy_from_slice(&value.to_be_bytes::<32>()) +} + +fn arithmetic_panic_output() -> Bytes { + let mut output = vec![0x4e, 0x48, 0x7b, 0x71]; + let mut code = [0u8; 32]; + code[31] = 0x11; + output.extend_from_slice(&code); + Bytes::from(output) +} + +fn call_plan(unknown_selector: [u8; 4]) -> Vec { + vec![ + LogicalCall { + label: "number.initial", + calldata: CalldataSpec::Function { + function: CounterFunction::Number, + arguments: Vec::new(), + }, + value: U256::ZERO, + expected_class: OutcomeClass::Success, + expected_output: encoded_word(U256::ZERO), + expected_storage: U256::ZERO, + }, + LogicalCall { + label: "increment.valid", + calldata: CalldataSpec::Function { + function: CounterFunction::Increment, + arguments: Vec::new(), + }, + value: U256::ZERO, + expected_class: OutcomeClass::Success, + expected_output: Bytes::new(), + expected_storage: U256::from(1), + }, + LogicalCall { + label: "number.after_increment", + calldata: CalldataSpec::Function { + function: CounterFunction::Number, + arguments: Vec::new(), + }, + value: U256::ZERO, + expected_class: OutcomeClass::Success, + expected_output: encoded_word(U256::from(1)), + expected_storage: U256::from(1), + }, + LogicalCall { + label: "setNumber.missing_argument", + calldata: CalldataSpec::Function { + function: CounterFunction::SetNumber, + arguments: Vec::new(), + }, + value: U256::ZERO, + expected_class: OutcomeClass::Revert, + expected_output: Bytes::new(), + expected_storage: U256::from(1), + }, + LogicalCall { + label: "setNumber.short_argument", + calldata: CalldataSpec::Function { + function: CounterFunction::SetNumber, + arguments: vec![0u8; 31], + }, + value: U256::ZERO, + expected_class: OutcomeClass::Revert, + expected_output: Bytes::new(), + expected_storage: U256::from(1), + }, + LogicalCall { + label: "dispatcher.unknown_selector", + calldata: CalldataSpec::Raw(unknown_selector.to_vec()), + value: U256::ZERO, + expected_class: OutcomeClass::Revert, + expected_output: Bytes::new(), + expected_storage: U256::from(1), + }, + LogicalCall { + label: "dispatcher.short_calldata", + calldata: CalldataSpec::Raw(vec![0x12, 0x34, 0x56]), + value: U256::ZERO, + expected_class: OutcomeClass::Revert, + expected_output: Bytes::new(), + expected_storage: U256::from(1), + }, + LogicalCall { + label: "number.nonpayable_value", + calldata: CalldataSpec::Function { + function: CounterFunction::Number, + arguments: Vec::new(), + }, + value: U256::from(1), + expected_class: OutcomeClass::Revert, + expected_output: Bytes::new(), + expected_storage: U256::from(1), + }, + LogicalCall { + label: "number.after_invalid_calls", + calldata: CalldataSpec::Function { + function: CounterFunction::Number, + arguments: Vec::new(), + }, + value: U256::ZERO, + expected_class: OutcomeClass::Success, + expected_output: encoded_word(U256::from(1)), + expected_storage: U256::from(1), + }, + LogicalCall { + label: "setNumber.max", + calldata: CalldataSpec::Function { + function: CounterFunction::SetNumber, + arguments: word(U256::MAX), + }, + value: U256::ZERO, + expected_class: OutcomeClass::Success, + expected_output: Bytes::new(), + expected_storage: U256::MAX, + }, + LogicalCall { + label: "increment.overflow", + calldata: CalldataSpec::Function { + function: CounterFunction::Increment, + arguments: Vec::new(), + }, + value: U256::ZERO, + expected_class: OutcomeClass::Revert, + expected_output: arithmetic_panic_output(), + expected_storage: U256::MAX, + }, + LogicalCall { + label: "number.after_overflow", + calldata: CalldataSpec::Function { + function: CounterFunction::Number, + arguments: Vec::new(), + }, + value: U256::ZERO, + expected_class: OutcomeClass::Success, + expected_output: encoded_word(U256::MAX), + expected_storage: U256::MAX, + }, + LogicalCall { + label: "setNumber.42", + calldata: CalldataSpec::Function { + function: CounterFunction::SetNumber, + arguments: word(U256::from(42)), + }, + value: U256::ZERO, + expected_class: OutcomeClass::Success, + expected_output: Bytes::new(), + expected_storage: U256::from(42), + }, + LogicalCall { + label: "number.trailing_calldata", + calldata: CalldataSpec::Function { + function: CounterFunction::Number, + arguments: vec![0xa5; 32], + }, + value: U256::ZERO, + expected_class: OutcomeClass::Success, + expected_output: encoded_word(U256::from(42)), + expected_storage: U256::from(42), + }, + LogicalCall { + label: "increment.after_reset", + calldata: CalldataSpec::Function { + function: CounterFunction::Increment, + arguments: Vec::new(), + }, + value: U256::ZERO, + expected_class: OutcomeClass::Success, + expected_output: Bytes::new(), + expected_storage: U256::from(43), + }, + LogicalCall { + label: "number.final", + calldata: CalldataSpec::Function { + function: CounterFunction::Number, + arguments: Vec::new(), + }, + value: U256::ZERO, + expected_class: OutcomeClass::Success, + expected_output: encoded_word(U256::from(43)), + expected_storage: U256::from(43), + }, + ] +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct OutcomeObservation { + class: OutcomeClass, + output: Option, + logs: Vec, + halt_reason: Option, +} + +fn observe_outcome(result: &ExecutionResult) -> OutcomeObservation { + match result { + ExecutionResult::Success { output, logs, .. } => OutcomeObservation { + class: OutcomeClass::Success, + output: Some(output.data().clone()), + logs: logs.clone(), + halt_reason: None, + }, + ExecutionResult::Revert { output, .. } => OutcomeObservation { + class: OutcomeClass::Revert, + output: Some(output.clone()), + logs: Vec::new(), + halt_reason: None, + }, + ExecutionResult::Halt { reason, .. } => OutcomeObservation { + class: OutcomeClass::Halt, + output: None, + logs: Vec::new(), + halt_reason: Some(format!("{reason:?}")), + }, + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct StateSnapshot { + caller_balance: U256, + caller_nonce: u64, + contract_balance: U256, + contract_nonce: u64, + contract_storage: BTreeMap, +} + +impl StateSnapshot { + fn storage_value(&self, slot: U256) -> U256 { + self.contract_storage + .get(&slot) + .copied() + .unwrap_or(U256::ZERO) + } +} + +fn state_snapshot(db: &InMemoryDB, contract: Address) -> Result { + let caller = db + .cache + .accounts + .get(&DEPLOYER) + .ok_or_else(|| eyre!("deployer account missing from REVM state"))?; + let contract = db + .cache + .accounts + .get(&contract) + .ok_or_else(|| eyre!("Counter account missing from REVM state"))?; + + Ok(StateSnapshot { + caller_balance: caller.info.balance, + caller_nonce: caller.info.nonce, + contract_balance: contract.info.balance, + contract_nonce: contract.info.nonce, + contract_storage: contract + .storage + .iter() + .map(|(slot, value)| (*slot, *value)) + .collect(), + }) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct StepObservation { + outcome: OutcomeObservation, + state: StateSnapshot, +} + +#[derive(Debug)] +struct ExecutionTranscript { + address: Address, + runtime_len: usize, + deployment_logs: Vec, + deployment_state: StateSnapshot, + steps: Vec, +} + +fn execute_sequence( + creation_bytecode: Bytes, + selectors: CounterSelectors, + calls: &[LogicalCall], +) -> Result { + let mut db = InMemoryDB::default(); + db.insert_account_info( + DEPLOYER, + AccountInfo { + balance: U256::from(INITIAL_BALANCE), + nonce: 0, + code_hash: revm::primitives::KECCAK_EMPTY, + code: None, + }, + ); + let mut evm = Context::mainnet().with_db(db).build_mainnet(); + + let deployment = evm + .transact(TxEnv { + caller: DEPLOYER, + gas_limit: DEPLOY_GAS_LIMIT, + kind: TxKind::Create, + data: creation_bytecode, + value: U256::ZERO, + nonce: 0, + ..Default::default() + }) + .map_err(|error| eyre!("Counter deployment execution failed: {error:?}"))?; + + let (address, runtime_len, deployment_logs) = match &deployment.result { + ExecutionResult::Success { + output: Output::Create(runtime, Some(address)), + logs, + .. + } => (*address, runtime.len(), logs.clone()), + ExecutionResult::Success { output, .. } => { + return Err(eyre!( + "Counter deployment returned unexpected output: {output:?}" + )); + } + ExecutionResult::Revert { output, .. } => { + return Err(eyre!( + "Counter deployment reverted: 0x{}", + hex::encode(output) + )); + } + ExecutionResult::Halt { reason, .. } => { + return Err(eyre!("Counter deployment halted: {reason:?}")); + } + }; + evm.db_mut().commit(deployment.state); + let deployment_state = state_snapshot(evm.db(), address)?; + + let mut steps = Vec::with_capacity(calls.len()); + for (index, call) in calls.iter().enumerate() { + let execution = evm + .transact(TxEnv { + caller: DEPLOYER, + gas_limit: CALL_GAS_LIMIT, + kind: TxKind::Call(address), + data: call.encoded_calldata(selectors), + value: call.value, + nonce: (index + 1) as u64, + ..Default::default() + }) + .map_err(|error| eyre!("{} execution failed: {error:?}", call.label))?; + let outcome = observe_outcome(&execution.result); + evm.db_mut().commit(execution.state); + let state = state_snapshot(evm.db(), address)?; + steps.push(StepObservation { outcome, state }); + } + + Ok(ExecutionTranscript { + address, + runtime_len, + deployment_logs, + deployment_state, + steps, + }) +} + +fn choose_unknown_selector(obfuscated: CounterSelectors) -> [u8; 4] { + let forbidden: HashSet<[u8; 4]> = CounterSelectors::original() + .all() + .into_iter() + .chain(obfuscated.all()) + .collect(); + let mut candidate = 0xdecafbad_u32; + + loop { + let bytes = candidate.to_be_bytes(); + if !forbidden.contains(&bytes) { + return bytes; + } + candidate = candidate.wrapping_add(0x9e37_79b9); + } +} + +fn assert_mapping_is_unambiguous(mapped: CounterSelectors, seed_label: &str) { + let originals: HashSet<_> = CounterSelectors::original().all().into_iter().collect(); + let mapped_values = mapped.all(); + let unique: HashSet<_> = mapped_values.into_iter().collect(); + + assert_eq!( + unique.len(), + mapped_values.len(), + "mapped selectors must be unique for seed {seed_label}" + ); + for token in mapped_values { + assert!( + !originals.contains(&token), + "mapped token 0x{} aliases an original Counter selector for seed {seed_label}", + hex::encode(token) + ); + } +} + +fn assert_original_semantics( + transcript: &ExecutionTranscript, + calls: &[LogicalCall], + seed_label: &str, +) { + assert!( + transcript.deployment_logs.is_empty(), + "Counter constructor unexpectedly emitted logs for seed {seed_label}" + ); + assert_eq!(transcript.deployment_state.contract_balance, U256::ZERO); + assert_eq!( + transcript.deployment_state.caller_balance, + U256::from(INITIAL_BALANCE) + ); + + for (index, (call, observed)) in calls.iter().zip(&transcript.steps).enumerate() { + assert_eq!( + observed.outcome.class, call.expected_class, + "unexpected original outcome class at {} for seed {seed_label}", + call.label + ); + assert_eq!( + observed.outcome.output.as_ref(), + Some(&call.expected_output), + "unexpected original output at {} for seed {seed_label}", + call.label + ); + assert!( + observed.outcome.logs.is_empty(), + "Counter unexpectedly emitted logs at {} for seed {seed_label}", + call.label + ); + assert!( + observed.outcome.halt_reason.is_none(), + "Counter unexpectedly halted at {} for seed {seed_label}", + call.label + ); + assert_eq!( + observed.state.storage_value(U256::ZERO), + call.expected_storage, + "unexpected slot zero at {} for seed {seed_label}", + call.label + ); + assert_eq!( + observed.state.contract_balance, + U256::ZERO, + "Counter retained value at {} for seed {seed_label}", + call.label + ); + assert_eq!( + observed.state.caller_balance, + U256::from(INITIAL_BALANCE), + "deployer value effect differed at {} for seed {seed_label}", + call.label + ); + assert_eq!( + observed.state.caller_nonce, + (index + 2) as u64, + "unexpected deployer nonce at {} for seed {seed_label}", + call.label + ); + } +} + +fn assert_transcripts_equivalent( + original: &ExecutionTranscript, + obfuscated: &ExecutionTranscript, + calls: &[LogicalCall], + seed_label: &str, +) { + assert_eq!( + original.address, obfuscated.address, + "CREATE address changed for seed {seed_label}" + ); + assert_eq!( + original.deployment_logs, obfuscated.deployment_logs, + "constructor logs changed for seed {seed_label}" + ); + assert_eq!( + original.deployment_state, obfuscated.deployment_state, + "constructor state/value effects changed for seed {seed_label}" + ); + assert_eq!(original.steps.len(), obfuscated.steps.len()); + + for ((call, original_step), obfuscated_step) in + calls.iter().zip(&original.steps).zip(&obfuscated.steps) + { + assert_eq!( + original_step.outcome, obfuscated_step.outcome, + "observable execution diverged at {} for seed {seed_label}", + call.label + ); + assert_eq!( + original_step.state, obfuscated_step.state, + "post-state/value effects diverged at {} for seed {seed_label}", + call.label + ); + } +} + +#[tokio::test] +async fn production_counter_is_differentially_equivalent_for_fixed_seed_corpus() -> Result<()> { + let original_creation = Bytes::from(hex::decode( + COUNTER_DEPLOYMENT_BYTECODE.trim().trim_start_matches("0x"), + )?); + let original_runtime = hex::decode(COUNTER_RUNTIME_BYTECODE.trim().trim_start_matches("0x"))?; + let mut distinct_outputs = HashSet::new(); + + for seed_bytes in REGRESSION_SEEDS { + let seed_label = hex::encode(seed_bytes); + let first = obfuscate_bytecode( + COUNTER_DEPLOYMENT_BYTECODE, + COUNTER_RUNTIME_BYTECODE, + ObfuscationConfig::with_seed(Seed::from_bytes(seed_bytes)), + ) + .await + .map_err(|error| eyre!("obfuscation failed for seed {seed_label}: {error}"))?; + let repeat = obfuscate_bytecode( + COUNTER_DEPLOYMENT_BYTECODE, + COUNTER_RUNTIME_BYTECODE, + ObfuscationConfig::with_seed(Seed::from_bytes(seed_bytes)), + ) + .await + .map_err(|error| eyre!("repeat obfuscation failed for seed {seed_label}: {error}"))?; + + assert_eq!( + first.obfuscated_bytecode, repeat.obfuscated_bytecode, + "deployment output was nondeterministic for seed {seed_label}" + ); + assert_eq!( + first.obfuscated_runtime, repeat.obfuscated_runtime, + "runtime output was nondeterministic for seed {seed_label}" + ); + assert_eq!( + first.selector_mapping, repeat.selector_mapping, + "selector mapping was nondeterministic for seed {seed_label}" + ); + assert_eq!( + first.metadata.transforms_applied, repeat.metadata.transforms_applied, + "applied-pass metadata was nondeterministic for seed {seed_label}" + ); + assert_eq!( + first.metadata.transform_outcomes, repeat.metadata.transform_outcomes, + "pass outcomes were nondeterministic for seed {seed_label}" + ); + + let obfuscated_creation = Bytes::from(hex::decode( + first.obfuscated_bytecode.trim_start_matches("0x"), + )?); + let obfuscated_runtime = hex::decode(first.obfuscated_runtime.trim_start_matches("0x"))?; + assert_ne!( + obfuscated_creation, original_creation, + "production pipeline made no bytecode change for seed {seed_label}" + ); + assert!( + obfuscated_creation.len() <= original_creation.len() * MAX_RELATIVE_SIZE_MULTIPLIER, + "initcode exceeded {}x relative ceiling for seed {seed_label}: {} > {}", + MAX_RELATIVE_SIZE_MULTIPLIER, + obfuscated_creation.len(), + original_creation.len() * MAX_RELATIVE_SIZE_MULTIPLIER + ); + assert!( + obfuscated_runtime.len() <= original_runtime.len() * MAX_RELATIVE_SIZE_MULTIPLIER, + "runtime template exceeded {}x relative ceiling for seed {seed_label}: {} > {}", + MAX_RELATIVE_SIZE_MULTIPLIER, + obfuscated_runtime.len(), + original_runtime.len() * MAX_RELATIVE_SIZE_MULTIPLIER + ); + assert!(obfuscated_creation.len() <= MAX_INITCODE_SIZE); + assert!(obfuscated_runtime.len() <= MAX_RUNTIME_SIZE); + + let mapping = first + .selector_mapping + .as_ref() + .ok_or_else(|| eyre!("missing selector mapping for seed {seed_label}"))?; + let mapped_selectors = CounterSelectors::from_mapping(mapping)?; + assert_mapping_is_unambiguous(mapped_selectors, &seed_label); + + let unknown_selector = choose_unknown_selector(mapped_selectors); + let calls = call_plan(unknown_selector); + let original_transcript = execute_sequence( + original_creation.clone(), + CounterSelectors::original(), + &calls, + )?; + let obfuscated_transcript = + execute_sequence(obfuscated_creation, mapped_selectors, &calls)?; + + assert_original_semantics(&original_transcript, &calls, &seed_label); + assert_transcripts_equivalent( + &original_transcript, + &obfuscated_transcript, + &calls, + &seed_label, + ); + assert!( + obfuscated_transcript.runtime_len + <= original_transcript.runtime_len * MAX_RELATIVE_SIZE_MULTIPLIER, + "deployed runtime exceeded {}x ceiling for seed {seed_label}", + MAX_RELATIVE_SIZE_MULTIPLIER + ); + assert!(obfuscated_transcript.runtime_len <= MAX_RUNTIME_SIZE); + distinct_outputs.insert(first.obfuscated_bytecode); + } + + assert_eq!( + distinct_outputs.len(), + REGRESSION_SEEDS.len(), + "fixed seed corpus should produce distinct deployment bytecode" + ); + + Ok(()) +} diff --git a/tests/src/e2e/layout_safety.rs b/tests/src/e2e/layout_safety.rs new file mode 100644 index 00000000..330bc1ff --- /dev/null +++ b/tests/src/e2e/layout_safety.rs @@ -0,0 +1,231 @@ +use azoth_core::seed::Seed; +use azoth_transform::cluster_shuffle::ClusterShuffle; +use azoth_transform::jump_trampoline::JumpTrampoline; +use azoth_transform::obfuscator::{obfuscate_bytecode, ObfuscationConfig}; +use color_eyre::eyre::{eyre, Result}; +use revm::bytecode::Bytecode; +use revm::context::result::{ExecutionResult, Output}; +use revm::context::{ContextTr, TxEnv}; +use revm::database::InMemoryDB; +use revm::primitives::{keccak256, Address, Bytes, TxKind, U256}; +use revm::state::AccountInfo; +use revm::{Context, DatabaseCommit, ExecuteEvm, MainBuilder, MainContext}; + +const SEED: [u8; 32] = [0x42; 32]; +const COLLISION_DEPLOYMENT: &str = + "0x6017600a5f3960175ff361000c6100105600000000005b0000005b5f5260205ff3"; +const COLLISION_RUNTIME: &str = "0x61000c6100105600000000005b0000005b5f5260205ff3"; +const DYNAMIC_JUMP_DEPLOYMENT: &str = "0x6010600a5f3960105ff35f35565b005b005b602a5f5260205ff3"; +const DYNAMIC_JUMP_RUNTIME: &str = "0x5f35565b005b005b602a5f5260205ff3"; +const TAIL_DEPLOYMENT: &str = "0x600b600a5f39600b5ff3610008565bfe5bfe5b6002"; +const TAIL_RUNTIME: &str = "0x610008565bfe5bfe5b6002"; +const UNRESOLVED_JUMPI_DEPLOYMENT: &str = + "0x6011600a5f3960115ff36000600f805057602a5f5260205ff35bfe"; +const UNRESOLVED_JUMPI_RUNTIME: &str = "0x6000600f805057602a5f5260205ff35bfe"; +const INIT_DATA_COLLISION_DEPLOYMENT: &str = + "0x601260105f395f600c015f5560125ff35f600857600c56fe5b6010565b6010565b00"; +const INIT_DATA_COLLISION_RUNTIME: &str = "0x5f600857600c56fe5b6010565b6010565b00"; +const UNPROVEN_IMMUTABLE_BASE_DEPLOYMENT: &str = "0x604b60315f397f5b602a5f5260205ff30000000000000000000000000000000000000000000000602260090152604b5ff336600757602b565b7f000000000000000000000000000000000000000000000000000000000000000050005b00000000000000000000000000000000000000000000000000000000000000"; +const UNPROVEN_IMMUTABLE_BASE_RUNTIME: &str = "0x36600757602b565b7f000000000000000000000000000000000000000000000000000000000000000050005b00000000000000000000000000000000000000000000000000000000000000"; + +fn decode_hex(value: &str) -> Result> { + hex::decode(value.trim_start_matches("0x")).map_err(Into::into) +} + +fn execute_runtime(runtime: &[u8], calldata: &[u8]) -> Result { + let caller = Address::new([0x45; 20]); + let contract = Address::new([0x24; 20]); + let mut db = InMemoryDB::default(); + db.insert_account_info( + caller, + AccountInfo { + balance: U256::from(1_000_000_000_000_000_000u128), + nonce: 0, + code_hash: revm::primitives::KECCAK_EMPTY, + code: None, + }, + ); + db.insert_account_info( + contract, + AccountInfo { + balance: U256::ZERO, + nonce: 1, + code_hash: keccak256(runtime), + code: Some(Bytecode::new_raw(Bytes::copy_from_slice(runtime))), + }, + ); + let mut evm = Context::mainnet().with_db(db).build_mainnet(); + let result = evm + .transact(TxEnv { + caller, + gas_limit: 5_000_000, + kind: TxKind::Call(contract), + data: Bytes::copy_from_slice(calldata), + value: U256::ZERO, + nonce: 0, + ..Default::default() + }) + .map_err(|error| eyre!("runtime execution failed: {error:?}"))?; + + match result.result { + ExecutionResult::Success { output, .. } => match output { + Output::Call(bytes) => Ok(bytes), + Output::Create(..) => Err(eyre!("runtime call returned create output")), + }, + ExecutionResult::Revert { output, .. } => { + Err(eyre!("runtime reverted with 0x{}", hex::encode(output))) + } + ExecutionResult::Halt { reason, .. } => Err(eyre!("runtime halted: {reason:?}")), + } +} + +fn deploy_storage_slot_zero(creation: &[u8]) -> Result { + let caller = Address::new([0x45; 20]); + let mut db = InMemoryDB::default(); + db.insert_account_info( + caller, + AccountInfo { + balance: U256::from(1_000_000_000_000_000_000u128), + nonce: 0, + code_hash: revm::primitives::KECCAK_EMPTY, + code: None, + }, + ); + let mut evm = Context::mainnet().with_db(db).build_mainnet(); + let deployment = evm + .transact(TxEnv { + caller, + gas_limit: 5_000_000, + kind: TxKind::Create, + data: Bytes::copy_from_slice(creation), + value: U256::ZERO, + nonce: 0, + ..Default::default() + }) + .map_err(|error| eyre!("deployment execution failed: {error:?}"))?; + let address = match &deployment.result { + ExecutionResult::Success { + output: Output::Create(_, Some(address)), + .. + } => *address, + other => return Err(eyre!("deployment failed: {other:?}")), + }; + evm.db_mut().commit(deployment.state); + let account = evm + .db() + .cache + .accounts + .get(&address) + .ok_or_else(|| eyre!("deployed account is missing"))?; + Ok(account + .storage + .get(&U256::ZERO) + .copied() + .unwrap_or_default()) +} + +#[tokio::test] +async fn stack_carried_data_equal_to_jumpdest_is_not_relocated() -> Result<()> { + let mut config = ObfuscationConfig::with_seed(Seed::from_bytes(SEED)); + config.transforms = vec![Box::new(ClusterShuffle::new())]; + let transformed = obfuscate_bytecode(COLLISION_DEPLOYMENT, COLLISION_RUNTIME, config).await?; + let original_output = execute_runtime(&decode_hex(COLLISION_RUNTIME)?, &[])?; + let transformed_runtime = decode_hex(&transformed.obfuscated_runtime)?; + let transformed_output = execute_runtime(&transformed_runtime, &[])?; + + let mut expected = [0u8; 32]; + expected[31] = 0x0c; + assert_eq!(original_output.as_ref(), expected); + assert_eq!(transformed_output, original_output); + Ok(()) +} + +#[tokio::test] +async fn immutable_like_write_with_unproven_runtime_base_fails_closed() -> Result<()> { + let mut config = ObfuscationConfig::with_seed(Seed::from_bytes([ + 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, + 0xef, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, + 0xcd, 0xef, + ])); + config.transforms = vec![Box::new(ClusterShuffle::new())]; + let error = obfuscate_bytecode( + UNPROVEN_IMMUTABLE_BASE_DEPLOYMENT, + UNPROVEN_IMMUTABLE_BASE_RUNTIME, + config, + ) + .await + .expect_err("an unrelated ADD base must not be patched as a Solidity immutable write"); + assert!( + error + .to_string() + .contains("does not reuse the proven CODECOPY base and length"), + "unexpected error: {error}" + ); + Ok(()) +} + +#[tokio::test] +async fn unresolved_dynamic_jump_fails_closed() { + let mut config = ObfuscationConfig::with_seed(Seed::from_bytes(SEED)); + config.transforms = vec![Box::new(ClusterShuffle::new())]; + let error = obfuscate_bytecode(DYNAMIC_JUMP_DEPLOYMENT, DYNAMIC_JUMP_RUNTIME, config) + .await + .expect_err("calldata-derived jump target must not be guessed"); + assert!(error.message.contains("unresolved dynamic JUMP/JUMPI")); +} + +#[tokio::test] +async fn eof_falloff_is_preserved_by_each_layout_pass() -> Result<()> { + let original_output = execute_runtime(&decode_hex(TAIL_RUNTIME)?, &[])?; + for transforms in [ + vec![Box::new(ClusterShuffle::new()) as Box], + vec![Box::new(JumpTrampoline::new()) as Box], + ] { + let mut config = ObfuscationConfig::with_seed(Seed::from_bytes(SEED)); + config.transforms = transforms; + let transformed = obfuscate_bytecode(TAIL_DEPLOYMENT, TAIL_RUNTIME, config).await?; + let output = execute_runtime(&decode_hex(&transformed.obfuscated_runtime)?, &[])?; + assert_eq!(output, original_output); + } + Ok(()) +} + +#[tokio::test] +async fn unresolved_jumpi_false_fallthrough_stays_adjacent() -> Result<()> { + let mut config = ObfuscationConfig::with_seed(Seed::from_bytes(SEED)); + config.transforms = vec![Box::new(ClusterShuffle::new())]; + let transformed = obfuscate_bytecode( + UNRESOLVED_JUMPI_DEPLOYMENT, + UNRESOLVED_JUMPI_RUNTIME, + config, + ) + .await?; + let original_output = execute_runtime(&decode_hex(UNRESOLVED_JUMPI_RUNTIME)?, &[])?; + let output = execute_runtime(&decode_hex(&transformed.obfuscated_runtime)?, &[])?; + assert_eq!(output, original_output); + assert_eq!(output.len(), 32); + assert_eq!(output[31], 0x2a); + Ok(()) +} + +#[tokio::test] +async fn init_arithmetic_literal_equal_to_runtime_pc_is_not_relocated() -> Result<()> { + let mut config = ObfuscationConfig::with_seed( + Seed::from_hex("0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + .expect("fixed seed"), + ); + config.transforms = vec![Box::new(ClusterShuffle::new())]; + let transformed = obfuscate_bytecode( + INIT_DATA_COLLISION_DEPLOYMENT, + INIT_DATA_COLLISION_RUNTIME, + config, + ) + .await?; + + let original_slot = deploy_storage_slot_zero(&decode_hex(INIT_DATA_COLLISION_DEPLOYMENT)?)?; + let transformed_slot = + deploy_storage_slot_zero(&decode_hex(&transformed.obfuscated_bytecode)?)?; + assert_eq!(original_slot, U256::from(0x0c)); + assert_eq!(transformed_slot, original_slot); + Ok(()) +} diff --git a/tests/src/e2e/mod.rs b/tests/src/e2e/mod.rs index f9a980af..5cd9adeb 100644 --- a/tests/src/e2e/mod.rs +++ b/tests/src/e2e/mod.rs @@ -412,3 +412,9 @@ mod test_original; #[cfg(test)] mod test_counter; + +#[cfg(test)] +mod counter_differential; + +#[cfg(test)] +mod layout_safety; diff --git a/tests/src/e2e/test_counter.rs b/tests/src/e2e/test_counter.rs index a0eb5bd6..f94f7ead 100644 --- a/tests/src/e2e/test_counter.rs +++ b/tests/src/e2e/test_counter.rs @@ -1,3 +1,5 @@ +use azoth_analysis::similarity::conservative_lcs_retention; +use azoth_core::seed::Seed; use azoth_transform::obfuscator::{obfuscate_bytecode, ObfuscationConfig}; use color_eyre::eyre::eyre; use color_eyre::Result; @@ -19,6 +21,9 @@ const COUNTER_RUNTIME_BYTECODE: &str = include_str!("../../bytecode/counter/coun const SELECTOR_SET_NUMBER: u32 = 0x3fb5c1cb; const SELECTOR_NUMBER: u32 = 0x8381f58a; const SELECTOR_INCREMENT: u32 = 0xd09de08a; +// This seed previously exposed stale runtime bounds when a size-growing pass ran before +// ClusterShuffle. Keep it pinned as a relocation regression for the production pipeline. +const FIXED_SEED: &str = "0x0000000000000000000000000000000000000000000000000000000000000000"; fn selector_token(mapping: &HashMap>, selector: u32) -> Result { let token = mapping @@ -56,10 +61,12 @@ async fn test_obfuscated_counter_deploys_and_counts() -> Result<()> { .without_time() .try_init(); + let seed = Seed::from_hex(FIXED_SEED) + .map_err(|error| eyre!("Invalid fixed Counter regression seed: {error}"))?; let obfuscation_result = obfuscate_bytecode( COUNTER_DEPLOYMENT_BYTECODE, COUNTER_RUNTIME_BYTECODE, - ObfuscationConfig::default(), + ObfuscationConfig::with_seed(seed), ) .await .map_err(|e| eyre!("Bytecode transformation failed: {}", e))?; @@ -114,6 +121,18 @@ async fn test_obfuscated_counter_deploys_and_counts() -> Result<()> { .trim_start_matches("0x"), ) .map_err(|e| eyre!("Failed to decode obfuscated bytecode: {}", e))?; + let original_bytes = hex::decode(COUNTER_DEPLOYMENT_BYTECODE.trim()) + .map_err(|e| eyre!("Failed to decode original Counter bytecode: {e}"))?; + let conservative_change = 1.0 - conservative_lcs_retention(&original_bytes, &obfuscated_bytes); + println!( + "Conservative changed-byte lower bound: {:.2}%", + conservative_change * 100.0 + ); + assert!( + conservative_change >= 0.50, + "fixed production seed must conservatively change at least 50% of Counter bytecode; got {:.2}%", + conservative_change * 100.0 + ); println!( "Deploying {} bytes, init code (first 28 bytes, hex): {}", diff --git a/tests/src/transforms/determinism.rs b/tests/src/transforms/determinism.rs index dd14a53d..a6ab2d2f 100644 --- a/tests/src/transforms/determinism.rs +++ b/tests/src/transforms/determinism.rs @@ -114,19 +114,22 @@ async fn slot_shuffle_is_deterministic_for_same_seed() { } #[tokio::test] -async fn string_obfuscate_is_deterministic_for_same_seed() { - assert_transform_deterministic_for_bytecode( - "escrow runtime", +async fn string_obfuscate_fails_closed_without_mutation() { + let seed = Seed::from_hex(FIXED_SEED).unwrap(); + let (mut cfg, _, _, _) = process_bytecode_to_cfg( ESCROW_CONTRACT_RUNTIME_BYTECODE, - "StringObfuscate", - || Box::new(StringObfuscate::new()), - ) - .await; - assert_transform_deterministic_for_bytecode( - "escrow deployment", - ESCROW_CONTRACT_DEPLOYMENT_BYTECODE, - "StringObfuscate", - || Box::new(StringObfuscate::new()), + false, + ESCROW_CONTRACT_RUNTIME_BYTECODE, + false, ) - .await; + .await + .unwrap(); + let before = cfg_instruction_snapshot(&cfg); + let mut rng = seed.create_deterministic_rng(); + let error = StringObfuscate::new() + .apply(&mut cfg, &mut rng) + .unwrap_err(); + + assert!(error.to_string().contains("StringObfuscate is disabled")); + assert_eq!(cfg_instruction_snapshot(&cfg), before); } diff --git a/tests/src/transforms/function_dispatcher.rs b/tests/src/transforms/function_dispatcher.rs index 773fa7cc..b2d6ea61 100644 --- a/tests/src/transforms/function_dispatcher.rs +++ b/tests/src/transforms/function_dispatcher.rs @@ -9,6 +9,8 @@ use azoth_transform::obfuscator::ObfuscationConfig; const SIMPLE_BYTECODE: &str = "0x60003560e01c80637ff36ab514601e578063a9059cbb14602357600080fd5b600080fd5b600080fd"; +const SIMPLE_DEPLOYMENT: &str = + "0x6028600c60003960286000f360003560e01c80637ff36ab514601e578063a9059cbb14602357600080fd5b600080fd5b600080fd"; const COUNTER_BYTECODE: &str = "0x6080604052348015600e575f5ffd5b506101d98061001c5f395ff3fe608060405234801561000f575f5ffd5b506004361061004a575f3560e01c806306661abd1461004e578063371303c01461006c5780636d4ce63c14610076578063b3bcfa8214610094575b5f5ffd5b61005661009e565b60405161006391906100f7565b60405180910390f35b6100746100a3565b005b61007e6100bd565b60405161008b91906100f7565b60405180910390f35b61009c6100c5565b005b5f5481565b60015f5f8282546100b4919061013d565b92505081905550565b5f5f54905090565b60015f5f8282546100d69190610170565b92505081905550565b5f819050919050565b6100f1816100df565b82525050565b5f60208201905061010a5f8301846100e8565b92915050565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52601160045260245ffd5b5f610147826100df565b9150610152836100df565b925082820190508082111561016a57610169610110565b5b92915050565b5f61017a826100df565b9150610185836100df565b925082820390508181111561019d5761019c610110565b5b9291505056fea264697066735822122078c44612ebfc52f8c09e96e351b62f1c6feebaa2694fa7d29431ccb4ae9ed15064736f6c634300081c0033"; @@ -62,10 +64,10 @@ async fn test_dispatcher_transformation_and_determinism() { let config1 = ObfuscationConfig::with_seed(seed.clone()); let config2 = ObfuscationConfig::with_seed(seed.clone()); - let result1 = obfuscate_bytecode(SIMPLE_BYTECODE, SIMPLE_BYTECODE, config1) + let result1 = obfuscate_bytecode(SIMPLE_DEPLOYMENT, SIMPLE_BYTECODE, config1) .await .unwrap(); - let result2 = obfuscate_bytecode(SIMPLE_BYTECODE, SIMPLE_BYTECODE, config2) + let result2 = obfuscate_bytecode(SIMPLE_DEPLOYMENT, SIMPLE_BYTECODE, config2) .await .unwrap(); @@ -86,7 +88,7 @@ async fn test_dispatcher_transformation_and_determinism() { "FunctionDispatcher transform should be applied" ); assert_ne!( - result1.obfuscated_bytecode, SIMPLE_BYTECODE, + result1.obfuscated_bytecode, SIMPLE_DEPLOYMENT, "Bytecode should be modified" ); diff --git a/tests/src/transforms/jump_address.rs b/tests/src/transforms/jump_address.rs index 47fe48cf..fac121f2 100644 --- a/tests/src/transforms/jump_address.rs +++ b/tests/src/transforms/jump_address.rs @@ -5,11 +5,11 @@ use azoth_transform::Transform; #[tokio::test] async fn test_jump_address_transformer() { - tracing_subscriber::fmt() + let _ = tracing_subscriber::fmt() .with_max_level(tracing::Level::DEBUG) .with_ansi(false) .without_time() - .init(); + .try_init(); // Simple bytecode with a conditional jump let bytecode = "0x60085760015b00"; // PUSH1 0x08, JUMPI, PUSH1 0x01, JUMPDEST, STOP diff --git a/tests/src/transforms/opaque_predicate.rs b/tests/src/transforms/opaque_predicate.rs index 17b0ce28..6162c98b 100644 --- a/tests/src/transforms/opaque_predicate.rs +++ b/tests/src/transforms/opaque_predicate.rs @@ -6,26 +6,30 @@ use azoth_transform::Transform; #[tokio::test] async fn test_opaque_predicate_adds_blocks() { - tracing_subscriber::fmt() + let _ = tracing_subscriber::fmt() .with_max_level(tracing::Level::DEBUG) .with_ansi(false) .without_time() - .init(); + .try_init(); let bytecode = "0x6001600260016003"; // PUSH1 0x01, PUSH1 0x02, PUSH1 0x01, PUSH1 0x03 let (mut cfg_ir, _, _, _) = process_bytecode_to_cfg(bytecode, false, bytecode, false) .await .unwrap(); - let before = collect_metrics(&cfg_ir, &cfg_ir.clean_report).unwrap(); + let before = collect_metrics(&cfg_ir).unwrap(); let seed = Seed::from_hex("0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef") .unwrap(); let mut rng = seed.create_deterministic_rng(); let transform = OpaquePredicate::new(); let changed = transform.apply(&mut cfg_ir, &mut rng).unwrap(); assert!(changed, "OpaquePredicate should insert predicates"); - let after = collect_metrics(&cfg_ir, &cfg_ir.clean_report).unwrap(); + let after = collect_metrics(&cfg_ir).unwrap(); assert!( after.block_cnt > before.block_cnt, "Block count should increase" ); + assert!( + after.byte_len > before.byte_len, + "Current byte length should include inserted predicate instructions" + ); } diff --git a/tests/src/transforms/shuffle.rs b/tests/src/transforms/shuffle.rs index 9d2fe4ed..2cfde6d1 100644 --- a/tests/src/transforms/shuffle.rs +++ b/tests/src/transforms/shuffle.rs @@ -16,12 +16,12 @@ async fn test_shuffle_reorders_blocks() { .await .unwrap(); - let before = collect_metrics(&cfg_ir, &cfg_ir.clean_report).unwrap(); + let before = collect_metrics(&cfg_ir).unwrap(); let seed = Seed::generate(); let mut rng = seed.create_deterministic_rng(); let transform = Shuffle; let changed = transform.apply(&mut cfg_ir, &mut rng).unwrap(); - let after = collect_metrics(&cfg_ir, &cfg_ir.clean_report).unwrap(); + let after = collect_metrics(&cfg_ir).unwrap(); assert!(changed, "Shuffle should reorder blocks"); assert_eq!( before.byte_len, after.byte_len,