kit

kit
git clone https://git.ryansepassi.com/git/kit.git
Log | Files | Refs | README

commit 85f4d72210d79fa1a6841c7d0fab48343a28620e
parent 8e44a1d43cfe18ffe958c88434c62ee2fa6cef34
Author: Ryan Sepassi <rsepassi@gmail.com>
Date:   Thu, 16 Jul 2026 10:12:44 -0700

cpio: validate archives before extraction

Diffstat:
Mdriver/cmd/cpio.c | 139+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Mtest/cpio/run.sh | 80++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
2 files changed, 186 insertions(+), 33 deletions(-)

diff --git a/driver/cmd/cpio.c b/driver/cmd/cpio.c @@ -201,9 +201,11 @@ static int cpio_parse(const uint8_t* data, size_t len, CpioVisit fn, while (off + CPIO_HDR_LEN <= len) { const uint8_t* h = data + off; - uint32_t mode, filesize, namesize; + uint32_t fields[13]; + uint32_t mode, filesize, namesize, check; size_t name_off, data_off; const char* name; + size_t i; if (!cpio_is_magic(h)) { driver_errf(CPIO_TOOL, @@ -211,17 +213,21 @@ static int cpio_parse(const uint8_t* data, size_t len, CpioVisit fn, : "corrupt cpio header (bad magic mid-stream)"); return 1; } - if (cpio_get_hex8(h + 14, &mode) || cpio_get_hex8(h + 54, &filesize) || - cpio_get_hex8(h + 94, &namesize)) { - driver_errf(CPIO_TOOL, "corrupt cpio header (non-hex field)"); - return 1; - } + for (i = 0; i < 13; ++i) + if (cpio_get_hex8(h + 6u + i * 8u, &fields[i])) { + driver_errf(CPIO_TOOL, "corrupt cpio header (non-hex field)"); + return 1; + } + mode = fields[1]; + filesize = fields[6]; + namesize = fields[11]; + check = fields[12]; if (namesize == 0) { driver_errf(CPIO_TOOL, "corrupt cpio header (zero name size)"); return 1; } name_off = off + CPIO_HDR_LEN; - if (name_off + namesize > len) { + if ((size_t)namesize > len - name_off) { driver_errf(CPIO_TOOL, "truncated cpio archive (name runs past end)"); return 1; } @@ -230,9 +236,21 @@ static int cpio_parse(const uint8_t* data, size_t len, CpioVisit fn, driver_errf(CPIO_TOOL, "corrupt cpio header (name not NUL-terminated)"); return 1; } + if (name_off + (size_t)namesize > SIZE_MAX - 3u) { + driver_errf(CPIO_TOOL, "corrupt cpio header (name size overflow)"); + return 1; + } data_off = cpio_round4(name_off + namesize); + if (data_off > len) { + driver_errf(CPIO_TOOL, "truncated cpio archive (name padding missing)"); + return 1; + } if (namesize == 11u && memcmp(name, "TRAILER!!!", 11) == 0) { + if (filesize != 0) { + driver_errf(CPIO_TOOL, "corrupt cpio TRAILER!!! record"); + return 1; + } saw_trailer = 1; off = data_off; /* filesize is 0 for the trailer */ /* Skip inter-segment zero padding; a following non-zero run that is a @@ -242,18 +260,26 @@ static int cpio_parse(const uint8_t* data, size_t len, CpioVisit fn, if (off >= len) break; if (off + 6u > len || !cpio_is_magic(data + off)) { driver_errf(CPIO_TOOL, - "note: ignoring %lu trailing byte(s) after TRAILER (not a " - "concatenated cpio archive)", + "corrupt cpio archive: %lu trailing byte(s) after TRAILER", (unsigned long)(len - off)); - break; + return 1; } + saw_trailer = 0; /* the concatenated segment needs its own trailer */ continue; } - if (data_off + filesize > len) { + if ((size_t)filesize > len - data_off) { driver_errf(CPIO_TOOL, "truncated cpio archive (data runs past end)"); return 1; } + if (memcmp(h, CPIO_MAGIC_CRC, 6) == 0) { + uint32_t actual = 0; + for (i = 0; i < filesize; ++i) actual += data[data_off + i]; + if (actual != check) { + driver_errf(CPIO_TOOL, "corrupt cpio checksum for: %s", name); + return 1; + } + } { CpioEntry e; e.name = name; @@ -262,13 +288,21 @@ static int cpio_parse(const uint8_t* data, size_t len, CpioVisit fn, e.filetype = cpio_ft_from_mode(mode); e.data = data + data_off; e.size = filesize; - if (fn(user, &e)) return 1; + if (fn && fn(user, &e)) return 1; + } + if (data_off + (size_t)filesize > SIZE_MAX - 3u) { + driver_errf(CPIO_TOOL, "corrupt cpio header (data size overflow)"); + return 1; } off = cpio_round4(data_off + filesize); } if (!saw_trailer) { - driver_errf(CPIO_TOOL, "warning: archive has no TRAILER!!! record"); + if (off < len) + driver_errf(CPIO_TOOL, "truncated cpio archive (incomplete header)"); + else + driver_errf(CPIO_TOOL, "corrupt cpio archive: missing TRAILER!!! record"); + return 1; } return 0; } @@ -573,8 +607,8 @@ void driver_help_cpio(void) { "OPERANDS AND EXTRACTION\n" " Create mode takes PATH operands; list/extract take no member\n" " operands. Extraction writes below the current working directory,\n" - " so change to the intended destination first. Prefix a create\n" - " operand beginning with `-` by `./`; `--` is not accepted.\n" + " so change to the intended destination first. Use -- before a\n" + " create operand beginning with `-`.\n" "\n" "EXAMPLES\n" " kit cpio -o -F root.cpio root/\n" @@ -587,12 +621,6 @@ void driver_help_cpio(void) { " kit cpio -o --lz4 root/ > root.cpio.lz4\n" " kit cpio -i < root.cpio.lz4 # extract into cwd\n" "\n" - "KNOWN LIMITATION\n" - " Malformed input and some conflicting mode combinations can\n" - " currently diagnose an error but return status 0. Treat any cpio\n" - " diagnostic as failure; the exit convention below is the intended\n" - " contract after remediation.\n" - "\n" "EXIT CODES\n" " 0 success 1 I/O or format error 2 bad usage\n"))); } @@ -716,15 +744,44 @@ static int cpio_extract_visit(void* user, const CpioEntry* e) { return 1; } } else { - driver_errf(CPIO_TOOL, "skipping unsupported member type: %.*s", + driver_errf(CPIO_TOOL, "unsupported member type: %.*s", (int)e->name_len, name); - return 0; + return 1; } if (c->verbose) driver_printf("%.*s\n", (int)e->name_len, name); return 0; } +/* Semantic validation runs over the complete archive before extraction starts, + * so an invalid later member cannot leave a partially trusted tree behind. */ +static int cpio_extract_validate(void* user, const CpioEntry* e) { + size_t i; + (void)user; + if (!cpio_name_safe(e->name, e->name_len)) { + driver_errf(CPIO_TOOL, "refusing unsafe member name: %.*s", + (int)e->name_len, e->name); + return 1; + } + if (e->filetype != 3 && e->filetype != 4 && e->filetype != 7) { + driver_errf(CPIO_TOOL, "unsupported member type: %.*s", + (int)e->name_len, e->name); + return 1; + } + if (e->filetype == 7) { + if (e->size >= CPIO_LINK_MAX) { + driver_errf(CPIO_TOOL, "symlink target too long: %s", e->name); + return 1; + } + for (i = 0; i < e->size; ++i) + if (e->data[i] == 0) { + driver_errf(CPIO_TOOL, "symlink target contains NUL: %s", e->name); + return 1; + } + } + return 0; +} + /* --------------------------------------------------------------------------- * Create * ------------------------------------------------------------------------- */ @@ -951,9 +1008,16 @@ static int cpio_read(DriverEnv* env, KitContext* ctx, const CpioOpts* opt) { payload_len = dlen; } else if (opt->decompress) { driver_errf(CPIO_TOOL, "-d given but input is not gzip/lz4-compressed"); - /* not fatal: fall through and parse as a raw archive */ + goto done; } + /* Structural validation is intentionally separate from presentation and + * extraction. For extraction the first pass also validates every path and + * supported member type before the first filesystem mutation. */ + if (cpio_parse(payload, payload_len, + opt->mode == 'i' ? cpio_extract_validate : NULL, NULL) != 0) + goto done; + if (opt->mode == 't') { CpioListCtx lc; lc.verbose = opt->verbose; @@ -994,6 +1058,16 @@ static int cpio_opt_arg(int argc, char** argv, int* i, const char* tool, return 0; } +static int cpio_set_mode(CpioOpts* opt, char mode, const char* spelling) { + if (opt->mode && opt->mode != mode) { + driver_errf(CPIO_TOOL, "conflicting archive modes: -%c and %s", opt->mode, + spelling); + return 1; + } + opt->mode = mode; + return 0; +} + int driver_cpio(int argc, char** argv) { DriverEnv env; KitContext ctx; @@ -1028,11 +1102,11 @@ int driver_cpio(int argc, char** argv) { if (driver_strneq(a, "--", 2)) { /* long option */ if (driver_streq(a, "--create")) { - o.mode = 'o'; + if (cpio_set_mode(&o, 'o', a)) goto done; } else if (driver_streq(a, "--list")) { - o.mode = 't'; + if (cpio_set_mode(&o, 't', a)) goto done; } else if (driver_streq(a, "--extract")) { - o.mode = 'i'; + if (cpio_set_mode(&o, 'i', a)) goto done; } else if (driver_streq(a, "--verbose")) { o.verbose = 1; } else if (driver_streq(a, "--decompress")) { @@ -1075,11 +1149,11 @@ int driver_cpio(int argc, char** argv) { for (j = 1; a[j]; ++j) { char ch = a[j]; if (ch == 'o') { - o.mode = 'o'; + if (cpio_set_mode(&o, 'o', "-o")) goto done; } else if (ch == 't') { - o.mode = 't'; + if (cpio_set_mode(&o, 't', "-t")) goto done; } else if (ch == 'i') { - o.mode = 'i'; + if (cpio_set_mode(&o, 'i', "-i")) goto done; } else if (ch == 'v') { o.verbose = 1; } else if (ch == 'd') { @@ -1130,8 +1204,9 @@ int driver_cpio(int argc, char** argv) { } if (o.mode != 'o' && nops > 0) { driver_errf(CPIO_TOOL, - "warning: member patterns are not supported; operating on the " - "whole archive"); + "member patterns are not supported: %s", ops[0]); + rc = 1; + goto done; } if (o.mode == 'o') { diff --git a/test/cpio/run.sh b/test/cpio/run.sh @@ -28,6 +28,28 @@ KIT_KIT_DIR="$repo_root/test/lib" . "$repo_root/test/lib/kit_sh_kit.sh" kit_report_init +# Assert the documented 0/1/2 command classification, preserving stdout and +# stderr beside the other lane artifacts when a case fails. +expect_status() { + es_name=$1 + es_want=$2 + shift 2 + "$@" > "$work/$es_name.out" 2> "$work/$es_name.err" + es_got=$? + if [ "$es_got" -eq "$es_want" ]; then + ok "$es_name" + else + { + printf 'status=%s (expected %s)\n' "$es_got" "$es_want" + sed 's/^/stdout: /' "$work/$es_name.out" + sed 's/^/stderr: /' "$work/$es_name.err" + } > "$work/$es_name.diag" + not_ok "$es_name" "$work/$es_name.diag" + fi +} + +cpio_hdr() { printf '%s' "$1"; shift; for f in "$@"; do printf '%08X' "$f"; done; } + # ---- fixture: a small tree exercising every supported member type ----------- # regular file, executable regular file, zero-byte file, binary file, a nested # subdirectory, an empty directory, and a symlink. @@ -122,6 +144,35 @@ run_fail cpio-d-with-create create -o -d -F "$work/d.cpio" root run_fail cpio-no-mode "$KIT" cpio -F "$work/out.cpio" run_fail cpio-create-dotdot "$KIT" cpio -o -F "$work/dd.cpio" ../payload +# Mode conflicts are usage errors; archive/decompression/pattern failures are +# operational errors. Assert their exact classes instead of merely nonzero. +expect_status cpio-mode-create-extract 2 "$KIT" cpio -o -i -F "$work/out.cpio" +expect_status cpio-mode-create-list 2 "$KIT" cpio -o -t -F "$work/out.cpio" +expect_status cpio-mode-extract-list 2 "$KIT" cpio -i -t -F "$work/out.cpio" +expect_status cpio-explicit-decompress-raw 1 "$KIT" cpio -t -d -F "$work/out.cpio" +expect_status cpio-unsupported-read-pattern 1 "$KIT" cpio -t -F "$work/out.cpio" root + +printf '070701' > "$work/truncated.cpio" +expect_status cpio-truncated-header 1 "$KIT" cpio -t -F "$work/truncated.cpio" + +{ + printf '999999' + dd if="$work/out.cpio" bs=1 skip=6 2>/dev/null +} > "$work/bad-magic.cpio" +expect_status cpio-bad-magic 1 "$KIT" cpio -t -F "$work/bad-magic.cpio" + +{ + # One valid record with no TRAILER!!! record. + cpio_hdr 070701 0 33188 0 0 1 0 2 0 0 0 0 10 0 + printf 'safe-file\000' + printf 'ok\000\000' +} > "$work/no-trailer.cpio" +expect_status cpio-missing-trailer 1 "$KIT" cpio -t -F "$work/no-trailer.cpio" + +cp "$work/out.cpio" "$work/trailing-garbage.cpio" +printf 'not-an-archive' >> "$work/trailing-garbage.cpio" +expect_status cpio-trailing-garbage 1 "$KIT" cpio -t -F "$work/trailing-garbage.cpio" + # ---- extract-time path-traversal safety ------------------------------------ # Hand-craft a newc archive whose sole member is named "../escape" and confirm # extract refuses it (no file is written outside the destination). The 110-byte @@ -129,7 +180,6 @@ run_fail cpio-create-dotdot "$KIT" cpio -o -F "$work/dd.cpio" ../payload # mtime filesize devmajor devminor rdevmajor rdevminor namesize check) so the # field widths can't drift. ev="$work/evil.cpio" -cpio_hdr() { printf '%s' "$1"; shift; for f in "$@"; do printf '%08X' "$f"; done; } { # mode 0100644 = 33188; name "../escape\0" = 10 bytes; data "pwn" = 3 bytes. cpio_hdr 070701 0 33188 0 0 1 0 3 0 0 0 0 10 0 @@ -150,6 +200,34 @@ fi "$KIT" cpio -t -F "$ev" > "$work/evil.list" 2>/dev/null contains cpio-list-shows-dotdot "$work/evil.list" "../escape" +# Validation is a complete first pass: a later unsafe member must prevent an +# earlier, otherwise-valid member from being materialized. +mixed="$work/mixed-evil.cpio" +{ + cpio_hdr 070701 0 33188 0 0 1 0 2 0 0 0 0 10 0 + printf 'safe-file\000' + printf 'ok\000\000' + cpio_hdr 070701 0 33188 0 0 1 0 3 0 0 0 0 10 0 + printf '../escape\000' + printf 'pwn\000' + cpio_hdr 070701 0 0 0 0 1 0 0 0 0 0 0 11 0 + printf 'TRAILER!!!\000\000\000\000' +} > "$mixed" +mkdir -p "$work/ex-mixed" +( cd "$work/ex-mixed" && "$KIT" cpio -i -F "$mixed" ) \ + > "$work/mixed.out" 2> "$work/mixed.err" +mixed_rc=$? +if [ "$mixed_rc" -eq 1 ] && [ ! -e "$work/ex-mixed/safe-file" ]; then + ok cpio-prevalidate-before-extract +else + { + printf 'status=%s safe-file=%s\n' "$mixed_rc" \ + "$([ -e "$work/ex-mixed/safe-file" ] && printf present || printf absent)" + sed 's/^/stderr: /' "$work/mixed.err" + } > "$work/mixed.diag" + not_ok cpio-prevalidate-before-extract "$work/mixed.diag" +fi + # ---- optional cross-check against the host cpio ---------------------------- if [ "${KIT_CPIO_TEST_HOST:-0}" = 1 ] && command -v cpio >/dev/null 2>&1; then if cpio -t < "$work/out.cpio" > "$work/host.list" 2>/dev/null; then