From 6425c838c04c4891497137acc681ee62b7444f13 Mon Sep 17 00:00:00 2001 From: Erick Cestari Date: Fri, 18 Sep 2026 12:25:00 -0300 Subject: [PATCH] workloads/lnd: map coverage counters directly onto AFL's map Remap Go's libfuzzer counter section onto the AFL shared memory instead of copying it on every sync. Startup coverage no longer pollutes every map, and crashing or timed-out inputs now report coverage. The trigger/ack pipes stay as a liveness handshake: try_wait still sees a just-crashed LND as running. --- smite-scenarios/src/targets/lnd.rs | 47 +++++---- workloads/lnd/Dockerfile | 7 +- workloads/lnd/align.ld | 7 ++ workloads/lnd/sancov.go | 154 ++++++++++------------------- 4 files changed, 89 insertions(+), 126 deletions(-) create mode 100644 workloads/lnd/align.ld diff --git a/smite-scenarios/src/targets/lnd.rs b/smite-scenarios/src/targets/lnd.rs index d6613562..1e861286 100644 --- a/smite-scenarios/src/targets/lnd.rs +++ b/smite-scenarios/src/targets/lnd.rs @@ -57,25 +57,22 @@ impl LndConfig { } } -/// Pipes for LND coverage synchronization. +/// Pipes for the LND liveness handshake. /// -/// Go can't write directly to AFL's shared memory, so we use pipes: -/// 1. Scenario writes trigger byte -/// 2. LND copies coverage to AFL shared memory -/// 3. LND writes ack byte -/// 4. If scenario's ack read fails (EOF), LND crashed -struct CoveragePipes { +/// The scenario writes a trigger byte and LND echoes it back as an ack. EOF +/// instead of the ack means LND died. `try_wait` can't replace this: a dying +/// process closes its sockets before it becomes reapable, so right after a +/// crash it still looks alive. +struct LivenessPipes { trigger_write: PipeWriter, ack_read: PipeReader, } -impl CoveragePipes { - /// Triggers LND to copy coverage counters to AFL shared memory. - fn sync(&mut self) -> std::io::Result<()> { +impl LivenessPipes { + /// Blocks until LND acks the trigger byte. Fails if LND died. + fn check(&mut self) -> std::io::Result<()> { let mut buf = [0u8; 1]; - // Write 1 byte to trigger coverage copy self.trigger_write.write_all(&buf)?; - // Wait for coverage copy to finish (EOF = crash) self.ack_read.read_exact(&mut buf)?; Ok(()) } @@ -99,7 +96,7 @@ pub struct LndTarget { lnd: ManagedProcess, #[allow(dead_code)] // bitcoind shuts down on drop bitcoind: ManagedProcess, - coverage_pipes: Option, + liveness_pipes: Option, pubkey: secp256k1::PublicKey, addr: SocketAddr, bitcoin_cli: BitcoinCli, @@ -108,12 +105,12 @@ pub struct LndTarget { } impl LndTarget { - /// Starts LND and waits for it to be ready. Returns the process, coverage + /// Starts LND and waits for it to be ready. Returns the process, liveness /// pipes (if in fuzzing mode), and LND's identity pubkey. fn start_lnd( config: &LndConfig, data_dir: &Path, - ) -> Result<(ManagedProcess, Option, secp256k1::PublicKey), TargetError> { + ) -> Result<(ManagedProcess, Option, secp256k1::PublicKey), TargetError> { log::info!("Starting lnd..."); let lnd_dir = data_dir.join("lnd"); @@ -147,7 +144,7 @@ impl LndTarget { .stdout(Stdio::null()) .stderr(Stdio::null()); - // Set up coverage pipes if in fuzzing mode. We keep all four pipe ends alive + // Set up liveness pipes if in fuzzing mode. We keep all four pipe ends alive // until after spawn so the FDs are valid when the child forks. let pipe_ends = if std::env::var("__AFL_SHM_ID").is_ok() { let (trigger_read, trigger_write) = std::io::pipe()?; @@ -206,10 +203,10 @@ impl LndTarget { None }; - let lnd = ManagedProcess::spawn(&mut cmd, "lnd")?; + let mut lnd = ManagedProcess::spawn(&mut cmd, "lnd")?; // Extract parent-side pipe ends; child-side ends are dropped (closed) here - let coverage_pipes = pipe_ends.map(|(_, trigger_write, ack_read, _)| CoveragePipes { + let liveness_pipes = pipe_ends.map(|(_, trigger_write, ack_read, _)| LivenessPipes { trigger_write, ack_read, }); @@ -218,10 +215,13 @@ impl LndTarget { // block_height matches the initial blocks we generated. log::info!("Waiting for lnd to be ready and synced..."); for _ in 0..120 { + if !lnd.is_running() { + return Err(TargetError::StartFailed("lnd exited during startup".into())); + } if let Ok((pubkey, blockheight, synced_to_chain)) = Self::query_info(config, &lnd_dir) { if blockheight >= bitcoind::INITIAL_BLOCKS && synced_to_chain { log::info!("lnd synced (blockheight={blockheight})"); - return Ok((lnd, coverage_pipes, pubkey)); + return Ok((lnd, liveness_pipes, pubkey)); } log::debug!( "lnd not yet synced (blockheight={blockheight}, synced_to_chain={synced_to_chain})" @@ -287,7 +287,7 @@ impl Target for LndTarget { let (data_path, temp_dir) = bitcoind::resolve_data_dir()?; let (bitcoind, bitcoin_cli) = bitcoind::start(&config.bitcoind_config(), &data_path)?; - let (lnd, coverage_pipes, pubkey) = Self::start_lnd(&config, &data_path)?; + let (lnd, liveness_pipes, pubkey) = Self::start_lnd(&config, &data_path)?; let addr = SocketAddr::from(([127, 0, 0, 1], config.lnd_p2p_port)); log::info!("Both daemons are running, ready to fuzz"); @@ -295,7 +295,7 @@ impl Target for LndTarget { Ok(Self { lnd, bitcoind, - coverage_pipes, + liveness_pipes, pubkey, addr, bitcoin_cli, @@ -320,9 +320,8 @@ impl Target for LndTarget { } fn check_alive(&mut self) -> Result<(), TargetError> { - // If we have coverage pipes, sync triggers coverage copy AND detects crashes - if let Some(pipes) = &mut self.coverage_pipes { - pipes.sync().map_err(|_| TargetError::Crashed)?; + if let Some(pipes) = &mut self.liveness_pipes { + pipes.check().map_err(|_| TargetError::Crashed)?; } else { // No pipes (local mode) - just check process is running if !self.lnd.is_running() { diff --git a/workloads/lnd/Dockerfile b/workloads/lnd/Dockerfile index dc2671ef..4b767241 100644 --- a/workloads/lnd/Dockerfile +++ b/workloads/lnd/Dockerfile @@ -57,9 +57,12 @@ RUN wget https://bitcoincore.org/bin/bitcoin-core-${BITCOIN_VERSION}/bitcoin-${B WORKDIR /lnd RUN cd cmd/lncli && go build -# Copy sancov.go and build LND with coverage instrumentation +# Copy sancov.go and build LND with coverage instrumentation. align.ld +# page-aligns the coverage counters so sancov.go can map them onto AFL's map. COPY ./workloads/lnd/sancov.go /lnd/sancov.go -RUN cd cmd/lnd && CGO_ENABLED=1 go build -v -tags=libfuzzer -gcflags=all=-d=libfuzzer +COPY ./workloads/lnd/align.ld /lnd/align.ld +RUN cd cmd/lnd && CGO_ENABLED=1 go build -v -tags=libfuzzer -gcflags=all=-d=libfuzzer \ + -ldflags="-extldflags=-Wl,-T,/lnd/align.ld" # Copy smite workspace files and build all scenario binaries WORKDIR /smite diff --git a/workloads/lnd/align.ld b/workloads/lnd/align.ld new file mode 100644 index 00000000..1df440f1 --- /dev/null +++ b/workloads/lnd/align.ld @@ -0,0 +1,7 @@ +/* Gives Go's libfuzzer counter section its own pages, so sancov.go can remap + * them onto AFL's shared memory without touching neighboring data. */ +SECTIONS +{ + .go.fuzzcntrs ALIGN(4096) : { *(.go.fuzzcntrs) . = ALIGN(4096); } +} +INSERT AFTER .bss; diff --git a/workloads/lnd/sancov.go b/workloads/lnd/sancov.go index dac197bd..3068640c 100644 --- a/workloads/lnd/sancov.go +++ b/workloads/lnd/sancov.go @@ -3,81 +3,68 @@ package lnd /* #cgo CFLAGS: -fPIC +#define _GNU_SOURCE #include #include #include #include -#include +#include #include +#include -static uint8_t *__coverage_map = NULL; -static size_t __coverage_map_size = 0; -static int __coverage_initialized = 0; - -// Track multiple counter regions (from different instrumented modules). -#define MAX_COUNTER_REGIONS 128 - -struct counter_region { - uint8_t *start; - uint8_t *end; -}; +static void fatal(const char *msg) { + fprintf(stderr, "sancov: %s\n", msg); + exit(1); +} -static struct counter_region __counter_regions[MAX_COUNTER_REGIONS]; -static size_t __num_regions = 0; -static size_t __total_counters = 0; +// Called once by Go's libfuzzer runtime with the bounds of the counter section. +// +// Remaps the counter pages onto AFL's shared memory, so instrumented code +// writes coverage straight into AFL's map. Relies on align.ld giving the +// section its own pages. +void __sanitizer_cov_8bit_counters_init(char *start, char *end) { + static int initialized = 0; + size_t size = (size_t)(end - start); -static int __init_coverage_map(void) { - if (__coverage_initialized) { - return 1; + if (getenv("AFL_DUMP_MAP_SIZE")) { + printf("%zu\n", size); + exit(0); } const char *shm_id_str = getenv("__AFL_SHM_ID"); if (!shm_id_str) { - printf("Warning: __AFL_SHM_ID not set, coverage tracking disabled\n"); - return 0; + return; // Not fuzzing. } - const char *map_size_str = getenv("AFL_MAP_SIZE"); - if (!map_size_str) { - printf("Warning: AFL_MAP_SIZE not set, coverage tracking disabled\n"); - return 0; + if (initialized) { + fatal("counters registered twice"); } + initialized = 1; - int shm_id = atoi(shm_id_str); - if (shm_id < 0) { - printf("Warning: Invalid __AFL_SHM_ID value: %s\n", shm_id_str); - return 0; + size_t page = (size_t)sysconf(_SC_PAGESIZE); + if ((uintptr_t)start % page != 0) { + fatal("counter section is not page-aligned, was LND linked with align.ld?"); } - __coverage_map = (uint8_t *)shmat(shm_id, NULL, 0); - if (__coverage_map == (void *)-1) { - printf("Warning: Failed to attach to shared memory segment %d\n", shm_id); - __coverage_map = NULL; - return 0; + int shm_id = atoi(shm_id_str); + struct shmid_ds ds; + if (shmctl(shm_id, IPC_STAT, &ds) == -1) { + fatal("failed to stat the AFL shared memory segment"); + } + if (ds.shm_segsz < size) { + fatal("AFL map is smaller than the counter section"); } - __coverage_map_size = (size_t)atoi(map_size_str); - - printf("Coverage map initialized: %p (size: %zu)\n", __coverage_map, - __coverage_map_size); - __coverage_initialized = 1; - return 1; -} - -// Copy all counter regions to AFL shared memory. -void sancov_copy_coverage_to_shmem(void) { - if (!__coverage_map || __num_regions == 0) { - return; + uint8_t *shm = (uint8_t *)shmat(shm_id, NULL, 0); + if (shm == (void *)-1) { + fatal("failed to attach the AFL shared memory segment"); } - size_t offset = 0; - for (size_t i = 0; i < __num_regions && offset < __coverage_map_size; ++i) { - size_t region_size = __counter_regions[i].end - __counter_regions[i].start; - size_t copy_size = region_size; - if (offset + copy_size > __coverage_map_size) { - copy_size = __coverage_map_size - offset; - } - memcpy(__coverage_map + offset, __counter_regions[i].start, copy_size); - offset += copy_size; + // Keep hits recorded before this point, then move only the counter pages. + // The rest of the segment (e.g. the scenario's map) stays where it is. + memcpy(shm, start, size); + size_t len = (size + page - 1) / page * page; + if (mremap(shm, len, len, MREMAP_MAYMOVE | MREMAP_FIXED, start) == MAP_FAILED) { + fatal("failed to remap the counters onto the AFL map"); } } @@ -86,37 +73,6 @@ void __sanitizer_cov_pcs_init(const uintptr_t *pcs_beg, // PC table not used for AFL coverage. } -// Called by Go's libfuzzer instrumentation to register coverage counters. -// May be called multiple times if multiple modules are instrumented. -void __sanitizer_cov_8bit_counters_init(char *start, char *end) { - const char *dump_map_size_str = getenv("AFL_DUMP_MAP_SIZE"); - if (dump_map_size_str) { - printf("%zu\n", (size_t)(end - start)); - exit(0); - } - - __init_coverage_map(); - - if (__num_regions >= MAX_COUNTER_REGIONS) { - fprintf(stderr, "Error: Too many counter regions (max %d)\n", - MAX_COUNTER_REGIONS); - exit(1); - } - size_t region_size = end - start; - __counter_regions[__num_regions].start = (uint8_t *)start; - __counter_regions[__num_regions].end = (uint8_t *)end; - ++__num_regions; - __total_counters += region_size; - - printf("Registered counter region %zu: %zu counters\n", __num_regions, - region_size); - - if (__total_counters > __coverage_map_size) { - printf("Warning: Total counter size (%zu) exceeds map size (%zu)\n", - __total_counters, __coverage_map_size); - } -} - // Empty stubs for comparison tracing hooks. Go's libfuzzer instrumentation // emits calls to these, so we need to provide them to satisfy the linker. // Marked weak so they can be overridden by real implementations if desired. @@ -144,8 +100,10 @@ __attribute__((weak)) void __sanitizer_cov_trace_const_cmp4(uint32_t arg1, __attribute__((weak)) void __sanitizer_cov_trace_const_cmp8(uint64_t arg1, uint64_t arg2) {} -__attribute__((weak)) void __sanitizer_weak_hook_strcmp(const char *s1, - const char *s2) {} +__attribute__((weak)) void __sanitizer_weak_hook_strcmp(void *caller_pc, + const char *s1, + const char *s2, + int result) {} */ import "C" @@ -154,16 +112,16 @@ import ( ) // This file provides coverage tracking for Go programs built with -d=libfuzzer. -// It integrates with AFL's shared memory coverage tracking. +// The C code above maps the coverage counters onto AFL's shared memory, so no +// per-execution work is needed to report coverage. // -// Coverage sync is triggered via pipe IPC: -// - Scenario requests a sync by writing a byte to the trigger fd -// - Coverage loop reads from the trigger fd, copies coverage, and writes to the -// ack fd -// - Scenario reads from ack fd (synchronous handshake) +// The Go code answers the scenario's liveness handshake over pipes: +// - Scenario writes a byte to the trigger fd +// - We echo it on the ack fd +// - Scenario reads the ack; EOF means LND died func init() { - // Only start coverage loop if we're in fuzzing mode + // Only answer the handshake if we're in fuzzing mode if os.Getenv("__AFL_SHM_ID") == "" { return } @@ -171,8 +129,8 @@ func init() { // Any scenario that starts LND as a subprocess must set FDs as follows: // 3: read end of trigger pipe // 4: write end of ack pipe - triggerFile := os.NewFile(uintptr(3), "coverage_trigger") - ackFile := os.NewFile(uintptr(4), "coverage_ack") + triggerFile := os.NewFile(uintptr(3), "liveness_trigger") + ackFile := os.NewFile(uintptr(4), "liveness_ack") go func() { defer triggerFile.Close() @@ -180,15 +138,11 @@ func init() { buf := make([]byte, 1) for { - // Wait for request to copy coverage _, err := triggerFile.Read(buf) if err != nil { return // Pipe closed, exit loop } - C.sancov_copy_coverage_to_shmem() - - // Signal that coverage has been copied ackFile.Write(buf) } }()