diff --git a/Cargo.lock b/Cargo.lock index 9990be00..95e23325 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1389,6 +1389,7 @@ dependencies = [ "hex", "petgraph", "rand 0.9.2", + "revm", "serde", "serde_json", "sha3", diff --git a/README.md b/README.md index 6aca8b2b..d91c3ca5 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,12 @@ Azoth is a deterministic EVM bytecode obfuscator designed to make Mirage's execu 2. Transformation: apply deterministic transformations (e.g dispatcher transforms, block shuffling etc.) that changes the structure of the bytecode without blowing gas or size limits. -3. Recovery: lower the rewritten runtime alongside untouched init/constructor data so the final bytecode stays deployable. +3. Recovery: lower the rewritten runtime, patch init-code offsets, and mask any exact constructor-argument suffix so the final bytecode stays deployable without retaining an ABI-aligned plaintext tail. Azoth also incorporates a formal verification system that provides mathematical guarantees of functional equivalence between original and obfuscated contracts. +Constructor-argument masking is an obfuscation boundary, not encryption: it defeats verbatim static suffix recovery, but public creation code can still be analyzed or executed to recover values. See the [constructor-argument security and benchmark report](docs/constructor-argument-obfuscation.md). + ## Status Azoth is under active development: the parsing pipeline, CFG builder, and several core transforms are in daily use, while additional passes, verification tooling, and resilience metrics are landing incrementally as we harden the stack for production-facing deployments. diff --git a/crates/cli/README.md b/crates/cli/README.md index 1758b475..a7dfd2af 100644 --- a/crates/cli/README.md +++ b/crates/cli/README.md @@ -61,11 +61,13 @@ Applies obfuscation transformations to bytecode. azoth obfuscate -D -R azoth obfuscate --deployment 0x6080... --runtime 0x6080... --seed 12345 azoth obfuscate -D path/to/deployment.hex -R path/to/runtime.hex --passes shuffle +azoth obfuscate -D path/to/deployment.hex -R path/to/runtime.hex --constructor-args 0x... ``` Options: - `-D, --deployment ` - Input deployment bytecode (required) - `-R, --runtime ` - Input runtime bytecode (required) +- `--constructor-args ` - ABI-encoded constructor suffix to append and obfuscate; omit when `-D` already contains it - `--seed ` - Cryptographic seed for deterministic obfuscation - `--passes ` - Comma-separated list of transforms (default: shuffle) - `--emit ` - Path to write gas/size report as JSON @@ -74,6 +76,8 @@ Options: Note: `function_dispatcher` is always applied automatically. +The runtime is used as an exact, authoritative deployment boundary. A supplied runtime that is missing or occurs more than once is rejected. Constructor masking does not parse the ABI and is not cryptographic confidentiality; it removes the stable plaintext suffix while preserving constructor behavior. + ### `azoth analyze` Generates multiple obfuscated variants and reports how much of the original bytecode survives unchanged. diff --git a/crates/cli/src/commands/fuzz.rs b/crates/cli/src/commands/fuzz.rs index 09ea6d80..f31c26b2 100644 --- a/crates/cli/src/commands/fuzz.rs +++ b/crates/cli/src/commands/fuzz.rs @@ -212,6 +212,7 @@ impl FuzzInput { enum ErrorKind { Obfuscation, Validation, + ConstructorArgsVisible, DeploymentMismatch { original: usize, obfuscated: usize }, } @@ -220,6 +221,7 @@ impl fmt::Display for ErrorKind { match self { Self::Obfuscation => write!(f, "obfuscation failed"), Self::Validation => write!(f, "validation failed"), + Self::ConstructorArgsVisible => write!(f, "constructor arguments remain visible"), Self::DeploymentMismatch { original, obfuscated, @@ -297,18 +299,24 @@ impl FuzzStats { } const MOCK_TOKEN_ADDR: Address = Address::new([0x11; 20]); -const MOCK_RECIPIENT_ADDR: Address = Address::new([0x22; 20]); -fn prepare_escrow_bytecode(deployment_hex: &str) -> Option> { +fn prepare_escrow_bytecode(deployment_hex: &str, seed: [u8; 32]) -> Option> { let normalized = deployment_hex.trim().trim_start_matches("0x"); let mut bytecode = hex::decode(normalized).ok()?; + let mut rng = SmallRng::from_seed(seed); + let mut recipient = [0u8; 20]; + let mut expected_amount = [0u8; 32]; + let mut payment_amount = [0u8; 32]; + rng.fill_bytes(&mut recipient); + rng.fill_bytes(&mut expected_amount); + rng.fill_bytes(&mut payment_amount); bytecode.extend_from_slice(&[0; 12]); bytecode.extend_from_slice(MOCK_TOKEN_ADDR.as_slice()); bytecode.extend_from_slice(&[0; 12]); - bytecode.extend_from_slice(MOCK_RECIPIENT_ADDR.as_slice()); - bytecode.extend_from_slice(&[0; 32]); - bytecode.extend_from_slice(&[0; 32]); + bytecode.extend_from_slice(&recipient); + bytecode.extend_from_slice(&expected_amount); bytecode.extend_from_slice(&[0; 32]); + bytecode.extend_from_slice(&payment_amount); Some(bytecode) } @@ -317,9 +325,9 @@ fn prepare_counter_bytecode(deployment_hex: &str) -> Option> { hex::decode(normalized).ok() } -fn prepare_bytecode(contract: Contract, deployment_hex: &str) -> Option> { +fn prepare_bytecode(contract: Contract, deployment_hex: &str, seed: [u8; 32]) -> Option> { match contract { - Contract::Escrow => prepare_escrow_bytecode(deployment_hex), + Contract::Escrow => prepare_escrow_bytecode(deployment_hex, seed), Contract::Counter => prepare_counter_bytecode(deployment_hex), } } @@ -433,6 +441,15 @@ async fn run_fuzz_input(input: &FuzzInput, check_deploy: bool) -> Result<(), Fuz let deployment_hex = input.contract.deployment_hex(); let runtime_hex = input.contract.runtime_hex(); let seed = Seed::from_bytes(input.seed_bytes()); + let original_bytes = prepare_bytecode(input.contract, deployment_hex, input.seed_bytes()) + .ok_or_else(|| FuzzFailure { + kind: ErrorKind::Obfuscation, + message: "failed to prepare original bytecode".into(), + trace: Vec::new(), + obfuscated_bytecode: None, + logs: Vec::new(), + })?; + let full_deployment_hex = format!("0x{}", hex::encode(&original_bytes)); let transforms = build_passes(&input.passes).map_err(|e| FuzzFailure { kind: ErrorKind::Obfuscation, @@ -448,7 +465,7 @@ async fn run_fuzz_input(input: &FuzzInput, check_deploy: bool) -> Result<(), Fuz preserve_unknown_opcodes: true, }; - let result = obfuscate_bytecode(deployment_hex, runtime_hex, config) + let result = obfuscate_bytecode(&full_deployment_hex, runtime_hex, config) .await .map_err(|e| { let kind = if e.message.contains("validation") || e.message.contains("invalid jump") { @@ -465,25 +482,60 @@ async fn run_fuzz_input(input: &FuzzInput, check_deploy: bool) -> Result<(), Fuz } })?; + if input.contract == Contract::Escrow { + let base_len = hex::decode(deployment_hex.trim().trim_start_matches("0x")) + .map_err(|error| FuzzFailure { + kind: ErrorKind::Obfuscation, + message: format!("failed to decode base deployment: {error}"), + trace: result.trace.clone(), + obfuscated_bytecode: Some(result.obfuscated_bytecode.clone()), + logs: Vec::new(), + })? + .len(); + let args = &original_bytes[base_len..]; + let obfuscated = + hex::decode(result.obfuscated_bytecode.trim_start_matches("0x")).map_err(|error| { + FuzzFailure { + kind: ErrorKind::Obfuscation, + message: format!("failed to decode obfuscated deployment: {error}"), + trace: result.trace.clone(), + obfuscated_bytecode: Some(result.obfuscated_bytecode.clone()), + logs: Vec::new(), + } + })?; + if obfuscated.windows(args.len()).any(|window| window == args) { + return Err(FuzzFailure { + kind: ErrorKind::ConstructorArgsVisible, + message: "the complete ABI constructor suffix survived obfuscation".into(), + trace: result.trace, + obfuscated_bytecode: Some(result.obfuscated_bytecode), + logs: Vec::new(), + }); + } + for word_index in [0usize, 1, 2, 4] { + let word = &args[word_index * 32..(word_index + 1) * 32]; + if obfuscated.windows(32).any(|window| window == word) { + return Err(FuzzFailure { + kind: ErrorKind::ConstructorArgsVisible, + message: format!("constructor ABI word {word_index} survived obfuscation"), + trace: result.trace, + obfuscated_bytecode: Some(result.obfuscated_bytecode), + logs: Vec::new(), + }); + } + } + } + if !check_deploy { return Ok(()); } - let original_bytes = - prepare_bytecode(input.contract, deployment_hex).ok_or_else(|| FuzzFailure { - kind: ErrorKind::Obfuscation, - message: "failed to prepare original bytecode".into(), - trace: result.trace.clone(), - obfuscated_bytecode: Some(result.obfuscated_bytecode.clone()), - logs: Vec::new(), - })?; - let original_deployed = deploy_to_revm(&original_bytes, input.contract).is_ok(); - let prepared_obfuscated = prepare_bytecode(input.contract, &result.obfuscated_bytecode) - .ok_or_else(|| FuzzFailure { + let prepared_obfuscated = hex::decode(result.obfuscated_bytecode.trim_start_matches("0x")) + .map_err(|error| FuzzFailure { kind: ErrorKind::Obfuscation, - message: "failed to prepare obfuscated bytecode".into(), + message: format!("failed to decode obfuscated bytecode: {error}"), trace: result.trace.clone(), obfuscated_bytecode: Some(result.obfuscated_bytecode.clone()), logs: Vec::new(), diff --git a/crates/cli/src/commands/obfuscate.rs b/crates/cli/src/commands/obfuscate.rs index ed019bf9..4681c35d 100644 --- a/crates/cli/src/commands/obfuscate.rs +++ b/crates/cli/src/commands/obfuscate.rs @@ -25,6 +25,10 @@ pub struct ObfuscateArgs { /// Input runtime bytecode as a hex string, .hex file, or binary file containing EVM bytecode. #[arg(short = 'R', long = "runtime")] pub runtime_bytecode: String, + /// ABI-encoded constructor argument suffix to append before obfuscation. + /// May be omitted when the deployment input already contains the suffix. + #[arg(long, value_name = "HEX")] + constructor_args: Option, /// Cryptographic seed for deterministic obfuscation. #[arg(long)] seed: Option, @@ -50,6 +54,7 @@ impl super::Command for ObfuscateArgs { let ObfuscateArgs { deployment_bytecode, runtime_bytecode, + constructor_args, seed, passes, emit, @@ -58,8 +63,13 @@ impl super::Command for ObfuscateArgs { } = self; // Step 1: Read and normalize input - let input_bytecode = read_input(&deployment_bytecode)?; + let mut input_bytecode = read_input(&deployment_bytecode)?; let runtime_bytecode_hex = read_input(&runtime_bytecode)?; + if let Some(constructor_args) = constructor_args { + let deployment = normalise_hex(&input_bytecode)?; + let args = normalise_hex(&constructor_args)?; + input_bytecode = format!("0x{deployment}{args}"); + } // Step 2: Build transforms from CLI args let transforms = build_passes(&passes)?; diff --git a/crates/core/src/detection/sections.rs b/crates/core/src/detection/sections.rs index 7d6c801c..9025a50d 100644 --- a/crates/core/src/detection/sections.rs +++ b/crates/core/src/detection/sections.rs @@ -47,6 +47,16 @@ pub fn locate_sections( instructions: &[Instruction], runtime_bytes: &[u8], ) -> Result, Error> { + // A caller-supplied runtime is an authoritative boundary marker. Matching the complete + // runtime avoids interpreting ABI words at the end of a creation payload as a CBOR length + // and, unlike the fallback detector below, does not infer constructor arguments from an + // opcode pattern. + if !runtime_bytes.is_empty() { + let sections = locate_sections_from_exact_runtime(deployment_bytes, runtime_bytes)?; + validate_sections(§ions, deployment_bytes.len())?; + return Ok(sections); + } + let mut sections = Vec::new(); let total_len = deployment_bytes.len(); @@ -241,6 +251,76 @@ pub fn locate_sections( Ok(sections) } +/// Builds deployment sections from an exact runtime byte sequence supplied by the caller. +/// +/// Solidity appends constructor arguments after the complete compiler-generated creation +/// bytecode. The runtime (including its CBOR trailer) therefore gives two exact boundaries: +/// its first byte ends init code, and its final byte begins the constructor-argument suffix. +/// This path deliberately performs no ABI decoding and supports static and dynamic arguments +/// alike. +fn locate_sections_from_exact_runtime( + deployment_bytes: &[u8], + runtime_bytes: &[u8], +) -> Result, Error> { + if runtime_bytes.is_empty() || runtime_bytes.len() > deployment_bytes.len() { + return Err(Error::SuppliedRuntimeNotFound); + } + + let matches: Vec = deployment_bytes + .windows(runtime_bytes.len()) + .enumerate() + .filter_map(|(offset, window)| (window == runtime_bytes).then_some(offset)) + .collect(); + let runtime_start = match matches.as_slice() { + [offset] => *offset, + [] => return Err(Error::SuppliedRuntimeNotFound), + _ => return Err(Error::AmbiguousRuntimeMatch(matches.len())), + }; + let runtime_end = runtime_start + runtime_bytes.len(); + let runtime_auxdata = detect_auxdata(runtime_bytes); + let runtime_code_len = runtime_auxdata + .map(|(offset, _)| offset) + .unwrap_or(runtime_bytes.len()); + + let mut sections = Vec::with_capacity(4); + if runtime_start > 0 { + sections.push(Section { + kind: SectionKind::Init, + offset: 0, + len: runtime_start, + }); + } + if runtime_code_len > 0 { + sections.push(Section { + kind: SectionKind::Runtime, + offset: runtime_start, + len: runtime_code_len, + }); + } + if let Some((aux_offset, aux_len)) = runtime_auxdata { + sections.push(Section { + kind: SectionKind::Auxdata, + offset: runtime_start + aux_offset, + len: aux_len, + }); + } + if runtime_end < deployment_bytes.len() { + sections.push(Section { + kind: SectionKind::ConstructorArgs, + offset: runtime_end, + len: deployment_bytes.len() - runtime_end, + }); + } + + tracing::debug!( + "Exact runtime layout: runtime_start={}, runtime_end={}, constructor_args={}", + runtime_start, + runtime_end, + deployment_bytes.len().saturating_sub(runtime_end) + ); + Ok(sections) +} + /// Helper to extract runtime instructions from full bytecode pub fn extract_runtime_instructions( instructions: &[Instruction], @@ -591,4 +671,48 @@ mod tests { assert_eq!(auxdata_offset, 35); assert_eq!(auxdata_length, 53); } + + #[test] + fn exact_runtime_places_constructor_args_after_auxdata() { + let runtime = vec![0x60, 0x00, 0x00, 0xa1, 0x01, 0x02, 0x00, 0x03]; + let mut deployment = vec![0x60, 0x00, 0xf3]; + deployment.extend_from_slice(&runtime); + deployment.extend_from_slice(&[0xabu8; 64]); + + let sections = locate_sections_from_exact_runtime(&deployment, &runtime).unwrap(); + assert_eq!( + sections, + vec![ + Section { + kind: SectionKind::Init, + offset: 0, + len: 3, + }, + Section { + kind: SectionKind::Runtime, + offset: 3, + len: 3, + }, + Section { + kind: SectionKind::Auxdata, + offset: 6, + len: 5, + }, + Section { + kind: SectionKind::ConstructorArgs, + offset: 11, + len: 64, + }, + ] + ); + } + + #[test] + fn exact_runtime_rejects_ambiguous_boundaries() { + let runtime = vec![0x60, 0x00, 0x00]; + let deployment = [runtime.as_slice(), runtime.as_slice()].concat(); + + let error = locate_sections_from_exact_runtime(&deployment, &runtime).unwrap_err(); + assert!(matches!(error, Error::AmbiguousRuntimeMatch(2))); + } } diff --git a/crates/core/src/result.rs b/crates/core/src/result.rs index 536b36b7..b19dddc7 100644 --- a/crates/core/src/result.rs +++ b/crates/core/src/result.rs @@ -67,6 +67,14 @@ pub enum Error { #[error("no runtime found")] NoRuntimeFound, + /// The caller-supplied runtime does not occur in the deployment payload. + #[error("caller-supplied runtime was not found in deployment bytecode")] + SuppliedRuntimeNotFound, + + /// The caller-supplied runtime occurs more than once, so its boundary is ambiguous. + #[error("caller-supplied runtime occurs {0} times in deployment bytecode")] + AmbiguousRuntimeMatch(usize), + /// Obfuscation operation failed. #[error("obfuscation failed: {0}")] ObfuscationFailed(String), diff --git a/crates/core/src/strip.rs b/crates/core/src/strip.rs index d34171f2..82cb115e 100644 --- a/crates/core/src/strip.rs +++ b/crates/core/src/strip.rs @@ -134,6 +134,63 @@ struct PushInfo { value: usize, } +fn opcode_positions(bytes: &[u8], target: u8) -> Vec { + let mut positions = Vec::new(); + let mut pc = 0usize; + while pc < bytes.len() { + let opcode = bytes[pc]; + if opcode == target { + positions.push(pc); + } + pc += if (0x60..=0x7f).contains(&opcode) { + 1 + (opcode - 0x5f) as usize + } else { + 1 + }; + } + positions +} + +fn patch_constructor_arg_base( + bytes: &mut [u8], + old_value: usize, + new_value: usize, +) -> Result { + let mut patched = 0usize; + let mut pc = 0usize; + while pc < bytes.len() { + let opcode = bytes[pc]; + if !(0x60..=0x7f).contains(&opcode) { + pc += 1; + continue; + } + + let width = (opcode - 0x5f) as usize; + let end = pc + 1 + width; + if end > bytes.len() { + break; + } + let value = bytes[pc + 1..end] + .iter() + .fold(0usize, |acc, &byte| (acc << 8) | byte as usize); + let is_constructor_length = bytes.get(end..end + 3) == Some(&[0x80, 0x38, 0x03]); + if value == old_value && is_constructor_length { + if width < std::mem::size_of::() && new_value >= (1usize << (width * 8)) { + return Err(format!( + "constructor argument base 0x{new_value:x} does not fit in PUSH{width}" + )); + } + for index in 0..width { + let shift = (width - 1 - index) * 8; + bytes[pc + 1 + index] = ((new_value >> shift) & 0xff) as u8; + } + patched += 1; + } + pc = end; + } + Ok(patched) +} + impl CleanReport { /// Updates init code CODECOPY and RETURN parameters to reflect new runtime length and offset. /// @@ -157,30 +214,44 @@ impl CleanReport { self.clean_len ); - // 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 runtime_offset = new_runtime_offset; - let post_runtime_len: usize = self + let new_runtime_offset: usize = self + .removed + .iter() + .filter(|removed| removed.offset < original_runtime_offset) + .map(|removed| removed.data.len()) + .sum(); + let deployed_suffix_len: usize = self .removed .iter() - .filter(|removed| removed.offset >= runtime_offset) + .filter(|removed| { + removed.offset >= original_runtime_offset + && !matches!(removed.kind, SectionKind::ConstructorArgs) + }) .map(|removed| removed.data.len()) .sum(); - let runtime_tail_len = new_runtime_len + post_runtime_len; - let original_runtime_tail_len = self.clean_len + post_runtime_len; + let new_deployed_runtime_len = new_runtime_len + deployed_suffix_len; + let original_deployed_runtime_len = self.clean_len + deployed_suffix_len; + let original_creation_len = original_runtime_offset + original_deployed_runtime_len; + let new_creation_len = new_runtime_offset + new_deployed_runtime_len; + let has_constructor_args = self + .removed + .iter() + .any(|removed| matches!(removed.kind, SectionKind::ConstructorArgs)); tracing::debug!( - "Calculated values: runtime_offset={}, post_runtime_len={}, runtime_tail_len={}, original_runtime_tail_len={}", - runtime_offset, - post_runtime_len, - runtime_tail_len, - original_runtime_tail_len + "Calculated values: runtime_offset={} -> {}, deployed_runtime_len={} -> {}, creation_len={} -> {}", + original_runtime_offset, + new_runtime_offset, + original_deployed_runtime_len, + new_deployed_runtime_len, + original_creation_len, + new_creation_len ); let init_section = self @@ -265,38 +336,39 @@ impl CleanReport { Ok(()) } - let codecopy_positions: Vec<_> = init_bytes - .iter() - .enumerate() - .filter_map(|(idx, &b)| (b == 0x39).then_some(idx)) - .collect(); + let codecopy_positions = opcode_positions(&init_bytes, 0x39); - let mut codecopy_patched = false; + let mut codecopy_patched = original_runtime_offset == new_runtime_offset + && original_deployed_runtime_len == new_deployed_runtime_len; for pos in codecopy_positions { let pushes = collect_previous_pushes(&init_bytes, pos, 6); let has_len = pushes .iter() - .any(|info| info.value == original_runtime_tail_len); - let has_offset = pushes.iter().any(|info| info.value == runtime_offset); + .any(|info| info.value == original_deployed_runtime_len); + let has_offset = pushes + .iter() + .any(|info| info.value == original_runtime_offset); if !(has_len && has_offset) { continue; } for info in &pushes { - if info.value == original_runtime_tail_len { - write_push_value(&mut init_bytes, info, runtime_tail_len)?; + if info.value == original_deployed_runtime_len { + write_push_value(&mut init_bytes, info, new_deployed_runtime_len)?; codecopy_patched = true; tracing::debug!( "Updated CODECOPY length PUSH at 0x{:x} to 0x{:x}", info.pos, - runtime_tail_len + new_deployed_runtime_len ); break; } } for info in &pushes { - if info.value == runtime_offset && new_runtime_offset != runtime_offset { + if info.value == original_runtime_offset + && new_runtime_offset != original_runtime_offset + { write_push_value(&mut init_bytes, info, new_runtime_offset)?; tracing::debug!( "Updated CODECOPY offset PUSH at 0x{:x} to 0x{:x}", @@ -315,82 +387,55 @@ impl CleanReport { ); } - let original_total_len = runtime_offset + original_runtime_tail_len; - let new_total_len = new_runtime_offset + runtime_tail_len; - if original_total_len != new_total_len { - let mut idx = 0usize; - let mut total_patched = false; - while idx < init_bytes.len() { - let opcode = init_bytes[idx]; - if (0x60..=0x7f).contains(&opcode) { - let width = (opcode - 0x60 + 1) as usize; - if idx + 1 + width <= init_bytes.len() { - let mut value = 0usize; - for &byte in &init_bytes[idx + 1..idx + 1 + width] { - value = (value << 8) | byte as usize; - } - if value == original_total_len { - let info = PushInfo { - pos: idx, - width, - value, - }; - write_push_value(&mut init_bytes, &info, new_total_len)?; - total_patched = true; - tracing::debug!( - "Updated total bytecode size PUSH at 0x{:x} to 0x{:x}", - idx, - new_total_len - ); - break; - } - } - idx += width + 1; - } else { - idx += 1; - } - } - - if !total_patched { - tracing::warn!( - "Expected to update init metadata length (0x{:x}) but no PUSH matched", - original_total_len - ); + if original_creation_len != new_creation_len { + let patched = patch_constructor_arg_base( + &mut init_bytes, + original_creation_len, + new_creation_len, + )?; + // A caller may obfuscate bare creation bytecode and append its constructor + // arguments afterwards. Patch a supported constructor-copy base whenever it is + // present, but only require it when this payload already contains arguments. + if patched == 0 && has_constructor_args { + return Err(format!( + "Could not locate constructor argument base 0x{:x} before CODESIZE/SUB", + original_creation_len + )); } } - let return_positions: Vec<_> = init_bytes - .iter() - .enumerate() - .filter_map(|(idx, &b)| (b == 0xf3).then_some(idx)) - .collect(); + let return_positions = opcode_positions(&init_bytes, 0xf3); - let mut return_patched = false; + let mut return_patched = original_deployed_runtime_len == new_deployed_runtime_len; for pos in return_positions { let pushes = collect_previous_pushes(&init_bytes, pos, 4); - if let Some(info) = pushes - .iter() - .find(|info| info.value == original_runtime_tail_len) - { - write_push_value(&mut init_bytes, info, runtime_tail_len)?; + if let Some(info) = pushes.iter().find(|info| { + info.value == original_deployed_runtime_len + || info.value == new_deployed_runtime_len + }) { + if info.value == original_deployed_runtime_len { + write_push_value(&mut init_bytes, info, new_deployed_runtime_len)?; + } return_patched = true; tracing::debug!( "Updated RETURN length PUSH at 0x{:x} to 0x{:x}", info.pos, - runtime_tail_len + new_deployed_runtime_len ); break; } } if !return_patched { - return Err("Could not find RETURN length PUSH to update".into()); + tracing::debug!( + "RETURN reuses the CODECOPY length already patched on the constructor stack" + ); } tracing::debug!( "Updated init code CODECOPY/RETURN for runtime offset=0x{:x}, len=0x{:x}", new_runtime_offset, - new_runtime_len + new_deployed_runtime_len ); init_section.data = Bytes::from(init_bytes); @@ -507,255 +552,90 @@ impl CleanReport { Ok(()) } - /// Reassemble bytecode by placing the clean runtime at original offsets - /// and filling removed sections with their original data. - pub fn reassemble(&mut self, clean: &[u8]) -> Vec { - // Check if runtime length changed and update init code if needed + /// Reassemble bytecode and return an error if changed init-code constants cannot be patched. + /// + /// Obfuscation pipelines should use this checked form so a layout Azoth cannot safely lower + /// is rejected instead of producing deployment bytecode with stale offsets. + pub fn reassemble_checked(&mut self, clean: &[u8]) -> Result, String> { let original_runtime_len = self.clean_len; let new_runtime_len = clean.len(); - tracing::debug!( "reassemble: original_runtime_len={}, new_runtime_len={}", original_runtime_len, new_runtime_len ); - if new_runtime_len != original_runtime_len { - tracing::debug!( - "Runtime length changed from {} to {} bytes, updating init code", - original_runtime_len, - new_runtime_len - ); - - if let Err(e) = self.update_init_code_size(new_runtime_len) { - tracing::warn!("Targeted init code patching failed: {}", e); - tracing::warn!("Will attempt fallback patching during reassembly"); - } - } - // Check if runtime size changed - if so, use simple sequential assembly - let runtime_size_changed = clean.len() != original_runtime_len; - - if runtime_size_changed { - tracing::debug!( - "Runtime size changed - using sequential reassembly: prefix + runtime + suffix" - ); - - // Get the original runtime start offset to determine prefix/suffix split - let runtime_start_offset = self - .runtime_layout - .iter() - .map(|span| span.offset) - .min() - .unwrap_or(0); - - tracing::debug!( - "Original runtime started at offset {}, preserving prefix structure", - runtime_start_offset - ); - - let mut out = Vec::new(); - - // Sort removed sections by their original offset - let mut sorted_removed = self.removed.clone(); - sorted_removed.sort_by_key(|r| r.offset); - - // Compute suffix size to keep track of metadata lengths - let post_runtime_len: usize = sorted_removed - .iter() - .filter(|removed| removed.offset >= runtime_start_offset) - .map(|removed| removed.data.len()) - .sum(); - - // Add all sections that were BEFORE the runtime (prefix: init + any padding/constructor args) - for removed in &sorted_removed { - if removed.offset < runtime_start_offset { - out.extend_from_slice(&removed.data); - tracing::debug!( - "Added pre-runtime {:?} section: {} bytes (original offset: {})", - removed.kind, - removed.data.len(), - removed.offset - ); - } - } - - // Add obfuscated runtime - out.extend_from_slice(clean); - tracing::debug!("Added runtime code: {} bytes", clean.len()); - - // Add all sections that were AFTER the runtime (suffix: auxdata, etc.) - for removed in &sorted_removed { - if removed.offset >= runtime_start_offset { - out.extend_from_slice(&removed.data); - tracing::debug!( - "Added post-runtime {:?} section: {} bytes (original offset: {})", - removed.kind, - removed.data.len(), - removed.offset - ); - } - } - - // 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()); - 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; - - if original_tail_len != new_tail_len { - let replaced = patch_push_value(prefix, original_tail_len, new_tail_len, Some(1)); - if replaced == 0 { - tracing::warn!( - "Failed to update CODECOPY length from 0x{:x} to 0x{:x} in final bytecode", - original_tail_len, - new_tail_len - ); - } - } - - if original_total_len != new_total_len { - let replaced = patch_push_value(prefix, original_total_len, new_total_len, Some(1)); - if replaced == 0 { - tracing::warn!( - "Failed to update total bytecode size from 0x{:x} to 0x{:x} in final bytecode", - original_total_len, - new_total_len - ); - } - } - - tracing::debug!("Sequential reassembly complete: {} bytes total", out.len()); - out - } else { - // Original logic for unchanged runtime size - let max_runtime_end = self - .runtime_layout - .iter() - .map(|span| span.offset + span.len) - .max() - .unwrap_or(0); + let runtime_start_offset = self + .runtime_layout + .iter() + .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(); + let layout_changed = new_runtime_len != original_runtime_len + || actual_runtime_start_offset != runtime_start_offset; - let max_removed_end = self + if layout_changed + && self .removed .iter() - .map(|r| r.offset + r.data.len()) - .max() - .unwrap_or(0); - - let required_size = max_runtime_end.max(max_removed_end).max(clean.len()); - - tracing::debug!( - "Reassembling: clean_len={}, bytes_saved={}, required_size={}", - clean.len(), - self.bytes_saved, - required_size - ); - - let mut out = vec![0u8; required_size]; + .any(|removed| matches!(removed.kind, SectionKind::Init)) + { + self.update_init_code_size(new_runtime_len)?; + } - // Copy clean runtime to original positions - let mut clean_pos = 0; - for span in &self.runtime_layout { - let end_pos = clean_pos + span.len; - if end_pos <= clean.len() && span.offset + span.len <= out.len() { - out[span.offset..span.offset + span.len] - .copy_from_slice(&clean[clean_pos..end_pos]); - clean_pos = end_pos; - } else { - tracing::error!( - "Reassembly bounds error: clean_pos={}, span.offset={}, span.len={}, out.len()={}", - clean_pos, - span.offset, - span.len, - out.len() - ); - } - } + Ok(self.assemble_sequential(clean, runtime_start_offset)) + } - // Restore removed sections (constructor, auxdata, etc.) - for removed in &self.removed { - if removed.offset + removed.data.len() <= out.len() { - out[removed.offset..removed.offset + removed.data.len()] - .copy_from_slice(&removed.data); - } else { - tracing::error!( - "Reassembly bounds error: removed.offset={}, removed.data.len()={}, out.len()={}", - removed.offset, - removed.data.len(), - out.len() - ); - } + /// Reassemble bytecode, retaining the historical best-effort behavior for library callers. + /// Prefer [`Self::reassemble_checked`] when returning malformed deployment code is unsafe. + pub fn reassemble(&mut self, clean: &[u8]) -> Vec { + match self.reassemble_checked(clean) { + Ok(output) => output, + Err(error) => { + tracing::warn!("Targeted init code patching failed: {}", error); + let runtime_start_offset = self + .runtime_layout + .iter() + .map(|span| span.offset) + .min() + .unwrap_or(0); + self.assemble_sequential(clean, runtime_start_offset) } - - out } } -} -fn patch_push_value( - bytes: &mut [u8], - old_value: usize, - new_value: usize, - max_replacements: Option, -) -> usize { - if old_value == new_value { - return 0; - } + fn assemble_sequential(&self, clean: &[u8], runtime_start_offset: usize) -> Vec { + let mut sorted_removed = self.removed.clone(); + sorted_removed.sort_by_key(|removed| removed.offset); + let mut out = Vec::with_capacity( + sorted_removed + .iter() + .map(|removed| removed.data.len()) + .sum::() + + clean.len(), + ); - let mut replaced = 0usize; - let mut idx = 0usize; - while idx < bytes.len() { - let opcode = bytes[idx]; - if (0x60..=0x7f).contains(&opcode) { - let width = (opcode - 0x60 + 1) as usize; - if idx + 1 + width <= bytes.len() { - let mut value = 0usize; - for &byte in &bytes[idx + 1..idx + 1 + width] { - value = (value << 8) | byte as usize; - } - if value == old_value { - if width < std::mem::size_of::() { - let max = (1usize << (width * 8)) - 1; - if new_value > max { - tracing::warn!( - "New value 0x{:x} does not fit in PUSH{} at 0x{:x}", - new_value, - width, - idx - ); - idx += width + 1; - continue; - } - } - let bit_width = usize::BITS as usize; - for j in 0..width { - let shift = j * 8; - let byte = if shift >= bit_width { - 0 - } else { - ((new_value >> shift) & 0xff) as u8 - }; - bytes[idx + 1 + width - 1 - j] = byte; - } - replaced += 1; - if let Some(limit) = max_replacements - && replaced >= limit - { - break; - } - } + for removed in &sorted_removed { + if removed.offset < runtime_start_offset { + out.extend_from_slice(&removed.data); + } + } + out.extend_from_slice(clean); + for removed in &sorted_removed { + if removed.offset >= runtime_start_offset { + out.extend_from_slice(&removed.data); } - idx += width + 1; - } else { - idx += 1; } - } - replaced + tracing::debug!("Sequential reassembly complete: {} bytes total", out.len()); + out + } } #[cfg(test)] @@ -906,4 +786,32 @@ mod tests { "init code should be updated to push new runtime tail length (runtime + auxdata)" ); } + + #[test] + fn reassembly_relocates_constructor_base_before_arguments_are_appended() { + // The init code retains the runtime length across CODECOPY for RETURN, then contains + // Solidity's constructor-data base sequence: PUSH creation_len; DUP1; CODESIZE; SUB. + // No argument suffix is present yet, matching callers that append ABI data later. + let init = [ + 0x60, 0x03, 0x80, 0x60, 0x0e, 0x5f, 0x39, 0x5f, 0xf3, 0x60, 0x11, 0x80, 0x38, 0x03, + ]; + let runtime = [0x5b, 0x00, 0x00]; + let bytes = [init.as_slice(), runtime.as_slice()].concat(); + let sections = vec![ + section(SectionKind::Init, 0, init.len()), + section(SectionKind::Runtime, init.len(), runtime.len()), + ]; + let (_, mut report) = strip_bytecode(&bytes, §ions).unwrap(); + let grown_runtime = [0x5b, 0x5b, 0x5b, 0x00, 0x00]; + + let rebuilt = report.reassemble_checked(&grown_runtime).unwrap(); + + assert_eq!(&rebuilt[1..2], &[grown_runtime.len() as u8]); + assert_eq!( + &rebuilt[9..14], + &[0x60, 0x13, 0x80, 0x38, 0x03], + "constructor-data base must track the grown creation bytecode" + ); + assert_eq!(&rebuilt[init.len()..], grown_runtime.as_slice()); + } } diff --git a/crates/transforms/Cargo.toml b/crates/transforms/Cargo.toml index b7a91fe2..af1b4799 100644 --- a/crates/transforms/Cargo.toml +++ b/crates/transforms/Cargo.toml @@ -15,3 +15,6 @@ hex.workspace = true petgraph.workspace = true tokio.workspace = true serde_json.workspace = true + +[dev-dependencies] +revm.workspace = true diff --git a/crates/transforms/README.md b/crates/transforms/README.md index ba3f1774..d0bea4aa 100644 --- a/crates/transforms/README.md +++ b/crates/transforms/README.md @@ -13,6 +13,12 @@ The transforms crate implements a pass-based architecture where each transformat ## Current Transforms +### Constructor arguments (`constructor_args.rs`) + +When the deployment payload contains bytes after the exact caller-supplied runtime, the pipeline masks every byte of that suffix and injects a seed-varied init-code decoder. Detection uses the complete runtime as an authoritative boundary and does not inspect the ABI, source, address shapes, or zero padding. Unsupported or ambiguous constructor copy layouts fail closed instead of returning plaintext arguments. This pass is automatic and is reported as `ConstructorArgs` in result metadata. + +The bytecode contains everything required to reverse the mask, so this is obfuscation against literal/static recovery rather than encryption. Trampoline form, chunk order, arithmetic mask synthesis, and constants vary with the seed; no marker or fixed decoder byte string is emitted. + ### 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/constructor_args.rs b/crates/transforms/src/constructor_args.rs new file mode 100644 index 00000000..c5f91045 --- /dev/null +++ b/crates/transforms/src/constructor_args.rs @@ -0,0 +1,704 @@ +//! Constructor-argument payload obfuscation. +//! +//! This pass masks the exact constructor-argument suffix identified from the caller-supplied +//! runtime and injects seed-derived, straight-line decoding code immediately after the init +//! code copies that suffix into memory. It does not inspect an ABI or contract source. The +//! masking is intentionally described as obfuscation rather than encryption: every value needed +//! to decode the arguments remains in public creation bytecode and can be recovered by a capable +//! symbolic or dynamic analyst. + +use crate::arithmetic_chain::{compile_chain_inline, generate_chain, ChainConfig, ScatterStrategy}; +use crate::{Error, Result}; +use azoth_core::strip::CleanReport; +use azoth_core::{encoder, Opcode}; +use rand::rngs::StdRng; +use rand::seq::SliceRandom; +use rand::{Rng, RngCore, SeedableRng}; +use sha3::{Digest, Sha3_256}; + +/// Measurements from constructor-argument obfuscation. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ConstructorArgsObfuscation { + /// Whether a constructor-argument suffix was present and transformed. + pub applied: bool, + /// Number of constructor-argument bytes masked in the creation payload. + pub argument_bytes: usize, + /// Number of decoder bytes inserted into init code. + pub decoder_bytes: usize, + /// Number of 32-byte memory chunks decoded during construction. + pub chunks: usize, +} + +#[derive(Clone, Copy, Debug)] +struct InitOp { + pc: usize, + opcode: u8, + value: Option, +} + +/// Obfuscates an exactly located constructor-argument suffix and injects its decoder. +/// +/// The function fails closed when constructor arguments exist but the init code does not expose +/// a supported, unambiguous `CODESIZE - creation_length` copy site. Returning the original +/// plaintext suffix in that situation would violate the caller's expectation that the pass was +/// applied. +pub fn obfuscate_constructor_args( + report: &mut CleanReport, + seed: &[u8; 32], +) -> Result { + let Some(args_index) = report.removed.iter().position(|removed| { + matches!( + removed.kind, + azoth_core::detection::SectionKind::ConstructorArgs + ) + }) else { + return Ok(ConstructorArgsObfuscation::default()); + }; + let Some(init_index) = report + .removed + .iter() + .position(|removed| matches!(removed.kind, azoth_core::detection::SectionKind::Init)) + else { + return Err(Error::Generic( + "constructor arguments were detected without init code".into(), + )); + }; + + let argument_bytes = report.removed[args_index].data.len(); + if argument_bytes == 0 { + return Ok(ConstructorArgsObfuscation::default()); + } + + let runtime_offset = report + .runtime_layout + .iter() + .map(|span| span.offset) + .min() + .ok_or_else(|| Error::Generic("constructor arguments require a runtime section".into()))?; + let deployed_suffix_len: usize = report + .removed + .iter() + .filter(|removed| { + removed.offset >= runtime_offset + && !matches!( + removed.kind, + azoth_core::detection::SectionKind::ConstructorArgs + ) + }) + .map(|removed| removed.data.len()) + .sum(); + let creation_len = runtime_offset + report.clean_len + deployed_suffix_len; + + let original_init = report.removed[init_index].data.to_vec(); + let (copy_pc, destination_depth, block_end) = find_argument_copy(&original_init, creation_len)?; + + let mut hasher = Sha3_256::new(); + hasher.update(b"AZOTH_CONSTRUCTOR_ARGUMENTS_V1"); + hasher.update(seed); + hasher.update((argument_bytes as u64).to_be_bytes()); + let mut rng = StdRng::from_seed(hasher.finalize().into()); + + let original_args = report.removed[args_index].data.to_vec(); + let chunks = argument_bytes.div_ceil(32); + let mut masked_args = original_args.clone(); + let mut masks = Vec::with_capacity(chunks); + for chunk_index in 0..chunks { + let start = chunk_index * 32; + let used = (argument_bytes - start).min(32); + let mut mask = [0u8; 32]; + rng.fill_bytes(&mut mask[..used]); + if mask[..used].iter().all(|byte| *byte == 0) { + mask[0] = 1; + } + for index in 0..used { + masked_args[start + index] ^= mask[index]; + } + masks.push(mask); + } + + let mut order: Vec = (0..chunks).collect(); + order.shuffle(&mut rng); + let mut decode_body = Vec::new(); + for chunk_index in order { + emit_chunk_decoder( + &mut decode_body, + destination_depth, + chunk_index * 32, + masks[chunk_index], + &mut rng, + )?; + } + + let replay_tail = original_init[copy_pc + 1..block_end].to_vec(); + let mut rewritten_init = original_init; + let trampoline_pc = copy_pc - 1; + let decoder_pc = rewritten_init.len(); + if decoder_pc > u16::MAX as usize { + return Err(Error::Generic(format!( + "constructor decoder target 0x{decoder_pc:x} exceeds PUSH2 capacity" + ))); + } + if block_end <= copy_pc + 1 || trampoline_pc + 4 > block_end { + return Err(Error::Generic( + "constructor argument copy block is too short for an in-place trampoline".into(), + )); + } + + // The seed-varied trampoline overwrites bytes in the current basic block without moving any + // existing init PC. The appended decoder replays the whole original block tail after + // decoding, so every pre-existing jump target remains unchanged. + let trampoline = make_trampoline( + trampoline_pc, + decoder_pc, + block_end - trampoline_pc, + &mut rng, + )?; + rewritten_init[trampoline_pc..trampoline_pc + trampoline.len()].copy_from_slice(&trampoline); + + let duplicated_opcode = 0x7f + (destination_depth + 2) as u8; + let mut decoder = Vec::with_capacity(7 + decode_body.len() + block_end - copy_pc); + decoder.push(Opcode::JUMPDEST.to_byte()); + emit_stack_neutral_noise(&mut decoder, &mut rng); + decoder.push(duplicated_opcode); + emit_stack_neutral_noise(&mut decoder, &mut rng); + decoder.push(Opcode::CODECOPY.to_byte()); + decoder.extend_from_slice(&decode_body); + decoder.extend_from_slice(&replay_tail); + rewritten_init.extend_from_slice(&decoder); + + report.removed[init_index].data = rewritten_init.into(); + report.removed[args_index].data = masked_args.into(); + + Ok(ConstructorArgsObfuscation { + applied: true, + argument_bytes, + decoder_bytes: decoder.len(), + chunks, + }) +} + +fn emit_stack_neutral_noise(out: &mut Vec, rng: &mut StdRng) { + match rng.random_range(0..4) { + 0 => {} + 1 => { + out.push(Opcode::PUSH0.to_byte()); + out.push(Opcode::POP.to_byte()); + } + 2 => { + out.push(Opcode::PC.to_byte()); + out.push(Opcode::POP.to_byte()); + } + 3 => { + out.push(Opcode::PUSH(1).to_byte()); + out.push(rng.random()); + out.push(Opcode::POP.to_byte()); + } + _ => unreachable!(), + } +} + +fn decode_init(bytes: &[u8]) -> Result> { + let mut ops = Vec::new(); + let mut pc = 0usize; + while pc < bytes.len() { + let opcode = bytes[pc]; + let width = if (0x60..=0x7f).contains(&opcode) { + (opcode - 0x5f) as usize + } else { + 0 + }; + let end = pc + 1 + width; + if end > bytes.len() { + return Err(Error::Generic(format!( + "truncated PUSH{width} in init code at 0x{pc:x}" + ))); + } + let value = (width > 0).then(|| { + bytes[pc + 1..end] + .iter() + .fold(0usize, |acc, &byte| (acc << 8) | byte as usize) + }); + ops.push(InitOp { pc, opcode, value }); + pc = end; + } + Ok(ops) +} + +fn find_argument_copy(init: &[u8], creation_len: usize) -> Result<(usize, usize, usize)> { + let ops = decode_init(init)?; + let mut candidates = Vec::new(); + + for (index, op) in ops.iter().enumerate() { + if op.value != Some(creation_len) + || ops.get(index + 1).map(|next| next.opcode) != Some(0x80) + || ops.get(index + 2).map(|next| next.opcode) != Some(0x38) + || ops.get(index + 3).map(|next| next.opcode) != Some(0x03) + { + continue; + } + + let Some((copy_index, copy)) = ops + .iter() + .enumerate() + .skip(index + 4) + .find(|(_, candidate)| candidate.opcode == 0x39) + else { + continue; + }; + let Some(previous) = copy_index.checked_sub(1).and_then(|i| ops.get(i)) else { + continue; + }; + if !(0x82..=0x8f).contains(&previous.opcode) { + continue; + } + let duplicated_depth = (previous.opcode - 0x7f) as usize; + let destination_depth_after_copy = duplicated_depth - 2; + if destination_depth_after_copy == 0 || destination_depth_after_copy > 16 { + continue; + } + let Some(block_end) = ops + .iter() + .skip(copy_index + 1) + .find(|candidate| candidate.opcode == 0x5b) + .map(|candidate| candidate.pc) + else { + continue; + }; + if ops[copy_index + 1..] + .iter() + .take_while(|candidate| candidate.pc < block_end) + .any(|candidate| { + matches!( + candidate.opcode, + opcode if opcode == Opcode::PC.to_byte() + || opcode == Opcode::CODESIZE.to_byte() + || opcode == Opcode::CODECOPY.to_byte() + ) + }) + { + continue; + } + candidates.push((copy.pc, destination_depth_after_copy, block_end)); + } + + if candidates.len() != 1 { + return Err(Error::Generic(format!( + "expected one constructor argument CODECOPY for creation length 0x{creation_len:x}, found {}", + candidates.len() + ))); + } + Ok(candidates[0]) +} + +fn emit_chunk_decoder( + out: &mut Vec, + destination_depth: usize, + offset: usize, + mask: [u8; 32], + rng: &mut StdRng, +) -> Result<()> { + let offset_first = offset > 0 && destination_depth < 16 && rng.random::(); + if offset_first { + emit_push_usize(out, offset); + emit_dup(out, destination_depth + 1)?; + out.push(Opcode::ADD.to_byte()); + } else { + emit_dup(out, destination_depth)?; + if offset > 0 { + emit_push_usize(out, offset); + out.push(Opcode::ADD.to_byte()); + } + } + out.push(Opcode::DUP(1).to_byte()); + out.push(Opcode::MLOAD.to_byte()); + + let chain = generate_chain( + mask, + &ChainConfig { + chain_depth: 1..=3, + inline_ratio: 1.0, + ..Default::default() + }, + rng, + ); + let mut chain = chain; + chain.scatter_locations = vec![ScatterStrategy::Inline; chain.initial_values.len()]; + let instructions = compile_chain_inline(&chain); + let encoded = encoder::encode(&instructions, &[]) + .map_err(|error| Error::EncodingError(error.to_string()))?; + out.extend_from_slice(&encoded); + out.push(Opcode::XOR.to_byte()); + out.push(Opcode::SWAP(1).to_byte()); + out.push(Opcode::MSTORE.to_byte()); + Ok(()) +} + +fn make_trampoline( + pc: usize, + target: usize, + available: usize, + rng: &mut StdRng, +) -> Result> { + let mut variants = Vec::new(); + if target <= u16::MAX as usize && available >= 4 { + variants.push(0u8); // direct PUSH2 + } + if target <= 0x00ff_ffff && available >= 5 { + variants.push(1u8); // direct PUSH3 + } + if target >= pc && target - pc <= u16::MAX as usize && available >= 6 { + variants.push(2u8); // PC-relative ADD + } + if target <= u16::MAX as usize && available >= 8 { + variants.push(3u8); // split XOR + variants.push(4u8); // split SUB + } + if variants.is_empty() { + return Err(Error::Generic( + "constructor argument copy block cannot hold a decoder trampoline".into(), + )); + } + let variant = variants[rng.random_range(0..variants.len())]; + + let mut out = Vec::new(); + match variant { + 0 => emit_push_width(&mut out, target, 2), + 1 => emit_push_width(&mut out, target, 3), + 2 => { + out.push(Opcode::PC.to_byte()); + emit_push_width(&mut out, target - pc, 2); + out.push(Opcode::ADD.to_byte()); + } + 3 => { + let lhs = rng.random::() as usize; + emit_push_width(&mut out, lhs, 2); + emit_push_width(&mut out, lhs ^ target, 2); + out.push(Opcode::XOR.to_byte()); + } + 4 => { + let max_salt = u16::MAX as usize - target; + let salt = rng.random_range(0..=max_salt); + emit_push_width(&mut out, salt, 2); + emit_push_width(&mut out, target + salt, 2); + out.push(Opcode::SUB.to_byte()); + } + _ => unreachable!(), + } + out.push(Opcode::JUMP.to_byte()); + Ok(out) +} + +fn emit_push_width(out: &mut Vec, value: usize, width: usize) { + out.push(Opcode::PUSH(width as u8).to_byte()); + for index in 0..width { + let shift = (width - 1 - index) * 8; + out.push(((value >> shift) & 0xff) as u8); + } +} + +fn emit_dup(out: &mut Vec, depth: usize) -> Result<()> { + if !(1..=16).contains(&depth) { + return Err(Error::StackOverflow); + } + out.push(Opcode::DUP(depth as u8).to_byte()); + Ok(()) +} + +fn emit_push_usize(out: &mut Vec, value: usize) { + if value == 0 { + out.push(Opcode::PUSH0.to_byte()); + return; + } + let width = ((usize::BITS - value.leading_zeros()) as usize).div_ceil(8); + out.push(Opcode::PUSH(width as u8).to_byte()); + for index in 0..width { + let shift = (width - 1 - index) * 8; + out.push(((value >> shift) & 0xff) as u8); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::obfuscator::{obfuscate_bytecode, ObfuscationConfig}; + use azoth_core::detection::locate_sections; + use azoth_core::seed::Seed; + use azoth_core::strip::strip_bytecode; + use rand::RngCore; + use revm::bytecode::Bytecode; + use revm::context::result::{ExecutionResult, Output}; + use revm::context::TxEnv; + use revm::database::InMemoryDB; + use revm::primitives::{Address, Bytes, TxKind, U256}; + use revm::state::AccountInfo; + use revm::{Context, ExecuteEvm, MainBuilder, MainContext}; + use std::time::Instant; + + const ESCROW_DEPLOYMENT: &str = + include_str!("../../../examples/escrow-bytecode/artifacts/erc20_deployment.hex"); + const ESCROW_RUNTIME: &str = + include_str!("../../../examples/escrow-bytecode/artifacts/erc20_runtime.hex"); + const MOCK_TOKEN: Address = Address::new([0x11; 20]); + + fn argument_words(recipient: [u8; 20], amount: [u8; 32], payment: [u8; 32]) -> Vec { + let mut args = Vec::with_capacity(160); + args.extend_from_slice(&[0; 12]); + args.extend_from_slice(MOCK_TOKEN.as_slice()); + args.extend_from_slice(&[0; 12]); + args.extend_from_slice(&recipient); + args.extend_from_slice(&amount); + args.extend_from_slice(&[0; 32]); + args.extend_from_slice(&payment); + args + } + + fn creation_with_args(args: &[u8]) -> Vec { + let mut deployment = + hex::decode(ESCROW_DEPLOYMENT.trim().trim_start_matches("0x")).unwrap(); + deployment.extend_from_slice(args); + deployment + } + + fn apply_mask(deployment: &[u8], seed: &Seed) -> (Vec, ConstructorArgsObfuscation) { + let runtime = hex::decode(ESCROW_RUNTIME.trim().trim_start_matches("0x")).unwrap(); + let sections = locate_sections(deployment, &[], &runtime).unwrap(); + let (clean, mut report) = strip_bytecode(deployment, §ions).unwrap(); + let metrics = obfuscate_constructor_args(&mut report, seed.as_bytes()).unwrap(); + (report.reassemble_checked(&clean).unwrap(), metrics) + } + + fn deploy(bytecode: &[u8]) -> (Bytes, u64) { + let mut db = InMemoryDB::default(); + db.insert_account_info( + MOCK_TOKEN, + AccountInfo { + balance: U256::ZERO, + nonce: 1, + code_hash: revm::primitives::KECCAK_EMPTY, + code: Some(Bytecode::new_raw(Bytes::from_static(&[ + 0x60, 0x01, 0x60, 0x00, 0x52, 0x60, 0x20, 0x60, 0x00, 0xf3, + ]))), + }, + ); + 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, + }, + ); + let mut evm = Context::mainnet().with_db(db).build_mainnet(); + let result = evm + .transact(TxEnv { + caller: deployer, + gas_limit: 30_000_000, + kind: TxKind::Create, + data: bytecode.to_vec().into(), + value: U256::ZERO, + ..Default::default() + }) + .unwrap(); + match result.result { + ExecutionResult::Success { + output: Output::Create(runtime, Some(_)), + gas_used, + .. + } => (runtime, gas_used), + other => panic!("deployment failed: {other:?}"), + } + } + + #[test] + fn push_encoder_uses_minimal_width() { + let mut encoded = Vec::new(); + emit_push_usize(&mut encoded, 0x1234); + assert_eq!(encoded, vec![0x61, 0x12, 0x34]); + } + + #[test] + fn decoder_rejects_ambiguous_copy_sites() { + let error = find_argument_copy(&[0x60, 0x01, 0x00], 1).unwrap_err(); + assert!(error.to_string().contains("found 0")); + } + + #[test] + fn masked_constructor_deploys_identical_runtime_without_plaintext_suffix() { + let args = argument_words([0x22; 20], [0; 32], [0; 32]); + let original = creation_with_args(&args); + let seed = Seed::from_bytes([0x55; 32]); + let (masked, metrics) = apply_mask(&original, &seed); + + assert!(metrics.applied); + assert_eq!(metrics.argument_bytes, args.len()); + assert!(metrics.decoder_bytes > 0); + assert!( + !masked.windows(args.len()).any(|window| window == args), + "the ABI suffix must not survive verbatim" + ); + assert!(!masked.windows(5).any(|window| window == b"AZOTH")); + assert_eq!(deploy(&original).0, deploy(&masked).0); + + let (repeat, _) = apply_mask(&original, &seed); + assert_eq!(masked, repeat, "same seed must be deterministic"); + let (different, _) = apply_mask(&original, &Seed::from_bytes([0x56; 32])); + assert_ne!(masked, different, "different seeds must vary the payload"); + } + + #[test] + fn fuzzed_constructor_values_preserve_deployed_runtime() { + let mut rng = StdRng::seed_from_u64(0xA207_2026); + for case in 0..64u64 { + let mut recipient = [0u8; 20]; + let mut amount = [0u8; 32]; + let mut payment = [0u8; 32]; + let mut seed = [0u8; 32]; + rng.fill_bytes(&mut recipient); + rng.fill_bytes(&mut amount); + rng.fill_bytes(&mut payment); + rng.fill_bytes(&mut seed); + + let mut args = argument_words(recipient, amount, payment); + // Exercise decoder scaling and partial final words. Case zero uses the report-sized + // 704-byte suffix; the remaining cases cover arbitrary trailing lengths. + let trailing_len = if case == 0 { + 704 - args.len() + } else { + rng.random_range(0..=544) + }; + let mut trailing = vec![0u8; trailing_len]; + rng.fill_bytes(&mut trailing); + args.extend_from_slice(&trailing); + let original = creation_with_args(&args); + let (masked, metrics) = apply_mask(&original, &Seed::from_bytes(seed)); + assert!(metrics.applied, "case {case}"); + assert_eq!(metrics.argument_bytes, args.len(), "case {case}"); + assert!( + !masked.windows(args.len()).any(|window| window == args), + "plaintext suffix survived fuzz case {case}" + ); + assert_eq!( + deploy(&original).0, + deploy(&masked).0, + "deployed runtime mismatch in fuzz case {case}" + ); + } + } + + #[tokio::test] + async fn full_pipeline_preserves_constructor_initialized_runtime() { + let recipient = [0x22; 20]; + let amount = [0x33; 32]; + let args = argument_words(recipient, amount, [0; 32]); + let full_creation = creation_with_args(&args); + let full_hex = format!("0x{}", hex::encode(&full_creation)); + let seed = Seed::from_bytes([0x77; 32]); + + let protected = obfuscate_bytecode( + &full_hex, + ESCROW_RUNTIME, + ObfuscationConfig::with_seed(seed), + ) + .await + .unwrap(); + let protected_creation = + hex::decode(protected.obfuscated_bytecode.trim_start_matches("0x")).unwrap(); + + assert!(protected.metadata.constructor_args_obfuscated); + assert_eq!(protected.metadata.constructor_argument_bytes, args.len()); + assert!(!protected_creation + .windows(args.len()) + .any(|window| window == args)); + assert!(!protected_creation + .windows(recipient.len()) + .any(|window| window == recipient)); + assert!(!protected_creation + .windows(amount.len()) + .any(|window| window == amount)); + let protected_runtime = deploy(&protected_creation).0; + assert!( + protected_runtime + .windows(recipient.len()) + .any(|window| window == recipient), + "decoded recipient must be written into transformed immutable references" + ); + assert!( + protected_runtime + .windows(amount.len()) + .any(|window| window == amount), + "decoded amount must be written into transformed immutable references" + ); + } + + #[tokio::test] + async fn full_pipeline_rejects_oversized_decoded_creation_payload() { + let mut args = argument_words([0x22; 20], [0x33; 32], [0; 32]); + args.resize(10_000, 0x5a); + let full_hex = format!("0x{}", hex::encode(creation_with_args(&args))); + + let error = obfuscate_bytecode( + &full_hex, + ESCROW_RUNTIME, + ObfuscationConfig::with_seed(Seed::from_bytes([0x88; 32])), + ) + .await + .unwrap_err(); + + assert!(error.message.contains("exceeds an EVM size limit")); + } + + #[test] + #[ignore = "release-mode benchmark; run explicitly with --ignored --nocapture"] + fn benchmark_constructor_argument_obfuscation() { + let args = argument_words([0x22; 20], [0x33; 32], [0x44; 32]); + let original = creation_with_args(&args); + let (original_runtime, original_gas) = deploy(&original); + let iterations = 100u64; + let started = Instant::now(); + let mut decoder_bytes = 0usize; + let mut min_decoder = usize::MAX; + let mut max_decoder = 0usize; + let mut representative = None; + + for index in 0..iterations { + let mut seed = [0u8; 32]; + seed[..8].copy_from_slice(&index.to_be_bytes()); + let (masked, metrics) = apply_mask(&original, &Seed::from_bytes(seed)); + decoder_bytes += metrics.decoder_bytes; + min_decoder = min_decoder.min(metrics.decoder_bytes); + max_decoder = max_decoder.max(metrics.decoder_bytes); + representative.get_or_insert(masked); + } + let elapsed = started.elapsed(); + let masked = representative.unwrap(); + let (masked_runtime, masked_gas) = deploy(&masked); + assert_eq!(original_runtime, masked_runtime); + + let calldata_gas = |bytes: &[u8]| -> u64 { + bytes + .iter() + .map(|byte| if *byte == 0 { 4 } else { 16 }) + .sum() + }; + println!( + "BENCH original_bytes={} masked_bytes={} delta_bytes={} original_deploy_gas={} \ + masked_deploy_gas={} delta_deploy_gas={} original_calldata_gas={} \ + masked_calldata_gas={} avg_decoder_bytes={:.1} min_decoder_bytes={} \ + max_decoder_bytes={} avg_transform_us={:.1}", + original.len(), + masked.len(), + masked.len() as i64 - original.len() as i64, + original_gas, + masked_gas, + masked_gas as i64 - original_gas as i64, + calldata_gas(&original), + calldata_gas(&masked), + decoder_bytes as f64 / iterations as f64, + min_decoder, + max_decoder, + elapsed.as_micros() as f64 / iterations as f64, + ); + } +} diff --git a/crates/transforms/src/lib.rs b/crates/transforms/src/lib.rs index d15a1cdc..bba9e70f 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 constructor_args; 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 f69228da..e363c518 100644 --- a/crates/transforms/src/obfuscator.rs +++ b/crates/transforms/src/obfuscator.rs @@ -1,4 +1,5 @@ use crate::arithmetic_chain::ArithmeticChain; +use crate::constructor_args::obfuscate_constructor_args; use crate::function_dispatcher::FunctionDispatcher; use crate::push_split::PushSplit; use crate::slot_shuffle::SlotShuffle; @@ -13,6 +14,9 @@ use serde::{Deserialize, Serialize}; use serde_json::json; use std::collections::{HashMap, HashSet}; +const MAX_INITCODE_SIZE: usize = 49_152; +const MAX_RUNTIME_CODE_SIZE: usize = 24_576; + /// Error from the obfuscation pipeline, including a partial trace for debugging. #[derive(Debug)] pub struct ObfuscationError { @@ -127,6 +131,15 @@ pub struct ObfuscationMetadata { pub size_limit_exceeded: bool, /// Whether unknown opcodes were preserved pub unknown_opcodes_preserved: bool, + /// Whether an exact constructor-argument suffix was masked and decoded during init. + #[serde(default)] + pub constructor_args_obfuscated: bool, + /// Number of constructor-argument bytes masked in the creation payload. + #[serde(default)] + pub constructor_argument_bytes: usize, + /// Number of seed-varied decoder bytes inserted into init code. + #[serde(default)] + pub constructor_decoder_bytes: usize, } /// Main obfuscation pipeline @@ -666,8 +679,26 @@ pub async fn obfuscate_bytecode( } } + // Step 7c: Mask an exact constructor-argument suffix and inject a seed-varied decoder. + // This runs after init immutable patching so its insertion can remap all existing init jumps + // once. It fails closed when arguments exist but their copy site is unsupported. + let constructor_args = + obfuscate_constructor_args(&mut cfg_ir.clean_report, config.seed.as_bytes()) + .map_err(|e| ObfuscationError::from_err(e, &cfg_ir.trace))?; + if constructor_args.applied { + transforms_applied.push("ConstructorArgs".to_string()); + tracing::debug!( + " Obfuscated {} constructor argument bytes with a {}-byte decoder", + constructor_args.argument_bytes, + constructor_args.decoder_bytes + ); + } + // Step 8: Reassemble final bytecode (init + runtime with data section + auxdata) - let final_bytecode = cfg_ir.clean_report.reassemble(&obfuscated_bytes); + let final_bytecode = cfg_ir + .clean_report + .reassemble_checked(&obfuscated_bytes) + .map_err(|error| ObfuscationError::from_err(error, &cfg_ir.trace))?; let obfuscated_size = final_bytecode.len(); // CRITICAL DEBUGGING: Compare final bytecode to original @@ -713,14 +744,35 @@ pub async fn obfuscate_bytecode( tracing::debug!(" Obfuscated gas: {}", obfuscated_gas); tracing::debug!(" Gas delta: {:+}", gas_delta); - // Step 10: Check size limits + // Step 10: Enforce protocol size limits. Constructor arguments are part of the creation + // transaction's initcode for EIP-3860 accounting, while compiler auxdata is part of the + // EIP-170 deployed-code limit. let size_increase_percentage = if original_size > 0 { ((obfuscated_size as f64 - original_size as f64) / original_size as f64) * 100.0 } else { 0.0 }; - - let size_limit_exceeded = false; + let deployed_suffix_size: usize = sections + .iter() + .filter(|section| { + matches!( + section.kind, + detection::SectionKind::Auxdata | detection::SectionKind::Padding + ) + }) + .map(|section| section.len) + .sum(); + let deployed_runtime_size = obfuscated_bytes.len() + deployed_suffix_size; + let size_limit_exceeded = + obfuscated_size > MAX_INITCODE_SIZE || deployed_runtime_size > MAX_RUNTIME_CODE_SIZE; + if size_limit_exceeded { + return Err(ObfuscationError::from_err( + format!( + "obfuscated bytecode exceeds an EVM size limit: initcode {obfuscated_size}/{MAX_INITCODE_SIZE} bytes, deployed runtime {deployed_runtime_size}/{MAX_RUNTIME_CODE_SIZE} bytes" + ), + &cfg_ir.trace, + )); + } // Step 11: Build result tracing::debug!("=== Building ObfuscationResult ==="); @@ -760,6 +812,9 @@ pub async fn obfuscate_bytecode( transforms_applied, size_limit_exceeded, unknown_opcodes_preserved: config.preserve_unknown_opcodes, + constructor_args_obfuscated: constructor_args.applied, + constructor_argument_bytes: constructor_args.argument_bytes, + constructor_decoder_bytes: constructor_args.decoder_bytes, }, selector_mapping: cfg_ir.selector_mapping, trace, @@ -870,6 +925,12 @@ pub fn print_obfuscation_analysis(result: &ObfuscationResult) { if result.instructions_added > 0 { println!("Instructions added: {}", result.instructions_added); } + if result.metadata.constructor_args_obfuscated { + println!( + "Constructor arguments: {} bytes obfuscated, {} decoder bytes", + result.metadata.constructor_argument_bytes, result.metadata.constructor_decoder_bytes + ); + } // Print success summary if result.unknown_opcodes_count > 0 { @@ -904,6 +965,9 @@ pub fn create_gas_report(result: &ObfuscationResult) -> serde_json::Value { "blocks_created": result.blocks_created, "instructions_added": result.instructions_added, "transforms_applied": result.metadata.transforms_applied, + "constructor_args_obfuscated": result.metadata.constructor_args_obfuscated, + "constructor_argument_bytes": result.metadata.constructor_argument_bytes, + "constructor_decoder_bytes": result.metadata.constructor_decoder_bytes, "notes": if result.unknown_opcodes_count > 0 { "Unknown opcodes were preserved as raw bytes to maintain functionality" } else { diff --git a/docs/constructor-argument-obfuscation.md b/docs/constructor-argument-obfuscation.md new file mode 100644 index 00000000..e61fb647 --- /dev/null +++ b/docs/constructor-argument-obfuscation.md @@ -0,0 +1,68 @@ +# Constructor-argument obfuscation report + +## Executive report + +Azoth now removes the report's literal constructor-tail disclosure without changing the input Solidity, source bytecode, ABI, or deployed contract behavior. The deployment runtime supplied to Azoth defines the boundary exactly; all bytes after that complete runtime are masked, and seed-varied init code restores them in memory before the original constructor continues. The returned creation payload therefore no longer contains the original ABI suffix verbatim. + +This is the strongest honest Azoth-only response to `mirage-adversarial-privacy-report.md`. Constructor code and transaction input are public, so bytecode-only obfuscation cannot provide cryptographic confidentiality: a capable analyst can execute the init code or reverse its data flow, and any constructor value later written to public runtime code, storage, logs, calls, or proofs remains observable there. The change specifically raises the report's zero-effort static ABI-tail recovery into a program-analysis problem. It does not claim to solve the report's public-state or proof-disclosure findings, and it does not alter the report's CBOR metadata fingerprint finding. + +The implementation introduces no ABI-shaped heuristic and emits no Azoth marker, version header, fixed key, or fixed decoder byte string. The full runtime is already a required Azoth input and is used as an authoritative boundary. Decoder chunk order, arithmetic constants, instruction chains, and trampoline form are seed-derived. As with any public program transformation, a semantic classifier may still recognize self-decoding behavior; no non-ZK construction can honestly guarantee otherwise. + +Safety is fail-closed. If the supplied runtime is absent or ambiguous, if the constructor has no single supported argument-copy site, if the trampoline cannot preserve existing init-code program counters, or if the result exceeds EIP-170/EIP-3860 limits, obfuscation returns an error instead of exposing plaintext arguments or emitting known-undeployable output. + +## Technical report + +### Root cause + +The previous pipeline treated constructor data as untouched recovery material. Section detection also examined the end of the whole creation payload for Solidity CBOR metadata even though constructor arguments follow the compiler-generated creation bytecode. ABI words could therefore be mistaken for metadata, while the real argument suffix was reassembled unchanged. In the reported deployment this made all six recipient/token/amount rows recoverable by reading aligned words at the tail; no EVM analysis was necessary. + +### Design and implementation + +The fix has four cooperating parts: + +1. **Exact section boundaries.** The complete caller-supplied runtime must occur exactly once in the deployment payload. Its start separates init from runtime, its own CBOR trailer is split as auxdata, and every byte after its end is classified as `ConstructorArgs`. This is byte-exact and works for static, dynamic, packed-looking, all-zero, and adversarial argument values without ABI guessing. + +2. **Seed-derived masking.** The argument suffix is divided into 32-byte chunks and XOR-masked byte-for-byte. Masks are derived deterministically from the Azoth seed, a domain separator, and the argument length. Chunk order is shuffled, and each mask is synthesized with the existing arithmetic-chain vocabulary rather than stored as one direct key constant. + +3. **Init-code decoding.** Azoth locates one exact Solidity-style `CODESIZE - creation_length` argument `CODECOPY`. It replaces bytes inside that basic block with a trampoline and appends a decoder to init code. The decoder performs the original copy, unmasks memory in seed-shuffled order, then replays the displaced original instructions. Existing init jump destinations do not move. Direct, PC-relative, XOR-split, and SUB-split trampoline forms are selected by seed and available space. PC-sensitive displaced blocks are rejected. + +4. **Correct recovery.** Reassembly now distinguishes deployed suffixes such as CBOR auxdata from transaction-only constructor arguments. Runtime `CODECOPY`/`RETURN` lengths exclude the arguments, creation offsets account for decoder growth, and the constructor's original creation-length constant is patched. Immutable-reference offsets continue to be remapped after runtime transforms. The final payload is checked against the 24,576-byte EIP-170 runtime limit and 49,152-byte EIP-3860 initcode limit. + +The transform is automatically applied when an argument suffix exists. Callers may pass a full creation payload to `-D`, or pass compiler creation bytecode plus `--constructor-args `. Result metadata exposes `constructor_args_obfuscated`, `constructor_argument_bytes`, and `constructor_decoder_bytes` so release tooling can enforce that expected sensitive inputs were actually handled. + +### Soundness and adversarial verification + +| Check | Scope | Result | +|---|---:|---:| +| Core, transform, and CLI tests | 105 tests | Passed; 1 explicit benchmark ignored in normal runs | +| Focused randomized differential test | 64 argument/seed/length cases; 128 REVM deployments | Byte-for-byte identical deployed runtimes; includes a 704-byte report-sized suffix and partial final words | +| Full-pipeline immutable test | Runtime transforms plus constructor decoding | Decoded recipient and amount reached remapped immutable locations | +| Built-in parallel fuzz campaign | 1,000 successful cases | 0 errors, 0 deployment mismatches, 0 saved crashes; 12.4 s (81.3 iterations/s) | +| Plaintext oracle | Every escrow fuzz case | Complete 160-byte suffix and each nonzero address/amount ABI word absent from output | +| Determinism/diversity | Repeated and distinct seeds | Same seed reproduced output; different seed changed it | +| Static analysis | `cargo clippy ... -D warnings` | Passed | +| Formatting | `cargo fmt --all -- --check` | Passed | + +The built-in campaign randomly varied seeds, constructor recipients and amounts, and transform selections. Its existing REVM oracle required every transformed payload whose original deployed successfully to deploy successfully as well. The focused differential test supplied the stronger byte-for-byte deployed-runtime comparison. Unsupported and ambiguous copy layouts have explicit rejection tests. + +The full workspace build reaches the external Z3-backed verification crate but cannot compile it in the current environment because the system `z3.h` header is not installed. This is an environment prerequisite, not a failure in the changed core/transform/CLI crates; those crates compile and test cleanly. + +### Benchmark + +Fixture: the repository's Solidity 0.8.30 ERC20 escrow with a 160-byte constructor suffix. Measurements use a release build and REVM. Size/gas deltas compare the original creation payload with the constructor-mask-only result for one representative deterministic seed; decoder distribution and transform time cover 100 deterministic seeds. + +| Metric | Before | After | Delta | +|---|---:|---:|---:| +| Creation payload | 9,129 B | 9,700 B | +571 B (+6.26%) | +| REVM deployment gas | 1,861,722 | 1,869,364 | +7,642 (+0.410%) | +| Intrinsic calldata gas | 139,224 | 146,608 | +7,384 (+5.30%) | +| Deployed runtime | baseline | byte-for-byte identical | 0 B | +| Verbatim 160-byte ABI suffix | present | absent | removed | + +Across 100 seeds, decoder size averaged 569.7 bytes (433 minimum, 711 maximum), and masking averaged 124.1 microseconds per creation payload in the release benchmark. Cost grows approximately with the number of 32-byte chunks; these figures should not be extrapolated as measurements of the report's larger multi-row payload without benchmarking that exact fixture. + +### Security boundary and rollout guidance + +This mitigation closes the report's direct plaintext-suffix extraction path. It does not encrypt transaction calldata, hide values after the EVM decodes them, suppress storage/log/call/proof disclosures, remove Solidity CBOR metadata, or prevent dynamic/symbolic recovery. Teams requiring confidentiality from validators, archive nodes, or skilled reverse engineers need a cryptographic protocol change, which was explicitly outside this work. + +For rollout, require `constructor_args_obfuscated: true` whenever an expected deployment has constructor inputs, retain differential deployment testing for each production compiler/version, and treat a fail-closed unsupported-layout error as a release blocker. Re-run the benchmark on the exact production constructor payload because decoder overhead is argument-length dependent. diff --git a/tests/src/transforms/function_dispatcher.rs b/tests/src/transforms/function_dispatcher.rs index f4b40b0d..773fa7cc 100644 --- a/tests/src/transforms/function_dispatcher.rs +++ b/tests/src/transforms/function_dispatcher.rs @@ -168,8 +168,11 @@ async fn test_counter_dispatcher_detection() { .without_time() .try_init(); + // COUNTER_BYTECODE is creation bytecode. Its runtime begins at the CODECOPY offset encoded + // by the compiler, 0x1c, and must be supplied separately to preserve runtime-relative PCs. + let counter_runtime = &COUNTER_BYTECODE[2 + 0x1c * 2..]; let (_, instructions, sections, _) = - process_bytecode_to_cfg(COUNTER_BYTECODE, false, COUNTER_BYTECODE, false) + process_bytecode_to_cfg(COUNTER_BYTECODE, false, counter_runtime, false) .await .unwrap(); @@ -197,7 +200,7 @@ async fn test_counter_dispatcher_detection() { let result = obfuscate_bytecode( COUNTER_BYTECODE, - COUNTER_BYTECODE, + counter_runtime, ObfuscationConfig::default(), ) .await