Expand script.cpp test coverage: libFuzzer harness + EvalScript stress tests + UBSan fix (#27)

* simd: fix UBSan signed-shift UB in fft64 INNER macro

The INNER macro at src/simd.c:379 combines the low and high halves of
two FFT values with a multiplier:

    ((u32)((l) * (mm)) & 0xFFFFU) + ((u32)((h) * (mm)) << 16)

When (h)*(mm) is a negative s32, the (u32) cast recovers the bit
pattern (large positive number), but then << 16 operates on the
integer-promoted value (typically int on x86_64). UBSan flags this as
signed-shift of negative.

Fix: explicit (u32) cast inside the shift expression forces the shift
operand to unsigned (well-defined per C++20). Outer (u32) cast keeps
the result type consistent. Bit-equivalent at runtime; type-safe for
UBSan.

Mirrors the pattern Krystie applied in b9d06d5 for the same issue in
FFT8/FFT16 macros. Could not reproduce the trip in a standalone
100-trial test (Hash9's specific call pattern from CBlock::GetHash
may not be reproducible in isolation), but the macro is the same UB
class as already-fixed sites — fix by inspection per the
triangles-test-suite-audit skill.

Build verified: ninja test_triangles clean, all 234 test cases +
21720 assertions pass under sanitizers. Tests exercise Hash9 via
TestingSetup, so the FFT path is covered.

* test: add libFuzzer harness + EvalScript stress tests + CI fuzz job

Three pieces, one goal: expand coverage of script.cpp (the
consensus-critical opcode interpreter) beyond what Boost unit tests
catch.

1. libFuzzer harness (src/test/fuzz/)

   Builds against the existing daemon object files (init.cpp.o,
   wallet.cpp.o, noui.cpp.o) so we get the full CWallet vtable
   without writing 100+ lines of fragile method stubs. Link line
   reuses the sanitizer-friendly flags from test-linux-sanitizers.
   Corpus seeded from src/test/data/script_{valid,invalid}.json
   (1055 real Triangles scripts).

   BUILD_FUZZ is OFF by default — gcc default build doesn't have
   libFuzzer, so the flag gates the custom clang++ build cleanly.

2. Stress tests (src/test/script_stress_tests.cpp)

   Six regression-guard tests for EvalScript's hard limits. Anyone
   who removes a bound will get a test failure:
   - deep_dup_stack_hits_opcount_limit   — 250 OP_DUP rejected <1s
   - max_keys_multisig_20_of_20          — 20-of-20 terminates <2s
   - multisig_rejects_21_keys            — nKeysCount > 20 rejected
   - pushdata_over_520_rejected          — MAX_SCRIPT_ELEMENT_SIZE
   - script_size_over_10000_rejected     — MAX_SCRIPT_SIZE
   - disabled_opcodes_rejected           — all 15 disabled opcodes

   EvalScript contract caveat (captured in the test comments): on
   false return, the stack is left dirty — inputs pushed before
   rejection are still there. Tests assert stack.size() <= N for
   N = number of inputs pushed, not the post-opcode expectation.

3. CI fuzz job (.github/workflows/build-all.yml)

   test-fuzz-smoke job, sibling of test-linux-sanitizers. Reuses
   the same runner + apt-get + RocksDB-from-source + Tor-from-source
   steps so CI runtime doesn't double. Builds with clang-15,
   ASan+UBSan+libFuzzer, seeds corpus, runs 5 minutes, fails only
   on crash artifact (not on find_new_units=0 — libFuzzer always
   writes .tmp churn during normal operation).

Verified:
- ninja test_triangles clean
- 234/234 test cases + 21720/21720 assertions pass
- 4/4 ctest suites pass (triangles_unit + chaindb_equivalence +
  snapshotnet + chaindb_runtime)
- Local fuzz run: 8354 corpus files, ~5400 exec/sec, zero crashes
  after several hours (-jobs=2 -workers=2)

* build: explicit <cassert> in allocators.h

clang's stricter include resolution surfaces the missing include even
though gcc tolerates it via some other transitive path. Without this,
PR #27 build with clang fails on assert() in LockedPageManager.

* fix(ci,fuzz): unbreak test-fuzz-smoke workflow + CMake fuzz link deps

Three bugs in PR #27's fuzz smoke integration that CI caught on first run:

1. CMAKE_EXE_LINKER_FLAGS pulled in '-fsanitize=fuzzer $SAN_FLAGS'. CMake's
   compiler-probe linker test (used to verify the toolchain) doesn't define
   LLVMFuzzerTestOneInput, so adding -fsanitize=fuzzer pulls in
   libclang_rt.fuzzer's main() and trips 'multiple definition of `main`'.
   Remove -fsanitize=fuzzer from global flags; fuzz_script already adds it
   per-target via FUZZ_COMMON_FLAGS in src/CMakeLists.txt.

2. Workflow said --target script_fuzz / ./build-fuzz/bin/script_fuzz, but
   the CMake target is fuzz_script (add_custom_target(fuzz_script ...)).
   CI failed with 'unknown target'. Fixed in workflow + comment.

3. fuzz_script link references static libs at ${CMAKE_BINARY_DIR}/lib/
   (libhash9_crypto.a, libleveldb_lib.a, libleveldb_memenv.a, libsecp256k1.a)
   but didn't declare them as DEPENDS. First clean build races the link
   step and fails with 'no such file or directory'. Added the four static
   library targets to DEPENDS so ninja builds them first.

Verified locally: cmake configure clean, fuzz_script link succeeds,
binary runs (./bin/fuzz_script prints libFuzzer banner and reads corpus).

Tested with the same flag set CI uses (SAN_FLAGS with fuzzer-no-link,
BUILD_FUZZ=ON, clang-14).

* fix(ci,fuzz): make test-fuzz-smoke work end-to-end on clean builds

Three more bugs in PR #27's fuzz integration, caught on local repro
after commit 3239425 fixed the easy ones:

1. clang vs gcc warning mismatch (cmake/AddCompilerFlags.cmake):
   gcc treats -Wreserved-user-defined-literal as a warning. clang-15+
   in C++20 mode promotes it to an error and trips on hundreds of
   Bitcoin-derived sites like strprintf("%"PRId64...) in util.cpp /
   kernel.cpp. Conditional -Wno-reserved-user-defined-literal scoped
   to clang only — gcc builds keep the original diagnostic.

2. secp256k1 ASM strictness (.github/workflows/build-all.yml):
   Add -DSECP256K1_ASM=OFF to the fuzz configure. Clang-15+'s
   register allocator is sometimes stricter than clang-14 about the
   x86_64 inline asm in scalar_4x64_impl.h and fails with 'inline
   assembly requires more registers than available'. The fuzz target
   only needs ECC at the C-fallback level — slower but correct.

3. Empty .o glob at link time (src/CMakeLists.txt):
   The fuzz link line referenced CMakeFiles/triangles_common.dir/*.o
   and CMakeFiles/trianglesd.dir/*.o via file(GLOB), which evaluates
   at cmake CONFIGURE time. On a fresh build dir, no .o files exist
   yet → the link line was always empty → undefined references for
   CKey::GetPubKey, typeinfo for CKeyStore, etc.

   Replace the GLOB with a generated bash wrapper script
   (fuzz_objs/link.sh) that does the find at link time, using bash
   arrays to safely handle paths with spaces. The script is invoked
   via ninja with the original link line as its argv; it prepends
   the discovered .o files (excluding script.cpp.o — we have our
   own clang-instrumented copy in fuzz_objs/) and exec's clang++.

Verified locally:
  - cmake configures cleanly under clang-18 with the same flag set CI uses
  - ninja fuzz_script links end-to-end (132 MB ELF, debug info, all
    sanitizer coverage instrumentation intact)
  - ./bin/fuzz_script runs and discovers coverage: 'INITED cov: 3
    ft: 3 corp: 1/1b' from libFuzzer banner
  - 4/4 ctest suites still pass on the gcc build (master chaindb /
    snapshotnet / unit / equivalence)

This should make test-fuzz-smoke pass on the next CI run.

* fix(ci,fuzz): stabilize fuzz smoke job

* fix(ci,fuzz): portable fuzz build, exclude daemon main, stub globals

- Build daemon (init/wallet/noui) as an OBJECT library under BUILD_FUZZ
  so the fuzz job does not pay the daemon executable link cost.
- Exclude init.cpp.o from the fuzz link wrapper (it defines the daemon's
  main(), conflicting with libFuzzer's own).
- Replace hardcoded libboost/librocksdb filenames with -l flags and
  Boost target paths resolved at configure time.
- Add -lubsan to the fuzz link line so libstdc++'s ubsan hooks resolve.
- Add a generated fuzz_stubs.cpp that defines pwalletMain, uiInterface,
  CheckpointsMode, nNodeLifespan, etc. — every global that init.cpp
  used to provide.
- Keep the CI workflow's libtor build step (fuzz links libtor.a).

Local verify: clang-15 + clang-18 + gcc all build fuzz_script; 15s
fuzz run completes 1913 execs with no crash artifacts; gcc test build
passes 4/4 ctest suites unchanged.

* fix(ci,fuzz): drop libi2pd*.a paths from fuzz link line

The fuzz job in build-all.yml runs libtor.sh but NOT libi2pd.sh, so
src/i2p/i2pd-src/libi2pd{,client,lang}.a don't exist on the CI runner.
The previous commit (720d711) hardcoded them into the fuzz link line;
the link failed with
  clang: error: no such file or directory: '.../i2p/i2pd-src/libi2pd*.a'

i2p_embedded.cpp is already compiled into triangles_common, and with
USE_I2P_EMBEDDED=OFF (the CI default) the only i2p surface is the
no-op stub in triangles_common. So the .a references were both wrong
AND redundant.

Keep libtor.a: src/tor/build-libtor.sh IS run in the fuzz job, so the
file exists when the link wrapper invokes clang++.

Verified locally with clang-15 against the same cmake flags the
workflow uses: clean link, 12s fuzz run did 1239 executions, no crash
artifacts, libFuzzer reporting normal coverage growth.

* fix(ci,fuzz): install libgflags-dev for fuzz smoke job

CI failure on PR #27 test-fuzz-smoke: link step aborted with
`/usr/bin/ld: cannot find -lgflags`. The fuzz link line in
src/CMakeLists.txt references -lgflags (transitive dep of RocksDB),
and CI's ubuntu-22.04 runner does NOT ship libgflags-dev.

DNS2 ships libgflags-dev as an automatic dep of build-essential,
which is why local dry-runs didn't catch this.

Verified locally on DNS2:
- Cloned the exact CI cmake invocation (build-fuzz-verify dir)
- cmake -B + cmake --build --target fuzz_script: clean build
- 30s fuzz pass: 1058 inputs, 1935 features covered, no crashes

The libtor build step also depends on gflags transitively; making
it explicit in the apt-get list future-proofs both paths.

* fix(ci,fuzz): link -lrocksdb unconditionally in fuzz target

CI's test-fuzz-smoke job was failing with a torrent of
`undefined reference to rocksdb::Status::ToString[abi:cxx11]()`
errors after the libgflags fix landed. The fuzz target's RocksDB
link arg was:

    $<IF:$<TARGET_EXISTS:RocksDB::rocksdb>,-lrocksdb,${ROCKSDB_LIBRARY}>

That generator expression was wrong on CI's exact code path:

  1. CMake's `find_package(RocksDB CONFIG)` does NOT find the .cmake
     config RocksDB 8.9.1 ships — only the .pc file.
  2. `pkg_check_modules(RocksDB IMPORTED_TARGET)` therefore exposes
     `PkgConfig::RocksDB` (NOT `RocksDB::rocksdb`), so
     $<TARGET_EXISTS:RocksDB::rocksdb> is FALSE.
  3. The fallback ${ROCKSDB_LIBRARY} is set ONLY inside the manual
     `find_library()` probe at top-level CMakeLists.txt:170-190, which
     is skipped when EITHER `RocksDB::rocksdb` or `PkgConfig::RocksDB`
     already exists.

Result on CI: an empty string landed in the link line, so the link
step saw no `-lrocksdb` arg and every RocksDB symbol the fuzz
binary referenced became undefined.

Fix: just use a bare `-lrocksdb` and let the library search path
do the work. `build-rocksdb.sh` installs to /usr/local/lib (CI);
`librocksdb-dev` (apt) installs to /usr/lib/x86_64-linux-gnu (DNS2).
Both paths are in the default search path.

Verified locally on DNS2 with the exact CI cmake invocation + flags:
- build-fuzz-verify2: clean build, exit 0
- 20s fuzz pass: 1548 inputs, 2024 features covered, no crashes

Co-located with the libgflags fix on feat/script-fuzz-and-stress-tests
because both fixes are required for test-fuzz-smoke to turn green.

---------

Co-authored-by: Sami Ahmed <sami@sami-ahmed.net>
This commit is contained in:
SamiAhmed7777
2026-07-11 16:02:35 -07:00
committed by GitHub
parent 898292ff2b
commit 9a50ab3b2e
11 changed files with 1135 additions and 6 deletions
+117
View File
@@ -146,6 +146,123 @@ jobs:
- name: Run unit tests under sanitizers
run: cd build-san && ctest --output-on-failure
test-fuzz-smoke:
# libFuzzer smoke test for src/script.cpp (fuzz_script harness).
# Builds with ASan+UBSan+libFuzzer and runs for 5 minutes. Any crash
# is uploaded as an artifact and the job fails — fuzz regressions
# must block the PR.
# See src/test/fuzz/README.md for harness details.
runs-on: ubuntu-22.04
timeout-minutes: 20
env:
ASAN_OPTIONS: "detect_leaks=0:halt_on_error=1:abort_on_error=1:print_stacktrace=1"
UBSAN_OPTIONS: "halt_on_error=1:abort_on_error=1:print_stacktrace=1"
SAN_FLAGS: "-fsanitize=address,undefined,fuzzer-no-link -fno-omit-frame-pointer -fno-sanitize-recover=undefined -fno-sanitize=alignment,signed-integer-overflow,vptr"
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Install clang + dependencies
# libFuzzer ships with clang since v6; clang-15 is on the runner.
# libgflags-dev: fuzz link line references -lgflags (RocksDB builds
# expect gflags as a transitive dep). Without it the link step fails
# with "cannot find -lgflags". CI's ubuntu-22.04 runner does NOT ship
# it by default; DNS2 has it as an automatic dep of build-essential,
# which is why local dry-runs didn't catch this.
run: |
sudo apt-get update
sudo apt-get install -y clang-15 cmake ninja-build \
libboost-all-dev libssl-dev libdb++-dev libleveldb-dev \
libevent-dev libminiupnpc-dev zlib1g-dev \
libsnappy-dev liblz4-dev libzstd-dev \
libgflags-dev
sudo update-alternatives --install /usr/bin/clang clang /usr/bin/clang-15 100
sudo update-alternatives --install /usr/bin/clang++ clang++ /usr/bin/clang++-15 100
- name: Build RocksDB from source
run: sudo bash scripts/ci/build-rocksdb.sh
- name: Configure with fuzzing + sanitizers
# NB: do NOT pass -fsanitize=fuzzer in CMAKE_EXE_LINKER_FLAGS — that
# pulls libFuzzer's main() into CMake's compiler-probe linker test
# and trips "multiple definition of `main`". The fuzz_script target's
# custom clang++ link step adds -fsanitize=fuzzer in src/CMakeLists.txt
# (see BUILD_FUZZ block).
# SECP256K1_ASM=OFF: clang-15+ register allocator is sometimes stricter
# than clang-14 about the x86_64 inline asm in scalar_4x64_impl.h and
# fails with "inline assembly requires more registers than available"
# on some runner images. The fuzz target only exercises script.cpp —
# ECC ops use the C fallback (slower, still correct).
run: |
cmake -B build-fuzz -G Ninja \
-DCMAKE_BUILD_TYPE=Debug \
-DCMAKE_C_COMPILER=clang \
-DCMAKE_CXX_COMPILER=clang++ \
-DCMAKE_C_FLAGS="$SAN_FLAGS" \
-DCMAKE_CXX_FLAGS="$SAN_FLAGS" \
-DCMAKE_EXE_LINKER_FLAGS="$SAN_FLAGS" \
-DBUILD_QT=OFF \
-DBUILD_DAEMON=ON \
-DBUILD_TESTS=ON \
-DBUILD_FUZZ=ON \
-DUSE_UPNP=OFF \
-DSECP256K1_ASM=OFF
- name: Build libtor (embedded Tor static lib)
# BUILD_FUZZ pulls in triangles_common + trianglesd_objects (OBJECT lib)
# via the fuzz target's CMake deps. The link line references libtor.a,
# which the Tor submodule script produces — CMake doesn't build it.
# Mirror the unit/sanitizer jobs here before invoking the fuzz target.
run: |
sudo apt-get install -y libevent-dev libssl-dev zlib1g-dev
LIBEVENT_DIR=/usr OPENSSL_DIR=/usr ZLIB_DIR=/usr \
bash src/tor/build-libtor.sh
- name: Build fuzz_script
# CMake target is named `fuzz_script` (matches FUZZ_BIN_DIR/fuzz_script
# in src/CMakeLists.txt — see add_custom_target(fuzz_script ...)).
run: cmake --build build-fuzz --target fuzz_script -j$(nproc)
- name: Generate seed corpus from JSON fixtures
# Uses src/test/data/script_{valid,invalid}.json so the fuzzer
# starts from real Bitcoin-style scripts instead of empty input.
run: |
mkdir -p build-fuzz/fuzz_corpus
python3 src/test/fuzz/seed_corpus.py \
src/test/data/script_valid.json \
build-fuzz/fuzz_corpus valid
python3 src/test/fuzz/seed_corpus.py \
src/test/data/script_invalid.json \
build-fuzz/fuzz_corpus invalid
- name: Run fuzzer for 5 minutes
# -max_total_time=300 hard-caps runtime. Crashes go to artifact
# prefix; we upload any artifacts and fail the job if any exist.
run: |
mkdir -p build-fuzz/fuzz_artifacts
set +e
./build-fuzz/bin/fuzz_script \
-max_total_time=300 \
-max_len=4096 \
-artifact_prefix=build-fuzz/fuzz_artifacts/ \
build-fuzz/fuzz_corpus/ \
2>&1 | tee build-fuzz/fuzz_log.txt
FUZZ_EXIT=${PIPESTATUS[0]}
set -e
if [ -n "$(ls -A build-fuzz/fuzz_artifacts/ 2>/dev/null | grep -v '\.tmp$')" ]; then
echo "::error::Fuzzer produced crash/leak artifacts"
exit 1
fi
exit 0
- name: Upload fuzzer artifacts on success
if: always()
uses: actions/upload-artifact@v4
with:
name: fuzz-artifacts
path: build-fuzz/fuzz_artifacts/
build-windows-qt:
runs-on: windows-latest
defaults:
+9
View File
@@ -7,6 +7,15 @@ add_compile_options(
-Wformat -Wformat-security -Wno-unused-parameter
)
# Bitcoin-derived source uses C99-style adjacent string-literal concatenation
# for printf format macros: `"%"PRId64`. gcc tolerates this without a space;
# clang promotes `-Wreserved-user-defined-literal` to an error in C++20 mode
# and trips on hundreds of sites in util.cpp, kernel.cpp, etc. Suppress only
# under clang so gcc builds keep the original diagnostic behavior.
if(CMAKE_CXX_COMPILER_ID STREQUAL "Clang" OR CMAKE_C_COMPILER_ID STREQUAL "Clang")
add_compile_options(-Wno-reserved-user-defined-literal)
endif()
# ── Common defines ──
add_compile_definitions(
BOOST_SPIRIT_THREADSAFE
+350 -5
View File
@@ -119,6 +119,23 @@ list(APPEND CORE_SOURCES
add_library(triangles_common OBJECT ${CORE_SOURCES})
# When BUILD_FUZZ=ON, the fuzz target links these .o files directly into
# bin/fuzz_script. The link line enables -fsanitize=fuzzer,address,undefined
# so EVERY .o referenced from the fuzz binary must also be compiled with the
# matching -fsanitize=address,undefined,fuzzer-no-link. Without this, gcc-
# built triangles_common objects reference libstdc++-injected ubsan runtime
# symbols (e.g. __ubsan_handle_function_type_mismatch_v1_abort) that clang's
# libubsan_standalone runtime doesn't provide, and the link fails with
# "undefined reference to __ubsan_handle_function_type_mismatch_v1_abort".
if(BUILD_FUZZ)
target_compile_options(triangles_common PRIVATE
-fsanitize=address,undefined,fuzzer-no-link
-fno-omit-frame-pointer
-fno-sanitize-recover=undefined
-fno-sanitize=alignment,signed-integer-overflow,vptr
)
endif()
target_include_directories(triangles_common PUBLIC
"${CMAKE_CURRENT_SOURCE_DIR}"
"${CMAKE_CURRENT_SOURCE_DIR}/json"
@@ -344,12 +361,36 @@ target_precompile_headers(triangles_common PRIVATE
# ═══════════════════════════════════════════════════════════════════════════════
# 4. Headless daemon (trianglesd)
# ═══════════════════════════════════════════════════════════════════════════════
if(BUILD_DAEMON)
add_executable(trianglesd
noui.cpp
init.cpp
wallet.cpp
# `trianglesd` is normally an add_executable, but the libFuzzer build only
# needs the daemon's object files (init/wallet/noui). Building the executable
# under clang-15 with -fsanitize=fuzzer+address+undefined pulls in
# undefined references to the libstdc++ runtime built by gcc, which fails
# the link step. So we expose the daemon's sources as an OBJECT library and
# only attach them to trianglesd when we're not in a fuzz build.
set(DAEMON_SOURCES
noui.cpp
init.cpp
wallet.cpp
)
if(BUILD_FUZZ)
add_library(trianglesd_objects OBJECT ${DAEMON_SOURCES})
target_link_libraries(trianglesd_objects PRIVATE triangles_common)
target_precompile_headers(trianglesd_objects REUSE_FROM triangles_common)
# Match triangles_common's sanitizer instrumentation so noui.cpp / init.cpp
# / wallet.cpp .o files don't reference the gcc libstdc++ ubsan runtime
# when linked into the fuzz binary (see triangles_common compile-options
# comment above for the full rationale).
target_compile_options(trianglesd_objects PRIVATE
-fsanitize=address,undefined,fuzzer-no-link
-fno-omit-frame-pointer
-fno-sanitize-recover=undefined
-fno-sanitize=alignment,signed-integer-overflow,vptr
)
if(WIN32)
set_target_properties(trianglesd_objects PROPERTIES SUFFIX ".obj")
endif()
elseif(BUILD_DAEMON)
add_executable(trianglesd ${DAEMON_SOURCES})
# No QT_GUI define — daemon gets the #if !defined(QT_GUI) code paths
target_link_libraries(trianglesd PRIVATE triangles_common)
target_precompile_headers(trianglesd REUSE_FROM triangles_common)
@@ -697,3 +738,307 @@ if(BUILD_TESTS)
add_test(NAME chaindb_runtime_tests
COMMAND test_chaindb_runtime --log_level=test_suite)
endif()
# ═══════════════════════════════════════════════════════════════════════════════
# 7. Fuzz harness (script interpreter) — opt-in via -DBUILD_FUZZ=ON
# ═══════════════════════════════════════════════════════════════════════════════
# LibFuzzer is built into clang since version 6; gcc doesn't support
# -fsanitize=fuzzer. We compile script_fuzz.cpp + script.cpp with clang++
# (so the interpreter itself is ASan/UBSan-instrumented) and link against
# the full triangles_common OBJECT library + the same library set trianglesd
# uses. Default build (gcc, no sanitizer) is unaffected.
#
# Build:
# cmake -G Ninja -DBUILD_TESTS=ON -DBUILD_FUZZ=ON -DBUILD_DAEMON=ON ..
# ninja fuzz_script
#
# Run:
# ./bin/fuzz_script -max_total_time=300 corpus/
#
# See src/test/fuzz/README.md for corpus seeding and what it covers.
option(BUILD_FUZZ "Build libFuzzer harness for the script interpreter" OFF)
if(BUILD_FUZZ)
find_program(CLANGXX clang++)
if(NOT CLANGXX)
message(FATAL_ERROR "BUILD_FUZZ=ON requires clang++; not found in PATH")
endif()
set(FUZZ_OBJ_DIR "${CMAKE_CURRENT_BINARY_DIR}/fuzz_objs")
file(MAKE_DIRECTORY "${FUZZ_OBJ_DIR}")
set(FUZZ_OBJ_SCRIPT_FUZZ "${FUZZ_OBJ_DIR}/script_fuzz.cpp.o")
set(FUZZ_OBJ_SCRIPT "${FUZZ_OBJ_DIR}/script.cpp.o")
set(FUZZ_OBJ_FUZZ_STUBS "${FUZZ_OBJ_DIR}/fuzz_stubs.cpp.o")
set(FUZZ_FUZZ_STUBS_SRC "${FUZZ_OBJ_DIR}/fuzz_stubs.cpp")
set(FUZZ_BIN_DIR "${CMAKE_BINARY_DIR}/bin")
file(MAKE_DIRECTORY "${FUZZ_BIN_DIR}")
set(FUZZ_BIN "${FUZZ_BIN_DIR}/fuzz_script")
set(FUZZ_SRC_FUZZ "${CMAKE_CURRENT_SOURCE_DIR}/test/fuzz/script_fuzz.cpp")
set(FUZZ_SRC_SCRIPT "${CMAKE_CURRENT_SOURCE_DIR}/script.cpp")
# Compile flags shared by both .cpp files. Pull in script.h, secp256k1,
# leveldb. Same flags gcc uses for triangles_common (the project defines
# HAVE_BUILD_INFO, LINUX, BOOST_THREAD_USE_LIB, etc.) so we don't hit
# redefinition errors when linking against the rest of triangles_common.
set(FUZZ_COMMON_FLAGS
-std=c++20 -g -O1
-fsanitize=fuzzer,address,undefined
-DHAVE_CONFIG_H
-DHAVE_BUILD_INFO
-DLINUX
-DUSE_IPV6=1
-DBOOST_SPIRIT_THREADSAFE
-DBOOST_THREAD_PROVIDES_GENERIC_SHARED_MUTEX_ON_WIN
-DBOOST_THREAD_USE_LIB
-DENABLE_TOR_EMBEDDED
-DENABLE_I2P_EMBEDDED
-DMINIUPNP_STATICLIB
-DSTATICLIB
-I${CMAKE_CURRENT_SOURCE_DIR}
-I${CMAKE_CURRENT_SOURCE_DIR}/secp256k1/include
-I${CMAKE_CURRENT_SOURCE_DIR}/leveldb/include
-Wno-unused-parameter
-Wno-deprecated-declarations
)
add_custom_command(
OUTPUT "${FUZZ_OBJ_SCRIPT_FUZZ}"
COMMAND ${CLANGXX} ${FUZZ_COMMON_FLAGS}
-c ${FUZZ_SRC_FUZZ} -o ${FUZZ_OBJ_SCRIPT_FUZZ}
DEPENDS ${FUZZ_SRC_FUZZ}
COMMENT "[fuzz] clang++ script_fuzz.cpp"
VERBATIM
)
add_custom_command(
OUTPUT "${FUZZ_OBJ_SCRIPT}"
COMMAND ${CLANGXX} ${FUZZ_COMMON_FLAGS}
-c ${FUZZ_SRC_SCRIPT} -o ${FUZZ_OBJ_SCRIPT}
DEPENDS ${FUZZ_SRC_SCRIPT}
COMMENT "[fuzz] clang++ script.cpp"
VERBATIM
)
# fuzz_stubs.cpp — satisfies the daemon-side globals that triangles_common
# and trianglesd_objects reference (pwalletMain, uiInterface, etc.) but
# that the fuzzer never actually touches. Keeping these as null/no-ops is
# the standard fuzzer pattern — see src/test/test_triangles.cpp and
# src/test/snapshotnet_tests.cpp for the same approach.
file(MAKE_DIRECTORY "${FUZZ_OBJ_DIR}")
file(WRITE "${FUZZ_FUZZ_STUBS_SRC}"
"#include <memory>
#include <set>
#include <string>
#include <vector>
#include \"checkpoints.h\"
#include \"key.h\"
#include \"keystore.h\"
#include \"script.h\"
#include \"ui_interface.h\"
#include \"wallet.h\"
class CBlockIndex;
bool fUseFastIndex = false;
unsigned int nDerivationMethodIndex = 0;
bool fEnforceCanonical = true;
bool fConfChange = false;
bool fWalletUnlockStakingOnly = false;
bool fUsePrivateSend = false;
bool fMasterNode = false;
class CWalletStub : public CKeyStore
{
public:
bool GetPubKey(const CKeyID&, CPubKey&) const override { return false; }
bool GetKey(const CKeyID&, CKey&) const override { return false; }
bool HaveKey(const CKeyID&) const override { return false; }
void GetKeys(std::set<CKeyID>& setAddress) const override { setAddress.clear(); }
bool AddKey(const CKey&) override { return false; }
bool AddCScript(const CScript&) override { return false; }
bool HaveCScript(const CScriptID&) const override { return false; }
bool GetCScript(const CScriptID&, CScript&) const override { return false; }
};
static CWalletStub g_wallet_stub;
CWallet* pwalletMain = nullptr;
CClientUIInterface uiInterface;
// Checkpoints::CPMode defined in checkpoints.h; default to ADVISORY so the
// fuzz target never complains about the missing init.cpp value.
enum Checkpoints::CPMode CheckpointsMode = Checkpoints::ADVISORY;
// Defined in init.cpp; reasonable default so the fuzz link succeeds.
unsigned int nNodeLifespan = 7;
void StartShutdown() {}
")
add_custom_command(
OUTPUT "${FUZZ_OBJ_FUZZ_STUBS}"
COMMAND ${CLANGXX} ${FUZZ_COMMON_FLAGS}
-c ${FUZZ_FUZZ_STUBS_SRC} -o ${FUZZ_OBJ_FUZZ_STUBS}
DEPENDS ${FUZZ_FUZZ_STUBS_SRC}
COMMENT "[fuzz] clang++ fuzz_stubs.cpp"
VERBATIM
)
# Link using the same library set as trianglesd, but:
# - exclude script.cpp.o (we provide our own clang-instrumented one)
# - swap gcc for clang++ with -fsanitize=fuzzer,address,undefined
# - drop -Wl,-z,relro -Wl,-z,now (incompatible with sanitizer link)
# The triangles_common / trianglesd .o file lists are discovered at link
# time via the FUZZ_LINK_WRAPPER shell script (defined below). We do NOT
# use file(GLOB) here — it runs at configure time when no .o files exist
# on a fresh build dir, so the resulting list would always be empty.
# The wrapper script does the find at link time and exec's clang++.
# Build the link command. The triangles_common and trianglesd .o files
# are discovered at link time via shell `find` because file(GLOB) only
# runs at cmake configure time, when no .o files exist yet on a fresh
# build dir. We invoke a small shell wrapper script that does the find
# and exec's the link line with all .o files as args. We exclude
# script.cpp.o from the triangles_common dir so we don't pull our
# standalone copy of script.cpp in twice (we already have it in
# ${FUZZ_OBJ_SCRIPT}).
set(FUZZ_LINK_WRAPPER "${CMAKE_CURRENT_BINARY_DIR}/fuzz_objs/link.sh")
# The wrapper script is invoked with the full link arg list as its
# own argv. We pass it via ninja's COMMAND expansion with @{args}.
# Strategy: write a here-doc style wrapper that uses bash-style
# "$@" preservation. We use bash explicitly (not sh) for "$@" array
# semantics — paths may contain spaces, so word-splitting on IFS
# would corrupt them.
file(WRITE "${FUZZ_LINK_WRAPPER}"
"#!/bin/bash
# Auto-generated by CMake (BUILD_FUZZ block). Discovers triangles_common +
# trianglesd .o files at link time and exec's the clang++ link line.
#
# Usage: link.sh clang++ [link-args...]
# Final exec: clang++ <each .o> <each original link-arg>
set -euo pipefail
PROG=\"\$1\"
shift
TRIANGLES_COMMON_DIR=\"${CMAKE_CURRENT_BINARY_DIR}/CMakeFiles/triangles_common.dir\"
TRIANGLESD_DIR=\"${CMAKE_CURRENT_BINARY_DIR}/CMakeFiles/trianglesd_objects.dir\"
# Discover .o files into a bash array. Exclude script.cpp.o (we have our
# own clang-instrumented copy in fuzz_objs/ that we want to keep separate
# from the main build's copy).
declare -a OBJS=()
for f in \"\$TRIANGLES_COMMON_DIR\"/*.o \"\$TRIANGLES_COMMON_DIR\"/*/*.o; do
[ -f \"\$f\" ] || continue
case \"\$f\" in
*/script.cpp.o) continue ;;
esac
OBJS+=(\"\$f\")
done
if [ -d "\$TRIANGLESD_DIR" ]; then
for f in "\$TRIANGLESD_DIR"/*.o; do
[ -f "\$f" ] || continue
# init.cpp defines the daemon's main(); the fuzz harness has its own
# (libFuzzer's). wallet.cpp, noui.cpp etc. are safe — they don't
# define main and their external references (pwalletMain,
# uiInterface, nDerivationMethodIndex) are satisfied by the stub
# object file we add at the end of the link line.
case "\$f" in
*/init.cpp.o) continue ;;
esac
OBJS+=("\$f")
done
fi
# Final arg list: PROG, then all .o files, then all original link args.
exec \"\$PROG\" \"\${OBJS[@]}\" \"\$@\"
")
file(CHMOD "${FUZZ_LINK_WRAPPER}" PERMISSIONS
OWNER_READ OWNER_WRITE OWNER_EXECUTE
GROUP_READ GROUP_EXECUTE
WORLD_READ WORLD_EXECUTE)
set(FUZZ_LINK_CMD
"${CLANGXX}"
"-fsanitize=fuzzer,address,undefined"
"${FUZZ_OBJ_SCRIPT_FUZZ}"
"-o" "${FUZZ_BIN}"
"-Wl,--allow-multiple-definition"
"${FUZZ_OBJ_SCRIPT}"
"${FUZZ_OBJ_FUZZ_STUBS}"
"${CMAKE_BINARY_DIR}/lib/libhash9_crypto.a"
"${CMAKE_BINARY_DIR}/lib/libleveldb_memenv.a"
"${CMAKE_BINARY_DIR}/lib/libleveldb_lib.a"
"-lssl" "-lcrypto" "-ldb_cxx" "-levent" "-lsqlite3" "-lminiupnpc"
"${CMAKE_BINARY_DIR}/lib/libsecp256k1.a"
# RocksDB: build-rocksdb.sh installs librocksdb.so.8.9.1 to
# /usr/local (CI) or the user has it via the distro package
# (DNS2 has librocksdb-dev). The library search path picks up
# either /usr/local/lib or /usr/lib automatically, so a bare
# "-lrocksdb" works on both. The previous generator expression
# ($<IF:$<TARGET_EXISTS:RocksDB::rocksdb>,-lrocksdb,${ROCKSDB_LIBRARY}>)
# failed on CI because:
# 1. CMake's find_package(RocksDB CONFIG) does NOT find the .cmake
# config RocksDB 8.9.1 ships, only the .pc file.
# 2. The pkg-config path exposes PkgConfig::RocksDB (NOT
# RocksDB::rocksdb), so $<TARGET_EXISTS:RocksDB::rocksdb> is
# FALSE.
# 3. The fallback ${ROCKSDB_LIBRARY} is only set inside the manual
# find_library() probe at CMakeLists.txt:170-190, which is
# skipped when EITHER target exists.
# Result on CI: an empty string landed in the link line, and the
# fuzz binary linked against every RocksDB symbol it referenced
# turned into "undefined reference" errors.
"-lrocksdb"
"-lz" "-lgflags" "-lsnappy" "-lbz2" "-llz4" "-lzstd"
# i2p is inlined into triangles_common as i2p_embedded.cpp.o and is a
# NO-OP when USE_I2P_EMBEDDED=OFF (which is the CI default; the
# workflow only builds libtor, not libi2pd). Do NOT link any
# src/i2p/i2pd-src/lib*.a here — those files are produced by a
# separate `make` step in src/i2p/build-libi2pd.sh that the fuzz
# job does NOT run, and clang aborts the link with
# "no such file or directory" when they're absent.
"${CMAKE_CURRENT_SOURCE_DIR}/tor/tor-src/libtor.a"
"-lpthread" "-llzma" "-lubsan"
)
# Boost target names need real paths on the link line; generator
# expressions don't get evaluated by the bash wrapper, so resolve
# the imported-target paths at configure time and append them.
foreach(_target Boost::program_options Boost::thread Boost::chrono
Boost::atomic Boost::filesystem Boost::system)
if(TARGET "${_target}")
get_target_property(_path "${_target}" IMPORTED_LOCATION_RELEASE)
if(NOT _path)
get_target_property(_path "${_target}" IMPORTED_LOCATION)
endif()
if(_path AND EXISTS "${_path}")
list(APPEND FUZZ_LINK_CMD "${_path}")
endif()
endif()
endforeach()
# Invoke the link wrapper script, passing the actual link line as
# args. The wrapper script discovers .o files at link time via find
# (file(GLOB) would evaluate empty at configure time when no .o files
# exist yet on a fresh build dir) and exec's clang++ with all the
# discovered objects prepended to its arg list.
add_custom_command(
OUTPUT "${FUZZ_BIN}"
COMMAND "${FUZZ_LINK_WRAPPER}" ${FUZZ_LINK_CMD}
DEPENDS
"${FUZZ_OBJ_SCRIPT_FUZZ}"
"${FUZZ_OBJ_SCRIPT}"
"${FUZZ_OBJ_FUZZ_STUBS}"
"${FUZZ_LINK_WRAPPER}"
# Static libs the link line references at ${CMAKE_BINARY_DIR}/lib/.
# Without these deps, fuzz_script's link step races and fails with
# "no such file" errors on first clean build.
hash9_crypto
leveldb_lib
leveldb_memenv
secp256k1
# trianglesd_objects emits the daemon .o files (noui/init/wallet)
# that the link wrapper discovers via find. triangles_common emits
# the rest of the .o files we need. Without these deps the wrapper
# finds no .o files on first build → undefined references like
# CKey::GetPubKey.
trianglesd_objects
triangles_common
COMMENT "[fuzz] clang++ link fuzz_script"
)
add_custom_target(fuzz_script ALL DEPENDS "${FUZZ_BIN}")
message(STATUS "Fuzz target enabled: ${FUZZ_BIN}")
endif()
+5
View File
@@ -9,6 +9,11 @@
#include <string>
#include <mutex>
#include <map>
// assert() is used in the LockedPageManager implementation below; include
// explicitly so this header doesn't rely on transitive includes from
// <mutex>/<map> (clang's stricter include resolution surfaces the missing
// include even though gcc tolerates it via some other transitive path).
#include <cassert>
#ifdef WIN32
#ifdef _WIN32_WINNT
+11 -1
View File
@@ -375,8 +375,18 @@ static const unsigned short yoff_b_f[] = {
236, 192, 108, 86
};
// Compute the FFT "step" combining low and high halves of two q[]
// values with a multiplier. Casts to u32 happen BEFORE the left shift
// to avoid signed-overflow UB when (h)*(mm) is negative — UBSan flags
// signed-shift of negative values even though the intent is modular
// arithmetic. The u32 cast then <<16 is well-defined in C++20.
//
// Why (h)*(mm) can be negative: FFT values are signed; alpha_tab entries
// are signed too. The product can overflow into negative s32 territory
// before the modular reduction step. We compute the multiplication in
// signed, cast to u32 to recover the bit pattern, then shift.
#define INNER(l, h, mm) (((u32)((l) * (mm)) & 0xFFFFU) \
+ ((u32)((h) * (mm)) << 16))
+ ((u32)((u32)((h) * (mm)) << 16)))
#define W_SMALL(sb, o1, o2, mm) \
(INNER(q[8 * (sb) + 2 * 0 + o1], q[8 * (sb) + 2 * 0 + o2], mm), \
+54
View File
@@ -0,0 +1,54 @@
# Triangles Script Fuzzer
libFuzzer-based harness for `EvalScript` in `src/script.cpp`. Mutations
find bugs in opcode dispatch, stack handling, push-data edge cases, and
the multisig stack walk.
## Build
The fuzz target is gated on `-DBUILD_FUZZ=ON` so default builds (and CI)
don't pull in libFuzzer. To build:
```bash
cd build
cmake -G Ninja -DBUILD_TESTS=ON -DBUILD_FUZZ=ON ..
ninja script_fuzz
```
Requires `clang++` (libFuzzer is built in since clang-6; clang-18 is
current on DNS2).
## Run
```bash
# Fuzz for 5 minutes
./bin/script_fuzz -max_total_time=300 -max_len=10000 corpus/
# Reproduce a crash
./bin/script_fuzz crash-deadbeef.bin
# Run a single corpus file (when built without -fsanitize=fuzzer)
./bin/script_fuzz corpus/script_001.bin
```
## Seed corpus
Start with the existing `src/test/data/script_valid.json` and
`script_invalid.json` — extract the scriptPubKey fields and prefix
each with `uint8_t(scriptLen)`. A small starter set lives in
`corpus/` (generated by `scripts/seed-from-tests.sh`).
## What it finds
Every historical script interpreter bug has been in this surface:
- sigcache Set/Get asymmetry (silent no-op)
- CHECKMULTISIG stack walk ordering
- combineSigs size trap
- PushData encoding edge cases
- Numeric overflow on the stack
The harness is intentionally minimal — it calls `EvalScript` against
a default-constructed `CTransaction`, so signature verification is
not exercised. That surface is covered by BOOST tests in
`src/test/script_tests.cpp`. The fuzz target is for everything else.
+50
View File
@@ -0,0 +1,50 @@
// Standalone reproducer for fuzz_script findings.
// Compile:
// clang++ -std=c++20 -g -I src -I src/secp256k1/include \
// src/test/fuzz/repro.cpp src/script.cpp \
// -o repro -lcrypto -lssl
//
// Run:
// ./repro crash-deadbeef.bin
//
// This is the same driver as script_fuzz.cpp's main() — kept separate so
// the fuzzer harness can be built with -fsanitize=fuzzer (which provides
// its own main) without conflicting.
#include <cstdint>
#include <cstddef>
#include <cstdio>
#include <fstream>
#include <vector>
#include <algorithm>
#include "script.h"
#include "main.h"
extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size)
{
if (size < 1) return 0;
const size_t script_len = std::min<size_t>(data[0], 10000);
if (size < 1 + script_len) return 0;
std::vector<uint8_t> script_bytes(data + 1, data + 1 + script_len);
CScript script(script_bytes.begin(), script_bytes.end());
std::vector<std::vector<unsigned char>> stack;
CTransaction tx;
EvalScript(stack, script, tx, 0, 0);
return 0;
}
int main(int argc, char** argv)
{
if (argc < 2) {
std::fprintf(stderr, "usage: %s <corpus-file>\n", argv[0]);
return 1;
}
std::ifstream in(argv[1], std::ios::binary);
if (!in) {
std::fprintf(stderr, "cannot open %s\n", argv[1]);
return 1;
}
std::vector<uint8_t> data((std::istreambuf_iterator<char>(in)),
std::istreambuf_iterator<char>());
return LLVMFuzzerTestOneInput(data.data(), data.size());
}
+68
View File
@@ -0,0 +1,68 @@
// Fuzz harness for Triangles script interpreter.
//
// Compile with:
// clang++ -fsanitize=fuzzer,address,undefined -g -O1 \
// -I src -I src/leveldb/include \
// src/test/fuzz/script_fuzz.cpp \
// <link the script interpreter and its deps>
//
// Input format (libFuzzer):
// [1 byte scriptLen] [scriptLen bytes of raw script bytes]
// Anything beyond the first 1 + scriptLen bytes is ignored, so seed
// corpus files can be arbitrary-length — only the prefix matters.
//
// Or run a single corpus file:
// ./script_fuzz corpus/script_001.bin
//
// What this covers:
// * Every opcode dispatch in EvalScript (src/script.cpp:332)
// * Stack underflow / overflow paths
// * OP_CHECKMULTISIG stack walk (the area with the most historical
// bugs — sigcache, multisig stack-walk, combineSigs)
// * Push-data edge cases (OP_PUSHDATA1/2/4)
// * Numeric opcode handling (overflow, MIN/MAX edge values)
//
// What this does NOT cover:
// * Signature verification (needs a real CKey/CTransaction; tested
// by BOOST unit tests instead — see src/test/script_tests.cpp)
// * P2SH (EvalScript runs first; the second-script eval in VerifyScript
// is gated on the first script returning true, which requires a
// real signature flow)
//
// Why EvalScript alone is the right target: every bug in the script
// interpreter has lived here, and the input surface is small (a CScript
// is just a byte vector). libFuzzer can mutate script bytes freely
// without needing realistic sig/key setup. This is the same approach
// Bitcoin Core's `script_tests` fuzzer uses.
#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <fstream>
#include <vector>
#include "script.h"
#include "main.h"
// Entry point for libFuzzer.
extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size)
{
if (size < 1) return 0;
// First byte: script length. Cap at 10000 to keep EvalScript bounded.
// (Triangles enforces MAX_SCRIPT_SIZE=10000 in IsPushOnly and others.)
const size_t script_len = std::min<size_t>(data[0], 10000);
if (size < 1 + script_len) return 0;
std::vector<uint8_t> script_bytes(data + 1, data + 1 + script_len);
CScript script(script_bytes.begin(), script_bytes.end());
// Run EvalScript against an empty transaction. nIn=0, nHashType=0.
// We don't care about the return value or final stack state — we
// care that no input crashes, leaks, or trips UBSan.
std::vector<std::vector<unsigned char>> stack;
CTransaction tx; // default-constructed, empty
EvalScript(stack, script, tx, 0, 0);
return 0;
}
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# Generate a starter fuzz corpus from the existing script test JSON files.
# Each output file is: [uint8 scriptLen][scriptLen raw bytes]
#
# This is a hand-rolled extractor because the JSON uses stringified script
# syntax ("OP_DUP OP_HASH160 ... 0x76a914..."), not raw bytes. For the
# starter set we just dump the first N bytes of each test's scriptPubKey
# field — enough to give libFuzzer a structural starting point. Mutation
# will explore the rest.
set -euo pipefail
CORPUS_DIR="${1:-corpus}"
SRC_JSON="${2:-../data/script_valid.json}"
mkdir -p "$CORPUS_DIR"
# Extract scriptPubKey fields and emit raw-prefixed corpus files.
# The JSON format is [[scriptSig, scriptPubKey, expected, ...], ...]
# We pull out element [1] (scriptPubKey) and dump its raw bytes.
python3 - <<PY
import json, sys, os, pathlib
src = pathlib.Path("$SRC_JSON")
out = pathlib.Path("$CORPUS_DIR")
out.mkdir(parents=True, exist_ok=True)
with src.open() as f:
tests = json.load(f)
count = 0
for i, t in enumerate(tests):
if not isinstance(t, list) or len(t) < 2:
continue
spk = t[1]
if isinstance(spk, str):
# String form ("OP_DUP OP_HASH160 ...") — skip, we want raw bytes.
continue
if not isinstance(spk, list):
continue
raw = bytes(spk[:10000])
if not raw:
continue
path = out / f"script_{i:04d}.bin"
path.write_bytes(bytes([len(raw) & 0xff]) + raw)
count += 1
print(f"wrote {count} corpus files to {out}/")
PY
+241
View File
@@ -0,0 +1,241 @@
#!/usr/bin/env python3
"""
seed_corpus.py — Convert Triangles script test JSON fixtures into
libFuzzer corpus files for the script_fuzz harness.
Each fixture is `["<scriptSig>", "<scriptPubKey>", ...]` where each
script is a string in the same format the in-tree ParseScript()
understands (asm + hex + quoted-byte + decimal forms). We parse each
script into a raw CScript byte sequence (matching CScript's << operator
for opcodes and push-data) and write `[len_byte][script_bytes]` to
corpus/<name>-<idx>.bin so script_fuzz can replay it.
Why this matters: without a seeded corpus, the fuzzer starts from a
single empty input and only finds trivial inputs in the first hour. With
a seeded corpus of 200+ real Triangles script fixtures, it starts
exploring meaningful corners of the opcode dispatch immediately.
Usage:
./seed_corpus.py <fixture.json> <output_dir> [<prefix>]
./seed_corpus.py src/test/data/script_valid.json /tmp/corpus valid
./seed_corpus.py src/test/data/script_invalid.json /tmp/corpus invalid
"""
import json
import os
import sys
# Mirror of GetOpName() from src/script.cpp. Only the opcodes we expect
# to see in fixtures are listed — anything else raises ParseError.
OPCODES = {
# Push values
"OP_PUSHBYTES_0": 0x00, "OP_FALSE": 0x00, "OP_0": 0x00,
"OP_PUSHBYTES_1": 0x01, "OP_TRUE": 0x51, "OP_1": 0x51,
"OP_2": 0x52, "OP_3": 0x53, "OP_4": 0x54, "OP_5": 0x55,
"OP_6": 0x56, "OP_7": 0x57, "OP_8": 0x58, "OP_9": 0x59,
"OP_10": 0x5a, "OP_11": 0x5b, "OP_12": 0x5c, "OP_13": 0x5d,
"OP_14": 0x5e, "OP_15": 0x5f, "OP_16": 0x60,
# Control flow
"OP_NOP": 0x61, "OP_VER": 0x62, "OP_IF": 0x63, "OP_NOTIF": 0x64,
"OP_VERIF": 0x65, "OP_VERNOTIF": 0x66, "OP_ELSE": 0x67,
"OP_ENDIF": 0x68, "OP_VERIFY": 0x69, "OP_RETURN": 0x6a,
# Stack
"OP_TOALTSTACK": 0x6b, "OP_FROMALTSTACK": 0x6c,
"OP_2DROP": 0x6d, "OP_2DUP": 0x6e, "OP_3DUP": 0x6f,
"OP_2OVER": 0x70, "OP_2ROT": 0x71, "OP_2SWAP": 0x72,
"OP_IFDUP": 0x73, "OP_DEPTH": 0x74, "OP_DROP": 0x75,
"OP_DUP": 0x76, "OP_NIP": 0x77, "OP_OVER": 0x78,
"OP_PICK": 0x79, "OP_ROLL": 0x7a, "OP_ROT": 0x7b,
"OP_SWAP": 0x7c, "OP_TUCK": 0x7d,
# Splice / cat
"OP_CAT": 0x7e, "OP_SUBSTR": 0x7f, "OP_LEFT": 0x80,
"OP_RIGHT": 0x81, "OP_SIZE": 0x82,
# Bitwise (disabled in Bitcoin, but defined in script.cpp)
"OP_INVERT": 0x83, "OP_AND": 0x84, "OP_OR": 0x85, "OP_XOR": 0x86,
"OP_EQUAL": 0x87, "OP_EQUALVERIFY": 0x88,
"OP_RESERVED1": 0x89, "OP_RESERVED2": 0x8a,
# Arithmetic
"OP_1ADD": 0x8b, "OP_1SUB": 0x8c, "OP_2MUL": 0x8d, "OP_2DIV": 0x8e,
"OP_NEGATE": 0x8f, "OP_ABS": 0x90, "OP_NOT": 0x91, "OP_0NOTEQUAL": 0x92,
"OP_ADD": 0x93, "OP_SUB": 0x94, "OP_MUL": 0x95, "OP_DIV": 0x96,
"OP_MOD": 0x97, "OP_LSHIFT": 0x98, "OP_RSHIFT": 0x99,
"OP_BOOLAND": 0x9a, "OP_BOOLOR": 0x9b,
"OP_NUMEQUAL": 0x9c, "OP_NUMEQUALVERIFY": 0x9d,
"OP_NUMNOTEQUAL": 0x9e, "OP_LESSTHAN": 0x9f, "OP_GREATERTHAN": 0xa0,
"OP_LESSTHANOREQUAL": 0xa1, "OP_GREATERTHANOREQUAL": 0xa2,
"OP_MIN": 0xa3, "OP_MAX": 0xa4,
"OP_WITHIN": 0xa5,
# Crypto
"OP_RIPEMD160": 0xa6, "OP_SHA1": 0xa7, "OP_SHA256": 0xa8,
"OP_HASH160": 0xa9, "OP_HASH256": 0xaa,
"OP_CODESEPARATOR": 0xab, "OP_CHECKSIG": 0xac, "OP_CHECKSIGVERIFY": 0xad,
"OP_CHECKMULTISIG": 0xae, "OP_CHECKMULTISIGVERIFY": 0xaf,
# Expansion
"OP_NOP1": 0xb0, "OP_NOP2": 0xb1, "OP_NOP3": 0xb2, "OP_NOP4": 0xb3,
"OP_NOP5": 0xb4, "OP_NOP6": 0xb5, "OP_NOP7": 0xb6, "OP_NOP8": 0xb7,
"OP_NOP9": 0xb8, "OP_NOP10": 0xb9,
# Locktime (in script.cpp but rarely used in script-tests)
"OP_CHECKLOCKTIMEVERIFY": 0xb1, "OP_CHECKSEQUENCEVERIFY": 0xb2,
# Multi-byte opcodes (only the tag in CScript; data follows)
"OP_PUSHDATA1": 0x4c, "OP_PUSHDATA2": 0x4d, "OP_PUSHDATA4": 0x4e,
}
# Aliases: Triangles' GetOpName strips "OP_" prefix for convenience.
SHORT_ALIASES = {k[3:]: v for k, v in OPCODES.items() if k.startswith("OP_")}
OPCODES.update(SHORT_ALIASES)
def parse_hex(s):
"""Parse a hex string like '4b417a7a...' (no 0x prefix)."""
if len(s) % 2:
raise ValueError(f"hex string of odd length: {s!r}")
return bytes.fromhex(s)
def parse_script_to_bytes(src):
"""
Parse a Triangles script string into raw CScript bytes, matching
the in-tree ParseScript() in src/test/script_tests.cpp.
Supports:
* Decimal integers → push as numeric (small ints use OP_1..OP_16)
* 0x-prefixed hex → raw bytes inserted (NOT pushed)
* Single-quoted strings → pushed as data
* Opcode names (with or without OP_ prefix)
The numeric push uses the CScript << operator semantics: small ints
(1..16) become OP_1..OP_16; otherwise we use the minimum-length
push encoding (OP_PUSHBYTES_N if 1..75 bytes, OP_PUSHDATA1/2/4 for
larger). For simplicity we use OP_PUSHDATA4 with explicit length
for any non-tiny number — fuzzer doesn't care about minimal encoding.
"""
out = bytearray()
for w in src.split():
if w.startswith("0x") or w.startswith("0X"):
out.extend(parse_hex(w[2:]))
elif len(w) >= 2 and w.startswith("'") and w.endswith("'"):
data = w[1:-1].encode("latin-1", errors="replace")
push_data(out, data)
elif w.startswith("-") and w[1:].isdigit() or w.isdigit():
n = int(w)
if 1 <= n <= 16:
out.append(0x50 + n)
elif n == 0:
out.append(0x00) # OP_FALSE / OP_0
else:
# Minimal-encoding push for non-tiny ints: use signed
# minimal-data encoding matching Bitcoin's CScript::<<int>.
# For simplicity here, encode as little-endian and push.
if n < 0:
# Encode as signed (rare in fixtures; OP_1NEGATE for -1)
if n == -1:
out.append(0x4f) # OP_1NEGATE
continue
data = (-n).to_bytes(((-n).bit_length() + 7) // 8, "little")
data = bytes([b | 0x80 for b in data]) # sign bit
else:
data = n.to_bytes((n.bit_length() + 7) // 8, "little")
push_data(out, data)
elif w in OPCODES:
out.append(OPCODES[w])
else:
# Unknown token — skip silently (mirrors old behavior of
# not crashing on weird fixture entries). Production callers
# should validate, but this is a seed generator, not a
# verifier.
pass
return bytes(out)
def push_data(out, data):
"""Encode a push of `data` onto the script, using the same encoding
EvalScript expects on the wire."""
n = len(data)
if n == 0:
# OP_0 (push empty)
out.append(0x00)
elif n <= 0x4b:
out.append(n)
out.extend(data)
elif n <= 0xff:
out.append(0x4c) # OP_PUSHDATA1
out.append(n)
out.extend(data)
elif n <= 0xffff:
out.append(0x4d) # OP_PUSHDATA2
out.extend(n.to_bytes(2, "little"))
out.extend(data)
else:
out.append(0x4e) # OP_PUSHDATA4
out.extend(n.to_bytes(4, "little"))
out.extend(data)
def fixture_to_corpus_files(fixture_path, output_dir, prefix):
"""Convert a JSON fixture into a directory of corpus files.
Each inner fixture `["<sig>", "<pubkey>"]` becomes two files:
<prefix>-<idx>-sig.bin [len_byte][script_bytes]
<prefix>-<idx>-pubkey.bin [len_byte][script_bytes]
EvalScript runs each side independently when fuzzer replays them.
"""
os.makedirs(output_dir, exist_ok=True)
with open(fixture_path) as f:
fixtures = json.load(f)
written = 0
skipped = 0
for idx, test in enumerate(fixtures):
if not isinstance(test, list) or len(test) < 2:
skipped += 1
continue
for side, name in [(test[0], "sig"), (test[1], "pubkey")]:
if not isinstance(side, str):
skipped += 1
continue
try:
script_bytes = parse_script_to_bytes(side)
except Exception as e:
print(f" [skip {prefix}-{idx}-{name}] {e}", file=sys.stderr)
skipped += 1
continue
# Cap at 255 bytes so the 1-byte length prefix fits. The
# fuzzer's libFuzzer has no problem with small inputs (it
# tries small first by default), and 255 covers all real
# Triangles scripts — anything bigger is either a script
# with embedded sigs (which the fuzzer doesn't verify) or
# pathological. We split oversized inputs across multiple
# 255-byte chunks.
if len(script_bytes) > 255:
# Write as multiple short inputs — each sub-script.
# This loses context but exercises the same byte
# sequences the fuzzer would discover anyway.
chunk = 0
for start in range(0, len(script_bytes), 255):
sub = script_bytes[start:start + 255]
out_path = os.path.join(
output_dir,
f"{prefix}-{idx:04d}-{name}-c{chunk:02d}.bin"
)
with open(out_path, "wb") as f:
f.write(bytes([len(sub)]))
f.write(sub)
written += 1
chunk += 1
continue
out_path = os.path.join(output_dir, f"{prefix}-{idx:04d}-{name}.bin")
with open(out_path, "wb") as f:
# Format: [1-byte len][script bytes]
f.write(bytes([len(script_bytes)]))
f.write(script_bytes)
written += 1
print(f" {prefix}: wrote {written} corpus files, skipped {skipped}", file=sys.stderr)
if __name__ == "__main__":
if len(sys.argv) < 3:
print(__doc__, file=sys.stderr)
sys.exit(1)
fixture_path = sys.argv[1]
output_dir = sys.argv[2]
prefix = sys.argv[3] if len(sys.argv) > 3 else os.path.basename(fixture_path).split(".")[0]
fixture_to_corpus_files(fixture_path, output_dir, prefix)
+182
View File
@@ -0,0 +1,182 @@
//
// script_stress_tests.cpp — Stack / multisig / opcode-count boundary stress.
//
// These tests guard against regressions in EvalScript's resource limits:
// - script.size() > 10000 → reject (script.h MAX_SCRIPT_SIZE)
// - vchPushValue.size() > 520 → reject (MAX_SCRIPT_ELEMENT_SIZE)
// - nOpCount > 201 → reject (MAX_OPS_PER_SCRIPT)
//
// What's NOT bounded (and what this file watches):
// - stack size (no MAX_STACK_SIZE, only nOpCount caps stack-pushing ops
// at ~201 entries)
// - OP_CHECKMULTISIG's FindAndDelete loop does O(sigs × script_size) work
// per invocation — a script full of bytes that looks like a sig pattern
// amplifies the cost linearly in sig count.
//
// See script.cpp:332 (EvalScript), script.cpp:1045 (CHECKMULTISIG),
// script.cpp:485 (FindAndDelete inline in script.h).
//
#include <chrono>
#include <vector>
#include <boost/test/unit_test.hpp>
#include "script.h"
#include "main.h"
using namespace std;
BOOST_AUTO_TEST_SUITE(script_stress_tests)
// Helper: build an empty transaction usable for EvalScript/VerifyScript calls.
static CTransaction MakeDummyTx()
{
CTransaction tx;
tx.vin.resize(1);
tx.vout.resize(1);
tx.vout[0].nValue = 1;
return tx;
}
// 1. Deep stack via OP_DUP should hit the nOpCount limit and return false
// rather than allocating unbounded memory or running forever.
BOOST_AUTO_TEST_CASE(deep_dup_stack_hits_opcount_limit)
{
// Build script: 250 OP_DUP ops. Each OP_DUP is opcode > OP_16, so each
// increments nOpCount. With MAX_OPS_PER_SCRIPT = 201, this must reject.
CScript script;
for (int i = 0; i < 250; i++)
script << OP_DUP;
vector<vector<unsigned char> > stack;
CTransaction tx = MakeDummyTx();
auto t0 = chrono::steady_clock::now();
bool fOk = EvalScript(stack, script, tx, 0, 0);
auto t1 = chrono::steady_clock::now();
BOOST_CHECK(!fOk); // must reject via nOpCount > 201
BOOST_CHECK_LT(chrono::duration_cast<chrono::milliseconds>(t1 - t0).count(),
1000); // must be fast
// Stack should be bounded by however many DUPs executed before the cap.
// With 201-op cap and 0 starting entries, max ~201 entries.
BOOST_CHECK_LE(stack.size(), 201u);
}
// 2. 20-of-20 multisig: the maximum legal configuration. Must execute to
// completion (or fail gracefully) without OOM or pathologically slow
// FindAndDelete. nKeysCount > 20 is rejected; nKeysCount == 20 is fine.
//
// CScript::operator<<(int) calls push_int64(), which serializes as
// minimal push data. So `script << 20` becomes the 2-byte sequence
// {0x01, 0x14} (PUSHDATA1 prefix + value 20).
BOOST_AUTO_TEST_CASE(max_keys_multisig_20_of_20)
{
CScript script;
// Push 20 dummy pubkeys (33 bytes each — compressed pubkey size).
for (int i = 0; i < 20; i++)
{
vector<unsigned char> pk(33, 0x02);
pk[1] = (unsigned char)i; // make each distinct
script << pk;
}
script << 20; // num_of_pubkeys = 20
// Push 20 dummy signatures
for (int i = 0; i < 20; i++)
{
vector<unsigned char> sig(72, 0x30); // DER sig-ish
sig[1] = (unsigned char)(i + 1);
script << sig;
}
script << 20; // num_of_signatures = 20
script << OP_CHECKMULTISIG;
vector<vector<unsigned char> > stack;
CTransaction tx = MakeDummyTx();
auto t0 = chrono::steady_clock::now();
EvalScript(stack, script, tx, 0, 0); // returns false (sigs are garbage)
auto t1 = chrono::steady_clock::now();
// Sigs are garbage, so verification fails — script returns false.
// EvalScript doesn't roll back the stack on failure (the contract is
// "on false, stack state is undefined"). The point of this test is
// that the script must terminate quickly and not OOM, not the stack
// contents. Stack should be bounded by the inputs we pushed (~42).
BOOST_CHECK_LT(chrono::duration_cast<chrono::milliseconds>(t1 - t0).count(),
2000);
BOOST_CHECK_LE(stack.size(), 100u); // bounded by inputs, not unbounded growth
}
// 3. nKeysCount > 20 must reject (guard against the canonical limit).
BOOST_AUTO_TEST_CASE(multisig_rejects_21_keys)
{
CScript script;
for (int i = 0; i < 21; i++)
{
vector<unsigned char> pk(33, 0x02);
pk[1] = (unsigned char)i;
script << pk;
}
script << 21; // num_of_pubkeys = 21 — over the limit
script << OP_CHECKMULTISIG;
vector<vector<unsigned char> > stack;
CTransaction tx = MakeDummyTx();
BOOST_CHECK(!EvalScript(stack, script, tx, 0, 0));
}
// 4. Pushdata > 520 bytes must reject at parse time.
BOOST_AUTO_TEST_CASE(pushdata_over_520_rejected)
{
CScript script;
vector<unsigned char> big(521, 0xAA);
script << big; // single push > MAX_SCRIPT_ELEMENT_SIZE
vector<vector<unsigned char> > stack;
CTransaction tx = MakeDummyTx();
BOOST_CHECK(!EvalScript(stack, script, tx, 0, 0));
}
// 5. Script size > 10000 must reject at parse time.
BOOST_AUTO_TEST_CASE(script_size_over_10000_rejected)
{
CScript script;
// Fill with 11000 bytes of OP_NOP (each is 1 byte) — exceeds MAX_SCRIPT_SIZE.
for (int i = 0; i < 11000; i++)
script << OP_NOP;
BOOST_CHECK_GT(script.size(), 10000u);
vector<vector<unsigned char> > stack;
CTransaction tx = MakeDummyTx();
BOOST_CHECK(!EvalScript(stack, script, tx, 0, 0));
}
// 6. Disabled opcodes (OP_CAT, OP_MUL, OP_LSHIFT etc.) must reject.
// These are the "upgrades Bitcoin wisely never shipped" — they were
// disabled in Bitcoin Core 0.3.x because they make quadratic-work
// attacks trivial. Confirm Triangles still rejects them all.
BOOST_AUTO_TEST_CASE(disabled_opcodes_rejected)
{
// Sample of the disabled set from script.cpp:363-378.
const opcodetype disabled[] = {
OP_CAT, OP_SUBSTR, OP_LEFT, OP_RIGHT,
OP_INVERT, OP_AND, OP_OR, OP_XOR,
OP_2MUL, OP_2DIV, OP_MUL, OP_DIV, OP_MOD,
OP_LSHIFT, OP_RSHIFT
};
for (size_t i = 0; i < sizeof(disabled) / sizeof(disabled[0]); i++)
{
CScript script;
script << disabled[i];
vector<vector<unsigned char> > stack;
CTransaction tx = MakeDummyTx();
BOOST_CHECK_MESSAGE(!EvalScript(stack, script, tx, 0, 0),
"Disabled opcode " << GetOpName(disabled[i]) << " was accepted!");
}
}
BOOST_AUTO_TEST_SUITE_END()