diff --git a/CMakeLists.txt b/CMakeLists.txt index 3380b26..85200e4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -256,6 +256,7 @@ if(Python3_Interpreter_FOUND) test_esp32_nvs test_legacy_fs test_reiserfs_extract + test_romfs_sizecap test_luks test_lzma test_jzlzma diff --git a/src/extract/romfs.cpp b/src/extract/romfs.cpp index 60366c0..25c5008 100644 --- a/src/extract/romfs.cpp +++ b/src/extract/romfs.cpp @@ -29,7 +29,12 @@ constexpr uint32_t TYPE_SYMLINK = 3; constexpr size_t MAX_ENTRIES = 2000000; constexpr size_t MAX_DEPTH = 128; -constexpr uint64_t MAX_FILE_BYTES = uint64_t(8) << 30; +// The romfs size field (header offset 8) is 32-bit, so a declared file size +// cannot exceed ~4 GiB. The cap has to be representable within that range to do +// anything; a larger value would make the guard dead code. 1 GiB is a generous +// ceiling for a read-only romfs image (itself bounded to <4 GiB by the 32-bit +// full_size field) and bounds a single allocation from a crafted size field. +constexpr uint64_t MAX_FILE_BYTES = uint64_t(1) << 30; uint64_t align16(uint64_t x) { return (x + 15) & ~uint64_t(15); } @@ -81,19 +86,22 @@ void walk_chain(Ctx& c, uint64_t hdr, const std::string& rel, size_t depth) { if (c.root.make_dir(full)) c.out.dirs++; walk_chain(c, c.base + *spec, child_rel, depth + 1); } else if (type == TYPE_REG) { - if (*size <= MAX_FILE_BYTES) { - auto d = c.r.bytes(data_off, *size); - if (d) { - std::vector data(d->begin(), d->end()); - if (c.root.write_file(full, data, 0644)) { - c.out.files++; - c.out.bytes += data.size(); - } else { - c.out.warnings.push_back("write failed: " + child_rel); - } + // Clamp an oversized declared size to the cap and flag the + // result partial, matching the other extractors, rather than + // trusting the field and attempting one huge allocation. + uint64_t fsize = *size; + if (fsize > MAX_FILE_BYTES) { fsize = MAX_FILE_BYTES; c.truncated = true; } + auto d = c.r.bytes(data_off, fsize); + if (d) { + std::vector data(d->begin(), d->end()); + if (c.root.write_file(full, data, 0644)) { + c.out.files++; + c.out.bytes += data.size(); } else { - c.truncated = true; + c.out.warnings.push_back("write failed: " + child_rel); } + } else { + c.truncated = true; } } else if (type == TYPE_SYMLINK) { auto d = c.r.bytes(data_off, *size); diff --git a/tests/run.sh b/tests/run.sh index fab8013..e7b9acc 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -36,6 +36,8 @@ python3 tests/test_esp32_nvs.py python3 tests/test_legacy_fs.py # ReiserFS extraction: synthetic 3.5/3.6 round-trip + real empty mkreiserfs image. python3 tests/test_reiserfs_extract.py +# romfs oversized-size-field clamp (issue #68): crafted size must not crash/over-allocate. +python3 tests/test_romfs_sizecap.py # LUKS1/LUKS2 identify with header parameters (synthetic + real cryptsetup). python3 tests/test_luks.py # Legacy standalone .lzma identify (verified via trial-decode) + byte-exact extract + FP guards. diff --git a/tests/test_romfs_sizecap.py b/tests/test_romfs_sizecap.py new file mode 100644 index 0000000..f162cc8 --- /dev/null +++ b/tests/test_romfs_sizecap.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""romfs oversized-size-field regression (issue #68). + +The romfs size field (header offset 8) is 32-bit, so the per-file size cap must +be representable within ~4 GiB to do anything; the old 8 GiB cap compared against +a uint32_t was dead code. This builds a real romfs with the bundled mkromfs.py, +patches one regular file's 32-bit size field to 0xFFFFFFFF, and checks that moria: + + - does not crash (the ASan build is the out-of-bounds / allocation oracle); + - reports a sane status (partial, since the oversized entry is clamped/skipped); + - does not emit a bogus multi-gigabyte file; the normal sibling file still + extracts. + +Run: python3 tests/test_romfs_sizecap.py [path/to/moria] +""" +import json +import os +import subprocess +import sys +import tempfile + +HERE = os.path.dirname(os.path.abspath(__file__)) +MORIA = sys.argv[1] if len(sys.argv) > 1 else os.path.join(HERE, "..", "build", "moria") +MKROMFS = os.path.join(HERE, "mkromfs.py") + + +def dir_size(path): + total = 0 + for dp, _, fns in os.walk(path): + for fn in fns: + fp = os.path.join(dp, fn) + if not os.path.islink(fp): + total += os.path.getsize(fp) + return total + + +def build_image(work): + src = os.path.join(work, "src") + os.makedirs(src) + with open(os.path.join(src, "BIGFILE"), "wb") as f: + f.write(b"small payload\n") + with open(os.path.join(src, "normal.txt"), "wb") as f: + f.write(b"intact\n") + img = os.path.join(work, "t.romfs") + r = subprocess.run([sys.executable, MKROMFS, src, img], capture_output=True) + if r.returncode != 0: + return None + return img + + +def patch_oversize(img): + """Set the 32-bit big-endian size field of the BIGFILE header to 0xFFFFFFFF. + + The name sits at header+16, so the header starts 16 bytes before the name and + the size field is at header+8. moria does not verify header checksums, so the + rest of the header stays valid and the parser reaches the regular-file path. + """ + data = bytearray(open(img, "rb").read()) + name_off = data.find(b"BIGFILE\x00") + assert name_off >= 16, "BIGFILE header not found" + hdr = name_off - 16 + data[hdr + 8: hdr + 12] = (0xFFFFFFFF).to_bytes(4, "big") + open(img, "wb").write(bytes(data)) + + +def main(): + if not os.path.exists(MORIA): + print("moria not built", file=sys.stderr) + return 1 + with tempfile.TemporaryDirectory() as work: + img = build_image(work) + if img is None: + print("SKIP [romfs-sizecap]: mkromfs.py failed") + return 0 + patch_oversize(img) + outdir = os.path.join(work, "out") + r = subprocess.run([MORIA, "-j", "--extract", "-C", outdir, img], + capture_output=True, timeout=120) + if r.returncode != 0: + print(f"FAIL [romfs-sizecap]: moria exit {r.returncode} (crash/sanitizer?)\n" + f"{r.stderr.decode()[-800:]}") + return 1 + try: + entry = json.loads(r.stdout.decode())["extraction"]["extracted"][0] + except Exception: + print(f"FAIL [romfs-sizecap]: no extraction manifest\n{r.stdout[:200]}") + return 1 + # The oversized entry must not have produced a huge write; the whole + # output tree stays tiny (well under any multi-gigabyte allocation). + sz = dir_size(outdir) + if sz > 16 * 1024 * 1024: + print(f"FAIL [romfs-sizecap]: output tree is {sz} bytes (oversized write?)") + return 1 + if entry["status"] != "partial": + print(f"FAIL [romfs-sizecap]: status={entry['status']!r} (expected partial)") + return 1 + # The intact sibling file is unaffected. + normal = os.path.join(outdir, entry["root"], "normal.txt") + if not os.path.isfile(normal) or open(normal, "rb").read() != b"intact\n": + print("FAIL [romfs-sizecap]: intact sibling file not extracted correctly") + return 1 + print(f"PASS [romfs-sizecap]: oversized size clamped, status=partial, " + f"no bogus write ({sz} bytes), sibling intact") + return 0 + + +if __name__ == "__main__": + sys.exit(main())