Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions crates/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,13 @@ Applies obfuscation transformations to bytecode.
azoth obfuscate -D <DEPLOYMENT_BYTECODE> -R <RUNTIME_BYTECODE>
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 <BYTECODE>` - Input deployment bytecode (required)
- `-R, --runtime <BYTECODE>` - Input runtime bytecode (required)
- `--constructor-args <HEX>` - ABI-encoded constructor suffix to append and obfuscate; omit when `-D` already contains it
- `--seed <value>` - Cryptographic seed for deterministic obfuscation
- `--passes <list>` - Comma-separated list of transforms (default: shuffle)
- `--emit <file>` - Path to write gas/size report as JSON
Expand All @@ -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.

Expand Down
92 changes: 72 additions & 20 deletions crates/cli/src/commands/fuzz.rs
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ impl FuzzInput {
enum ErrorKind {
Obfuscation,
Validation,
ConstructorArgsVisible,
DeploymentMismatch { original: usize, obfuscated: usize },
}

Expand All @@ -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,
Expand Down Expand Up @@ -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<Vec<u8>> {
fn prepare_escrow_bytecode(deployment_hex: &str, seed: [u8; 32]) -> Option<Vec<u8>> {
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)
}

Expand All @@ -317,9 +325,9 @@ fn prepare_counter_bytecode(deployment_hex: &str) -> Option<Vec<u8>> {
hex::decode(normalized).ok()
}

fn prepare_bytecode(contract: Contract, deployment_hex: &str) -> Option<Vec<u8>> {
fn prepare_bytecode(contract: Contract, deployment_hex: &str, seed: [u8; 32]) -> Option<Vec<u8>> {
match contract {
Contract::Escrow => prepare_escrow_bytecode(deployment_hex),
Contract::Escrow => prepare_escrow_bytecode(deployment_hex, seed),
Contract::Counter => prepare_counter_bytecode(deployment_hex),
}
}
Expand Down Expand Up @@ -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,
Expand All @@ -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") {
Expand All @@ -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(),
Expand Down
12 changes: 11 additions & 1 deletion crates/cli/src/commands/obfuscate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// Cryptographic seed for deterministic obfuscation.
#[arg(long)]
seed: Option<String>,
Expand All @@ -50,6 +54,7 @@ impl super::Command for ObfuscateArgs {
let ObfuscateArgs {
deployment_bytecode,
runtime_bytecode,
constructor_args,
seed,
passes,
emit,
Expand All @@ -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)?;
Expand Down
124 changes: 124 additions & 0 deletions crates/core/src/detection/sections.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,16 @@ pub fn locate_sections(
instructions: &[Instruction],
runtime_bytes: &[u8],
) -> Result<Vec<Section>, 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(&sections, deployment_bytes.len())?;
return Ok(sections);
}

let mut sections = Vec::new();
let total_len = deployment_bytes.len();

Expand Down Expand Up @@ -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<Vec<Section>, Error> {
if runtime_bytes.is_empty() || runtime_bytes.len() > deployment_bytes.len() {
return Err(Error::SuppliedRuntimeNotFound);
}

let matches: Vec<usize> = 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],
Expand Down Expand Up @@ -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)));
}
}
8 changes: 8 additions & 0 deletions crates/core/src/result.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Loading
Loading