From 0fede45090b8ad1c560c7fc6abc35a8f09a30b13 Mon Sep 17 00:00:00 2001 From: adrienlacombe <6303520+adrienlacombe@users.noreply.github.com> Date: Thu, 10 Sep 2026 07:49:56 +0200 Subject: [PATCH] Enforce witness entry and per-instruction stack limits --- src/lib.rs | 37 ++++++- tests/resource_limits.rs | 225 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 257 insertions(+), 5 deletions(-) create mode 100644 tests/resource_limits.rs diff --git a/src/lib.rs b/src/lib.rs index a237437..d0cfe31 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -86,7 +86,8 @@ pub struct Options { pub verify_csv: bool, /// Verify conditionals are minimally encoded. pub verify_minimal_if: bool, - /// Enfore a strict limit of 1000 total stack items. + /// Enforce a strict limit of 1000 total stack items after each instruction, + /// and at entry in the Tapscript context. pub enforce_stack_limit: bool, pub experimental: Experimental, @@ -299,6 +300,8 @@ impl Exec { if let Ok(exec) = &mut ret { exec.stack = stack; exec.altstack = altstack; + // The supplied stacks replace the witness as the initial state. + exec.stats.max_nb_stack_items = exec.stack.len() + exec.altstack.len(); } ret } @@ -480,6 +483,26 @@ impl Exec { return Err(res); } + if self.script_position() == 0 { + // ExecuteWitnessScript checks the initial item count for Tapscript + // only, and the initial element sizes for both witness versions. + if self.ctx == ExecCtx::Tapscript + && self.opt.enforce_stack_limit + && self.stack.len() + self.altstack.len() > MAX_STACK_SIZE + { + return self.fail(ExecError::StackSize); + } + if self.ctx != ExecCtx::Legacy + && self + .stack + .iter_str() + .chain(self.altstack.iter_str()) + .any(|item| item.len() > MAX_SCRIPT_ELEMENT_SIZE) + { + return self.fail(ExecError::PushSize); + } + } + self.current_position = self.script.len() - self.instructions.as_script().len(); let instruction = match self.instructions.next() { Some(Ok(i)) => i, @@ -541,6 +564,14 @@ impl Exec { } self.update_stats(); + // This must also cover PushBytes (including OP_0), which never calls + // exec_opcode. Record the peak before rejecting the overflowing step. + if self.opt.enforce_stack_limit && self.stack.len() + self.altstack.len() > MAX_STACK_SIZE { + return match instruction { + Instruction::Op(op) => self.failop(ExecError::StackSize, op), + Instruction::PushBytes(_) => self.fail(ExecError::StackSize), + }; + } Ok(()) } @@ -1020,10 +1051,6 @@ impl Exec { _ => return Err(ExecError::BadOpcode), } - if self.opt.enforce_stack_limit && self.stack.len() + self.altstack.len() > MAX_STACK_SIZE { - return Err(ExecError::StackSize); - } - Ok(()) } diff --git a/tests/resource_limits.rs b/tests/resource_limits.rs new file mode 100644 index 0000000..dbe46da --- /dev/null +++ b/tests/resource_limits.rs @@ -0,0 +1,225 @@ +//! Raw bytecode preserves temporary overflows that a script optimizer could erase. +//! Entry rules follow ExecuteWitnessScript, and per-instruction rules EvalScript: +//! https://github.com/bitcoin/bitcoin/blob/d0f6d9953a15d7c7111d46dcb76ab2bb18e5dee3/src/script/interpreter.cpp +//! These tests do not claim complete consensus compatibility (including OP_SUCCESSx). + +use bitcoin::{hashes::Hash, opcodes::all::OP_PUSHNUM_1, ScriptBuf, TapLeafHash, Transaction}; +use bitcoin_scriptexec::{Exec, ExecCtx, ExecError, Options, Stack, TxTemplate}; + +fn transaction() -> TxTemplate { + TxTemplate { + tx: Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: bitcoin::absolute::LockTime::ZERO, + input: vec![], + output: vec![], + }, + prevouts: vec![], + input_idx: 0, + taproot_annex_scriptleaf: Some((TapLeafHash::all_zeros(), None)), + } +} + +fn executor(ctx: ExecCtx, script: Vec, witness: Vec>, limit: bool) -> Exec { + Exec::new( + ctx, + Options { + enforce_stack_limit: limit, + ..Default::default() + }, + transaction(), + ScriptBuf::from_bytes(script), + witness, + ) + .unwrap() +} + +fn run(exec: &mut Exec) { + while exec.exec_next().is_ok() {} +} + +fn drop_items(count: usize) -> Vec { + let mut bytes = vec![0x6d; count / 2]; // OP_2DROP + if count % 2 != 0 { + bytes.push(0x75); // OP_DROP + } + bytes.push(0x51); // OP_TRUE + bytes +} + +#[test] +fn tapscript_rejects_oversized_initial_stack_before_cleanup() { + for count in [1000, 1001] { + for limit in [false, true] { + let mut exec = executor( + ExecCtx::Tapscript, + drop_items(count), + vec![vec![]; count], + limit, + ); + run(&mut exec); + let rejected = limit && count > 1000; + assert_eq!( + exec.result().unwrap().error, + rejected.then_some(ExecError::StackSize) + ); + assert_eq!(exec.result().unwrap().success, !rejected); + assert_eq!(exec.stats().max_nb_stack_items, count); + if rejected { + assert_eq!(exec.script_position(), 0); + assert_eq!(exec.stack().len(), count); + } + } + } +} + +#[test] +fn tapscript_checks_entry_even_for_an_empty_script() { + let mut exec = executor(ExecCtx::Tapscript, vec![], vec![vec![]; 1001], true); + assert_eq!( + exec.exec_next().unwrap_err().error, + Some(ExecError::StackSize) + ); + assert_eq!( + exec.exec_next().unwrap_err().error, + Some(ExecError::StackSize) + ); + assert_eq!(exec.script_position(), 0); +} + +#[test] +fn pre_tapscript_count_is_checked_after_the_first_instruction() { + for ctx in [ExecCtx::Legacy, ExecCtx::SegwitV0] { + // ExecuteWitnessScript has no entry item-count check for SegwitV0. + // Inspect a single step: a complete SegwitV0 leaf would also face its + // 201-opcode limit when attempting to clean up the remaining items. + let mut exec = executor(ctx, vec![0x75], vec![vec![]; 1001], true); + assert!(exec.exec_next().is_ok()); + assert_eq!(exec.stack().len(), 1000); + assert_eq!(exec.stats().max_nb_stack_items, 1001); + let mut exec = executor(ctx, vec![0x61], vec![vec![]; 1001], true); // OP_NOP + assert_eq!( + exec.exec_next().unwrap_err().error, + Some(ExecError::StackSize) + ); + } +} + +#[test] +fn witness_element_sizes_are_checked_independently_of_the_item_limit() { + for ctx in [ExecCtx::Legacy, ExecCtx::SegwitV0, ExecCtx::Tapscript] { + for size in [520, 521] { + for limit in [false, true] { + let mut exec = executor(ctx, drop_items(1), vec![vec![0x42; size]], limit); + run(&mut exec); + // A Legacy initial stack is supplied by the caller, not a + // witness. Its actual script pushes still enforce 520 bytes. + let rejected = ctx != ExecCtx::Legacy && size > 520; + assert_eq!( + exec.result().unwrap().error, + rejected.then_some(ExecError::PushSize) + ); + assert_eq!(exec.result().unwrap().success, !rejected); + if rejected { + assert_eq!(exec.script_position(), 0); + assert_eq!(exec.stack().len(), 1); + } + } + } + } +} + +#[test] +fn data_and_empty_pushes_enforce_the_limit_in_every_context() { + for ctx in [ExecCtx::Legacy, ExecCtx::SegwitV0, ExecCtx::Tapscript] { + for push in [vec![0x00], vec![0x01, 0x11]] { + for count in [999, 1000] { + for limit in [false, true] { + let mut bytes = push.clone(); + bytes.push(0x75); // OP_DROP cannot hide the preceding overflow. + let mut exec = executor(ctx, bytes, vec![vec![]; count], limit); + let rejected = limit && count == 1000; + assert_eq!(exec.exec_next().is_err(), rejected); + assert_eq!(exec.stats().max_nb_stack_items, count + 1); + assert_eq!(exec.stack().len(), count + 1); + if rejected { + assert_eq!(exec.result().unwrap().error, Some(ExecError::StackSize)); + assert_eq!(exec.remaining_script().as_bytes(), &[0x75]); + } else { + assert!(exec.exec_next().is_ok()); + assert_eq!(exec.stack().len(), count); + } + } + } + } + } +} + +#[test] +fn data_push_limit_includes_the_altstack() { + for ctx in [ExecCtx::Legacy, ExecCtx::SegwitV0, ExecCtx::Tapscript] { + // Main stack reaches 1000; the altstack still contains one live item. + let mut exec = executor( + ctx, + vec![0x6b, 0x01, 0x11, 0x01, 0x12], + vec![vec![]; 999], + true, + ); + assert!(exec.exec_next().is_ok()); + assert!(exec.exec_next().is_ok()); + assert_eq!( + exec.exec_next().unwrap_err().error, + Some(ExecError::StackSize) + ); + assert_eq!(exec.stack().len(), 1000); + assert_eq!(exec.altstack().len(), 1); + assert_eq!(exec.stats().max_nb_stack_items, 1001); + } +} + +#[test] +fn numeric_push_failure_records_peak_and_opcode() { + let mut exec = executor(ExecCtx::Tapscript, vec![0x51], vec![vec![]; 1000], true); + let result = exec.exec_next().unwrap_err(); + assert_eq!(result.error, Some(ExecError::StackSize)); + assert_eq!(result.opcode, Some(OP_PUSHNUM_1)); + assert_eq!(exec.stats().max_nb_stack_items, 1001); +} + +#[test] +fn skipped_pushes_do_not_increase_the_peak() { + let mut bytes = vec![0x63, 0x01, 0x11, 0x00, 0x68]; // OP_IF ... OP_ENDIF + bytes.extend(drop_items(999)); + let mut exec = executor(ExecCtx::Tapscript, bytes, vec![vec![]; 1000], true); + run(&mut exec); + assert!(exec.result().unwrap().success); + assert_eq!(exec.stats().max_nb_stack_items, 1000); +} + +#[test] +fn replacement_stacks_define_entry_limits_and_statistics() { + for (main_count, alt_count, limit) in [(999, 1, true), (1000, 1, true), (1000, 1, false)] { + let mut exec = Exec::with_stack( + ExecCtx::Tapscript, + Options { + enforce_stack_limit: limit, + ..Default::default() + }, + transaction(), + ScriptBuf::from_bytes(vec![0x75]), + // Used for validation weight, but replaced as the execution state. + vec![vec![]; 1010], + Stack::from_u8_vec(vec![vec![]; main_count]), + Stack::from_u8_vec(vec![vec![]; alt_count]), + ) + .unwrap(); + assert_eq!(exec.stats().max_nb_stack_items, main_count + alt_count); + let rejected = limit && main_count + alt_count > 1000; + assert_eq!(exec.exec_next().is_err(), rejected); + if rejected { + assert_eq!(exec.result().unwrap().error, Some(ExecError::StackSize)); + assert_eq!(exec.script_position(), 0); + } + assert_eq!(exec.stats().max_nb_stack_items, main_count + alt_count); + } +}