Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions crates/bashkit/docs/threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,12 @@ let bash = Bash::builder()
# }
```

`dd conv=sync` uses checked padding arithmetic and rejects padded lengths above
its 64 MiB copy cap before allocation. Copied payload capacity is reserved through
`BudgetedBytes` against the shared live-memory budget and retained through output
handling, including discarded output. Stdout cached text shares that budget
(TM-DOS-003, TM-DOS-103).

**Additional DoS hardening:**

| Threat | Attack Example | Mitigation | Status |
Expand Down
168 changes: 152 additions & 16 deletions crates/bashkit/src/builtins/dd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@
//! files. Every invocation moves at most `DD_MAX_BYTES`
//! (THREAT[TM-DOS-003]); an unbounded device read without `count=` stops at
//! the cap and exits 1, like dd running out of space.
//! Sync padding is admitted by checked length and live-capacity budget before
//! allocation; the lease remains with the copy buffer through output handling.

use async_trait::async_trait;

use super::limits::DD_MAX_BYTES;
use super::{Builtin, Context};
use crate::error::Result;
use crate::interpreter::ExecResult;
use crate::limits::{BudgetedBytes, ExecutionBudget};

/// `dd` builtin.
pub struct Dd;
Expand Down Expand Up @@ -143,6 +146,37 @@ fn parse_opts(args: &[String]) -> std::result::Result<Opts, String> {
Ok(o)
}

// THREAT[TM-DOS-003] THREAT[TM-DOS-103]: Padding must fit both limits before growth.
fn padded_len(len: usize, ibs: usize, limit: usize) -> Option<usize> {
let remainder = len % ibs;
let padding = if remainder == 0 { 0 } else { ibs - remainder };
len.checked_add(padding).filter(|&size| size <= limit)
}

fn copy_data(
input: &[u8],
ibs: usize,
sync: bool,
budget: Option<&ExecutionBudget>,
) -> Result<BudgetedBytes> {
let len = if sync {
padded_len(input.len(), ibs, DD_MAX_BYTES).ok_or_else(|| {
crate::LimitExceeded::Memory("dd padded data exceeds byte limit".into())
})?
} else {
input.len()
};
let mut buffer = BudgetedBytes::try_with_capacity(budget, len)?;
buffer.try_extend_from_slice(input)?;
// Every append stays within the already admitted full padded capacity.
const ZEROS: [u8; 1024] = [0; 1024];
while buffer.len() < len {
let padding = (len - buffer.len()).min(ZEROS.len());
buffer.try_extend_from_slice(&ZEROS[..padding])?;
}
Ok(buffer)
}

fn device_bytes(path: &str, len: usize) -> Option<std::result::Result<Vec<u8>, String>> {
match path {
"/dev/zero" => Some(Ok(vec![0; len])),
Expand Down Expand Up @@ -211,21 +245,20 @@ impl Builtin for Dd {
}
};
let start = skip_bytes.min(input.len());
let mut data = input[start..].to_vec();
if let Some(w) = want {
data.truncate(w);
}
if data.len() > DD_MAX_BYTES {
data.truncate(DD_MAX_BYTES);
capped = true;
}
let (full_in, partial_in) = (
data.len() / o.ibs,
usize::from(!data.len().is_multiple_of(o.ibs)),
);
if o.sync && partial_in == 1 {
data.resize((full_in + 1) * o.ibs, 0);
}
let available = input.len() - start;
let requested = want.unwrap_or(available).min(available);
capped |= requested > DD_MAX_BYTES;
let len = requested.min(DD_MAX_BYTES);
let budget = ctx
.execution_budget()
.map(|budget| {
budget
.try_with(Clone::clone)
.map_err(|_| crate::Error::Cancelled)
})
.transpose()?;
let (full_in, partial_in) = (len / o.ibs, usize::from(!len.is_multiple_of(o.ibs)));
let mut data = copy_data(&input[start..start + len], o.ibs, o.sync, budget.as_ref())?;
if o.ucase {
data.make_ascii_uppercase();
} else if o.lcase {
Expand All @@ -238,8 +271,18 @@ impl Builtin for Dd {
let copied = data.len();

let mut result = ExecResult::ok(String::new());
let mut _output_storage = [None, None];
match o.output.as_deref() {
None | Some("-") | Some("/dev/stdout") => result = ExecResult::ok_bytes(data),
None | Some("-") | Some("/dev/stdout") => {
let stdout = if let Some(budget) = &budget {
let (stdout, storage) = crate::StreamData::from_budgeted_bytes(data, budget)?;
_output_storage = storage;
stdout
} else {
crate::StreamData::from(data.into_parts().0)
};
result = ExecResult::ok(stdout);
}
Some("/dev/null") => {}
Some(path) => {
let p = super::resolve_path(ctx.cwd, path);
Expand Down Expand Up @@ -325,6 +368,99 @@ impl Builtin for Dd {
mod tests {
use super::*;

fn tiny_budget(bytes: u64) -> ExecutionBudget {
ExecutionBudget::new(
&crate::ExecutionLimits::new().max_live_intermediate_bytes(bytes),
std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
)
}

#[test]
fn padding_size_admission_is_checked_without_allocation() {
assert_eq!(padded_len(0, 4, 8), Some(0));
assert_eq!(padded_len(0, usize::MAX, 8), Some(0));
assert_eq!(padded_len(1, usize::MAX, 8), None);
assert_eq!(padded_len(4, 4, 4), Some(4));
assert_eq!(padded_len(5, 4, 8), Some(8));
assert_eq!(padded_len(5, 4, 7), None);
assert_eq!(padded_len(usize::MAX - 1, 4, usize::MAX), None);
}

#[test]
fn padded_copy_requires_live_capacity_admission() {
let budget = tiny_budget(4);
assert!(matches!(
copy_data(b"x", 8, true, Some(&budget)),
Err(crate::Error::ResourceLimit(
crate::LimitExceeded::ExecutionBudget(
crate::ExecutionBudgetExceeded::LiveBytes { .. }
)
))
));
}

#[test]
fn copy_capacity_lease_survives_until_owner_drops() {
let budget = tiny_budget(8);
let (data, lease) = copy_data(b"ab", 4, true, Some(&budget))
.unwrap()
.into_parts();
assert_eq!(data, b"ab\0\0");
assert!(lease.is_some());
let remaining = budget.lease_bytes(8 - data.capacity()).unwrap();
drop(remaining);
drop(data);
drop(lease);
assert!(budget.lease_bytes(8).is_ok());

let budget = tiny_budget(4);
let (_data, _lease) = copy_data(b"ab", 4, true, Some(&budget))
.unwrap()
.into_parts();
assert!(matches!(
budget.lease_bytes(1),
Err(crate::LimitExceeded::ExecutionBudget(
crate::ExecutionBudgetExceeded::LiveBytes { .. }
))
));
}

#[test]
fn ordinary_copy_and_complete_blocks_do_not_pad() {
for (input, ibs, sync, expected) in [
(&b""[..], 8, true, &b""[..]),
(&b"abcd"[..], 4, true, &b"abcd"[..]),
(&b"abc"[..], 8, false, &b"abc"[..]),
(&b"abcde"[..], 4, true, &b"abcde\0\0\0"[..]),
] {
let budget = tiny_budget(8);
let data = copy_data(input, ibs, sync, Some(&budget)).unwrap();
assert_eq!(&*data, expected);
}
}

#[test]
fn stdout_padding_admits_cached_text() {
for limit in [7, 8] {
let budget = tiny_budget(limit);
let data = copy_data(b"ab", 4, true, Some(&budget)).unwrap();
let result = crate::StreamData::from_budgeted_bytes(data, &budget);
if limit == 7 {
assert!(matches!(
result,
Err(crate::LimitExceeded::ExecutionBudget(
crate::ExecutionBudgetExceeded::LiveBytes { .. }
))
));
} else {
let (stream, _storage) = result.unwrap();
assert_eq!(stream.as_bytes(), b"ab\0\0");
assert_eq!(stream.text_lossy(), "ab\0\0");
assert!(budget.lease_bytes(1).is_err());
}
}
}

#[test]
fn parses_sizes_like_gnu() {
assert_eq!(parse_size("512"), Some(512));
Expand Down
121 changes: 121 additions & 0 deletions crates/bashkit/tests/integration/dd_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
//! Tiny ordinary dd copy contracts; admission arithmetic lives in dd unit tests.

use bashkit::{Bash, ExecOptions};

fn is_gnu_dd_version(stdout: &[u8]) -> bool {
// GNU dd prints PACKAGE (`coreutils`), not PACKAGE_NAME (`GNU coreutils`).
stdout.starts_with(b"dd (coreutils) ") || stdout.starts_with(b"dd (GNU coreutils) ")
}

#[test]
fn dd_oracle_recognizes_gnu_package_banner() {
assert!(is_gnu_dd_version(
b"dd (coreutils) 9.4\nCopyright (C) 2023 Free Software Foundation, Inc.\n"
));
assert!(is_gnu_dd_version(b"dd (GNU coreutils) 9.12\n"));
assert!(!is_gnu_dd_version(b"dd (uutils coreutils) 0.12.0\n"));
assert!(!is_gnu_dd_version(b"dd (BSD)\n"));
assert!(!is_gnu_dd_version(b"other program GNU coreutils\n"));
}

#[tokio::test]
async fn dd_normal_copies_match_gnu() {
use std::io::Write;
use std::process::{Command, Stdio};

let Some(program) = ["gdd", "dd"].into_iter().find(|program| {
Command::new(program)
.arg("--version")
.output()
.is_ok_and(|out| out.status.success() && is_gnu_dd_version(&out.stdout))
}) else {
assert!(
!(cfg!(target_os = "linux") && std::env::var_os("CI").is_some()),
"GNU dd is required for Linux CI"
);
eprintln!("skip: GNU dd is not installed");
return;
};

for (input, args) in [
("abcde", "ibs=4 obs=2 conv=sync status=noxfer"),
("abcd", "ibs=4 obs=2 conv=sync status=noxfer"),
("abcde", "bs=2 count=2 skip=1 status=noxfer"),
(
"abcde",
"ibs=4 count=3 iflag=count_bytes conv=sync status=noxfer",
),
("ab", "ibs=4 conv=sync,ucase status=none"),
("abcde", "ibs=4 count=0 conv=sync status=none"),
("", "ibs=4 conv=sync status=noxfer"),
("ab", "ibs=4 conv=sync of=/dev/null status=noxfer"),
] {
let mut host = Command::new(program)
.args(args.split_whitespace())
.env("LC_ALL", "C")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
host.stdin
.take()
.unwrap()
.write_all(input.as_bytes())
.unwrap();
let expected = host.wait_with_output().unwrap();
let actual = Bash::new()
.exec_with_options(&format!("dd {args}"), ExecOptions::new().stdin(input))
.await
.unwrap();
assert_eq!(actual.stdout.as_bytes(), expected.stdout, "{args}");
assert_eq!(actual.stderr.as_bytes(), expected.stderr, "{args}");
assert_eq!(actual.exit_code, expected.status.code().unwrap(), "{args}");
}
}

#[tokio::test]
async fn dd_padding_keeps_seek_and_file_errors() {
let mut bash = Bash::new();
bash.fs()
.write_file(std::path::Path::new("/copy"), b"abcdef")
.await
.unwrap();
let result = bash
.exec("printf XY | dd of=/copy ibs=4 obs=1 seek=1 conv=sync,notrunc status=none")
.await
.unwrap();
assert_eq!(result.exit_code, 0);
assert!(result.stdout.is_empty());
assert!(result.stderr.is_empty());
assert_eq!(
bash.fs()
.read_file(std::path::Path::new("/copy"))
.await
.unwrap(),
b"aXY\0\0f"
);

for (script, stderr) in [
(
"printf XY | dd of=/copy ibs=4 conv=sync,excl status=none",
"dd: failed to open '/copy': File exists\n",
),
(
"printf XY | dd of=/missing ibs=4 conv=sync,nocreat status=none",
"dd: failed to open '/missing': No such file or directory\n",
),
] {
let result = bash.exec(script).await.unwrap();
assert_eq!(result.exit_code, 1);
assert_eq!(result.stderr, stderr);
assert!(result.stdout.is_empty());
}
assert_eq!(
bash.fs()
.read_file(std::path::Path::new("/copy"))
.await
.unwrap(),
b"aXY\0\0f"
);
}
1 change: 1 addition & 0 deletions crates/bashkit/tests/integration/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ pub mod date_timezone_differential_tests;
pub mod date_timezone_no_tzdata_tests;
#[cfg(feature = "tzdata")]
pub mod date_timezone_tests;
pub mod dd_tests;
pub mod dev_fd_path_tests;
pub mod dev_null_tests;
pub mod diagnostic_prefix_tests;
Expand Down
6 changes: 6 additions & 0 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ individual threat IDs and mitigation status, see the
| **Resource limits** | Configurable caps on commands, loop iterations, recursion depth, AST depth, timeouts, and parser operations prevent denial-of-service from malicious scripts. |
| **Filesystem limits** | Total bytes, per-file size, file count, path depth, and filename length are all capped to prevent storage exhaustion (zip bombs, tar bombs, recursive copies). |

`dd conv=sync` checks its padded block length against the 64 MiB copy cap
before allocating or zero-filling. Copied payload capacity also counts against
`max_live_intermediate_bytes` until output handling completes, including when
output is discarded. Stdout cached text is admitted under the same budget.
Exceeding either limit returns a resource-limit error.

`pr` checks its 16 MiB output cap before appending headers, rows, margins,
newlines or form feeds. Repeated long headers cannot grow the output buffer past that cap.
Input dates are retained as timestamps, so long date formats are not copied
Expand Down
14 changes: 14 additions & 0 deletions knowledge/foundations/builtins.md
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,20 @@ probe of the 879 BRE/ERE regex cases except two whose pattern holds a raw
Gaps are L-GREP-001..003 in [Known Limitations](../operations/limitations.md);
regex caps are TM-DOS-023/025 in the [Threat Model](../security/threat-model.md).

### dd

`dd` keeps its whole-buffer copy model. The selected input slice is capped before
copying. `conv=sync` computes the rounded block length with checked arithmetic
and rejects lengths above `DD_MAX_BYTES` before allocation. The entire copied
payload capacity is admitted through `BudgetedBytes` against the shared live
memory budget; its lease remains through conversion and output handling,
including discarded output. Stdout conversion also admits cached text while
retaining both storage guards. Budget failures propagate as resource-limit errors.
Ordinary count/skip, padding, conversion, record reports, seek, and file-error
behavior is preserved. Unit tests use tiny admission budgets and allocation-free
overflow arithmetic; `dd_tests` compares small copies with GNU dd and checks VFS
seek/error behavior (TM-DOS-003, TM-DOS-103).

### fmt

`fmt` vendors the uutils/coreutils `fmt` engine (MIT): `builtins/fmt/
Expand Down
Loading
Loading