From c80b83b4e7ac3b7fc30446c74141407cf5064051 Mon Sep 17 00:00:00 2001 From: g4titanx Date: Mon, 27 Apr 2026 15:36:14 +0100 Subject: [PATCH 1/6] fix(transforms): add constant masking transform --- crates/cli/README.md | 2 +- crates/cli/src/commands/mod.rs | 2 +- crates/cli/src/commands/obfuscate.rs | 3 + crates/transforms/README.md | 44 + crates/transforms/src/constant_mask.rs | 1569 ++++++++++++++++++++++++ crates/transforms/src/lib.rs | 1 + crates/transforms/src/obfuscator.rs | 33 +- 7 files changed, 1639 insertions(+), 15 deletions(-) create mode 100644 crates/transforms/src/constant_mask.rs diff --git a/crates/cli/README.md b/crates/cli/README.md index 47b9ab15..32e09255 100644 --- a/crates/cli/README.md +++ b/crates/cli/README.md @@ -67,7 +67,7 @@ Options: - `-D, --deployment ` - Input deployment bytecode (required) - `-R, --runtime ` - Input runtime bytecode (required) - `--seed ` - Cryptographic seed for deterministic obfuscation -- `--passes ` - Comma-separated list of transforms (default: `arithmetic_chain,push_split,slot_shuffle,string_obfuscate,cluster_shuffle`) +- `--passes ` - Comma-separated list of transforms (default: `string_obfuscate,constant_mask,arithmetic_chain,push_split,slot_shuffle,cluster_shuffle`) - `--emit ` - Path to write gas/size report as JSON - `--emit-debug ` - Path to emit detailed CFG trace debug report as JSON - `--tui` - Launch TUI to view debug trace after obfuscation diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index fad6070b..a02ebf6f 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -14,7 +14,7 @@ pub mod tui; use thiserror::Error; pub const DEFAULT_PASSES: &str = - "arithmetic_chain, push_split, slot_shuffle, string_obfuscate, cluster_shuffle"; + "string_obfuscate, constant_mask, arithmetic_chain, push_split, slot_shuffle, 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 ed019bf9..d6dcb31b 100644 --- a/crates/cli/src/commands/obfuscate.rs +++ b/crates/cli/src/commands/obfuscate.rs @@ -165,6 +165,9 @@ pub(crate) fn build_passes(list: &str) -> Result>, Box Ok(Box::new( azoth_transform::arithmetic_chain::ArithmeticChain::new(), ) as Box), + "constant_mask" | "literal_mask" => Ok(Box::new( + azoth_transform::constant_mask::ConstantMask::new(), + ) as Box), "push_split" => { Ok(Box::new(azoth_transform::push_split::PushSplit::new()) as Box) } diff --git a/crates/transforms/README.md b/crates/transforms/README.md index ba3f1774..446ee10f 100644 --- a/crates/transforms/README.md +++ b/crates/transforms/README.md @@ -13,6 +13,50 @@ The transforms crate implements a pass-based architecture where each transformat ## Current Transforms +### String Obfuscate (`string_obfuscate.rs`) + +Owns revert-data sanitization. It randomizes custom-error selectors, rewrites +`Error(string)` selectors to seed-derived values, and scrambles ABI-encoded +string chunks before the broader literal masking pass runs. This removes +human-readable revert strings and public selector-database labels while +preserving revert behavior. + +### Constant Mask (`constant_mask.rs`) + +Masks constructor/init-code `PUSH4..PUSH32` literals and eligible runtime constants by reconstructing them from seed-varied shares at runtime, so bytecode does not expose plain custom-error selectors, ERC-20 selectors, event topics, timeout literals, or immutable addresses. Init-code `PUSH1..PUSH3` literals are deliberately skipped because solc constructors frequently use them for jump targets, return PCs, and ABI offsets: + +```assembly +PUSH4 0x23b872dd +``` + +becomes: + +```assembly +PUSH4 share_0 + +PUSH4 share_1 +XOR +... +PUSH4 share_N +XOR +``` + +The runtime reconstruction emits 3-5 XOR shares per site and injects +dynamic-zero identity noise through ADD, XOR, OR, SHL, SHR, and opaque-zero +templates. This keeps a simple straight-line constant folder from recovering +the original value while avoiding one fixed `masked ^ key` fingerprint. +Runtime immutable placeholders keep the older XOR-key shape because constructor +patching must write `value ^ key` into the first immediate. + +External-call selector writes are lowered to a decoy selector word followed by +seed-ordered `MSTORE8` patches whose bytes are also reconstructed instead of +emitted as raw `PUSH1` values. This covers the canonical +`PUSH4 selector; PUSH1 e0; SHL; DUP2; MSTORE` shape plus optimized shifted +selector forms such as `PUSH3 value; PUSH1 e5; SHL`. Jump-target literals are +skipped using stack-flow checks so stack-carried return addresses remain +available for final PC remapping. `ConstantMask::with_min_width(...)` can still +tune the runtime minimum PUSH width. + ### Shuffle (`shuffle.rs`) Reorders basic blocks within the CFG while updating jump targets to maintain correctness. Simple block-level randomization that changes program layout without affecting execution. diff --git a/crates/transforms/src/constant_mask.rs b/crates/transforms/src/constant_mask.rs new file mode 100644 index 00000000..4ec5c485 --- /dev/null +++ b/crates/transforms/src/constant_mask.rs @@ -0,0 +1,1569 @@ +//! Constant masking. +//! +//! This pass hides eligible literal constants from both deployed runtime bytecode +//! and deployment bytecode. Init-code `PUSH4..PUSH32` literals are handled +//! separately so constants that appear only in constructor/deployment code do not +//! remain greppable in transaction input. +//! +//! ```text +//! PUSH share_0 +//! +//! PUSH share_1 +//! XOR +//! ... +//! PUSH share_N +//! XOR +//! ``` +//! +//! The stack result is identical to the original single PUSH, but the raw +//! constant no longer appears as a contiguous byte sequence and there is no +//! fixed masked/key reconstruction shape. Per-site templates use 3-5 XOR +//! shares and mix dynamic zero identities through ADD, SUB, XOR, OR, SHL, and +//! SHR. Jump-target literals and immutable placeholder writes are handled +//! conservatively so size-growing runtime rewrites remain compatible with the +//! final PC-remapping phase. + +use crate::{collect_protected_pcs, Error, Result, Transform}; +use azoth_core::cfg_ir::{push_reaches_jump, Block, CfgIrBundle}; +use azoth_core::decoder::Instruction; +use azoth_core::detection::SectionKind; +use azoth_core::Opcode; +use rand::rngs::StdRng; +use rand::RngCore; +use std::collections::HashSet; +use tracing::debug; + +/// Obfuscates literal constants via per-use runtime reconstruction. +#[derive(Debug, Clone)] +pub struct ConstantMask { + min_width: u8, + mask_runtime: bool, +} + +impl Default for ConstantMask { + fn default() -> Self { + Self::new() + } +} + +impl ConstantMask { + /// Creates a runtime + init-code literal-mask transform. + pub fn new() -> Self { + Self { + min_width: 2, + mask_runtime: true, + } + } + + /// Creates a literal-mask transform with a custom runtime minimum PUSH width. + pub fn with_min_width(min_width: u8) -> Self { + Self { + min_width: min_width.clamp(1, 32), + mask_runtime: true, + } + } +} + +impl Transform for ConstantMask { + fn name(&self) -> &'static str { + "ConstantMask" + } + + fn apply(&self, ir: &mut CfgIrBundle, rng: &mut StdRng) -> Result { + debug!("ConstantMask: masking init literals"); + + let mut changed = false; + if rewrite_call_selector_mstores(ir, rng)? { + changed = true; + } + + if self.mask_runtime { + if mask_runtime_literals(ir, rng, self.min_width)? { + changed = true; + } + } + + if mask_init_literals(ir, rng)? { + changed = true; + } + + Ok(changed) + } +} + +fn rewrite_call_selector_mstores(ir: &mut CfgIrBundle, rng: &mut StdRng) -> Result { + let runtime_bounds = ir.runtime_bounds(); + let nodes: Vec<_> = ir.cfg.node_indices().collect(); + let mut changed = false; + let mut next_synthetic_pc = next_available_pc(ir); + + for node in nodes { + if ir.dispatcher_blocks.contains(&node.index()) { + continue; + } + + let Some(Block::Body(body)) = ir.cfg.node_weight(node) else { + continue; + }; + if !block_is_runtime(body.start_pc, runtime_bounds) { + continue; + } + + let original = body.instructions.clone(); + let mut rewritten = Vec::with_capacity(original.len()); + let mut idx = 0usize; + let mut block_changed = false; + + while idx < original.len() { + if let Some(pattern) = call_selector_mstore_at(&original, idx) { + let decoy = random_decoy_selector(pattern.selector, rng); + let mut selector_push = original[idx].clone(); + selector_push.op = Opcode::PUSH(4); + selector_push.imm = Some(format!("{decoy:08x}")); + rewritten.push(selector_push); + if let (Some(shift_idx), Some(shl_idx)) = (pattern.shift_idx, pattern.shl_idx) { + let mut shift = original[shift_idx].clone(); + shift.op = Opcode::PUSH(1); + shift.imm = Some("e0".to_string()); + rewritten.push(shift); + rewritten.push(original[shl_idx].clone()); + } else { + rewritten.push(Instruction { + pc: next_synthetic_pc, + op: Opcode::PUSH(1), + imm: Some("e0".to_string()), + }); + next_synthetic_pc = next_synthetic_pc.saturating_add(1); + rewritten.push(Instruction { + pc: next_synthetic_pc, + op: Opcode::SHL, + imm: None, + }); + next_synthetic_pc = next_synthetic_pc.saturating_add(1); + } + rewritten.push(original[pattern.dup_idx].clone()); + rewritten.push(original[pattern.mstore_idx].clone()); + emit_selector_mstore8_patches( + pattern.selector, + decoy, + &mut next_synthetic_pc, + &mut rewritten, + rng, + ); + block_changed = true; + idx = pattern.end_idx; + continue; + } + + rewritten.push(original[idx].clone()); + idx += 1; + } + + if block_changed { + let mut new_body = body.clone(); + new_body.instructions = rewritten; + new_body.max_stack = new_body.max_stack.saturating_add(3); + ir.overwrite_block(node, new_body) + .map_err(|e| Error::CoreError(e.to_string()))?; + changed = true; + } + } + + Ok(changed) +} + +#[derive(Clone, Copy)] +struct CallSelectorMstore { + selector: u32, + shift_idx: Option, + shl_idx: Option, + dup_idx: usize, + mstore_idx: usize, + end_idx: usize, +} + +fn call_selector_mstore_at(instructions: &[Instruction], idx: usize) -> Option { + if let Some((selector, dup_idx, mstore_idx, end_idx)) = + shifted_call_selector_mstore_at(instructions, idx) + { + if has_call_after(instructions, end_idx) { + return Some(CallSelectorMstore { + selector, + shift_idx: Some(idx + 1), + shl_idx: Some(idx + 2), + dup_idx, + mstore_idx, + end_idx, + }); + } + } + + if let Some((selector, dup_idx, mstore_idx, end_idx)) = + left_aligned_call_selector_mstore_at(instructions, idx) + { + if has_call_after(instructions, end_idx) { + return Some(CallSelectorMstore { + selector, + shift_idx: None, + shl_idx: None, + dup_idx, + mstore_idx, + end_idx, + }); + } + } + + None +} + +fn shifted_call_selector_mstore_at( + instructions: &[Instruction], + idx: usize, +) -> Option<(u32, usize, usize, usize)> { + let value = instructions.get(idx)?; + let shift = instructions.get(idx + 1)?; + let shl = instructions.get(idx + 2)?; + let dup = instructions.get(idx + 3)?; + let mstore = instructions.get(idx + 4)?; + + if !matches!(value.op, Opcode::PUSH(1..=4)) + || !matches!(shift.op, Opcode::PUSH(1)) + || shl.op != Opcode::SHL + || dup.op != Opcode::DUP(2) + || mstore.op != Opcode::MSTORE + { + return None; + } + + let value_bytes = parse_push_bytes(value, push_width(value)?)?; + let shift_bytes = parse_push_bytes(shift, 1)?; + let value = parse_usize_be(&value_bytes)?; + let shift = parse_usize_be(&shift_bytes)?; + let selector = recover_left_aligned_selector(value, shift)?; + Some((selector, idx + 3, idx + 4, idx + 5)) +} + +fn left_aligned_call_selector_mstore_at( + instructions: &[Instruction], + idx: usize, +) -> Option<(u32, usize, usize, usize)> { + let value = instructions.get(idx)?; + let dup = instructions.get(idx + 1)?; + let mstore = instructions.get(idx + 2)?; + + if !matches!(value.op, Opcode::PUSH(32)) + || dup.op != Opcode::DUP(2) + || mstore.op != Opcode::MSTORE + { + return None; + } + + let bytes = parse_push_bytes(value, 32)?; + if bytes[4..].iter().any(|byte| *byte != 0) { + return None; + } + let selector = u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]); + if selector == 0 { + return None; + } + + Some((selector, idx + 1, idx + 2, idx + 3)) +} + +fn push_width(instr: &Instruction) -> Option { + let Opcode::PUSH(width) = instr.op else { + return None; + }; + Some(width) +} + +fn recover_left_aligned_selector(value: usize, shift: usize) -> Option { + if shift < 224 { + return None; + } + let extra_shift = shift - 224; + if extra_shift >= 32 { + return None; + } + let selector = (value as u64).checked_shl(extra_shift as u32)?; + if selector == 0 || selector > u32::MAX as u64 { + return None; + } + Some(selector as u32) +} + +fn has_call_after(instructions: &[Instruction], start: usize) -> bool { + for instr in instructions.iter().skip(start) { + match instr.op { + Opcode::CALL | Opcode::CALLCODE | Opcode::DELEGATECALL | Opcode::STATICCALL => { + return true; + } + Opcode::REVERT + | Opcode::RETURN + | Opcode::STOP + | Opcode::JUMP + | Opcode::JUMPI + | Opcode::SELFDESTRUCT => return false, + _ => {} + } + } + false +} + +fn random_decoy_selector(original: u32, rng: &mut StdRng) -> u32 { + loop { + let value = rng.next_u32(); + if value != 0 && value != original { + return value; + } + } +} + +fn emit_selector_mstore8_patches( + selector: u32, + decoy: u32, + next_synthetic_pc: &mut usize, + out: &mut Vec, + rng: &mut StdRng, +) { + let real = selector.to_be_bytes(); + let decoy = decoy.to_be_bytes(); + let mut offsets = [0usize, 1, 2, 3]; + shuffle_offsets(&mut offsets, rng); + + for offset in offsets { + if real[offset] == decoy[offset] { + continue; + } + emit_selector_byte_patch(real[offset], offset, next_synthetic_pc, out, rng); + } +} + +fn shuffle_offsets(offsets: &mut [usize; 4], rng: &mut StdRng) { + for i in (1..offsets.len()).rev() { + let j = (rng.next_u32() as usize) % (i + 1); + offsets.swap(i, j); + } +} + +fn emit_selector_byte_patch( + byte: u8, + offset: usize, + next_synthetic_pc: &mut usize, + out: &mut Vec, + rng: &mut StdRng, +) { + let mut fresh_pc = || { + let pc = *next_synthetic_pc; + *next_synthetic_pc = next_synthetic_pc.saturating_add(1); + pc + }; + + emit_reconstructed_value_with_fresh_pc(&[byte], 1, &mut fresh_pc, out, rng); + + if offset == 0 { + out.push(Instruction { + pc: fresh_pc(), + op: Opcode::DUP(2), + imm: None, + }); + } else { + out.push(Instruction { + pc: fresh_pc(), + op: Opcode::PUSH(1), + imm: Some(format!("{offset:02x}")), + }); + out.push(Instruction { + pc: fresh_pc(), + op: Opcode::DUP(3), + imm: None, + }); + out.push(Instruction { + pc: fresh_pc(), + op: Opcode::ADD, + imm: None, + }); + } + + out.push(Instruction { + pc: fresh_pc(), + op: Opcode::MSTORE8, + imm: None, + }); +} + +fn mask_runtime_literals(ir: &mut CfgIrBundle, rng: &mut StdRng, min_width: u8) -> Result { + debug!("ConstantMask: scanning runtime PUSH{min_width}..PUSH32 literals"); + + let protected_pcs = collect_protected_pcs(ir); + let jumpdest_values = collect_jumpdest_values(ir); + let immutable_offsets = collect_init_immutable_offsets(ir); + let runtime_bounds = ir.runtime_bounds(); + let runtime_start = runtime_bounds.map(|(start, _)| start).unwrap_or(0); + let nodes: Vec<_> = ir.cfg.node_indices().collect(); + let mut changed = false; + let mut pending_immutable_masks: Vec<(usize, Vec)> = Vec::new(); + let mut next_synthetic_pc = next_available_pc(ir); + + for node in nodes { + if ir.dispatcher_blocks.contains(&node.index()) { + continue; + } + + let Some(Block::Body(body)) = ir.cfg.node_weight(node) else { + continue; + }; + if !block_is_runtime(body.start_pc, runtime_bounds) { + continue; + } + + let original = body.instructions.clone(); + let mut rewritten = Vec::with_capacity(original.len()); + let mut block_changed = false; + let mut new_max_stack = body.max_stack.saturating_add(1); + + for (idx, instr) in original.iter().enumerate() { + let Opcode::PUSH(width) = instr.op else { + rewritten.push(instr.clone()); + continue; + }; + let is_selector_shift = is_selector_shift_literal(&original, idx); + if width < min_width && !is_selector_shift { + rewritten.push(instr.clone()); + continue; + } + if protected_pcs.contains(&instr.pc) + || is_jump_literal(&original, idx, &jumpdest_values) + { + rewritten.push(instr.clone()); + continue; + } + + let Some(value) = parse_push_bytes(instr, width) else { + rewritten.push(instr.clone()); + continue; + }; + let old_immutable_offset = instr.pc.saturating_add(1).saturating_sub(runtime_start); + let is_zero = value.iter().all(|byte| *byte == 0); + let is_immutable_placeholder = + width == 32 && is_zero && immutable_offsets.contains(&old_immutable_offset); + if is_zero && !is_immutable_placeholder { + rewritten.push(instr.clone()); + continue; + } + + if is_immutable_placeholder { + let key = random_nonzero_bytes(width as usize, rng); + let masked = xor_bytes(&value, &key); + if masked == value { + rewritten.push(instr.clone()); + continue; + } + emit_xor_masked_literal( + instr.pc, + width, + &masked, + &key, + &mut rewritten, + &mut next_synthetic_pc, + rng, + ); + pending_immutable_masks.push((old_immutable_offset, key)); + } else { + emit_reconstructed_value( + instr.pc, + width, + &value, + &mut rewritten, + &mut next_synthetic_pc, + rng, + ); + } + block_changed = true; + new_max_stack = new_max_stack.max(body.max_stack.saturating_add(5)); + } + + if block_changed { + let mut new_body = body.clone(); + new_body.instructions = rewritten; + new_body.max_stack = new_max_stack; + ir.overwrite_block(node, new_body) + .map_err(|e| Error::CoreError(e.to_string()))?; + changed = true; + } + } + + for (offset, key) in pending_immutable_masks { + ir.immutable_masks.insert(offset, key); + } + + Ok(changed) +} + +fn is_selector_shift_literal(instructions: &[Instruction], idx: usize) -> bool { + let Some(instr) = instructions.get(idx) else { + return false; + }; + let Opcode::PUSH(1) = instr.op else { + return false; + }; + if !matches!(instr.imm.as_deref(), Some("e0" | "e5")) { + return false; + } + instructions + .get(idx + 1) + .is_some_and(|next| next.op == Opcode::SHL) +} + +fn block_is_runtime(start_pc: usize, runtime_bounds: Option<(usize, usize)>) -> bool { + match runtime_bounds { + Some((start, end)) => start_pc >= start && start_pc < end, + None => true, + } +} + +fn collect_jumpdest_values(ir: &CfgIrBundle) -> HashSet { + let mut values = HashSet::new(); + let runtime_start = ir.runtime_bounds().map(|(start, _)| start).unwrap_or(0); + + for node in ir.cfg.node_indices() { + let Some(Block::Body(body)) = ir.cfg.node_weight(node) else { + continue; + }; + for instr in &body.instructions { + if instr.op == Opcode::JUMPDEST { + values.insert(instr.pc); + values.insert(instr.pc.saturating_sub(runtime_start)); + } + } + } + + values +} + +fn collect_init_immutable_offsets(ir: &CfgIrBundle) -> HashSet { + let mut offsets = HashSet::new(); + let Some((runtime_start, _)) = ir.runtime_bounds() else { + return offsets; + }; + + for removed in &ir.clean_report.removed { + if removed.kind != SectionKind::Init { + continue; + } + + let bytes = removed.data.as_ref(); + let mut idx = 0usize; + while idx < bytes.len() { + let opcode = bytes[idx]; + if !(0x60..=0x7f).contains(&opcode) { + idx += 1; + continue; + } + + let width = (opcode - 0x5f) as usize; + if idx + 1 + width > bytes.len() { + idx += 1; + continue; + } + + let after = idx + 1 + width; + let is_immutable_store = after + 1 < bytes.len() + && bytes[after] == Opcode::ADD.to_byte() + && bytes[after + 1] == Opcode::MSTORE.to_byte(); + if is_immutable_store { + if let Some(value) = parse_usize_be(&bytes[idx + 1..idx + 1 + width]) { + if value >= 1 && value < ir.clean_report.clean_len { + offsets.insert(value); + } + if value > runtime_start && value - runtime_start < ir.clean_report.clean_len { + offsets.insert(value - runtime_start); + } + } + } + + idx += 1 + width; + } + } + + offsets +} + +fn is_jump_literal( + instructions: &[Instruction], + idx: usize, + jumpdest_values: &HashSet, +) -> bool { + if instructions + .get(idx + 1) + .is_some_and(|next| matches!(next.op, Opcode::JUMP | Opcode::JUMPI)) + { + return true; + } + + if instructions + .get(idx + 2) + .is_some_and(|jump| matches!(jump.op, Opcode::JUMP | Opcode::JUMPI)) + && instructions + .get(idx + 1) + .is_some_and(|op| matches!(op.op, Opcode::ADD | Opcode::PC)) + { + return true; + } + + if instructions + .get(idx + 3) + .is_some_and(|jump| matches!(jump.op, Opcode::JUMP | Opcode::JUMPI)) + { + let op1 = instructions.get(idx + 1).map(|instr| instr.op); + let op2 = instructions.get(idx + 2).map(|instr| instr.op); + if matches!( + (op1, op2), + (Some(Opcode::PUSH(_)), Some(Opcode::ADD)) | (Some(Opcode::PC), Some(Opcode::ADD)) + ) { + return true; + } + } + + let Some(value) = parse_push_usize(&instructions[idx]) else { + return false; + }; + jumpdest_values.contains(&value) && push_reaches_jump(instructions, idx) +} + +fn parse_push_bytes(instr: &Instruction, width: u8) -> Option> { + let bytes = hex::decode(instr.imm.as_deref()?).ok()?; + if bytes.len() != width as usize { + return None; + } + Some(bytes) +} + +fn parse_push_usize(instr: &Instruction) -> Option { + let Opcode::PUSH(width) = instr.op else { + return None; + }; + let bytes = parse_push_bytes(instr, width)?; + if bytes.len() > std::mem::size_of::() + && bytes[..bytes.len() - std::mem::size_of::()] + .iter() + .any(|byte| *byte != 0) + { + return None; + } + + let mut value = 0usize; + for byte in bytes { + value = value.checked_shl(8)? | byte as usize; + } + Some(value) +} + +fn parse_usize_be(bytes: &[u8]) -> Option { + if bytes.len() > std::mem::size_of::() + && bytes[..bytes.len() - std::mem::size_of::()] + .iter() + .any(|byte| *byte != 0) + { + return None; + } + + let mut value = 0usize; + for byte in bytes { + value = value.checked_shl(8)? | *byte as usize; + } + Some(value) +} + +fn random_nonzero_bytes(width: usize, rng: &mut StdRng) -> Vec { + loop { + let mut bytes = vec![0u8; width]; + rng.fill_bytes(&mut bytes); + if bytes.iter().any(|byte| *byte != 0) { + return bytes; + } + } +} + +fn xor_bytes(left: &[u8], right: &[u8]) -> Vec { + left.iter().zip(right.iter()).map(|(a, b)| a ^ b).collect() +} + +fn emit_reconstructed_value( + base_pc: usize, + width: u8, + value: &[u8], + out: &mut Vec, + next_synthetic_pc: &mut usize, + rng: &mut StdRng, +) { + let shares = random_xor_shares(value, rng); + let mut first = true; + out.push(Instruction { + pc: base_pc, + op: Opcode::PUSH(width), + imm: Some(hex::encode(&shares[0])), + }); + + let mut fresh_pc = || { + let pc = *next_synthetic_pc; + *next_synthetic_pc = next_synthetic_pc.saturating_add(1); + pc + }; + + for share in shares { + if first { + first = false; + // The first share was emitted at the original PC to preserve local + // source ordering. Apply identity noise before combining shares so + // straight-line constant folders stop tracking it as a pure PUSH. + emit_dynamic_accumulator_identity(&mut fresh_pc, out, rng); + continue; + } + + if rng.next_u32() & 1 == 0 { + emit_accumulator_identity(&mut fresh_pc, out, rng); + } + out.push(Instruction { + pc: fresh_pc(), + op: Opcode::PUSH(width), + imm: Some(hex::encode(&share)), + }); + out.push(Instruction { + pc: fresh_pc(), + op: Opcode::XOR, + imm: None, + }); + } + + if rng.next_u32() & 1 == 0 { + emit_accumulator_identity(&mut fresh_pc, out, rng); + } +} + +fn emit_reconstructed_value_with_fresh_pc( + value: &[u8], + width: u8, + fresh_pc: &mut impl FnMut() -> usize, + out: &mut Vec, + rng: &mut StdRng, +) { + let shares = random_xor_shares(value, rng); + out.push(Instruction { + pc: fresh_pc(), + op: Opcode::PUSH(width), + imm: Some(hex::encode(&shares[0])), + }); + emit_dynamic_accumulator_identity(fresh_pc, out, rng); + + for share in shares.iter().skip(1) { + if rng.next_u32() & 1 == 0 { + emit_accumulator_identity(fresh_pc, out, rng); + } + out.push(Instruction { + pc: fresh_pc(), + op: Opcode::PUSH(width), + imm: Some(hex::encode(share)), + }); + out.push(Instruction { + pc: fresh_pc(), + op: Opcode::XOR, + imm: None, + }); + } + + if rng.next_u32() & 1 == 0 { + emit_accumulator_identity(fresh_pc, out, rng); + } +} + +fn emit_xor_masked_literal( + base_pc: usize, + width: u8, + masked: &[u8], + key: &[u8], + out: &mut Vec, + next_synthetic_pc: &mut usize, + rng: &mut StdRng, +) { + out.push(Instruction { + pc: base_pc, + op: Opcode::PUSH(width), + imm: Some(hex::encode(masked)), + }); + let mut fresh_pc = || { + let pc = *next_synthetic_pc; + *next_synthetic_pc = next_synthetic_pc.saturating_add(1); + pc + }; + + emit_dynamic_zero(&mut fresh_pc, out, rng); + out.push(Instruction { + pc: fresh_pc(), + op: Opcode::PUSH(width), + imm: Some(hex::encode(key)), + }); + out.push(Instruction { + pc: fresh_pc(), + op: Opcode::XOR, + imm: None, + }); + out.push(Instruction { + pc: fresh_pc(), + op: Opcode::XOR, + imm: None, + }); +} + +fn random_xor_shares(value: &[u8], rng: &mut StdRng) -> Vec> { + let width = value.len(); + let share_count = 3 + (rng.next_u32() as usize % 3); + + for _ in 0..256 { + let mut shares = Vec::with_capacity(share_count); + let mut accumulator = vec![0u8; width]; + + for _ in 0..share_count - 1 { + let share = random_share(value, rng); + xor_assign(&mut accumulator, &share); + shares.push(share); + } + + let final_share = xor_bytes(value, &accumulator); + if final_share.iter().any(|byte| *byte != 0) + && final_share != value + && shares.iter().all(|share| share != value) + { + shares.push(final_share); + return shares; + } + } + + let first = random_share(value, rng); + let second = random_share(value, rng); + vec![first.clone(), second.clone(), { + let mut tail = xor_bytes(value, &first); + xor_assign(&mut tail, &second); + tail + }] +} + +fn random_share(value: &[u8], rng: &mut StdRng) -> Vec { + for _ in 0..256 { + let share = random_nonzero_bytes(value.len(), rng); + if share != value { + return share; + } + } + random_nonzero_bytes(value.len(), rng) +} + +fn xor_assign(left: &mut [u8], right: &[u8]) { + for (left, right) in left.iter_mut().zip(right) { + *left ^= *right; + } +} + +fn emit_accumulator_identity( + fresh_pc: &mut impl FnMut() -> usize, + out: &mut Vec, + rng: &mut StdRng, +) { + match rng.next_u32() % 5 { + 0 => { + emit_dynamic_zero(fresh_pc, out, rng); + out.push(Instruction { + pc: fresh_pc(), + op: Opcode::ADD, + imm: None, + }); + } + 1 => { + emit_dynamic_zero(fresh_pc, out, rng); + out.push(Instruction { + pc: fresh_pc(), + op: Opcode::XOR, + imm: None, + }); + } + 2 => { + emit_dynamic_zero(fresh_pc, out, rng); + out.push(Instruction { + pc: fresh_pc(), + op: Opcode::OR, + imm: None, + }); + } + 3 => { + out.push(Instruction { + pc: fresh_pc(), + op: Opcode::PUSH(1), + imm: Some("00".to_string()), + }); + out.push(Instruction { + pc: fresh_pc(), + op: Opcode::SHL, + imm: None, + }); + } + _ => { + out.push(Instruction { + pc: fresh_pc(), + op: Opcode::PUSH(1), + imm: Some("00".to_string()), + }); + out.push(Instruction { + pc: fresh_pc(), + op: Opcode::SHR, + imm: None, + }); + } + } +} + +fn emit_dynamic_accumulator_identity( + fresh_pc: &mut impl FnMut() -> usize, + out: &mut Vec, + rng: &mut StdRng, +) { + emit_dynamic_zero(fresh_pc, out, rng); + let op = match rng.next_u32() % 3 { + 0 => Opcode::ADD, + 1 => Opcode::XOR, + _ => Opcode::OR, + }; + out.push(Instruction { + pc: fresh_pc(), + op, + imm: None, + }); +} + +/// Emits a value that is unknown to simple static constant folders, then reduces +/// it to zero with an opaque identity such as `D - D` or `D < D`. +/// +/// The dynamic source opcodes used here have no stack inputs and no side +/// effects, so the sequence is safe in both runtime and init code while still +/// preventing the accumulator from looking like a pure PUSH-only expression. +fn emit_dynamic_zero( + fresh_pc: &mut impl FnMut() -> usize, + out: &mut Vec, + rng: &mut StdRng, +) { + let source = random_dynamic_source(rng); + out.push(Instruction { + pc: fresh_pc(), + op: source.opcode(), + imm: None, + }); + emit_opaque_zero_tail(fresh_pc, out, rng); +} + +fn emit_opaque_zero_tail( + fresh_pc: &mut impl FnMut() -> usize, + out: &mut Vec, + rng: &mut StdRng, +) { + // Stack before: [D, masked]. After: [0, masked]. + match rng.next_u32() % 5 { + 0 => { + // D - D == 0 + out.push(Instruction { + pc: fresh_pc(), + op: Opcode::DUP(1), + imm: None, + }); + out.push(Instruction { + pc: fresh_pc(), + op: Opcode::SUB, + imm: None, + }); + } + 1 => { + // D < D == 0 + out.push(Instruction { + pc: fresh_pc(), + op: Opcode::DUP(1), + imm: None, + }); + out.push(Instruction { + pc: fresh_pc(), + op: Opcode::LT, + imm: None, + }); + } + 2 => { + // D > D == 0 + out.push(Instruction { + pc: fresh_pc(), + op: Opcode::DUP(1), + imm: None, + }); + out.push(Instruction { + pc: fresh_pc(), + op: Opcode::GT, + imm: None, + }); + } + 3 => { + // iszero(D == D) == 0 + out.push(Instruction { + pc: fresh_pc(), + op: Opcode::DUP(1), + imm: None, + }); + out.push(Instruction { + pc: fresh_pc(), + op: Opcode::EQ, + imm: None, + }); + out.push(Instruction { + pc: fresh_pc(), + op: Opcode::ISZERO, + imm: None, + }); + } + _ => { + // NOT(D OR NOT(D)) == 0 + out.push(Instruction { + pc: fresh_pc(), + op: Opcode::DUP(1), + imm: None, + }); + out.push(Instruction { + pc: fresh_pc(), + op: Opcode::NOT, + imm: None, + }); + out.push(Instruction { + pc: fresh_pc(), + op: Opcode::OR, + imm: None, + }); + out.push(Instruction { + pc: fresh_pc(), + op: Opcode::NOT, + imm: None, + }); + } + } +} + +fn next_available_pc(ir: &CfgIrBundle) -> usize { + ir.cfg + .node_indices() + .filter_map(|node| match ir.cfg.node_weight(node) { + Some(Block::Body(body)) => body + .instructions + .iter() + .map(|instr| instr.pc.saturating_add(instr.byte_size())) + .max(), + _ => None, + }) + .max() + .unwrap_or(0) + .saturating_add(1_000_000) +} + +/// Side-effect-free EVM context opcodes used as entropy sources for opaque +/// zero expressions. +/// +/// Each variant maps to a zero-input opcode that pushes a context value onto +/// the stack. The transform immediately combines that value with itself to +/// produce zero, so the exact runtime value is irrelevant. +#[derive(Clone, Copy)] +enum DynamicSource { + Address, + Caller, + CallValue, + CalldataSize, + CodeSize, + GasPrice, + Timestamp, + Number, + ReturndataSize, + Pc, + Msize, + Gas, +} + +impl DynamicSource { + /// Returns the typed opcode for this context source. + fn opcode(self) -> Opcode { + match self { + Self::Address => Opcode::ADDRESS, + Self::Caller => Opcode::CALLER, + Self::CallValue => Opcode::CALLVALUE, + Self::CalldataSize => Opcode::CALLDATASIZE, + Self::CodeSize => Opcode::CODESIZE, + Self::GasPrice => Opcode::GASPRICE, + Self::Timestamp => Opcode::TIMESTAMP, + Self::Number => Opcode::NUMBER, + Self::ReturndataSize => Opcode::RETURNDATASIZE, + Self::Pc => Opcode::PC, + Self::Msize => Opcode::MSIZE, + Self::Gas => Opcode::GAS, + } + } + + fn byte(self) -> u8 { + self.opcode().to_byte() + } +} + +fn random_dynamic_source(rng: &mut StdRng) -> DynamicSource { + const SOURCES: &[DynamicSource] = &[ + DynamicSource::Address, + DynamicSource::Caller, + DynamicSource::CallValue, + DynamicSource::CalldataSize, + DynamicSource::CodeSize, + DynamicSource::GasPrice, + DynamicSource::Timestamp, + DynamicSource::Number, + DynamicSource::ReturndataSize, + DynamicSource::Pc, + DynamicSource::Msize, + DynamicSource::Gas, + ]; + let idx = (rng.next_u32() as usize) % SOURCES.len(); + SOURCES[idx] +} + +fn mask_init_literals(ir: &mut CfgIrBundle, rng: &mut StdRng) -> Result { + let runtime_offset = ir + .clean_report + .runtime_layout + .iter() + .map(|span| span.offset) + .min() + .unwrap_or(0); + let post_runtime_len: usize = ir + .clean_report + .removed + .iter() + .filter(|removed| removed.offset >= runtime_offset) + .map(|removed| removed.data.len()) + .sum(); + let original_runtime_tail_len = ir.clean_report.clean_len + post_runtime_len; + let original_total_len = runtime_offset + original_runtime_tail_len; + + let mut changed = false; + for removed in &mut ir.clean_report.removed { + if removed.kind != SectionKind::Init { + continue; + } + + let original = removed.data.to_vec(); + let jump_patches = collect_init_jump_patches(&original); + let jump_targets = collect_init_jumpdest_values(&original); + let mut rewritten = Vec::with_capacity(original.len()); + let mut insertions: Vec<(usize, usize)> = Vec::new(); + let mut idx = 0usize; + + while idx < original.len() { + let opcode = original[idx]; + if !(0x60..=0x7f).contains(&opcode) { + rewritten.push(opcode); + idx += 1; + continue; + } + + let width = (opcode - 0x5f) as usize; + let end = idx + 1 + width; + if end > original.len() { + rewritten.extend_from_slice(&original[idx..]); + break; + } + if width < 4 { + rewritten.extend_from_slice(&original[idx..end]); + idx = end; + continue; + } + + let value = &original[idx + 1..end]; + if should_skip_init_literal( + &original, + idx, + value, + &jump_targets, + runtime_offset, + original_runtime_tail_len, + original_total_len, + ) { + rewritten.extend_from_slice(&original[idx..end]); + idx = end; + continue; + } + + let new_pos = rewritten.len(); + emit_reconstructed_value_bytes(width as u8, value, &mut rewritten, rng); + let new_len = rewritten.len() - new_pos; + insertions.push((idx, new_len - (1 + width))); + debug!("ConstantMask: init PUSH{} at pc=0x{:x} masked", width, idx); + idx = end; + } + + if !insertions.is_empty() { + patch_rewritten_init_jump_targets(&mut rewritten, &jump_patches, &insertions)?; + removed.data = rewritten.into(); + changed = true; + } + } + + Ok(changed) +} + +fn emit_opaque_zero_bytes(out: &mut Vec, rng: &mut StdRng) -> usize { + match rng.next_u32() % 5 { + 0 => { + out.extend_from_slice(&[Opcode::DUP(1).to_byte(), Opcode::SUB.to_byte()]); + 2 + } + 1 => { + out.extend_from_slice(&[Opcode::DUP(1).to_byte(), Opcode::LT.to_byte()]); + 2 + } + 2 => { + out.extend_from_slice(&[Opcode::DUP(1).to_byte(), Opcode::GT.to_byte()]); + 2 + } + 3 => { + out.extend_from_slice(&[ + Opcode::DUP(1).to_byte(), + Opcode::EQ.to_byte(), + Opcode::ISZERO.to_byte(), + ]); + 3 + } + _ => { + out.extend_from_slice(&[ + Opcode::DUP(1).to_byte(), + Opcode::NOT.to_byte(), + Opcode::OR.to_byte(), + Opcode::NOT.to_byte(), + ]); + 4 + } + } +} + +fn emit_reconstructed_value_bytes(width: u8, value: &[u8], out: &mut Vec, rng: &mut StdRng) { + let shares = random_xor_shares(value, rng); + emit_push_bytes(width, &shares[0], out); + emit_dynamic_accumulator_identity_bytes(out, rng); + + for share in shares.iter().skip(1) { + if rng.next_u32() & 1 == 0 { + emit_accumulator_identity_bytes(out, rng); + } + emit_push_bytes(width, share, out); + out.push(Opcode::XOR.to_byte()); + } + + if rng.next_u32() & 1 == 0 { + emit_accumulator_identity_bytes(out, rng); + } +} + +fn emit_push_bytes(width: u8, value: &[u8], out: &mut Vec) { + debug_assert_eq!(value.len(), width as usize); + out.push(Opcode::PUSH(width).to_byte()); + out.extend_from_slice(value); +} + +fn emit_accumulator_identity_bytes(out: &mut Vec, rng: &mut StdRng) { + match rng.next_u32() % 5 { + 0 => { + emit_dynamic_zero_bytes(out, rng); + out.push(Opcode::ADD.to_byte()); + } + 1 => { + emit_dynamic_zero_bytes(out, rng); + out.push(Opcode::XOR.to_byte()); + } + 2 => { + emit_dynamic_zero_bytes(out, rng); + out.push(Opcode::OR.to_byte()); + } + 3 => { + out.extend_from_slice(&[Opcode::PUSH(1).to_byte(), 0x00, Opcode::SHL.to_byte()]); + } + _ => { + out.extend_from_slice(&[Opcode::PUSH(1).to_byte(), 0x00, Opcode::SHR.to_byte()]); + } + } +} + +fn emit_dynamic_zero_bytes(out: &mut Vec, rng: &mut StdRng) { + out.push(random_dynamic_source(rng).byte()); + emit_opaque_zero_bytes(out, rng); +} + +fn emit_dynamic_accumulator_identity_bytes(out: &mut Vec, rng: &mut StdRng) { + emit_dynamic_zero_bytes(out, rng); + let op = match rng.next_u32() % 3 { + 0 => Opcode::ADD, + 1 => Opcode::XOR, + _ => Opcode::OR, + }; + out.push(op.to_byte()); +} + +fn should_skip_init_literal( + bytes: &[u8], + idx: usize, + value: &[u8], + jump_targets: &HashSet, + runtime_offset: usize, + original_runtime_tail_len: usize, + original_total_len: usize, +) -> bool { + if value.iter().all(|byte| *byte == 0) { + return true; + } + + if let Some(value_usize) = parse_usize_be(value) { + if matches!( + value_usize, + v if v == runtime_offset || v == original_runtime_tail_len || v == original_total_len + ) { + return true; + } + + if jump_targets.contains(&value_usize) { + return true; + } + } + + let after = idx + 1 + value.len(); + if after < bytes.len() && matches!(bytes[after], 0x56 | 0x57) { + return true; + } + if after + 1 < bytes.len() && bytes[after] == 0x01 && bytes[after + 1] == 0x52 { + return true; + } + + false +} + +#[derive(Clone)] +struct InitJumpPatch { + push_pos: usize, + width: usize, + old_target: usize, +} + +fn collect_init_jumpdest_values(bytes: &[u8]) -> HashSet { + let mut jumpdests = HashSet::new(); + let mut idx = 0usize; + while idx < bytes.len() { + let opcode = bytes[idx]; + if opcode == Opcode::JUMPDEST.to_byte() { + jumpdests.insert(idx); + idx += 1; + } else if (0x60..=0x7f).contains(&opcode) { + idx += 1 + (opcode - 0x5f) as usize; + } else { + idx += 1; + } + } + jumpdests +} + +fn collect_init_jump_patches(bytes: &[u8]) -> Vec { + let jumpdests = collect_init_jumpdest_values(bytes); + + let mut patches = Vec::new(); + let mut idx = 0usize; + while idx < bytes.len() { + let opcode = bytes[idx]; + if !(0x60..=0x7f).contains(&opcode) { + idx += 1; + continue; + } + let width = (opcode - 0x5f) as usize; + if idx + 1 + width > bytes.len() { + break; + } + if let Some(value) = parse_usize_be(&bytes[idx + 1..idx + 1 + width]) { + if jumpdests.contains(&value) { + patches.push(InitJumpPatch { + push_pos: idx, + width, + old_target: value, + }); + } + } + idx += 1 + width; + } + patches +} + +fn patch_rewritten_init_jump_targets( + bytes: &mut [u8], + jump_patches: &[InitJumpPatch], + insertions: &[(usize, usize)], +) -> Result<()> { + for patch in jump_patches { + let new_push_pos = init_pc_after_rewrites(patch.push_pos, insertions); + let new_target = init_pc_after_rewrites(patch.old_target, insertions); + if new_target == patch.old_target { + continue; + } + if new_push_pos + 1 + patch.width > bytes.len() { + return Err(Error::CoreError(format!( + "init jump PUSH out of bounds at 0x{new_push_pos:x}" + ))); + } + if patch.width < std::mem::size_of::() { + let max = (1usize << (patch.width * 8)) - 1; + if new_target > max { + return Err(Error::CoreError(format!( + "init jump target 0x{new_target:x} does not fit in PUSH{}", + patch.width + ))); + } + } + for j in 0..patch.width { + let shift = (patch.width - 1 - j) * 8; + bytes[new_push_pos + 1 + j] = ((new_target >> shift) & 0xff) as u8; + } + } + Ok(()) +} + +fn init_pc_after_rewrites(pc: usize, insertions: &[(usize, usize)]) -> usize { + pc + insertions + .iter() + .filter(|(pos, _)| *pos <= pc) + .map(|(_, len)| *len) + .sum::() +} + +#[cfg(test)] +mod tests { + use super::*; + use azoth_core::encoder; + use azoth_core::process_bytecode_to_cfg; + use azoth_core::seed::Seed; + + const FIXED_SEED: &str = "0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + + #[tokio::test] + async fn runtime_mask_masks_push20_literal() { + let constant = "a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"; + let bytecode = format!("0x73{constant}00"); + let (mut ir, _, _, bytes) = process_bytecode_to_cfg(&bytecode, false, &bytecode, false) + .await + .unwrap(); + let seed = Seed::from_hex(FIXED_SEED).unwrap(); + let mut rng = seed.create_deterministic_rng(); + + let changed = ConstantMask::with_min_width(20) + .apply(&mut ir, &mut rng) + .unwrap(); + assert!(changed); + ir.reindex_pcs().unwrap(); + + let mut instructions = Vec::new(); + for node in ir.cfg.node_indices() { + if let Block::Body(body) = &ir.cfg[node] { + instructions.extend(body.instructions.clone()); + } + } + instructions.sort_by_key(|instr| instr.pc); + let encoded = encoder::encode(&instructions, &bytes).unwrap(); + assert!(!hex::encode(encoded).contains(constant)); + } + + #[tokio::test] + async fn skips_direct_jump_target() { + // PUSH2 0x0006 ; JUMP ; JUMPDEST ; STOP + let bytecode = "0x610006565b00"; + let (mut ir, _, _, _) = process_bytecode_to_cfg(bytecode, false, bytecode, false) + .await + .unwrap(); + let seed = Seed::from_hex(FIXED_SEED).unwrap(); + let mut rng = seed.create_deterministic_rng(); + + let changed = ConstantMask::new().apply(&mut ir, &mut rng).unwrap(); + assert!(!changed); + } + + #[tokio::test] + async fn lowers_call_selector_mstore_to_byte_patches() { + // PUSH1 0x40; MLOAD; PUSH4 a9059cbb; PUSH1 e0; SHL; DUP2; MSTORE; + // then enough zero args for CALL. The transform should decoy the + // selector word and patch the actual four selector bytes with MSTORE8. + let bytecode = "0x60405163a9059cbb60e01b8152600060006000600060006000f100"; + let (mut ir, _, _, _) = process_bytecode_to_cfg(bytecode, false, bytecode, false) + .await + .unwrap(); + let seed = Seed::from_hex(FIXED_SEED).unwrap(); + let mut rng = seed.create_deterministic_rng(); + + let changed = ConstantMask::new().apply(&mut ir, &mut rng).unwrap(); + assert!(changed); + + let mut saw_mstore8 = false; + for node in ir.cfg.node_indices() { + if let Block::Body(body) = &ir.cfg[node] { + for instr in &body.instructions { + assert_ne!(instr.imm.as_deref(), Some("a9059cbb")); + saw_mstore8 |= instr.op == Opcode::MSTORE8; + } + } + } + assert!(saw_mstore8); + } + + #[tokio::test] + async fn masks_wide_init_literal() { + let constant = "a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"; + // Constructor-only PUSH20 constant followed by a minimal CODECOPY/RETURN + // sequence that deploys a one-byte STOP runtime. + let deployment = format!("0x73{constant}506001602260003960016000f300"); + let runtime = "0x00"; + let seed = Seed::from_hex(FIXED_SEED).unwrap(); + let config = crate::obfuscator::ObfuscationConfig { + seed, + transforms: vec![Box::new(ConstantMask::new())], + preserve_unknown_opcodes: true, + }; + + let result = crate::obfuscator::obfuscate_bytecode(&deployment, runtime, config) + .await + .unwrap(); + + assert!(!result.obfuscated_bytecode.contains(constant)); + } + + #[tokio::test] + async fn lowers_optimized_call_selector_mstore_to_byte_patches() { + // 0x461bcd << 0xe5 reconstructs the left-aligned selector + // 0x08c379a0. This is the same optimized selector shape solc can use + // for revert selectors, but here it feeds an external CALL. + let bytecode = "0x60405162461bcd60e51b8152600060006000600060006000f100"; + let (mut ir, _, _, _) = process_bytecode_to_cfg(bytecode, false, bytecode, false) + .await + .unwrap(); + let seed = Seed::from_hex(FIXED_SEED).unwrap(); + let mut rng = seed.create_deterministic_rng(); + + let changed = ConstantMask::new().apply(&mut ir, &mut rng).unwrap(); + assert!(changed); + + let mut saw_mstore8 = false; + for node in ir.cfg.node_indices() { + if let Block::Body(body) = &ir.cfg[node] { + for instr in &body.instructions { + assert_ne!(instr.imm.as_deref(), Some("461bcd")); + assert_ne!(instr.imm.as_deref(), Some("08c379a0")); + saw_mstore8 |= instr.op == Opcode::MSTORE8; + } + } + } + assert!(saw_mstore8); + } +} diff --git a/crates/transforms/src/lib.rs b/crates/transforms/src/lib.rs index d15a1cdc..35486de7 100644 --- a/crates/transforms/src/lib.rs +++ b/crates/transforms/src/lib.rs @@ -1,5 +1,6 @@ pub mod arithmetic_chain; pub mod cluster_shuffle; +pub mod constant_mask; pub mod function_dispatcher; pub mod jump_address_transformer; pub mod obfuscator; diff --git a/crates/transforms/src/obfuscator.rs b/crates/transforms/src/obfuscator.rs index bd3dce19..242d1d13 100644 --- a/crates/transforms/src/obfuscator.rs +++ b/crates/transforms/src/obfuscator.rs @@ -1,5 +1,6 @@ use crate::arithmetic_chain::ArithmeticChain; use crate::cluster_shuffle::ClusterShuffle; +use crate::constant_mask::ConstantMask; use crate::function_dispatcher::FunctionDispatcher; use crate::push_split::PushSplit; use crate::slot_shuffle::SlotShuffle; @@ -66,10 +67,11 @@ impl Default for ObfuscationConfig { Self { seed: Seed::generate(), transforms: vec![ + Box::new(StringObfuscate::new()), + Box::new(ConstantMask::new()), Box::new(ArithmeticChain::new()), Box::new(PushSplit::new()), Box::new(SlotShuffle::new()), - Box::new(StringObfuscate::new()), Box::new(ClusterShuffle::new()), ], preserve_unknown_opcodes: true, @@ -368,12 +370,6 @@ pub async fn obfuscate_bytecode( .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. - cfg_ir - .remap_orphan_jump_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) // NOTE: These patches update the PUSH2 instructions (jump targets), not the PUSH4 token instructions if let (Some(controller_pcs), Some(dispatcher_patches)) = ( @@ -606,11 +602,18 @@ pub async fn obfuscate_bytecode( tracing::debug!(" Controller patches re-applied successfully"); } - // After all Step 5 dispatcher reapplies may have grown some PUSH widths - // post-reindex, recompute the actual runtime length and shift AC-emitted - // CODECOPY offsets so the data section still lines up. This is a no-op - // when ArithmeticChain didn't run or when the estimate already matches - // the real runtime length. + // Remap stack-carried return-address PUSHes exactly once, after every + // dispatcher patch has been re-applied. Running this before and after the + // dispatcher phase double-remaps values that already point at new PCs, + // which can redirect Solidity internal-call returns into the wrong helper. + cfg_ir + .remap_orphan_jump_pushes(&pc_mapping, old_runtime_bounds) + .map_err(|e| ObfuscationError::from_err(e, &cfg_ir.trace))?; + + // Dispatcher reapplies can grow some PUSH widths post-reindex. Recompute + // the actual runtime length and shift AC-emitted CODECOPY offsets so the + // data section still lines up. This is a no-op when ArithmeticChain didn't + // run or when the estimate already matches the real runtime length. cfg_ir .patch_arithmetic_chain_codecopy_offsets() .map_err(|e| ObfuscationError::from_err(e, &cfg_ir.trace))?; @@ -695,7 +698,11 @@ pub async fn obfuscate_bytecode( } }; - if let Err(e) = cfg_ir.clean_report.patch_init_immutable_refs(&remap) { + let immutable_masks = cfg_ir.immutable_masks.clone(); + if let Err(e) = cfg_ir + .clean_report + .patch_init_immutable_refs_with_masks(&remap, &immutable_masks) + { tracing::warn!("Failed to patch init immutable refs: {}", e); } } From 1cccfaaed2550f9fa80157475fc8bc1dc7d7b7d0 Mon Sep 17 00:00:00 2001 From: g4titanx Date: Mon, 27 Apr 2026 15:38:34 +0100 Subject: [PATCH 2/6] fix(core): support masked immutable init writes --- crates/core/src/cfg_ir/mod.rs | 5 + crates/core/src/strip.rs | 222 ++++++++++++++++-- crates/transforms/src/arithmetic_chain/mod.rs | 16 ++ crates/verification/src/semantics.rs | 3 + 4 files changed, 226 insertions(+), 20 deletions(-) diff --git a/crates/core/src/cfg_ir/mod.rs b/crates/core/src/cfg_ir/mod.rs index 4348761d..a1883a06 100644 --- a/crates/core/src/cfg_ir/mod.rs +++ b/crates/core/src/cfg_ir/mod.rs @@ -165,6 +165,10 @@ pub struct CfgIrBundle { /// rewrite every AC-emitted offset PUSH so CODECOPY still points into /// the appended data section. pub ac_runtime_length_estimate: Option, + /// Map of original runtime byte offsets for immutable placeholders to + /// XOR keys used by transforms that mask the placeholder. Init-code + /// immutable writes must store `value XOR key` at these offsets. + pub immutable_masks: HashMap>, } impl CfgIrBundle { @@ -1679,6 +1683,7 @@ pub fn build_cfg_ir( dispatcher_blocks: HashSet::new(), arithmetic_chain_data: None, ac_runtime_length_estimate: None, + immutable_masks: HashMap::new(), }; let body_blocks = bundle .cfg diff --git a/crates/core/src/strip.rs b/crates/core/src/strip.rs index d34171f2..da384de2 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::{HashMap, HashSet}; /// Represents a runtime section with its original offset and length. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -134,6 +135,13 @@ struct PushInfo { value: usize, } +#[derive(Clone, Debug)] +struct InitJumpPatch { + push_pos: usize, + width: usize, + old_target: usize, +} + impl CleanReport { /// Updates init code CODECOPY and RETURN parameters to reflect new runtime length and offset. /// @@ -158,14 +166,20 @@ impl CleanReport { ); // Find Init section in removed - let new_runtime_offset = self + let original_runtime_offset = self .runtime_layout .iter() .map(|span| span.offset) .min() .ok_or("No runtime layout found")?; + let new_runtime_offset: usize = self + .removed + .iter() + .filter(|removed| removed.offset < original_runtime_offset) + .map(|removed| removed.data.len()) + .sum(); - let runtime_offset = new_runtime_offset; + let runtime_offset = original_runtime_offset; let post_runtime_len: usize = self .removed .iter() @@ -411,6 +425,20 @@ impl CleanReport { pub fn patch_init_immutable_refs( &mut self, remap: &dyn Fn(usize) -> Option, + ) -> Result<(), String> { + self.patch_init_immutable_refs_with_masks(remap, &HashMap::new()) + } + + /// Patch immutable reference offsets and optionally mask immutable values. + /// + /// `immutable_masks` maps the original runtime byte offset of an immutable + /// placeholder to a 32-byte XOR key. For those offsets, the init code is + /// rewritten to store `value XOR key` into the runtime placeholder, matching + /// runtime code that later computes `(value XOR key) XOR key`. + pub fn patch_init_immutable_refs_with_masks( + &mut self, + remap: &dyn Fn(usize) -> Option, + immutable_masks: &HashMap>, ) -> Result<(), String> { let runtime_start = self .runtime_layout @@ -428,6 +456,7 @@ impl CleanReport { let mut init_bytes = init_section.data.to_vec(); let mut patched = 0usize; + let mut mask_insertions: Vec<(usize, Vec)> = Vec::new(); let mut idx = 0usize; while idx < init_bytes.len() { @@ -458,12 +487,12 @@ impl CleanReport { }; // 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 - { + if is_add_target && value >= 1 && value < runtime_end.saturating_sub(runtime_start) { + let Some(new_value) = remap(value) else { + idx += 1 + width; + continue; + }; + let value_changed = new_value != value; // Check that new value fits in the same width let max = if width >= std::mem::size_of::() { usize::MAX @@ -483,19 +512,60 @@ impl CleanReport { 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 value_changed { + tracing::debug!( + "Patched immutable ref at init offset 0x{:x}: 0x{:x} -> 0x{:x}", + idx, + value, + new_value + ); + patched += 1; + } + } + + if let Some(key) = immutable_masks.get(&value) { + if key.len() == 32 { + let mstore_pos = after + 1; + if mstore_pos < init_bytes.len() && init_bytes[mstore_pos] == 0x52 { + mask_insertions.push((mstore_pos, key.clone())); + } else { + tracing::warn!( + "Immutable mask for runtime offset 0x{:x} did not match \ + ADD; MSTORE pattern in init code", + value + ); + } + } else { + tracing::warn!( + "Ignoring immutable mask for runtime offset 0x{:x}: expected \ + 32-byte key, got {} bytes", + value, + key.len() + ); + } } } idx += 1 + width; } + if !mask_insertions.is_empty() { + mask_insertions.sort_by_key(|(pos, _)| *pos); + mask_insertions.dedup_by_key(|(pos, _)| *pos); + let jump_patches = collect_init_jump_patches(&init_bytes); + for (pos, key) in mask_insertions.iter().rev() { + let mut patch = Vec::with_capacity(36); + patch.push(0x90); // SWAP1: bring immutable value above destination. + patch.push(0x7f); // PUSH32 key + patch.extend_from_slice(&key); + patch.push(0x18); // XOR: value ^ key + patch.push(0x90); // SWAP1: restore MSTORE argument order. + init_bytes.splice(*pos..*pos, patch); + patched += 1; + } + patch_shifted_init_jump_targets(&mut init_bytes, &jump_patches, &mask_insertions)?; + } + if patched > 0 { tracing::debug!( "Patched {} immutable reference offsets in init code", @@ -547,6 +617,12 @@ impl CleanReport { .map(|span| span.offset) .min() .unwrap_or(0); + let actual_runtime_start_offset: usize = self + .removed + .iter() + .filter(|removed| removed.offset < runtime_start_offset) + .map(|removed| removed.data.len()) + .sum(); tracing::debug!( "Original runtime started at offset {}, preserving prefix structure", @@ -596,15 +672,14 @@ impl CleanReport { } } - // here, we take the final constructor prefix (prefix), looks for any PUSH immediates still holding - // the old runtime length or the old total bytecode length, and rewrites them to the new valuesright - // before the output is returned - let prefix_end = runtime_start_offset.min(out.len()); + // Patch the final constructor prefix in case targeted init-code + // patching missed a size/offset PUSH after earlier insertions. + let prefix_end = actual_runtime_start_offset.min(out.len()); let (prefix, _) = out.split_at_mut(prefix_end); let original_tail_len = self.clean_len + post_runtime_len; let new_tail_len = clean.len() + post_runtime_len; let original_total_len = runtime_start_offset + original_tail_len; - let new_total_len = runtime_start_offset + new_tail_len; + let new_total_len = actual_runtime_start_offset + new_tail_len; if original_tail_len != new_tail_len { let replaced = patch_push_value(prefix, original_tail_len, new_tail_len, Some(1)); @@ -617,6 +692,22 @@ impl CleanReport { } } + if actual_runtime_start_offset != runtime_start_offset { + let replaced = patch_push_value( + prefix, + runtime_start_offset, + actual_runtime_start_offset, + Some(1), + ); + if replaced == 0 { + tracing::warn!( + "Failed to update CODECOPY offset from 0x{:x} to 0x{:x} in final bytecode", + runtime_start_offset, + actual_runtime_start_offset + ); + } + } + if original_total_len != new_total_len { let replaced = patch_push_value(prefix, original_total_len, new_total_len, Some(1)); if replaced == 0 { @@ -696,6 +787,97 @@ impl CleanReport { } } +fn collect_init_jump_patches(bytes: &[u8]) -> Vec { + let mut jumpdests = HashSet::new(); + let mut idx = 0usize; + while idx < bytes.len() { + let opcode = bytes[idx]; + if opcode == 0x5b { + jumpdests.insert(idx); + idx += 1; + } else if (0x60..=0x7f).contains(&opcode) { + let width = (opcode - 0x5f) as usize; + idx += 1 + width; + } else { + idx += 1; + } + } + + let mut patches = Vec::new(); + idx = 0; + while idx < bytes.len() { + let opcode = bytes[idx]; + if !(0x60..=0x7f).contains(&opcode) { + idx += 1; + continue; + } + + let width = (opcode - 0x5f) as usize; + if idx + 1 + width > bytes.len() { + break; + } + + let mut value = 0usize; + for &byte in &bytes[idx + 1..idx + 1 + width] { + value = (value << 8) | byte as usize; + } + if jumpdests.contains(&value) { + patches.push(InitJumpPatch { + push_pos: idx, + width, + old_target: value, + }); + } + + idx += 1 + width; + } + + patches +} + +fn patch_shifted_init_jump_targets( + bytes: &mut [u8], + jump_patches: &[InitJumpPatch], + insertions: &[(usize, Vec)], +) -> Result<(), String> { + for patch in jump_patches { + let new_push_pos = pc_after_insertions(patch.push_pos, insertions); + let new_target = pc_after_insertions(patch.old_target, insertions); + if new_target == patch.old_target { + continue; + } + if new_push_pos + 1 + patch.width > bytes.len() { + return Err(format!( + "shifted init jump PUSH out of bounds at 0x{:x}", + new_push_pos + )); + } + if patch.width < std::mem::size_of::() { + let max = (1usize << (patch.width * 8)) - 1; + if new_target > max { + return Err(format!( + "shifted init jump target 0x{:x} does not fit in PUSH{}", + new_target, patch.width + )); + } + } + for j in 0..patch.width { + let shift = (patch.width - 1 - j) * 8; + bytes[new_push_pos + 1 + j] = ((new_target >> shift) & 0xff) as u8; + } + } + + Ok(()) +} + +fn pc_after_insertions(pc: usize, insertions: &[(usize, Vec)]) -> usize { + pc + insertions + .iter() + .filter(|(pos, _)| *pos <= pc) + .map(|(_, key)| key.len() + 4) + .sum::() +} + fn patch_push_value( bytes: &mut [u8], old_value: usize, diff --git a/crates/transforms/src/arithmetic_chain/mod.rs b/crates/transforms/src/arithmetic_chain/mod.rs index abab6794..427b2bc2 100644 --- a/crates/transforms/src/arithmetic_chain/mod.rs +++ b/crates/transforms/src/arithmetic_chain/mod.rs @@ -130,6 +130,8 @@ impl ArithmeticChain { ) -> Vec<(NodeIndex, usize, u8, [u8; 32])> { let mut targets = Vec::new(); + let runtime_start = ir.runtime_bounds().map(|(start, _)| start).unwrap_or(0); + for node_idx in ir.cfg.node_indices() { if let Block::Body(body) = &ir.cfg[node_idx] { for (instr_idx, instr) in body.instructions.iter().enumerate() { @@ -145,6 +147,20 @@ impl ArithmeticChain { continue; } + // ConstantMask records immutable placeholder PUSH sites so + // init-code patching can write constructor args into the + // first PUSH immediate and let runtime XOR reconstruction + // recover the value. Replacing that PUSH with an arithmetic + // chain removes the patch site and corrupts deployed state. + let immediate_offset = instr.pc.saturating_add(1).saturating_sub(runtime_start); + if ir.immutable_masks.contains_key(&immediate_offset) { + debug!( + "Skipping PUSH{} at PC {:#x} - immutable patch site", + push_size, instr.pc + ); + continue; + } + // Skip PUSH immediately followed by JUMP/JUMPI - these are jump targets if body .instructions diff --git a/crates/verification/src/semantics.rs b/crates/verification/src/semantics.rs index c7d16af9..f7ef0577 100644 --- a/crates/verification/src/semantics.rs +++ b/crates/verification/src/semantics.rs @@ -1216,6 +1216,7 @@ pub mod tests { dispatcher_blocks: std::collections::HashSet::new(), arithmetic_chain_data: None, ac_runtime_length_estimate: None, + immutable_masks: HashMap::new(), }; let analyzer = SemanticAnalyzer::new(cfg_bundle); @@ -1271,6 +1272,7 @@ pub mod tests { dispatcher_blocks: std::collections::HashSet::new(), arithmetic_chain_data: None, ac_runtime_length_estimate: None, + immutable_masks: HashMap::new(), }; let analyzer = SemanticAnalyzer::new(cfg_bundle); @@ -1300,6 +1302,7 @@ pub mod tests { dispatcher_blocks: std::collections::HashSet::new(), arithmetic_chain_data: None, ac_runtime_length_estimate: None, + immutable_masks: HashMap::new(), }; let analyzer = SemanticAnalyzer::new(cfg_bundle); From 6d831690872d1abab7d2386f4d9724dd3694926d Mon Sep 17 00:00:00 2001 From: g4titanx Date: Mon, 27 Apr 2026 15:39:24 +0100 Subject: [PATCH 3/6] fix(transforms): randomize revert selectors in string obfuscation --- crates/transforms/src/string_obfuscate.rs | 348 +++++++++++++++++++++- 1 file changed, 338 insertions(+), 10 deletions(-) diff --git a/crates/transforms/src/string_obfuscate.rs b/crates/transforms/src/string_obfuscate.rs index a514e924..3e865468 100644 --- a/crates/transforms/src/string_obfuscate.rs +++ b/crates/transforms/src/string_obfuscate.rs @@ -1,7 +1,8 @@ -//! Obfuscate Error(string) revert literals by rewriting string data PUSH immediates. +//! Obfuscate revert payloads by sanitizing selectors and Error(string) data. //! -//! This pass uses structural detection for ABI-encoded Error(string). -//! It handles two selector patterns: +//! This pass handles custom-error selectors and ABI-encoded Error(string) +//! payloads. Error(string) handling uses structural detection for two selector +//! patterns: //! 1. Direct: `PUSH4 0x08c379a0` //! 2. Computed: `PUSH3 0x461bcd ; PUSH1 0xe5 ; SHL` (Solidity optimization) //! @@ -14,11 +15,12 @@ use azoth_core::cfg_ir::{Block, CfgIrBundle}; use azoth_core::decoder::Instruction; use azoth_core::Opcode; use rand::rngs::StdRng; -use rand::RngCore; -use std::collections::HashMap; +use rand::{Rng, RngCore}; +use std::collections::{HashMap, HashSet}; use tracing::debug; -/// Obfuscate Error(string) literals by scrambling the encoded string data. +/// Obfuscate revert payloads by randomizing custom-error selectors and +/// scrambling Error(string) literals. #[derive(Default)] pub struct StringObfuscate; @@ -34,11 +36,13 @@ impl Transform for StringObfuscate { } fn apply(&self, ir: &mut CfgIrBundle, rng: &mut StdRng) -> Result { - debug!("StringObfuscate: scanning for Error(string) literals"); + debug!("StringObfuscate: scanning revert payloads"); + + let mut changed = randomize_custom_error_selectors(ir, rng)?; + let mut used_selectors = collect_push4_values(ir); 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 { @@ -53,6 +57,15 @@ impl Transform for StringObfuscate { continue; } + if randomize_error_string_selector( + &mut rewritten, + rng, + &mut used_selectors, + &protected_pcs, + ) { + block_changed = true; + } + for idx in data_push_indices { let instr = &rewritten[idx]; if protected_pcs.contains(&instr.pc) { @@ -84,14 +97,242 @@ impl Transform for StringObfuscate { } if changed { - debug!("StringObfuscate: obfuscated Error(string) literals"); + debug!("StringObfuscate: obfuscated revert payloads"); } else { - debug!("StringObfuscate: no eligible Error(string) literals found"); + debug!("StringObfuscate: no eligible revert payloads found"); } Ok(changed) } } +/// Randomize custom-error selectors in revert construction blocks. +/// +/// This is owned by StringObfuscate because custom errors and Error(string) +/// payloads are both revert-data fingerprints. +fn randomize_custom_error_selectors(ir: &mut CfgIrBundle, rng: &mut StdRng) -> Result { + debug!("StringObfuscate: scanning custom-error selectors"); + + let protected_pcs = collect_protected_pcs(ir); + let mut used = collect_push4_values(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; + + for idx in 0..rewritten.len() { + if protected_pcs.contains(&rewritten[idx].pc) { + continue; + } + let Some((selector, value_idx, shift_idx)) = shifted_selector_at(&rewritten, idx) + else { + continue; + }; + if protected_pcs.contains(&rewritten[shift_idx].pc) { + continue; + } + if is_solidity_builtin_error_selector(selector) { + continue; + } + if !has_nearby_mstore_then_revert(&rewritten, idx + 3) { + continue; + } + + used.insert(selector); + let replacement = next_selector(rng, &mut used); + debug!( + "StringObfuscate: custom error selector pc=0x{:x} 0x{:08x} -> 0x{:08x}", + rewritten[value_idx].pc, selector, replacement + ); + rewritten[value_idx].op = Opcode::PUSH(4); + rewritten[value_idx].imm = Some(format!("{replacement:08x}")); + rewritten[shift_idx].imm = Some("e0".to_string()); + 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; + } + } + + Ok(changed) +} + +fn collect_push4_values(ir: &CfgIrBundle) -> HashSet { + let mut values = HashSet::new(); + for node in ir.cfg.node_indices() { + let Some(Block::Body(body)) = ir.cfg.node_weight(node) else { + continue; + }; + for instr in &body.instructions { + if matches!(instr.op, Opcode::PUSH(4)) { + if let Some(value) = parse_u32(instr.imm.as_deref()) { + values.insert(value); + } + } + } + } + values +} + +fn randomize_error_string_selector( + instructions: &mut [Instruction], + rng: &mut StdRng, + used: &mut HashSet, + protected_pcs: &HashSet, +) -> bool { + let mut changed = false; + + for idx in 0..instructions.len() { + if protected_pcs.contains(&instructions[idx].pc) { + continue; + } + + if matches!(instructions[idx].op, Opcode::PUSH(4)) { + if let Some((_, bytes)) = parse_push_immediate(&instructions[idx]) { + if is_error_selector(&bytes) { + let replacement = next_selector(rng, used); + instructions[idx].imm = Some(format!("{replacement:08x}")); + changed = true; + continue; + } + } + } + + if matches!(instructions[idx].op, Opcode::SHL) && idx >= 2 { + let shift_idx = idx - 1; + let value_idx = idx - 2; + if protected_pcs.contains(&instructions[value_idx].pc) { + continue; + } + + let shift_ok = matches!( + parse_push_immediate(&instructions[shift_idx]), + Some((_, bytes)) if parse_usize_be(&bytes) == Some(0xe5) + ); + let value_ok = matches!( + parse_push_immediate(&instructions[value_idx]), + Some((_, bytes)) if bytes == [0x46, 0x1b, 0xcd] || + (bytes.len() > 3 && bytes.ends_with(&[0x46, 0x1b, 0xcd]) && + bytes[..bytes.len()-3].iter().all(|&b| b == 0)) + ); + if !shift_ok || !value_ok { + continue; + } + + let (replacement_push3, selector) = next_shifted_selector3(rng, used); + instructions[value_idx].imm = Some(format!("{replacement_push3:06x}")); + used.insert(selector); + changed = true; + } + } + + changed +} + +fn next_selector(rng: &mut StdRng, used: &mut HashSet) -> u32 { + loop { + let value = rng.random::(); + if value == 0 || is_solidity_builtin_error_selector(value) || used.contains(&value) { + continue; + } + used.insert(value); + return value; + } +} + +fn next_shifted_selector3(rng: &mut StdRng, used: &HashSet) -> (u32, u32) { + loop { + let value = rng.next_u32() & 0x00ff_ffff; + let selector = value << 5; + if value == 0 || is_solidity_builtin_error_selector(selector) || used.contains(&selector) { + continue; + } + return (value, selector); + } +} + +fn parse_u32(imm: Option<&str>) -> Option { + let imm = imm?; + if imm.len() != 8 { + return None; + } + u32::from_str_radix(imm, 16).ok() +} + +fn is_solidity_builtin_error_selector(selector: u32) -> bool { + matches!(selector, 0x08c3_79a0 | 0x4e48_7b71) +} + +fn shifted_selector_at(instructions: &[Instruction], idx: usize) -> Option<(u32, usize, usize)> { + let value = instructions.get(idx)?; + let Some(shift) = instructions.get(idx + 1) else { + return None; + }; + let Some(shl) = instructions.get(idx + 2) else { + return None; + }; + if !matches!(value.op, Opcode::PUSH(1..=4)) + || !matches!(shift.op, Opcode::PUSH(1)) + || shl.op != Opcode::SHL + { + return None; + } + + let (_, value_bytes) = parse_push_immediate(value)?; + let (_, shift_bytes) = parse_push_immediate(shift)?; + let value = parse_usize_be(&value_bytes)?; + let shift = parse_usize_be(&shift_bytes)?; + let selector = recover_left_aligned_selector(value, shift)?; + Some((selector, idx, idx + 1)) +} + +fn recover_left_aligned_selector(value: usize, shift: usize) -> Option { + if shift < 224 { + return None; + } + let extra_shift = shift - 224; + if extra_shift >= 32 { + return None; + } + let selector = (value as u64).checked_shl(extra_shift as u32)?; + if selector == 0 || selector > u32::MAX as u64 { + return None; + } + Some(selector as u32) +} + +fn has_nearby_mstore_then_revert(instructions: &[Instruction], start: usize) -> bool { + let end = (start + 16).min(instructions.len()); + let mut saw_mstore = false; + + for instr in &instructions[start..end] { + match instr.op { + Opcode::MSTORE => saw_mstore = true, + Opcode::REVERT => return saw_mstore, + Opcode::JUMP + | Opcode::JUMPI + | Opcode::STOP + | Opcode::RETURN + | Opcode::CALL + | Opcode::DELEGATECALL + | Opcode::STATICCALL => return false, + _ => {} + } + } + + false +} + fn collect_error_string_data_pushes(instructions: &[Instruction]) -> Vec { // Try structural detection first if let Some(indices) = try_structural_detection(instructions) { @@ -415,6 +656,12 @@ fn is_error_selector(bytes: &[u8]) -> bool { #[cfg(test)] mod tests { use super::*; + use azoth_core::cfg_ir::Block; + use azoth_core::process_bytecode_to_cfg; + use azoth_core::seed::Seed; + use std::collections::HashSet; + + const FIXED_SEED: &str = "0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; fn instr(pc: usize, op: Opcode, imm: Option<&str>) -> Instruction { Instruction { @@ -543,4 +790,85 @@ mod tests { let indices = collect_error_string_data_pushes(&instructions); assert_eq!(indices, vec![9, 12]); // both chunk indices } + + #[test] + fn randomizes_direct_error_string_selector() { + let mut instructions = vec![ + instr(0, Opcode::PUSH(4), Some("08c379a0")), + instr(5, Opcode::PUSH(1), Some("00")), + instr(7, Opcode::MSTORE, None), + ]; + let seed = Seed::from_hex(FIXED_SEED).unwrap(); + let mut rng = seed.create_deterministic_rng(); + let mut used = HashSet::new(); + let protected = HashSet::new(); + + let changed = + randomize_error_string_selector(&mut instructions, &mut rng, &mut used, &protected); + + assert!(changed); + assert_ne!(instructions[0].imm.as_deref(), Some("08c379a0")); + assert_ne!(instructions[0].imm.as_deref(), Some("4e487b71")); + } + + #[tokio::test] + async fn string_obfuscate_randomizes_custom_error_selector() { + let bytecode = "0x63d5ef09ba60e01b5f5260045ffd"; + let (mut ir, _, _, _) = process_bytecode_to_cfg(bytecode, false, bytecode, false) + .await + .unwrap(); + let seed = Seed::from_hex(FIXED_SEED).unwrap(); + let mut rng = seed.create_deterministic_rng(); + + let changed = StringObfuscate::new().apply(&mut ir, &mut rng).unwrap(); + assert!(changed); + + let mut selectors = Vec::new(); + for node in ir.cfg.node_indices() { + if let Block::Body(body) = &ir.cfg[node] { + for instr in &body.instructions { + if matches!(instr.op, Opcode::PUSH(4)) { + selectors.push(instr.imm.clone().unwrap()); + } + } + } + } + + assert_eq!(selectors.len(), 1); + assert_ne!(selectors[0], "d5ef09ba"); + } + + #[tokio::test] + async fn string_obfuscate_randomizes_optimized_custom_error_selector() { + // Solidity can compress a left-aligned selector when the low selector + // bit is zero: 0x022e2581 << 0xe1 reconstructs 0x045c4b02. + let bytecode = "0x63022e258160e11b5f5260045ffd"; + let (mut ir, _, _, _) = process_bytecode_to_cfg(bytecode, false, bytecode, false) + .await + .unwrap(); + let seed = Seed::from_hex(FIXED_SEED).unwrap(); + let mut rng = seed.create_deterministic_rng(); + + let changed = StringObfuscate::new().apply(&mut ir, &mut rng).unwrap(); + assert!(changed); + + let mut selector = None; + let mut shift = None; + for node in ir.cfg.node_indices() { + if let Block::Body(body) = &ir.cfg[node] { + for instr in &body.instructions { + if matches!(instr.op, Opcode::PUSH(4)) { + selector = instr.imm.clone(); + } + if matches!(instr.op, Opcode::PUSH(1)) && shift.is_none() { + shift = instr.imm.clone(); + } + } + } + } + + assert_ne!(selector.as_deref(), Some("045c4b02")); + assert_ne!(selector.as_deref(), Some("022e2581")); + assert_eq!(shift.as_deref(), Some("e0")); + } } From 46acd09b14bdb7d138868a072aca9353f259e185 Mon Sep 17 00:00:00 2001 From: g4titanx Date: Mon, 27 Apr 2026 15:41:16 +0100 Subject: [PATCH 4/6] fix(transforms): correct push split subtraction chains --- crates/transforms/src/push_split.rs | 97 ++++++++++++++++++++++------- 1 file changed, 75 insertions(+), 22 deletions(-) diff --git a/crates/transforms/src/push_split.rs b/crates/transforms/src/push_split.rs index 9c8e89a9..567b32b5 100644 --- a/crates/transforms/src/push_split.rs +++ b/crates/transforms/src/push_split.rs @@ -54,6 +54,7 @@ impl Transform for PushSplit { let _ = runtime_bounds; let nodes: Vec<_> = ir.cfg.node_indices().collect(); + let mut next_synthetic_pc = next_available_pc(ir); for node in nodes { if protected_nodes.contains(&node) { continue; @@ -176,10 +177,9 @@ impl Transform for PushSplit { ); // Seed the stack with the identity element for the chain's - // combine op (0 is identity for both ADD and XOR, and for - // SUB the first chain entry is always ADD by construction - // — see `generate_chain` where `can_sub = acc > 0` and - // `acc` starts at 0). Without this, the first op would + // combine op (0 is identity for ADD/XOR; SUB entries are + // emitted as `SWAP1; SUB` so the reduction computes + // accumulated - part). Without this, the first op would // combine the chain's first PUSH with whatever value the // preceding code left on the stack, giving // `(prev_top) ⊕ p1 ⊕ … ⊕ p_n` instead of `p1 ⊕ … ⊕ p_n`. @@ -188,12 +188,10 @@ impl Transform for PushSplit { op: Opcode::PUSH0, imm: None, }); - let mut pc = base_pc + 1; - let chain_len = chain.len() * 2 + 1; let rewritten_start = rewritten.len() - 1; for (part, op) in chain { - pc = emit_part(part, op, pc, &mut rewritten); + emit_part(part, op, &mut next_synthetic_pc, &mut rewritten); } let after_window = format_window(&rewritten, rewritten_start, chain_len.min(6)); @@ -204,7 +202,7 @@ impl Transform for PushSplit { ); } - new_max_stack = new_max_stack.max(2); + new_max_stack = new_max_stack.max(original_max_stack.saturating_add(1)); changed = true; continue; } @@ -378,25 +376,63 @@ fn format_window(instructions: &[Instruction], center_idx: usize, count: usize) out } -fn emit_part(part: u128, op: CombineOp, pc: usize, out: &mut Vec) -> usize { +fn emit_part(part: u128, op: CombineOp, next_synthetic_pc: &mut usize, out: &mut Vec) { + let mut fresh_pc = || { + let pc = *next_synthetic_pc; + *next_synthetic_pc = next_synthetic_pc.saturating_add(1); + pc + }; let width = minimal_push_width(part); + let push_pc = fresh_pc(); out.push(Instruction { - pc, + pc: push_pc, op: Opcode::PUSH(width), imm: Some(format_hex(part, width)), }); - let pc_after_push = pc + 1 + width as usize; - let opcode = match op { - CombineOp::Add => Opcode::ADD, - CombineOp::Sub => Opcode::SUB, - CombineOp::Xor => Opcode::XOR, - }; - out.push(Instruction { - pc: pc_after_push, - op: opcode, - imm: None, - }); - pc_after_push + 1 + match op { + CombineOp::Add | CombineOp::Xor => { + let opcode = match op { + CombineOp::Add => Opcode::ADD, + CombineOp::Xor => Opcode::XOR, + CombineOp::Sub => unreachable!(), + }; + out.push(Instruction { + pc: fresh_pc(), + op: opcode, + imm: None, + }); + } + CombineOp::Sub => { + // EVM SUB computes top - next. The reduction stack is + // [part, acc], so swap first to compute acc - part. + out.push(Instruction { + pc: fresh_pc(), + op: Opcode::SWAP(1), + imm: None, + }); + out.push(Instruction { + pc: fresh_pc(), + op: Opcode::SUB, + imm: None, + }); + } + } +} + +fn next_available_pc(ir: &CfgIrBundle) -> usize { + ir.cfg + .node_indices() + .filter_map(|node| match ir.cfg.node_weight(node) { + Some(Block::Body(body)) => body + .instructions + .iter() + .map(|instr| instr.pc.saturating_add(instr.byte_size())) + .max(), + _ => None, + }) + .max() + .unwrap_or(0) + .saturating_add(1_000_000) } #[cfg(test)] @@ -488,4 +524,21 @@ mod tests { } } } + + #[test] + fn sub_part_emits_swap_before_sub() { + let mut instructions = Vec::new(); + let mut next_synthetic_pc = 0x20; + emit_part( + 0x12, + CombineOp::Sub, + &mut next_synthetic_pc, + &mut instructions, + ); + + assert_eq!(next_synthetic_pc, 0x23); + assert!(matches!(instructions[0].op, Opcode::PUSH(1))); + assert!(matches!(instructions[1].op, Opcode::SWAP(1))); + assert_eq!(instructions[2].op, Opcode::SUB); + } } From 76895ab1377702a642d7b5038a8807b137b0fda7 Mon Sep 17 00:00:00 2001 From: g4titanx Date: Mon, 27 Apr 2026 15:47:00 +0100 Subject: [PATCH 5/6] tests(e2e): add constant masking tests and refactor tests to remove duplicate code --- tests/src/e2e/collect_proof.rs | 37 +- tests/src/e2e/constant_audit.rs | 608 ++++++++++++++++++++++++++++++++ tests/src/e2e/determinism.rs | 19 + tests/src/e2e/mod.rs | 191 +++++++--- tests/src/e2e/test_counter.rs | 270 ++++---------- tests/src/e2e/test_original.rs | 110 ++---- 6 files changed, 886 insertions(+), 349 deletions(-) create mode 100644 tests/src/e2e/constant_audit.rs diff --git a/tests/src/e2e/collect_proof.rs b/tests/src/e2e/collect_proof.rs index 231b8137..f13a6d8d 100644 --- a/tests/src/e2e/collect_proof.rs +++ b/tests/src/e2e/collect_proof.rs @@ -17,6 +17,7 @@ use super::{ use azoth_core::seed::Seed; use azoth_transform::arithmetic_chain::ArithmeticChain; use azoth_transform::cluster_shuffle::ClusterShuffle; +use azoth_transform::constant_mask::ConstantMask; use azoth_transform::obfuscator::{obfuscate_bytecode, ObfuscationConfig}; use azoth_transform::push_split::PushSplit; use azoth_transform::slot_shuffle::SlotShuffle; @@ -613,8 +614,18 @@ async fn test_collect_with_erc20_proof_dispatcher_plus_arithmetic_chain_succeeds assert_collect_flow_success(label, outcome) } -/// Regression probe for two PushSplit bugs that both made `collect()` -/// halt with `InvalidJump` at the 30M gas limit: +#[tokio::test] +async fn test_collect_with_erc20_proof_dispatcher_plus_constant_mask_succeeds() -> Result<()> { + let label = "dispatcher_plus_constant_mask"; + let (deployment_bytecode, bond_calldata, collect_selector) = + build_obfuscated_flow_inputs(label, vec![Box::new(ConstantMask::new())]).await?; + let outcome = + execute_collect_proof_flow(deployment_bytecode, bond_calldata, collect_selector, label)?; + assert_collect_flow_success(label, outcome) +} + +/// Regression probe for PushSplit/finalization bugs that made `collect()` +/// halt or revert after size-growing literal splits: /// /// 1. `push_split.rs` emitted the split chain as `PUSH p1; op; PUSH p2; /// op; ...`, where the first `op` consumed whatever value the @@ -637,6 +648,16 @@ async fn test_collect_with_erc20_proof_dispatcher_plus_arithmetic_chain_succeeds /// `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`. +/// +/// 3. PushSplit emitted `SUB` directly even though EVM `SUB` computes +/// top-minus-next. The chain accumulator stack is `[part, acc]`, so +/// `SUB` must be emitted as `SWAP1; SUB` to compute `acc - part`. +/// +/// 4. The obfuscator called `remap_orphan_jump_pushes` both before and +/// after dispatcher reapply. The second pass treated already-remapped +/// return addresses as old PCs and remapped them again, sending the +/// RLP parser into the `InvalidRLP()` helper even though every split +/// literal reconstructed correctly. #[tokio::test] async fn test_collect_with_erc20_proof_dispatcher_plus_push_split_succeeds() -> Result<()> { let label = "dispatcher_plus_push_split"; @@ -721,16 +742,8 @@ async fn test_collect_with_erc20_proof_dispatcher_plus_cluster_shuffle_succeeds( #[tokio::test] async fn test_collect_with_erc20_proof_default_pipeline_succeeds() -> Result<()> { let label = "default_pipeline"; - 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()), - ], - ) - .await?; + let (deployment_bytecode, bond_calldata, collect_selector) = + build_obfuscated_flow_inputs(label, ObfuscationConfig::default().transforms).await?; let outcome = execute_collect_proof_flow(deployment_bytecode, bond_calldata, collect_selector, label)?; assert_collect_flow_success(label, outcome) diff --git a/tests/src/e2e/constant_audit.rs b/tests/src/e2e/constant_audit.rs new file mode 100644 index 00000000..3cbe213f --- /dev/null +++ b/tests/src/e2e/constant_audit.rs @@ -0,0 +1,608 @@ +use super::{ + deploy_contract_and_get_runtime, ESCROW_CONTRACT_DEPLOYMENT_BYTECODE, + ESCROW_CONTRACT_RUNTIME_BYTECODE, +}; +use azoth_core::seed::Seed; +use azoth_transform::obfuscator::{obfuscate_bytecode, ObfuscationConfig}; +use color_eyre::eyre::eyre; +use color_eyre::Result; +use std::collections::{BTreeMap, BTreeSet}; + +const FIXED_SEED: &str = "0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +const REPORT_PATTERNS: &[(&str, &str)] = &[ + ("USDC address", "a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"), + ( + "ERC-20 Transfer topic", + "ddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + ), + ("ERC-20 transfer selector", "a9059cbb"), + ("ERC-20 transferFrom selector", "23b872dd"), + ("linked Mirage address prefix", "bb83df95"), + ("deployer address prefix", "40e0b656"), + ("bond timeout 300", "012c"), + ("USDC decimals 1e6", "0f4240"), + ("Solidity Panic selector", "4e487b71"), + ("Solidity Error(string) selector", "08c379a0"), + ( + "proof token fixture", + "be41a9ec942d5b52be07cc7f4d7e30e10e9b652a", + ), + ( + "proof recipient fixture", + "658d9c76ff358984d6436ea11ee1eda08894c818", + ), + ( + "proof executor fixture", + "e1a9d9c9abb872ddef70a4d108fd8fc3c7ce4dc4", + ), +]; + +#[tokio::test] +async fn default_pipeline_removes_report_constants_from_real_escrow_bytecode() -> Result<()> { + let seed = Seed::from_hex(FIXED_SEED).map_err(|e| eyre!("seed parse failed: {e}"))?; + let config = ObfuscationConfig { + seed, + ..ObfuscationConfig::default() + }; + let result = obfuscate_bytecode( + ESCROW_CONTRACT_DEPLOYMENT_BYTECODE, + ESCROW_CONTRACT_RUNTIME_BYTECODE, + config, + ) + .await + .map_err(|e| eyre!("default pipeline obfuscation failed: {e}"))?; + + let deployment = normalize_hex(&result.obfuscated_bytecode); + let runtime = hex::encode(deploy_contract_and_get_runtime( + &result.obfuscated_bytecode, + )?); + let audited_patterns = audited_patterns()?; + + assert!( + audited_patterns.iter().any(|(_, hex)| hex == "045c4b02"), + "audit should include optimized custom-error selector 0x045c4b02" + ); + + assert_no_raw_matches("obfuscated deployment", &deployment, &audited_patterns); + assert_no_raw_matches("deployed runtime", &runtime, &audited_patterns); + assert_no_shifted_selector_recovery("obfuscated deployment", &deployment, &audited_patterns)?; + assert_no_shifted_selector_recovery("deployed runtime", &runtime, &audited_patterns)?; + assert_no_raw_selector_byte_patches("obfuscated deployment", &deployment, &audited_patterns)?; + assert_no_raw_selector_byte_patches("deployed runtime", &runtime, &audited_patterns)?; + assert_no_constant_folder_recovery("obfuscated deployment", &deployment, &audited_patterns)?; + assert_no_constant_folder_recovery("deployed runtime", &runtime, &audited_patterns)?; + + Ok(()) +} + +fn audited_patterns() -> Result> { + let mut patterns = BTreeSet::new(); + for (name, hex) in REPORT_PATTERNS { + patterns.insert(((*name).to_string(), normalize_hex(hex))); + } + + for selector in collect_revert_selectors(ESCROW_CONTRACT_DEPLOYMENT_BYTECODE)? { + patterns.insert(( + format!("original custom-error selector {selector}"), + selector, + )); + } + for selector in collect_revert_selectors(ESCROW_CONTRACT_RUNTIME_BYTECODE)? { + patterns.insert(( + format!("original custom-error selector {selector}"), + selector, + )); + } + + Ok(patterns) +} + +fn assert_no_raw_matches(label: &str, haystack: &str, patterns: &BTreeSet<(String, String)>) { + for (name, needle) in patterns { + assert!( + !haystack.contains(needle), + "{label} still contains {name}: 0x{needle}" + ); + } +} + +fn assert_no_shifted_selector_recovery( + label: &str, + bytecode_hex: &str, + patterns: &BTreeSet<(String, String)>, +) -> Result<()> { + let audited_selectors: BTreeSet<&str> = patterns + .iter() + .map(|(_, hex)| hex.as_str()) + .filter(|hex| hex.len() == 8) + .collect(); + let recovered = collect_shifted_selectors(bytecode_hex)?; + + for selector in recovered { + assert!( + !audited_selectors.contains(selector.as_str()), + "{label} reconstructs audited selector via PUSH/SHL: 0x{selector}" + ); + } + + Ok(()) +} + +fn assert_no_raw_selector_byte_patches( + label: &str, + bytecode_hex: &str, + patterns: &BTreeSet<(String, String)>, +) -> Result<()> { + let bytes = decode_hex(bytecode_hex)?; + for (name, selector) in patterns.iter().filter(|(_, hex)| hex.len() == 8) { + let selector_bytes = decode_hex(selector)?; + for (offset, byte) in selector_bytes.iter().enumerate() { + assert!( + !contains_direct_mstore8_patch(&bytes, offset, *byte), + "{label} directly patches {name} byte[{offset}] via raw PUSH1 0x{byte:02x}; MSTORE8" + ); + } + } + + Ok(()) +} + +fn contains_direct_mstore8_patch(bytes: &[u8], offset: usize, value: u8) -> bool { + for idx in 0..bytes.len() { + if offset == 0 { + if bytes.get(idx..idx + 4) == Some(&[0x60, value, 0x81, 0x53]) { + return true; + } + } else if bytes.get(idx..idx + 7) + == Some(&[0x60, value, 0x60, offset as u8, 0x82, 0x01, 0x53]) + { + return true; + } + } + + false +} + +fn assert_no_constant_folder_recovery( + label: &str, + bytecode_hex: &str, + patterns: &BTreeSet<(String, String)>, +) -> Result<()> { + let bytes = decode_hex(bytecode_hex)?; + let audit_values = constant_folder_audit_values(patterns)?; + for (pc, value) in collect_folded_values(&bytes) { + if let Some(name) = audit_values.get(&value) { + return Err(eyre!( + "{label} constant-folder recovered {name} at pc=0x{pc:x}" + )); + } + } + + Ok(()) +} + +fn constant_folder_audit_values( + patterns: &BTreeSet<(String, String)>, +) -> Result> { + let mut values = BTreeMap::new(); + for (name, hex) in patterns { + let bytes = decode_hex(hex)?; + if bytes.len() > 32 { + continue; + } + + values.insert(right_aligned_word(&bytes), name.clone()); + if bytes.len() == 4 { + values.insert(left_aligned_word(&bytes), format!("{name} left-aligned")); + } + } + Ok(values) +} + +fn collect_folded_values(bytes: &[u8]) -> Vec<(usize, [u8; 32])> { + let mut values = Vec::new(); + let mut stack: Vec> = Vec::new(); + let mut idx = 0usize; + + while idx < bytes.len() { + let pc = idx; + let opcode = bytes[idx]; + idx += 1; + + let folded = match opcode { + 0x5f => { + let value = [0u8; 32]; + stack.push(Some(value)); + Some(value) + } + 0x60..=0x7f => { + let width = (opcode - 0x5f) as usize; + if idx + width > bytes.len() { + break; + } + let value = right_aligned_word(&bytes[idx..idx + width]); + idx += width; + stack.push(Some(value)); + Some(value) + } + 0x01 => fold_binary(&mut stack, add_words), + 0x03 => fold_binary(&mut stack, |left, right| sub_words(right, left)), + 0x10 => fold_binary(&mut stack, |left, right| gt_word(right, left)), + 0x11 => fold_binary(&mut stack, |left, right| lt_word(right, left)), + 0x14 => fold_binary(&mut stack, eq_word), + 0x15 => fold_unary(&mut stack, iszero_word), + 0x16 => fold_binary(&mut stack, and_words), + 0x17 => fold_binary(&mut stack, or_words), + 0x18 => fold_binary(&mut stack, xor_words), + 0x19 => fold_unary(&mut stack, not_word), + 0x1a => fold_binary(&mut stack, byte_word), + 0x1b => fold_binary(&mut stack, shl_word), + 0x1c => fold_binary(&mut stack, shr_word), + 0x30 | 0x33 | 0x34 | 0x36 | 0x38 | 0x3a | 0x3d | 0x42 | 0x43 | 0x58 | 0x59 | 0x5a => { + stack.push(None); + None + } + 0x50 => { + stack.pop(); + None + } + 0x5b => { + stack.clear(); + None + } + 0x80..=0x8f => { + let depth = (opcode - 0x7f) as usize; + if depth <= stack.len() { + let value = stack[stack.len() - depth]; + stack.push(value); + value + } else { + stack.clear(); + None + } + } + 0x90..=0x9f => { + let depth = (opcode - 0x8f) as usize; + if depth < stack.len() { + let top = stack.len() - 1; + stack.swap(top, top - depth); + } else { + stack.clear(); + } + None + } + _ => { + stack.clear(); + None + } + }; + + if let Some(value) = folded { + values.push((pc, value)); + } + } + + values +} + +fn fold_unary(stack: &mut Vec>, op: fn([u8; 32]) -> [u8; 32]) -> Option<[u8; 32]> { + let Some(value) = stack.pop() else { + return None; + }; + let result = value.map(op); + stack.push(result); + result +} + +fn fold_binary( + stack: &mut Vec>, + op: fn([u8; 32], [u8; 32]) -> [u8; 32], +) -> Option<[u8; 32]> { + let (Some(right), Some(left)) = (stack.pop(), stack.pop()) else { + return None; + }; + let result = match (left, right) { + (Some(left), Some(right)) => Some(op(left, right)), + _ => None, + }; + stack.push(result); + result +} + +fn collect_revert_selectors(bytecode_hex: &str) -> Result> { + let bytes = decode_hex(bytecode_hex)?; + let mut selectors = BTreeSet::new(); + + for idx in 0..bytes.len() { + if let Some((selector, end)) = shifted_selector_at(&bytes, idx) { + if !is_builtin_error_selector(selector) && has_nearby_mstore_then_revert(&bytes, end) { + selectors.insert(format!("{selector:08x}")); + } + } + + if let Some((selector, end)) = left_aligned_push32_selector_at(&bytes, idx) { + if !is_builtin_error_selector(selector) && has_nearby_mstore_then_revert(&bytes, end) { + selectors.insert(format!("{selector:08x}")); + } + } + } + + Ok(selectors) +} + +fn collect_shifted_selectors(bytecode_hex: &str) -> Result> { + let bytes = decode_hex(bytecode_hex)?; + let mut selectors = BTreeSet::new(); + + for idx in 0..bytes.len() { + if let Some((selector, _)) = shifted_selector_at(&bytes, idx) { + selectors.insert(format!("{selector:08x}")); + } + } + + Ok(selectors) +} + +fn shifted_selector_at(bytes: &[u8], idx: usize) -> Option<(u32, usize)> { + let opcode = *bytes.get(idx)?; + if !(0x60..=0x63).contains(&opcode) { + return None; + } + + let width = (opcode - 0x5f) as usize; + let shift_pos = idx + 1 + width; + if *bytes.get(shift_pos)? != 0x60 || *bytes.get(shift_pos + 2)? != 0x1b { + return None; + } + + let value = parse_usize_be(&bytes[idx + 1..idx + 1 + width])?; + let shift = *bytes.get(shift_pos + 1)? as usize; + let selector = recover_left_aligned_selector(value, shift)?; + Some((selector, shift_pos + 3)) +} + +fn left_aligned_push32_selector_at(bytes: &[u8], idx: usize) -> Option<(u32, usize)> { + if *bytes.get(idx)? != 0x7f || idx + 33 > bytes.len() { + return None; + } + + let immediate = &bytes[idx + 1..idx + 33]; + if immediate[4..].iter().any(|byte| *byte != 0) { + return None; + } + let selector = u32::from_be_bytes([immediate[0], immediate[1], immediate[2], immediate[3]]); + if selector == 0 { + return None; + } + + Some((selector, idx + 33)) +} + +fn recover_left_aligned_selector(value: usize, shift: usize) -> Option { + if shift < 224 { + return None; + } + let extra_shift = shift - 224; + if extra_shift >= 32 { + return None; + } + let selector = (value as u64).checked_shl(extra_shift as u32)?; + if selector == 0 || selector > u32::MAX as u64 { + return None; + } + Some(selector as u32) +} + +fn has_nearby_mstore_then_revert(bytes: &[u8], start: usize) -> bool { + let end = (start + 64).min(bytes.len()); + let mut saw_mstore = false; + let mut idx = start; + + while idx < end { + let opcode = bytes[idx]; + match opcode { + 0x52 => saw_mstore = true, + 0xfd => return saw_mstore, + 0x00 | 0x56 | 0x57 | 0xf1 | 0xf3 | 0xf4 | 0xfa => return false, + 0x60..=0x7f => { + idx += 1 + (opcode - 0x5f) as usize; + continue; + } + _ => {} + } + idx += 1; + } + + false +} + +fn is_builtin_error_selector(selector: u32) -> bool { + matches!(selector, 0x08c3_79a0 | 0x4e48_7b71) +} + +fn right_aligned_word(bytes: &[u8]) -> [u8; 32] { + let mut word = [0u8; 32]; + word[32 - bytes.len()..].copy_from_slice(bytes); + word +} + +fn left_aligned_word(bytes: &[u8]) -> [u8; 32] { + let mut word = [0u8; 32]; + word[..bytes.len()].copy_from_slice(bytes); + word +} + +fn add_words(left: [u8; 32], right: [u8; 32]) -> [u8; 32] { + let mut out = [0u8; 32]; + let mut carry = 0u16; + for idx in (0..32).rev() { + let sum = left[idx] as u16 + right[idx] as u16 + carry; + out[idx] = sum as u8; + carry = sum >> 8; + } + out +} + +fn sub_words(left: [u8; 32], right: [u8; 32]) -> [u8; 32] { + let mut out = [0u8; 32]; + let mut borrow = 0i16; + for idx in (0..32).rev() { + let diff = left[idx] as i16 - right[idx] as i16 - borrow; + if diff < 0 { + out[idx] = (diff + 256) as u8; + borrow = 1; + } else { + out[idx] = diff as u8; + borrow = 0; + } + } + out +} + +fn and_words(left: [u8; 32], right: [u8; 32]) -> [u8; 32] { + let mut out = [0u8; 32]; + for idx in 0..32 { + out[idx] = left[idx] & right[idx]; + } + out +} + +fn or_words(left: [u8; 32], right: [u8; 32]) -> [u8; 32] { + let mut out = [0u8; 32]; + for idx in 0..32 { + out[idx] = left[idx] | right[idx]; + } + out +} + +fn xor_words(left: [u8; 32], right: [u8; 32]) -> [u8; 32] { + let mut out = [0u8; 32]; + for idx in 0..32 { + out[idx] = left[idx] ^ right[idx]; + } + out +} + +fn not_word(value: [u8; 32]) -> [u8; 32] { + let mut out = [0u8; 32]; + for idx in 0..32 { + out[idx] = !value[idx]; + } + out +} + +fn eq_word(left: [u8; 32], right: [u8; 32]) -> [u8; 32] { + bool_word(left == right) +} + +fn lt_word(left: [u8; 32], right: [u8; 32]) -> [u8; 32] { + bool_word(left < right) +} + +fn gt_word(left: [u8; 32], right: [u8; 32]) -> [u8; 32] { + bool_word(left > right) +} + +fn iszero_word(value: [u8; 32]) -> [u8; 32] { + bool_word(value.iter().all(|byte| *byte == 0)) +} + +fn bool_word(value: bool) -> [u8; 32] { + let mut out = [0u8; 32]; + out[31] = u8::from(value); + out +} + +fn byte_word(value: [u8; 32], index: [u8; 32]) -> [u8; 32] { + let Some(index) = word_to_usize(index) else { + return [0u8; 32]; + }; + let mut out = [0u8; 32]; + if index < 32 { + out[31] = value[index]; + } + out +} + +fn shl_word(value: [u8; 32], shift: [u8; 32]) -> [u8; 32] { + let Some(shift) = word_to_usize(shift) else { + return [0u8; 32]; + }; + if shift >= 256 { + return [0u8; 32]; + } + + let byte_shift = shift / 8; + let bit_shift = shift % 8; + let mut out = [0u8; 32]; + for (idx, out_byte) in out.iter_mut().enumerate() { + let src = idx + byte_shift; + if src >= 32 { + continue; + } + *out_byte |= value[src] << bit_shift; + if bit_shift > 0 && src + 1 < 32 { + *out_byte |= value[src + 1] >> (8 - bit_shift); + } + } + out +} + +fn shr_word(value: [u8; 32], shift: [u8; 32]) -> [u8; 32] { + let Some(shift) = word_to_usize(shift) else { + return [0u8; 32]; + }; + if shift >= 256 { + return [0u8; 32]; + } + + let byte_shift = shift / 8; + let bit_shift = shift % 8; + let mut out = [0u8; 32]; + for (idx, out_byte) in out.iter_mut().enumerate() { + if idx < byte_shift { + continue; + } + let src = idx - byte_shift; + *out_byte |= value[src] >> bit_shift; + if bit_shift > 0 && src > 0 { + *out_byte |= value[src - 1] << (8 - bit_shift); + } + } + out +} + +fn word_to_usize(value: [u8; 32]) -> Option { + if value[..24].iter().any(|byte| *byte != 0) { + return None; + } + parse_usize_be(&value[24..]) +} + +fn parse_usize_be(bytes: &[u8]) -> Option { + if bytes.len() > std::mem::size_of::() + && bytes[..bytes.len() - std::mem::size_of::()] + .iter() + .any(|byte| *byte != 0) + { + return None; + } + + let mut value = 0usize; + for byte in bytes { + value = value.checked_shl(8)? | *byte as usize; + } + Some(value) +} + +fn decode_hex(hex: &str) -> Result> { + hex::decode(normalize_hex(hex)).map_err(|e| eyre!("hex decode failed: {e}")) +} + +fn normalize_hex(hex: &str) -> String { + hex.trim() + .trim_start_matches("0x") + .replace('_', "") + .to_ascii_lowercase() +} diff --git a/tests/src/e2e/determinism.rs b/tests/src/e2e/determinism.rs index bb60a67d..15ca88c5 100644 --- a/tests/src/e2e/determinism.rs +++ b/tests/src/e2e/determinism.rs @@ -5,6 +5,7 @@ use azoth_core::process_bytecode_to_cfg; use azoth_core::seed::Seed; use azoth_transform::arithmetic_chain::ArithmeticChain; use azoth_transform::cluster_shuffle::ClusterShuffle; +use azoth_transform::constant_mask::ConstantMask; use azoth_transform::obfuscator::{obfuscate_bytecode, ObfuscationConfig}; use azoth_transform::push_split::PushSplit; use azoth_transform::slot_shuffle::SlotShuffle; @@ -188,6 +189,24 @@ async fn string_obfuscate_is_deterministic_for_same_seed() { .await; } +#[tokio::test] +async fn constant_mask_is_deterministic_for_same_seed() { + assert_transform_deterministic_for_bytecode( + "escrow runtime", + ESCROW_CONTRACT_RUNTIME_BYTECODE, + "ConstantMask", + || Box::new(ConstantMask::new()), + ) + .await; + assert_transform_deterministic_for_bytecode( + "escrow deployment", + ESCROW_CONTRACT_DEPLOYMENT_BYTECODE, + "ConstantMask", + || Box::new(ConstantMask::new()), + ) + .await; +} + #[tokio::test] async fn cluster_shuffle_is_deterministic_for_same_seed() { assert_transform_deterministic_for_bytecode( diff --git a/tests/src/e2e/mod.rs b/tests/src/e2e/mod.rs index ace8179b..4c3db53f 100644 --- a/tests/src/e2e/mod.rs +++ b/tests/src/e2e/mod.rs @@ -14,7 +14,7 @@ use revm::bytecode::Bytecode; use revm::context::result::{ExecutionResult, Output}; use revm::context::TxEnv; use revm::database::InMemoryDB; -use revm::primitives::{Address, Bytes, FixedBytes, U256}; +use revm::primitives::{Address, Bytes, FixedBytes, TxKind, U256}; use revm::state::AccountInfo; use revm::{Context, ExecuteEvm, MainBuilder, MainContext}; use std::collections::HashMap; @@ -39,6 +39,35 @@ pub fn mock_token_bytecode() -> Bytes { ]) } +#[allow(dead_code)] +pub fn init_tracing() { + let _ = tracing_subscriber::fmt() + .with_max_level(tracing::Level::DEBUG) + .with_ansi(false) + .without_time() + .try_init(); +} + +#[allow(dead_code)] +pub fn funded_account_info(nonce: u64) -> AccountInfo { + AccountInfo { + balance: U256::from(1_000_000_000_000_000_000u128), + nonce, + code_hash: revm::primitives::KECCAK_EMPTY, + code: None, + } +} + +#[allow(dead_code)] +pub fn code_account_info(code: Bytes, nonce: u64) -> AccountInfo { + AccountInfo { + balance: U256::ZERO, + nonce, + code_hash: revm::primitives::KECCAK_EMPTY, + code: Some(Bytecode::new_raw(code)), + } +} + #[allow(dead_code)] pub const ESCROW_CONTRACT_DEPLOYMENT_BYTECODE: &str = include_str!("../../../examples/escrow-bytecode/artifacts/erc20_deployment.hex"); @@ -115,66 +144,135 @@ pub struct DeploymentOutcome { } #[allow(dead_code)] -pub fn deploy_contract(bytecode_hex: &str) -> Result { - let bytecode_bytes = prepare_bytecode(bytecode_hex)?; - - let mut db = InMemoryDB::default(); - db.insert_account_info( - MOCK_TOKEN_ADDR, - AccountInfo { - balance: U256::ZERO, - nonce: 1, - code_hash: revm::primitives::KECCAK_EMPTY, - code: Some(Bytecode::new_raw(mock_token_bytecode())), - }, - ); - - let deployer = Address::from([0x42u8; 20]); - db.insert_account_info( - deployer, - 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 tx_env = TxEnv { - caller: deployer, - gas_limit: 30_000_000, - kind: revm::primitives::TxKind::Create, - data: bytecode_bytes, +pub fn create_tx(caller: Address, data: Bytes, gas_limit: u64, nonce: u64) -> TxEnv { + TxEnv { + caller, + gas_limit, + kind: TxKind::Create, + data, value: U256::ZERO, - nonce: 0, + nonce, ..Default::default() - }; + } +} - let result = evm - .transact(tx_env) - .map_err(|e| eyre!("deployment execution failed: {:?}", e))?; +#[allow(dead_code)] +pub fn call_tx( + caller: Address, + contract: Address, + data: Bytes, + gas_limit: u64, + nonce: u64, +) -> TxEnv { + TxEnv { + caller, + gas_limit, + kind: TxKind::Call(contract), + data, + value: U256::ZERO, + nonce, + ..Default::default() + } +} - match result.result { +#[allow(dead_code)] +pub fn parse_create_result(result: ExecutionResult, label: &str) -> Result { + match result { ExecutionResult::Success { output, gas_used, .. } => match output { - Output::Create(bytes, Some(address)) => Ok(DeploymentOutcome { + Output::Create(runtime, Some(address)) => Ok(DeploymentOutcome { address, - runtime: bytes, + runtime, gas_used, }), - Output::Create(_, None) => Err(eyre!("deployment succeeded without address")), - _ => Err(eyre!("unexpected create output variant")), + Output::Create(_, None) => Err(eyre!("{label} succeeded without address")), + _ => Err(eyre!("{label} returned unexpected create output")), }, ExecutionResult::Revert { output, .. } => { - Err(eyre!("deployment reverted: 0x{}", hex::encode(output))) + Err(eyre!("{label} reverted: 0x{}", hex::encode(output))) } - ExecutionResult::Halt { reason, .. } => Err(eyre!("deployment halted: {:?}", reason)), + ExecutionResult::Halt { reason, .. } => Err(eyre!("{label} halted: {:?}", reason)), } } +#[allow(dead_code)] +pub fn parse_call_output(result: ExecutionResult, label: &str) -> Result { + match result { + ExecutionResult::Success { + output: Output::Call(data), + .. + } => Ok(data), + ExecutionResult::Success { .. } => Err(eyre!("{label} returned unexpected call output")), + ExecutionResult::Revert { output, .. } => { + Err(eyre!("{label} reverted: 0x{}", hex::encode(output))) + } + ExecutionResult::Halt { reason, .. } => Err(eyre!("{label} halted: {:?}", reason)), + } +} + +#[allow(dead_code)] +pub fn expect_success(result: ExecutionResult, label: &str) -> Result { + match result { + ExecutionResult::Success { gas_used, .. } => Ok(gas_used), + ExecutionResult::Revert { output, .. } => { + Err(eyre!("{label} reverted: 0x{}", hex::encode(output))) + } + ExecutionResult::Halt { reason, .. } => Err(eyre!("{label} halted: {:?}", reason)), + } +} + +#[allow(dead_code)] +pub fn parse_u256_word(data: &[u8]) -> U256 { + if data.len() >= 32 { + U256::from_be_slice(&data[..32]) + } else { + U256::ZERO + } +} + +#[allow(dead_code)] +pub fn parse_bool_word(data: &[u8]) -> bool { + data.len() >= 32 && data[31] != 0 +} + +#[allow(dead_code)] +pub fn selector_token_word(mapping: &HashMap>, selector: u32) -> Result { + let token = mapping + .get(&selector) + .ok_or_else(|| eyre!("missing token mapping for selector 0x{selector:08x}"))?; + + if token.is_empty() || token.len() > 32 { + return Err(eyre!( + "invalid token length {} for selector 0x{selector:08x}", + token.len() + )); + } + + let mut word = vec![0u8; 32]; + word[..token.len()].copy_from_slice(token); + Ok(Bytes::from(word)) +} + +#[allow(dead_code)] +pub fn deploy_contract(bytecode_hex: &str) -> Result { + let bytecode_bytes = prepare_bytecode(bytecode_hex)?; + + let mut db = InMemoryDB::default(); + db.insert_account_info(MOCK_TOKEN_ADDR, code_account_info(mock_token_bytecode(), 1)); + + let deployer = Address::from([0x42u8; 20]); + db.insert_account_info(deployer, funded_account_info(0)); + + let mut evm = Context::mainnet().with_db(db).build_mainnet(); + + let result = evm + .transact(create_tx(deployer, bytecode_bytes, 30_000_000, 0)) + .map_err(|e| eyre!("deployment execution failed: {:?}", e))?; + + parse_create_result(result.result, "deployment") +} + #[allow(dead_code)] pub fn deploy_contract_and_get_runtime(bytecode_hex: &str) -> Result { Ok(deploy_contract(bytecode_hex)?.runtime) @@ -404,6 +502,9 @@ mod escrow; #[cfg(test)] mod collect_proof; +#[cfg(test)] +mod constant_audit; + #[cfg(test)] mod test_original; diff --git a/tests/src/e2e/test_counter.rs b/tests/src/e2e/test_counter.rs index a0eb5bd6..a218b486 100644 --- a/tests/src/e2e/test_counter.rs +++ b/tests/src/e2e/test_counter.rs @@ -1,16 +1,15 @@ +use super::{ + call_tx, create_tx, expect_success, funded_account_info, init_tracing, parse_call_output, + parse_create_result, parse_u256_word, selector_token_word, +}; use azoth_transform::obfuscator::{obfuscate_bytecode, ObfuscationConfig}; use color_eyre::eyre::eyre; use color_eyre::Result; use hex::encode as hex_encode; -use revm::bytecode::Bytecode; -use revm::context::result::{ExecutionResult, Output}; use revm::context::ContextTr; -use revm::context::TxEnv; use revm::database::InMemoryDB; -use revm::primitives::{Address, Bytes, TxKind, U256}; -use revm::state::AccountInfo; +use revm::primitives::{Address, Bytes, U256}; use revm::{Context, DatabaseCommit, ExecuteEvm, MainBuilder, MainContext}; -use std::collections::HashMap; const COUNTER_DEPLOYMENT_BYTECODE: &str = include_str!("../../bytecode/counter/counter_deployment.hex"); @@ -20,41 +19,9 @@ const SELECTOR_SET_NUMBER: u32 = 0x3fb5c1cb; const SELECTOR_NUMBER: u32 = 0x8381f58a; const SELECTOR_INCREMENT: u32 = 0xd09de08a; -fn selector_token(mapping: &HashMap>, selector: u32) -> Result { - let token = mapping - .get(&selector) - .ok_or_else(|| eyre!("Missing token mapping for selector 0x{selector:08x}"))?; - - if token.is_empty() || token.len() > 32 { - return Err(eyre!( - "Invalid token length {} for selector 0x{selector:08x}", - token.len() - )); - } - - // The dispatcher uses CALLDATALOAD(0) which loads 32 bytes starting at offset 0, - // then shifts right by 0xe0 (224 bits) to extract the leftmost 4 bytes. - // So we need to left-pad the token to 32 bytes. - let mut padded = vec![0u8; 32]; - padded[..token.len()].copy_from_slice(token); - Ok(Bytes::from(padded)) -} - -fn parse_u256(data: &[u8]) -> U256 { - if data.len() >= 32 { - U256::from_be_slice(&data[..32]) - } else { - U256::ZERO - } -} - #[tokio::test] async fn test_obfuscated_counter_deploys_and_counts() -> Result<()> { - let _ = tracing_subscriber::fmt() - .with_max_level(tracing::Level::DEBUG) - .with_ansi(false) - .without_time() - .try_init(); + init_tracing(); let obfuscation_result = obfuscate_bytecode( COUNTER_DEPLOYMENT_BYTECODE, @@ -77,9 +44,9 @@ async fn test_obfuscated_counter_deploys_and_counts() -> Result<()> { .as_ref() .ok_or_else(|| eyre!("Selector mapping missing from obfuscation result"))?; - let set_number_token = selector_token(selector_mapping, SELECTOR_SET_NUMBER)?; - let number_token = selector_token(selector_mapping, SELECTOR_NUMBER)?; - let increment_token = selector_token(selector_mapping, SELECTOR_INCREMENT)?; + let set_number_token = selector_token_word(selector_mapping, SELECTOR_SET_NUMBER)?; + let number_token = selector_token_word(selector_mapping, SELECTOR_NUMBER)?; + let increment_token = selector_token_word(selector_mapping, SELECTOR_INCREMENT)?; println!( "Selector tokens (first 4 bytes):\n setNumber: {}\n number: {}\n increment: {}", @@ -96,15 +63,7 @@ async fn test_obfuscated_counter_deploys_and_counts() -> Result<()> { let mut db = InMemoryDB::default(); let deployer = Address::from([0x45; 20]); - db.insert_account_info( - deployer, - 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(deployer, funded_account_info(0)); let mut evm = Context::mainnet().with_db(db).build_mainnet(); @@ -121,70 +80,38 @@ async fn test_obfuscated_counter_deploys_and_counts() -> Result<()> { hex_encode(&obfuscated_bytes[..28.min(obfuscated_bytes.len())]) ); - let deploy_tx = TxEnv { - caller: deployer, - gas_limit: 20_000_000, - kind: TxKind::Create, - data: Bytes::from(obfuscated_bytes), - value: U256::ZERO, - nonce: 0, - ..Default::default() - }; - let deploy_result = evm - .transact(deploy_tx) + .transact(create_tx( + deployer, + Bytes::from(obfuscated_bytes), + 20_000_000, + 0, + )) .map_err(|e| eyre!("Deployment failed: {:?}", e))?; evm.db_mut().commit(deploy_result.state.clone()); - let contract_address = match deploy_result.result { - ExecutionResult::Success { output, .. } => match output { - Output::Create(_, Some(address)) => address, - _ => return Err(eyre!("Deployment failed: missing contract address")), - }, - ExecutionResult::Revert { output, .. } => { - return Err(eyre!("Deployment reverted: {:?}", output)); - } - ExecutionResult::Halt { reason, .. } => { - return Err(eyre!("Deployment halted: {:?}", reason)); - } - }; - - let deployed_code = evm - .db() - .cache - .accounts - .get(&contract_address) - .and_then(|acc| acc.info.code.as_ref()) - .ok_or_else(|| eyre!("Missing deployed code"))?; - - let runtime_len = match deployed_code { - Bytecode::LegacyAnalyzed(analyzed) => { - let bytes = analyzed.bytecode(); - println!( - "Deployed runtime (first 200 bytes): {}", - hex_encode(&bytes[..bytes.len().min(200)]) - ); - bytes.len() - } - _ => return Err(eyre!("Unexpected deployed bytecode format")), - }; + let deployment = parse_create_result(deploy_result.result, "Deployment")?; + let contract_address = deployment.address; + println!( + "Deployed runtime (first 200 bytes): {}", + hex_encode(&deployment.runtime[..deployment.runtime.len().min(200)]) + ); println!( "✓ Counter deployed at {} with {} bytes runtime", - contract_address, runtime_len + contract_address, + deployment.runtime.len() ); let mut nonce: u64 = 1; - let read_tx = TxEnv { - caller: deployer, - gas_limit: 5_000_000, - kind: TxKind::Call(contract_address), - data: number_token.clone(), - value: U256::ZERO, + let read_tx = call_tx( + deployer, + contract_address, + number_token.clone(), + 5_000_000, nonce, - ..Default::default() - }; + ); nonce += 1; let read_before = evm @@ -192,33 +119,18 @@ async fn test_obfuscated_counter_deploys_and_counts() -> Result<()> { .map_err(|e| eyre!("Initial number() failed: {:?}", e))?; evm.db_mut().commit(read_before.state.clone()); - let initial_value = match read_before.result { - ExecutionResult::Success { output, .. } => match output { - Output::Call(data) => { - println!("number() raw output: {}", hex_encode(&data)); - parse_u256(&data) - } - _ => return Err(eyre!("Unexpected output for number() call")), - }, - ExecutionResult::Revert { output, .. } => { - return Err(eyre!("Initial number() reverted: {:?}", output)); - } - ExecutionResult::Halt { reason, .. } => { - return Err(eyre!("Initial number() halted: {:?}", reason)); - } - }; + let output = parse_call_output(read_before.result, "Initial number()")?; + println!("number() raw output: {}", hex_encode(&output)); + let initial_value = parse_u256_word(&output); println!("Counter initial value: {}", initial_value); - // Call increment() - let inc_tx = TxEnv { - caller: deployer, - gas_limit: 5_000_000, - kind: TxKind::Call(contract_address), - data: increment_token.clone(), - value: U256::ZERO, + let inc_tx = call_tx( + deployer, + contract_address, + increment_token.clone(), + 5_000_000, nonce, - ..Default::default() - }; + ); nonce += 1; let inc_result = evm @@ -226,27 +138,15 @@ async fn test_obfuscated_counter_deploys_and_counts() -> Result<()> { .map_err(|e| eyre!("increment() call failed: {:?}", e))?; evm.db_mut().commit(inc_result.state.clone()); + expect_success(inc_result.result, "increment()")?; - match inc_result.result { - ExecutionResult::Success { .. } => {} - ExecutionResult::Revert { output, .. } => { - return Err(eyre!("increment() reverted: {:?}", output)); - } - ExecutionResult::Halt { reason, .. } => { - return Err(eyre!("increment() halted: {:?}", reason)); - } - } - - // Read value after increment - let read_after_tx = TxEnv { - caller: deployer, - gas_limit: 5_000_000, - kind: TxKind::Call(contract_address), - data: number_token.clone(), - value: U256::ZERO, + let read_after_tx = call_tx( + deployer, + contract_address, + number_token.clone(), + 5_000_000, nonce, - ..Default::default() - }; + ); nonce += 1; let read_after = evm @@ -254,21 +154,12 @@ async fn test_obfuscated_counter_deploys_and_counts() -> Result<()> { .map_err(|e| eyre!("number() after increment failed: {:?}", e))?; evm.db_mut().commit(read_after.state.clone()); - let after_value = match read_after.result { - ExecutionResult::Success { output, .. } => match output { - Output::Call(data) => { - println!("number() after increment raw output: {}", hex_encode(&data)); - parse_u256(&data) - } - _ => return Err(eyre!("Unexpected output for number() call")), - }, - ExecutionResult::Revert { output, .. } => { - return Err(eyre!("number() after increment reverted: {:?}", output)); - } - ExecutionResult::Halt { reason, .. } => { - return Err(eyre!("number() after increment halted: {:?}", reason)); - } - }; + let output = parse_call_output(read_after.result, "number() after increment")?; + println!( + "number() after increment raw output: {}", + hex_encode(&output) + ); + let after_value = parse_u256_word(&output); println!("Counter value after increment: {}", after_value); assert_eq!(after_value, initial_value.saturating_add(U256::from(1u64))); @@ -278,15 +169,13 @@ async fn test_obfuscated_counter_deploys_and_counts() -> Result<()> { calldata[..4].copy_from_slice(&set_number_token[..4]); calldata[4..36].copy_from_slice(&new_value.to_be_bytes::<32>()); - let set_tx = TxEnv { - caller: deployer, - gas_limit: 5_000_000, - kind: TxKind::Call(contract_address), - data: Bytes::from(calldata), - value: U256::ZERO, + let set_tx = call_tx( + deployer, + contract_address, + Bytes::from(calldata), + 5_000_000, nonce, - ..Default::default() - }; + ); nonce += 1; let set_result = evm @@ -294,48 +183,21 @@ async fn test_obfuscated_counter_deploys_and_counts() -> Result<()> { .map_err(|e| eyre!("setNumber() call failed: {:?}", e))?; evm.db_mut().commit(set_result.state.clone()); + expect_success(set_result.result, "setNumber()")?; - match set_result.result { - ExecutionResult::Success { .. } => {} - ExecutionResult::Revert { output, .. } => { - return Err(eyre!("setNumber() reverted: {:?}", output)); - } - ExecutionResult::Halt { reason, .. } => { - return Err(eyre!("setNumber() halted: {:?}", reason)); - } - } - - // Read final value - let read_final_tx = TxEnv { - caller: deployer, - gas_limit: 5_000_000, - kind: TxKind::Call(contract_address), - data: number_token, - value: U256::ZERO, - nonce, - ..Default::default() - }; + let read_final_tx = call_tx(deployer, contract_address, number_token, 5_000_000, nonce); let read_final = evm .transact(read_final_tx) .map_err(|e| eyre!("Final number() failed: {:?}", e))?; evm.db_mut().commit(read_final.state.clone()); - let final_value = match read_final.result { - ExecutionResult::Success { output, .. } => match output { - Output::Call(data) => { - println!("number() after setNumber raw output: {}", hex_encode(&data)); - parse_u256(&data) - } - _ => return Err(eyre!("Unexpected output for final number() call")), - }, - ExecutionResult::Revert { output, .. } => { - return Err(eyre!("Final number() reverted: {:?}", output)); - } - ExecutionResult::Halt { reason, .. } => { - return Err(eyre!("Final number() halted: {:?}", reason)); - } - }; + let output = parse_call_output(read_final.result, "Final number()")?; + println!( + "number() after setNumber raw output: {}", + hex_encode(&output) + ); + let final_value = parse_u256_word(&output); println!("Counter value after setNumber(42): {}", final_value); assert_eq!(final_value, new_value); diff --git a/tests/src/e2e/test_original.rs b/tests/src/e2e/test_original.rs index 4a590203..bca4aa65 100644 --- a/tests/src/e2e/test_original.rs +++ b/tests/src/e2e/test_original.rs @@ -1,26 +1,20 @@ //! Test original unobfuscated contract to verify baseline functionality use super::{ - mock_token_bytecode, prepare_bytecode, ESCROW_CONTRACT_DEPLOYMENT_BYTECODE, MOCK_TOKEN_ADDR, + call_tx, code_account_info, create_tx, funded_account_info, init_tracing, mock_token_bytecode, + parse_bool_word, parse_call_output, parse_create_result, prepare_bytecode, + ESCROW_CONTRACT_DEPLOYMENT_BYTECODE, MOCK_TOKEN_ADDR, }; use color_eyre::eyre::eyre; use color_eyre::Result; -use revm::bytecode::Bytecode; -use revm::context::result::{ExecutionResult, Output}; use revm::context::ContextTr; -use revm::context::TxEnv; use revm::database::InMemoryDB; -use revm::primitives::{Address, Bytes, TxKind, U256}; -use revm::state::AccountInfo; +use revm::primitives::{Address, Bytes}; use revm::{Context, DatabaseCommit, ExecuteEvm, MainBuilder, MainContext}; #[tokio::test] async fn test_original_is_bonded() -> Result<()> { - let _ = tracing_subscriber::fmt() - .with_max_level(tracing::Level::DEBUG) - .with_ansi(false) - .without_time() - .try_init(); + init_tracing(); let original_bytecode_hex = azoth_core::normalize_hex_string(ESCROW_CONTRACT_DEPLOYMENT_BYTECODE) @@ -31,78 +25,31 @@ async fn test_original_is_bonded() -> Result<()> { // setup EVM with mock token contract let mut db = InMemoryDB::default(); - db.insert_account_info( - MOCK_TOKEN_ADDR, - AccountInfo { - balance: U256::ZERO, - nonce: 1, - code_hash: revm::primitives::KECCAK_EMPTY, - code: Some(Bytecode::new_raw(mock_token_bytecode())), - }, - ); + db.insert_account_info(MOCK_TOKEN_ADDR, code_account_info(mock_token_bytecode(), 1)); let deployer = Address::from([0x42; 20]); - db.insert_account_info( - deployer, - 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(deployer, funded_account_info(0)); let mut evm = Context::mainnet().with_db(db).build_mainnet(); - let deploy_tx = TxEnv { - caller: deployer, - gas_limit: 30_000_000, - kind: TxKind::Create, - data: original_bytecode, - value: U256::ZERO, - nonce: 0, - ..Default::default() - }; - let deploy_result = evm - .transact(deploy_tx) + .transact(create_tx(deployer, original_bytecode, 30_000_000, 0)) .map_err(|e| eyre!("Deployment failed: {:?}", e))?; evm.db_mut().commit(deploy_result.state.clone()); - let contract_address = match deploy_result.result { - ExecutionResult::Success { output, .. } => match output { - Output::Create(_, Some(address)) => address, - _ => return Err(eyre!("Deployment failed: no address returned")), - }, - ExecutionResult::Revert { output, .. } => { - return Err(eyre!("Deployment reverted: {:?}", output)); - } - ExecutionResult::Halt { reason, .. } => { - return Err(eyre!("Deployment halted: {:?}", reason)); - } - }; + let deployment = parse_create_result(deploy_result.result, "Deployment")?; + let contract_address = deployment.address; println!("✓ Original contract deployed at: {}", contract_address); // Validate all PUSH+JUMP pairs in deployed bytecode println!("\n=== Validating Original Deployed Bytecode ==="); - let deployed_code = evm - .db() - .cache - .accounts - .get(&contract_address) - .and_then(|acc| acc.info.code.as_ref()) - .ok_or_else(|| eyre!("Failed to get deployed code"))?; - - let deployed_bytes = match deployed_code { - Bytecode::LegacyAnalyzed(analyzed) => analyzed.bytecode(), - _ => return Err(eyre!("Unexpected bytecode format")), - }; + let deployed_bytes = deployment.runtime; // Decode and validate let (deployed_instructions, _, _, _) = - azoth_core::decoder::decode_bytecode(&hex::encode(deployed_bytes), false) + azoth_core::decoder::decode_bytecode(&hex::encode(deployed_bytes.as_ref()), false) .await .map_err(|e| eyre!("Failed to decode deployed bytecode: {:?}", e))?; @@ -160,37 +107,24 @@ async fn test_original_is_bonded() -> Result<()> { // Call is_bonded() using standard 4-byte selector: 0xcb766a56 let is_bonded_selector = hex::decode("cb766a56").unwrap(); - let call_tx = TxEnv { - caller: deployer, - gas_limit: 10_000_000, - kind: TxKind::Call(contract_address), - data: Bytes::from(is_bonded_selector), - value: U256::ZERO, - nonce: 1, - ..Default::default() - }; + let call_env = call_tx( + deployer, + contract_address, + Bytes::from(is_bonded_selector), + 10_000_000, + 1, + ); println!("Calling is_bonded() with selector 0xcb766a56..."); - let call_result = evm.transact(call_tx); + let call_result = evm.transact(call_env); println!("Call result: {:?}", call_result); let call_result = call_result.map_err(|e| eyre!("Call failed: {:?}", e))?; - let is_bonded_result = match call_result.result { - ExecutionResult::Success { output, .. } => match output { - Output::Call(data) => data, - _ => return Err(eyre!("Unexpected output type")), - }, - ExecutionResult::Revert { output, .. } => { - return Err(eyre!("Call reverted: {:?}", output)); - } - ExecutionResult::Halt { reason, .. } => { - return Err(eyre!("Call halted: {:?}", reason)); - } - }; + let is_bonded_result = parse_call_output(call_result.result, "is_bonded()")?; // Parse boolean result - let is_bonded = is_bonded_result.len() >= 32 && is_bonded_result[31] != 0; + let is_bonded = parse_bool_word(&is_bonded_result); println!("✓ is_bonded() returned: {}", is_bonded); assert!(!is_bonded, "Expected is_bonded to be false initially"); From e9025124f2657722d0f0ca065defcb3afee4eb85 Mon Sep 17 00:00:00 2001 From: g4titanx Date: Mon, 27 Apr 2026 15:49:24 +0100 Subject: [PATCH 6/6] fix: decompilation failing panic error and analyze cli should use default transforms --- Cargo.lock | 1 + crates/analysis/Cargo.toml | 1 + crates/analysis/src/decompile_diff/mod.rs | 22 ++++++++- crates/analysis/src/obfuscation.rs | 57 +---------------------- crates/cli/src/commands/analyze.rs | 3 +- 5 files changed, 26 insertions(+), 58 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9990be00..986700de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1291,6 +1291,7 @@ dependencies = [ "azoth-core", "azoth-transform", "chrono", + "futures-util", "heimdall-decompiler", "hex", "imara-diff", diff --git a/crates/analysis/Cargo.toml b/crates/analysis/Cargo.toml index 0e0acdc4..d83f0d90 100644 --- a/crates/analysis/Cargo.toml +++ b/crates/analysis/Cargo.toml @@ -14,6 +14,7 @@ tracing-subscriber.workspace = true hex.workspace = true thiserror.workspace = true chrono.workspace = true +futures-util = "0.3" alloy = "1.1" heimdall-decompiler = { git = "https://github.com/Jon-Becker/heimdall-rs", tag = "0.9.0" } diff --git a/crates/analysis/src/decompile_diff/mod.rs b/crates/analysis/src/decompile_diff/mod.rs index 574db1ee..d6ad881d 100644 --- a/crates/analysis/src/decompile_diff/mod.rs +++ b/crates/analysis/src/decompile_diff/mod.rs @@ -13,11 +13,14 @@ pub mod parser; use alloy::primitives::Bytes; +use futures_util::FutureExt; use heimdall_decompiler::DecompilerArgsBuilder; use imara_diff::{Diff, InternedInput, Interner, Token, UnifiedDiffPrinter}; use owo_colors::OwoColorize; +use std::any::Any; use std::collections::{HashMap, HashSet}; use std::fmt; +use std::panic::AssertUnwindSafe; /// Errors that can occur during decompile diff analysis. #[derive(thiserror::Error, Debug)] @@ -29,6 +32,10 @@ pub enum DecompileDiffError { /// Decompilation produced no source output. #[error("decompilation produced no source output")] NoSource, + + /// The decompiler panicked while processing bytecode. + #[error("decompiler panic: {0}")] + DecompilerPanic(String), } /// A single replacement hunk representing lines removed and added. @@ -401,10 +408,23 @@ pub async fn decompile(target: Bytes) -> Result { .include_solidity(true) .build() .unwrap(); - let result = heimdall_decompiler::decompile(args).await?; + let result = AssertUnwindSafe(heimdall_decompiler::decompile(args)) + .catch_unwind() + .await + .map_err(|payload| DecompileDiffError::DecompilerPanic(panic_payload(payload)))??; result.source.ok_or(DecompileDiffError::NoSource) } +fn panic_payload(payload: Box) -> String { + if let Some(message) = payload.downcast_ref::() { + return message.clone(); + } + if let Some(message) = payload.downcast_ref::<&'static str>() { + return (*message).to_string(); + } + "unknown panic payload".to_string() +} + // ============================================================================ // Structured Diff Types and Implementation // ============================================================================ diff --git a/crates/analysis/src/obfuscation.rs b/crates/analysis/src/obfuscation.rs index 9b449a53..ffd86a9d 100644 --- a/crates/analysis/src/obfuscation.rs +++ b/crates/analysis/src/obfuscation.rs @@ -1,11 +1,5 @@ use azoth_core::seed::Seed; -use azoth_transform::{ - Transform, - jump_address_transformer::JumpAddressTransformer, - obfuscator::{ObfuscationConfig, obfuscate_bytecode}, - opaque_predicate::OpaquePredicate, - shuffle::Shuffle, -}; +use azoth_transform::obfuscator::{ObfuscationConfig, obfuscate_bytecode}; use chrono::{DateTime, Utc}; use hex::FromHexError; use serde::Serialize; @@ -16,13 +10,6 @@ use std::{ }; 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 = ""; - /// Configuration for running an obfuscation analysis experiment. #[derive(Debug, Clone)] pub struct AnalysisConfig<'a> { @@ -341,8 +328,6 @@ pub enum AnalysisError { #[source] source: Box, }, - #[error("invalid transform pass: {0}")] - InvalidPass(String), #[error("io error: {0}")] Io(#[from] std::io::Error), #[error("format error: {0}")] @@ -357,7 +342,6 @@ pub async fn analyze_obfuscation( return Err(AnalysisError::EmptyIterations); } - let passes = parse_passes(DEFAULT_PASSES)?; let original_bytes = hex_to_bytes(config.original_bytecode)?; let mut sequence_lengths = Vec::with_capacity(config.iterations); let mut sequence_counter: HashMap, usize> = HashMap::new(); @@ -374,7 +358,7 @@ pub async fn analyze_obfuscation( let seed_hex = seed.to_hex(); let mut obfuscation_config = ObfuscationConfig::with_seed(seed.clone()); obfuscation_config.preserve_unknown_opcodes = true; - obfuscation_config.transforms = passes.iter().map(|p| p.build()).collect(); + obfuscation_config.transforms = ObfuscationConfig::default().transforms; match obfuscate_bytecode( config.original_bytecode, @@ -676,43 +660,6 @@ fn truncate_hex(input: &str, max_len: usize) -> String { } } -fn parse_passes(passes: &str) -> Result, AnalysisError> { - let mut specs = Vec::new(); - if passes.trim().is_empty() { - return Ok(specs); - } - for raw in passes.split(',') { - let name = raw.trim(); - if name.is_empty() { - continue; - } - let spec = match name { - "shuffle" => TransformSpec::Shuffle, - "opaque_pred" | "opaque_predicate" => TransformSpec::OpaquePredicate, - "jump_transform" | "jump_addr" => TransformSpec::JumpTransform, - other => return Err(AnalysisError::InvalidPass(other.to_string())), - }; - specs.push(spec); - } - Ok(specs) -} - -enum TransformSpec { - Shuffle, - OpaquePredicate, - JumpTransform, -} - -impl TransformSpec { - fn build(&self) -> Box { - match self { - TransformSpec::Shuffle => Box::new(Shuffle), - TransformSpec::OpaquePredicate => Box::new(OpaquePredicate::new()), - TransformSpec::JumpTransform => Box::new(JumpAddressTransformer::new()), - } - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/cli/src/commands/analyze.rs b/crates/cli/src/commands/analyze.rs index 82ff8e03..6b471b66 100644 --- a/crates/cli/src/commands/analyze.rs +++ b/crates/cli/src/commands/analyze.rs @@ -1,4 +1,4 @@ -use crate::commands::{obfuscate::read_input, ObfuscateError}; +use crate::commands::obfuscate::read_input; use async_trait::async_trait; use azoth_analysis::obfuscation::{analyze_obfuscation, AnalysisConfig, AnalysisError}; use clap::Args; @@ -111,7 +111,6 @@ fn map_analysis_error(err: AnalysisError) -> Box { AnalysisError::UnknownOpcodes { count } => Box::new(std::io::Error::other(format!( "analysis aborted due to {count} unknown opcode(s)" ))), - AnalysisError::InvalidPass(name) => Box::new(ObfuscateError::InvalidPass(name)), AnalysisError::ObfuscationFailure { source, .. } => source, AnalysisError::Io(err) => Box::new(err), AnalysisError::Fmt(err) => Box::new(err),