From 1354cba09300dfd880773ef8e81dcd845d8481a1 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Thu, 16 Jul 2026 17:42:05 -0700 Subject: [PATCH 1/2] fix(service): gate system-level install against a user-writable binary (#565 LPE) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A system-level dig-node service runs as a privileged principal (Windows LocalSystem / a root daemon) and records the currently-running binary as its ExecStart / SCM binPath / launchd ProgramArguments. If that binary sits in a user-writable directory, a non-privileged local user can replace it and gain persistent SYSTEM/root code execution on the next service start — the §565 LPE class the dig-dns #23/#24 gates surfaced. `install` now verifies the program's directory is privileged-owned before registering a system-level service, refusing with PERMISSION_DENIED (before any side effect) otherwise. It reuses the existing spawn-free owner gate (crate::security::dir_is_privileged) shared with the self-heal spawn root (#565) and TLS material root (#661), so the three never drift. User-level installs (the Linux/macOS default) run as the invoking user, cross no privilege boundary, and stay allowed. Refs #700 Co-Authored-By: Claude --- CHANGELOG.md | 5 ++ Cargo.lock | 2 +- Cargo.toml | 2 +- SPEC.md | 17 ++++++ crates/dig-node-service/src/service.rs | 79 ++++++++++++++++++++++++++ 5 files changed, 103 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71a9aab..15101ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to this project are documented here. This project adheres to [Semantic Versioning](https://semver.org) and [Conventional Commits](https://www.conventionalcommits.org). +## [0.37.1] - 2026-07-16 + +### Bug Fixes +- **service:** Refuse a system-level `install` whose binary is in a user-writable dir (#565 LPE gate) + ## [0.37.0] - 2026-07-17 ### Features diff --git a/Cargo.lock b/Cargo.lock index ae2e166..03955de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1983,7 +1983,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.37.0" +version = "0.37.1" dependencies = [ "axum", "axum-server", diff --git a/Cargo.toml b/Cargo.toml index c4a0b2b..7b518ec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,7 @@ edition = "2021" # the ROOT manifest (`[workspace.package].version`), so it MUST be set here for a # release to fire (§3.6). The library crates (dig-node-core/dig-runtime/dig-wallet) # keep their own independent versions — only the released binary tracks the workspace version. -version = "0.37.0" +version = "0.37.1" # Release hardening, matching digstore: keep integer-overflow checks ON in release. # The node parses untrusted serialized input and does offset/length arithmetic over diff --git a/SPEC.md b/SPEC.md index d031eaa..5a9a208 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1457,6 +1457,23 @@ service does not hit Windows `CreateService` error 1073 ("the specified service service-manager-generated unit whose `Description` is the service id, matching `dig-dns`'s own established precedent for the CLI-only path. +9.2c. **Privileged-target gate (`install`, #565 LPE).** A **system-level** registration (Windows +SCM, always LocalSystem; a root systemd/launchd daemon) records the currently-running binary as its +`ExecStart` / SCM `binPath` / launchd `ProgramArguments` (§9.2). If that binary sits in a +user-writable directory, a non-privileged local user could replace it and gain persistent +SYSTEM/root code execution on the next service start — a privilege-escalation vector. So before +registering a system-level service, `install` MUST verify the program's directory is +**privileged-owned** (Unix: `root`/uid 0 and no group/other write bit; Windows: an owner SID equal +to the well-known LocalSystem `S-1-5-18` or BUILTIN\Administrators `S-1-5-32-544`) and **refuse with +`PERMISSION_DENIED`** otherwise, before any side effect (no state-dir harden, no service create). +This is the SAME spawn-free owner gate the self-heal spawn root (§7 #565) and the TLS material root +(§4.1a #661) use — one shared check, fail-closed on an indeterminate owner. A **user-level** install +(the Linux/macOS default) runs as the very user who owns the binary, crosses no privilege boundary, +and is always allowed. The canonical install path (native OS package, §9.7) places the binary in a +protected admin-owned location (`%ProgramFiles%\DIG Network\dig-node\`, `/usr/…`), so it satisfies +the gate; a manual `dig-node install` from a user-writable download directory is what the gate +refuses. + 9.3. **Entrypoint per platform.** The installed service runs `dig-node run-service` on Windows and `dig-node run` on systemd/launchd (which exec the foreground process directly). diff --git a/crates/dig-node-service/src/service.rs b/crates/dig-node-service/src/service.rs index b1ee961..f938574 100644 --- a/crates/dig-node-service/src/service.rs +++ b/crates/dig-node-service/src/service.rs @@ -374,6 +374,46 @@ fn current_exe() -> io::Result { std::env::current_exe() } +/// Refuse to register a PRIVILEGED (system-level) service whose program binary lives in a +/// user-writable directory — the §565 privilege-escalation class. +/// +/// A system-level service runs as a privileged principal (Windows LocalSystem / a root daemon). +/// If its recorded `ExecStart` / SCM `binPath` / launchd `ProgramArguments` points at a binary a +/// non-privileged user can replace, that user gains PERSISTENT privileged code execution: swap the +/// file, wait for the next service start, and the swapped code runs as SYSTEM/root. So before +/// registering a system-level service, its program's directory MUST be privileged-owned +/// (root/SYSTEM, no group/world write) — verified through the SAME spawn-free owner gate the +/// self-heal spawn root (#565) and the TLS material root (#661) use ([`crate::security`]), so the +/// three never drift. Fails CLOSED: an indeterminate owner is refused. +/// +/// A **user-level** install (Linux systemd / macOS launchd, the default there) runs as the very +/// user who owns the binary — there is no privilege boundary to cross — so it is always allowed. +fn ensure_service_target_is_safe(program: &std::path::Path, user_level: bool) -> io::Result<()> { + // A user-level service runs as the installing user: swapping a binary that user already owns + // grants that user nothing it lacked. No privilege boundary, no LPE — always allowed. + if user_level { + return Ok(()); + } + // The program's directory is the surface an attacker would need write access to; a + // privileged-owned directory keeps a non-privileged user from replacing the binary in it. + let root = program.parent().unwrap_or(program); + if crate::security::dir_is_privileged(root) { + return Ok(()); + } + Err(io::Error::new( + io::ErrorKind::PermissionDenied, + format!( + "dig-node: refusing to register a system-level (privileged) service pointing at \ + \"{}\", whose directory is writable by a non-privileged user. Registering it would \ + let any local user replace that binary and gain persistent SYSTEM/root code \ + execution (a privilege-escalation vector, #565). Install dig-node into a protected, \ + admin-owned location — via the DIG installer or a native OS package — and re-run \ + `dig-node install` from there.", + program.display() + ), + )) +} + /// On Windows, is this process running elevated (Administrator)? Used to fail /// `install`/`uninstall` early with a helpful message instead of a cryptic SCM /// access-denied. Always `true` off Windows (those paths are user-level). @@ -620,6 +660,11 @@ pub fn install(config: &Config) -> io::Result { let backend = SystemServiceBackend::new()?; let program = current_exe()?; + // §565 LPE gate: before touching anything, refuse a privileged (system-level) registration + // whose program binary sits in a user-writable directory — a swapped binary would run as + // SYSTEM/root on the next start. Checked FIRST so a refusal has no side effects (no state-dir + // harden, no service create). A user-level install runs as the invoking user and is allowed. + ensure_service_target_is_safe(&program, backend.user_level())?; let plan = build_plan(config, program.clone()); // HARDEN the machine-wide state dir NOW, as the INSTALLING (interactive) user, per the @@ -1020,6 +1065,40 @@ SERVICE_NAME: net.dignetwork.dig-node assert!(!is_2xx_status_line("")); } + // -- §565 privileged-install LPE gate (ensure_service_target_is_safe) ------------------- + + #[test] + fn user_level_install_is_always_allowed_regardless_of_binary_owner() { + // A user-level service runs as the installing user, so a user-writable program dir crosses + // NO privilege boundary — the gate must not refuse it even from a plainly user-owned dir. + let dir = tempfile::tempdir().unwrap(); + let program = dir.path().join("dig-node"); + assert!( + ensure_service_target_is_safe(&program, /* user_level */ true).is_ok(), + "a user-level install from a user-owned dir must be allowed" + ); + } + + #[test] + fn system_level_install_from_a_user_writable_dir_is_refused() { + // The §565 LPE: a privileged (system-level) service pointing at a binary in a + // user-writable directory lets any local user swap it for persistent SYSTEM/root exec. + // A freshly-created tempdir is owned by the (non-privileged) test user — exactly that + // condition — so registration MUST fail closed with PERMISSION_DENIED. + let dir = tempfile::tempdir().unwrap(); + let program = dir.path().join("dig-node"); + let err = ensure_service_target_is_safe(&program, /* user_level */ false) + .expect_err("a system-level install from a user-writable dir must be refused"); + assert_eq!(err.kind(), io::ErrorKind::PermissionDenied); + // The message must name the LPE class + the offending path so an operator can act. + let msg = err.to_string(); + assert!(msg.contains("#565"), "message cites the LPE class: {msg}"); + assert!( + msg.contains(&program.display().to_string()), + "message names the offending program path: {msg}" + ); + } + // -- the real OS-backed path (no state mutation): probe + status only ------------------- #[test] From 450262c0561b6cd23efbfb4929227ae968595fc9 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Thu, 16 Jul 2026 17:48:13 -0700 Subject: [PATCH 2/2] fix(service): add default-off insecure-target opt-out for test/dev installs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The §565 privileged-target gate would refuse the service-smoke CI job's system-level Windows install of target/release/dig-node from the runner's user-writable checkout (a legitimate test of an unreleased build). Add an explicit, default-off DIG_NODE_ALLOW_INSECURE_SERVICE_TARGET opt-out (truthy 1/true/yes) that bypasses the gate with a loud warning, set only in the service-smoke workflow; end-user installs via the native package land in a protected dir and never need it. SPEC §9.2c documents it. Refs #700 Co-Authored-By: Claude --- .github/workflows/service-smoke.yml | 7 ++ SPEC.md | 5 +- crates/dig-node-service/src/service.rs | 94 ++++++++++++++++++++++++-- 3 files changed, 100 insertions(+), 6 deletions(-) diff --git a/.github/workflows/service-smoke.yml b/.github/workflows/service-smoke.yml index 1b7a5c7..493961b 100644 --- a/.github/workflows/service-smoke.yml +++ b/.github/workflows/service-smoke.yml @@ -29,6 +29,13 @@ concurrency: env: CARGO_TERM_COLOR: always + # This smoke test installs the freshly-built `target/release/dig-node` from the runner's + # user-writable checkout. On Windows that registration is system-level (LocalSystem), which the + # §565 privileged-target gate refuses for a user-writable binary dir (a real LPE protection — + # see service.rs). This explicit, default-off opt-out permits the controlled test install of an + # unreleased build; end users install via the native OS package into a protected admin-owned dir + # (never this env). It is a harmless no-op on the Linux/macOS user-level installs. + DIG_NODE_ALLOW_INSECURE_SERVICE_TARGET: "1" jobs: smoke: diff --git a/SPEC.md b/SPEC.md index 5a9a208..52fb996 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1472,7 +1472,10 @@ This is the SAME spawn-free owner gate the self-heal spawn root (§7 #565) and t and is always allowed. The canonical install path (native OS package, §9.7) places the binary in a protected admin-owned location (`%ProgramFiles%\DIG Network\dig-node\`, `/usr/…`), so it satisfies the gate; a manual `dig-node install` from a user-writable download directory is what the gate -refuses. +refuses. A single explicit, **default-off** opt-out — the `DIG_NODE_ALLOW_INSECURE_SERVICE_TARGET` +env var (truthy `1`/`true`/`yes`) — bypasses the gate with a loud warning, intended ONLY for a +controlled test/dev install of an unreleased build from a build directory (e.g. the `service-smoke` +CI); it MUST NOT be set on an end-user machine. 9.3. **Entrypoint per platform.** The installed service runs `dig-node run-service` on Windows and `dig-node run` on systemd/launchd (which exec the foreground process directly). diff --git a/crates/dig-node-service/src/service.rs b/crates/dig-node-service/src/service.rs index f938574..1aad351 100644 --- a/crates/dig-node-service/src/service.rs +++ b/crates/dig-node-service/src/service.rs @@ -374,6 +374,25 @@ fn current_exe() -> io::Result { std::env::current_exe() } +/// The opt-in escape hatch that bypasses the §565 privileged-target gate +/// ([`ensure_service_target_is_safe`]). Set to a truthy value ONLY for a controlled test/dev +/// install of an unreleased build from a build directory (e.g. the `service-smoke` CI job installs +/// `target/release/dig-node` from the runner's user-writable checkout). It is default-OFF and MUST +/// NOT be set on an end-user machine — the canonical install (native OS package, §9.7) always lands +/// the binary in a protected admin-owned directory and never needs it. +const ALLOW_INSECURE_SERVICE_TARGET_ENV: &str = "DIG_NODE_ALLOW_INSECURE_SERVICE_TARGET"; + +/// Whether [`ALLOW_INSECURE_SERVICE_TARGET_ENV`] is set to a truthy value (`1`/`true`/`yes`, +/// case-insensitive). Any other value — or an unset var — leaves the gate ENABLED (default-safe). +fn insecure_service_target_allowed() -> bool { + std::env::var(ALLOW_INSECURE_SERVICE_TARGET_ENV) + .map(|v| { + let v = v.trim().to_ascii_lowercase(); + v == "1" || v == "true" || v == "yes" + }) + .unwrap_or(false) +} + /// Refuse to register a PRIVILEGED (system-level) service whose program binary lives in a /// user-writable directory — the §565 privilege-escalation class. /// @@ -388,7 +407,13 @@ fn current_exe() -> io::Result { /// /// A **user-level** install (Linux systemd / macOS launchd, the default there) runs as the very /// user who owns the binary — there is no privilege boundary to cross — so it is always allowed. -fn ensure_service_target_is_safe(program: &std::path::Path, user_level: bool) -> io::Result<()> { +/// `allow_insecure_override` is the explicit test/dev opt-out +/// ([`ALLOW_INSECURE_SERVICE_TARGET_ENV`]); it is default-`false` in production. +fn ensure_service_target_is_safe( + program: &std::path::Path, + user_level: bool, + allow_insecure_override: bool, +) -> io::Result<()> { // A user-level service runs as the installing user: swapping a binary that user already owns // grants that user nothing it lacked. No privilege boundary, no LPE — always allowed. if user_level { @@ -400,6 +425,18 @@ fn ensure_service_target_is_safe(program: &std::path::Path, user_level: bool) -> if crate::security::dir_is_privileged(root) { return Ok(()); } + // Explicit, default-off test/dev opt-out — a controlled install of an unreleased build from a + // build directory (see the env-var doc). Never set on an end-user machine. + if allow_insecure_override { + eprintln!( + "dig-node: WARN {ALLOW_INSECURE_SERVICE_TARGET_ENV} is set — registering a \ + system-level service pointing at \"{}\", a user-writable directory. This is a \ + privilege-escalation risk (#565) and is intended ONLY for test/dev installs of an \ + unreleased build.", + program.display() + ); + return Ok(()); + } Err(io::Error::new( io::ErrorKind::PermissionDenied, format!( @@ -664,7 +701,11 @@ pub fn install(config: &Config) -> io::Result { // whose program binary sits in a user-writable directory — a swapped binary would run as // SYSTEM/root on the next start. Checked FIRST so a refusal has no side effects (no state-dir // harden, no service create). A user-level install runs as the invoking user and is allowed. - ensure_service_target_is_safe(&program, backend.user_level())?; + ensure_service_target_is_safe( + &program, + backend.user_level(), + insecure_service_target_allowed(), + )?; let plan = build_plan(config, program.clone()); // HARDEN the machine-wide state dir NOW, as the INSTALLING (interactive) user, per the @@ -1070,11 +1111,12 @@ SERVICE_NAME: net.dignetwork.dig-node #[test] fn user_level_install_is_always_allowed_regardless_of_binary_owner() { // A user-level service runs as the installing user, so a user-writable program dir crosses - // NO privilege boundary — the gate must not refuse it even from a plainly user-owned dir. + // NO privilege boundary — the gate must not refuse it even from a plainly user-owned dir, + // and without needing the insecure override. let dir = tempfile::tempdir().unwrap(); let program = dir.path().join("dig-node"); assert!( - ensure_service_target_is_safe(&program, /* user_level */ true).is_ok(), + ensure_service_target_is_safe(&program, /* user_level */ true, false).is_ok(), "a user-level install from a user-owned dir must be allowed" ); } @@ -1087,7 +1129,7 @@ SERVICE_NAME: net.dignetwork.dig-node // condition — so registration MUST fail closed with PERMISSION_DENIED. let dir = tempfile::tempdir().unwrap(); let program = dir.path().join("dig-node"); - let err = ensure_service_target_is_safe(&program, /* user_level */ false) + let err = ensure_service_target_is_safe(&program, /* user_level */ false, false) .expect_err("a system-level install from a user-writable dir must be refused"); assert_eq!(err.kind(), io::ErrorKind::PermissionDenied); // The message must name the LPE class + the offending path so an operator can act. @@ -1099,6 +1141,48 @@ SERVICE_NAME: net.dignetwork.dig-node ); } + #[test] + fn insecure_override_permits_a_system_level_install_from_a_user_writable_dir() { + // The explicit, default-off test/dev opt-out lets a controlled install of an unreleased + // build proceed from a user-writable build dir (e.g. the service-smoke CI job installing + // target/release/dig-node). It is default-off, so this branch only opens when set. + let dir = tempfile::tempdir().unwrap(); + let program = dir.path().join("dig-node"); + assert!( + ensure_service_target_is_safe(&program, /* user_level */ false, true).is_ok(), + "the explicit insecure override must permit the otherwise-refused system-level install" + ); + } + + #[test] + fn insecure_override_env_parses_only_truthy_values() { + // The env reader is default-safe: unset or any non-truthy value keeps the gate ENABLED. + // (Uses process env, so restore it to avoid leaking into sibling tests.) + let prev = std::env::var(ALLOW_INSECURE_SERVICE_TARGET_ENV).ok(); + for (val, expected) in [ + ("1", true), + ("true", true), + ("YES", true), + ("0", false), + ("false", false), + ("", false), + ("nope", false), + ] { + std::env::set_var(ALLOW_INSECURE_SERVICE_TARGET_ENV, val); + assert_eq!( + insecure_service_target_allowed(), + expected, + "{val:?} must parse to {expected}" + ); + } + std::env::remove_var(ALLOW_INSECURE_SERVICE_TARGET_ENV); + assert!(!insecure_service_target_allowed(), "unset ⇒ gate enabled"); + match prev { + Some(v) => std::env::set_var(ALLOW_INSECURE_SERVICE_TARGET_ENV, v), + None => std::env::remove_var(ALLOW_INSECURE_SERVICE_TARGET_ENV), + } + } + // -- the real OS-backed path (no state mutation): probe + status only ------------------- #[test]