Skip to content

Commit d42ae92

Browse files
committed
fix(interpreter): bound nested bash/sh child shells
A script that re-runs itself through `sh` (`echo 'sh s.sh' > s.sh; sh s.sh`) nested in-process interpreters until the process stack overflowed, aborting every tenant in the process. Child shells now count against max_function_depth and a dedicated cap of 8 (TM-DOS-125, L-PROC-004). Claude-Session: https://claude.ai/code/session_019aFikmptPc91Fj4N2iDXQA
1 parent e262ff8 commit d42ae92

7 files changed

Lines changed: 71 additions & 0 deletions

File tree

‎crates/bashkit/docs/threat-model.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ through configurable limits.
116116
| Input wait vs. timeout (TM-DOS-120) | Abusing the keyboard-wait exclusion to run work past the deadline | Only time blocked reading the terminal is excluded; CPU work and `sleep` still count | MITIGATED |
117117
| Concurrent pipeline amplification (TM-DOS-124) | An endless loop piped into `head`, a producer whose reader quit, or recursive pipelines `f() { f \| f; }` | 4 KiB pipes with backpressure; a writer whose reader is gone ends with 141 (SIGPIPE); streaming nesting counts toward `max_subshell_depth` and falls back to sequential stages past 4 levels; stages share the session budget, timeout and cancellation | MITIGATED |
118118
| Random generator amplification (TM-DOS-123) | `openssl rand` asked for gigabytes | Requests capped at 1 MiB; fixed-size `uuidgen`/`$SRANDOM` | MITIGATED |
119+
| Child-shell recursion (TM-DOS-125) | Script that re-runs itself via `sh` | Child shells count against function depth, max 8 nested | MITIGATED |
119120
| Unbounded builtin output (TM-DOS-058) | `seq 1 1000000` produces 1M lines | Add `max_stdout_bytes` limit | **MITIGATED** |
120121
| Silent truncation at builtin caps (TM-DOS-109) | `seq 200000`, an awk loop past its cap, or an oversized `sprintf` expression returns incomplete output with exit 0 | Caps report `<cmd>: <what> limit (<N>) exceeded` on stderr and exit non-zero; awk caps and formatting errors are fatal | **MITIGATED** |
121122
| In-builtin memory growth (TM-DOS-110) | `awk 'BEGIN { s = "x"; while (1) s = s s }'` or `jq -n '"x" \| until(false; . + .)'` allocates until the host aborts | awk checks each string against a 16 MiB cap before allocating it, caps `$N` field indexes, and caps total variable memory at `max_live_intermediate_bytes` (fatal, exit 2). jq meters every live string, array and object against the same limit and fails before growing (exit 5); non-emitting jq loops and pure recursion stop at the timeout; non-tail recursion hits a live-context ceiling (64) before host stack exhaustion | **MITIGATED** |

‎crates/bashkit/src/interpreter/mod.rs‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,10 @@ const ENV_SHELL_ONLY_BUILTINS: &[&str] = &[
424424
"wait",
425425
];
426426

427+
/// Nested `bash`/`sh` cap (TM-DOS-125). Sized so the deepest nesting fits a
428+
/// 2 MiB debug-build stack with room for functions inside each level.
429+
const MAX_CHILD_SHELL_DEPTH: usize = 8;
430+
427431
const SPECIAL_BUILTIN_NAMES: &[&str] = &[
428432
".", "bash", "builtin", "command", "declare", "eval", "exec", "getopts", "let", "local", "sh",
429433
"source", "typeset", "unset",
@@ -1508,6 +1512,8 @@ pub struct Interpreter {
15081512
/// Nesting depth of `execute_script_body`; finished background job output
15091513
/// is delivered only between top-level (depth 1) commands.
15101514
script_depth: usize,
1515+
/// Nested `bash`/`sh` child shells (TM-DOS-125).
1516+
child_shell_depth: usize,
15111517
}
15121518

15131519
struct ArithmeticExpansionState {
@@ -2002,6 +2008,7 @@ impl Interpreter {
20022008
seconds_base: (crate::time_compat::Instant::now(), 0),
20032009
concurrent_jobs: true,
20042010
script_depth: 0,
2011+
child_shell_depth: 0,
20052012
}
20062013
}
20072014

@@ -2153,6 +2160,8 @@ impl Interpreter {
21532160
seconds_base: self.seconds_base,
21542161
concurrent_jobs: self.concurrent_jobs,
21552162
script_depth: 1,
2163+
// Forks are polled on this task's stack, so they inherit its depth.
2164+
child_shell_depth: self.child_shell_depth,
21562165
}
21572166
}
21582167

@@ -5268,6 +5277,19 @@ impl Interpreter {
52685277
// mutations the child performs must not leak back to the parent.
52695278
// Snapshot first so a full restore handles both directions; then
52705279
// wipe the isolated state before running. See issue #1777.
5280+
// THREAT[TM-DOS-125]: each child shell adds a deep chain of stack
5281+
// frames; without a cap a script that runs itself via `sh` overflows
5282+
// the process stack. It counts against the function depth and has a
5283+
// tighter cap of its own (a level costs several function calls).
5284+
if self.child_shell_depth >= MAX_CHILD_SHELL_DEPTH
5285+
|| self.counters.push_function(&self.limits).is_err()
5286+
{
5287+
return Ok(ExecResult::err(
5288+
format!("{shell_name}: maximum nesting depth exceeded\n"),
5289+
2,
5290+
));
5291+
}
5292+
self.child_shell_depth += 1;
52715293
let child_snapshot = self.snapshot_subshell_state();
52725294
self.reset_state_for_child_shell();
52735295

@@ -5319,6 +5341,8 @@ impl Interpreter {
53195341
// Restore parent state — full revert of the snapshot since the child
53205342
// is process-isolated. This also undoes OPTIND/SHOPT_* writes above.
53215343
self.restore_subshell_state(child_snapshot);
5344+
self.counters.pop_function();
5345+
self.child_shell_depth -= 1;
53225346

53235347
match result {
53245348
Ok(exec_result) => self.apply_redirections(exec_result, redirects).await,

‎crates/bashkit/tests/integration/limitations_evidence_tests.rs‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,3 +183,22 @@ async fn l_pipe_001_stages_run_sequentially() {
183183
assert_eq!(result.stdout, "y\n1 0\n");
184184
assert!(result.stderr.contains("output limit"), "{}", result.stderr);
185185
}
186+
187+
/// L-PROC-004: child shells nest at most 8 deep.
188+
#[tokio::test]
189+
async fn l_proc_004_child_shell_depth() {
190+
let mut bash = Bash::new();
191+
// /tmp/dN.sh runs /tmp/d(N+1).sh; /tmp/d9.sh prints.
192+
let setup = "for i in 1 2 3 4 5 6 7 8; do echo \"sh /tmp/d$((i+1)).sh\" > /tmp/d$i.sh; done; echo 'echo deep' > /tmp/d9.sh";
193+
bash.exec(setup).await.unwrap();
194+
// 8 nested shells: d2.sh .. d9.sh.
195+
let r = bash.exec("sh /tmp/d2.sh").await.unwrap();
196+
assert_eq!(r.stdout, "deep\n");
197+
let r = bash.exec("sh /tmp/d1.sh").await.unwrap();
198+
assert_eq!(r.stdout, "");
199+
assert!(
200+
r.stderr.contains("maximum nesting depth exceeded"),
201+
"{}",
202+
r.stderr
203+
);
204+
}

‎crates/bashkit/tests/integration/stack_overflow_regression_tests.rs‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,27 @@ async fn nested_subst_in_arithmetic_no_overflow() {
5656
// Must not panic or SIGABRT
5757
let _ = bash.exec(&script).await;
5858
}
59+
60+
/// THREAT[TM-DOS-125]: a script that re-runs itself through `sh`/`bash`
61+
/// recursed until the process stack overflowed (taking every tenant in the
62+
/// process with it). Child shells now count against the function depth.
63+
#[tokio::test]
64+
async fn recursive_child_shell_is_bounded() {
65+
let mut bash = Bash::new();
66+
let r = bash
67+
.exec("echo 'sh /tmp/s.sh' > /tmp/s.sh; sh /tmp/s.sh; echo rc=$?")
68+
.await;
69+
let r = r.unwrap();
70+
assert!(r.stdout.contains("rc="), "stdout: {}", r.stdout);
71+
assert!(
72+
r.stderr.contains("maximum nesting depth exceeded"),
73+
"stderr: {}",
74+
r.stderr
75+
);
76+
77+
let r = bash
78+
.exec("f() { bash -c 'f() { bash -c f; }; f'; }; f; echo done")
79+
.await
80+
.unwrap();
81+
assert!(r.stdout.contains("done"), "stdout: {}", r.stdout);
82+
}

‎knowledge/log.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## 2026-10-07
44

5+
* **Security**: A script that re-runs itself through `sh`/`bash` crashed the whole process with a stack overflow (TM-DOS-125); nothing bounded nested child shells. They now count against `max_function_depth` plus their own cap of 8, measured as what fits a 2 MiB debug-build stack (about 15 bare `sh` levels fit; a level costs several function calls). Found while bounding `make` recursion.
56
* **Fix**: awk follows POSIX `opt_nls`: a newline after the `)` of an `if`/`for`/`while` header, after `else`/`do`, `&&`, `||` or `,` continues the statement (it became `;`, so `for (...)⏎ body` failed with `unexpected character: ;`, the most frequent awk error in gap telemetry, 22 replayed scripts). `do body; while (...)` accepts the `;`. A regex rule pattern can be an operand (`/=/ && !/^#/`, `/^$/ || /^;/`), and a bare `/re/` in an expression is `$0 ~ /re/` (it was the truthy pattern text, so `if (/^\[/)` matched every line).
67
* **Feature**: Missing everyday commands: `arch`, `sum`, `shasum`, `egrep`, `fgrep`, `link`, `unlink`, `chgrp`, `nohup`, `nice`, `flock`, `getconf`, `tty`, `sync`, `hostid`, `groups`, `logname`, `users`, `who`, `uptime`, `free`. Output matches GNU coreutils/util-linux where the answer is not host identity (spec `system-commands.test.sh` checked against real bash); identity answers (`free`, `uptime`, `groups`, `hostid`) are virtual and agree with `nproc` and `/proc/meminfo`. `nohup`/`nice`/`flock` just run their command: there are no signals, priorities or competing lock holders inside one session. `/bin/bash` and `/bin/sh` now exist in the root filesystem, and `type`/`command -V` report interpreter-dispatched builtins (`builtin`, `command`, `let`, `typeset`, `exec`, `getopts`, `declare`) instead of "not found".
78
* **Fix**: `cmd N>file` with N≥3 used to send the command's stdout into the file, so `( flock -n 9 && ... ) 9>lock` printed nothing. Such redirects now open fd N only: stdout is untouched, the file is created/truncated, and writes to `>&N` inside a compound command or function land in it.

‎knowledge/operations/limitations.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ execution model. Evidence is a threat-model ID, a test, or `stance`
4545
| L-PROC-001 | `exec` does not replace the process; `exec cmd` runs cmd then stops execution (fd redirects work) | True process replace would break sandbox containment | TM-ESC-005 |
4646
| L-PROC-002 | Background jobs run concurrently (`jobs`, `wait -n`, `kill`, `ps`, `pgrep`), but cannot be stopped or resumed: `kill -STOP/-CONT` are ignored, there is no `suspend`/Ctrl-Z, `bg` is a no-op and `fg` just waits; jobs end when `exec()` returns | No terminal process groups in a virtual shell; a job must not outlive the call that owns it (TM-DOS-122) | `l_proc_002_no_job_control` |
4747
| L-PROC-003 | No process spawning; external commands run as builtins | Core sandbox model: no fork/exec escape surface | `l_proc_003_no_process_spawning` |
48+
| L-PROC-004 | `bash`/`sh` child shells nest at most 8 deep (and count against `max_function_depth`); deeper nesting fails `maximum nesting depth exceeded` | Child shells run in-process on one stack; unbounded nesting crashed the process (TM-DOS-125) | `l_proc_004_child_shell_depth` |
4849
| L-PIPE-001 | Pipeline stages stream only from the first stage that runs shell code (loop, group, function, `eval`). Leading single-builtin stages run to completion first and hand over their whole output, so they never get SIGPIPE: `yes \| head -1` gives `PIPESTATUS` `1 0` plus yes's output-cap notice, where bash gives `141 0`. Downstream of a streaming stage, a single builtin reads all its input before it starts, except `head` and `read`: `while :; do echo; done \| cat \| head -1` runs until a limit. Pipes hold 4 KiB, not 64 KiB, so a finite loop writing more than that into an early-exiting reader exits 141 where bash would exit 0. Pipelines nested more than 4 subshells deep run their stages in sequence | Builtins return their output as one value, not a stream; streaming them needs a reader/writer `Context`. The smaller pipe bounds how far a producer runs ahead (each byte costs budgeted commands); the nesting cap bounds native stack (TM-DOS-124) | `l_pipe_001_stages_run_sequentially` |
4950
| L-RAND-001 | `uuidgen` makes random (v4) UUIDs only (no `-t`, `-m`, `-s`); `openssl` implements only `rand` | Time/MAC-based UUIDs would leak host identity; a full TLS/crypto toolkit is out of scope | `uuidgen_time_based_unsupported` |
5051
| L-FS-001 | Symlinks are followed, but `..` after a linked directory resolves lexically (`/link/..` is the link's parent, the `cd -L` view), and `ln` without `-s` (and `link`) makes a symlink, not a hard link | The interpreter normalizes paths before the VFS sees them; the VFS has no inodes to share | `symlink.test.sh`, `ln_default_symbolic` |

‎knowledge/security/threat-model.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,7 @@ runaway scripts without permanently breaking the session.
292292
| TM-DOS-122 | Background job flooding | `while :; do sleep 99 & done`, or a fork bomb `f(){ f & f & }; f`, keeps unbounded concurrent forked interpreters alive | `ExecutionLimits::max_background_jobs` (default 64, `hardened()` 16) caps live jobs; the next `&` prints bash's `fork: retry: Resource temporarily unavailable` and returns 1 without forking. Jobs are polled on the caller's task (no `tokio::spawn`), share the session's command/loop budget, timeout and cancel token, and `exec()` waits for or drops every job before returning, so nothing outlives the call. Regressions: `jobs::tests::*`, `jobs.test.sh` spec cases, `background_job_limit` | **MITIGATED** |
293293
| TM-DOS-123 | Random generator amplification | `openssl rand 99999999999` asks one builtin call for gigabytes of CSPRNG output | `openssl rand` caps a request at `OPENSSL_RAND_MAX_BYTES` (1 MiB) and fails above it; `uuidgen` and `$SRANDOM` emit fixed sizes. Bytes come from `getrandom` (OS CSPRNG), never the `$RANDOM` LCG. Regression: `builtins::random::tests::openssl_rand_limits` | **MITIGATED** |
294294
| TM-DOS-124 | Concurrent pipeline amplification | An endless producer (`while :; do echo x; done \| head`) buffering without bound, a producer whose reader quit running forever, or recursive pipelines (`f() { f \| f; }`) nesting forked stages and native stack | Pipes hold 4 KiB and writers wait at command boundaries; a writer whose reader is gone ends with 141 at its next command; a reader that never reads stdin ends the producer when it finishes. A streaming group counts as one subshell level (`max_subshell_depth`) and past 4 levels stages run in sequence, keeping stack cost near plain function recursion. Stages are forked like jobs: they share the execution budget, timeout and cancel token and never outlive the pipeline. Regressions: `pipeline_tests::threat_endless_producer_into_non_reader_ends`, `threat_recursive_pipeline_is_bounded`, `pipes-redirects.test.sh` SIGPIPE cases | **MITIGATED** |
295+
| TM-DOS-125 | Child-shell recursion stack overflow | A script that runs itself via `sh`/`bash` (`echo 'sh s.sh' > s.sh; sh s.sh`) nested interpreters until the process stack overflowed, aborting every tenant in the process | Each child shell counts against `max_function_depth` and a separate cap of `MAX_CHILD_SHELL_DEPTH` (8) nested shells, sized so the deepest mix fits a 2 MiB debug stack; over the cap the shell fails `maximum nesting depth exceeded` (exit 2). Forked jobs inherit the depth because they are polled on the caller's stack. Regression: `recursive_child_shell_is_bounded` | **MITIGATED** |
295296
| TM-DOS-101 | yq structured-data amplification | YAML aliases can expand exponentially during deserialization; deep YAML/JSON, document floods, jaq generators, and YAML re-serialization can consume stack, CPU, or memory beyond the source size | Reject YAML alias tokens with a bounded lexical pass before deserialization; VFS/stdin and aggregate input budgets; serde_yaml_ng recursion cap 128 plus Bashkit depth 100; 4096-document cap; shared jaq work/deadline/output limits; post-serialization stdout cap; YAML+JSON depth/document/input/output regressions plus `yq_fuzz` and arbitrary-input proptest | **MITIGATED** |
296297
| TM-DOS-107 | Static-analysis command validation amplification | A large comment before thousands of commands makes a whole-script ordered-subsequence scan per command | Build one source-character position index, then validate each analyzed command-name character with a logarithmic position lookup instead of rescanning source | **MITIGATED** |
297298
**TM-DOS-051** is historical. The custom parser was deleted with the `yaml`

0 commit comments

Comments
 (0)