diff --git a/CMakeLists.txt b/CMakeLists.txt index 5ef5547..3380b26 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -91,6 +91,7 @@ add_executable(moria src/extract/compressed.cpp src/extract/android_sparse.cpp src/extract/ext.cpp + src/extract/reiserfs.cpp src/extract/jffs2.cpp src/extract/ubifs.cpp src/extract/tar.cpp @@ -254,6 +255,7 @@ if(Python3_Interpreter_FOUND) test_esp32_part test_esp32_nvs test_legacy_fs + test_reiserfs_extract test_luks test_lzma test_jzlzma diff --git a/README.md b/README.md index 4acf793..e14734c 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ moria --help Unpacked in-process, no external tools and no sudo: -- **Filesystems:** SquashFS, ext2/3/4, F2FS, FAT12/16/32, exFAT, NTFS, HFS+/HFSX, XFS, btrfs, JFFS2, UBI/UBIFS, romfs, YAFFS2, cramfs, EROFS +- **Filesystems:** SquashFS, ext2/3/4, F2FS, FAT12/16/32, exFAT, NTFS, HFS+/HFSX, XFS, btrfs, JFFS2, UBI/UBIFS, romfs, YAFFS2, cramfs, EROFS, ReiserFS - **Archives and images:** ZIP, tar, cpio, MS-CAB (stored/MSZIP/LZX), CFBF compound files / Windows Installer `.msi`, ISO 9660, Android sparse, Android boot - **Kernels and wrappers:** U-Boot uImage, U-Boot FIT, Windows CE XIP ROM (nk.bin / .cos), standalone gzip / xz / zstd / lz4 streams, Ingenic jzlzma (hardware LZ77) - **Firmware packages:** RAE Systems / Honeywell RFP (section table; LZARI-decompresses each section); Artosyn OTRA (AR8030 / VT4 / VR04; SHA-256-verified, LZO1X-decompresses each partition, or exposes the raw flash body on flat images) diff --git a/fuzz/build.sh b/fuzz/build.sh index 84e8fcd..0e7e39f 100755 --- a/fuzz/build.sh +++ b/fuzz/build.sh @@ -11,7 +11,7 @@ mapfile -t SRCS < <(find src -name '*.cpp' ! -name 'main.cpp' | sort) # Xcode clang lacks the libFuzzer runtime; override with a clang that has it # (e.g. CXX=/opt/homebrew/opt/llvm/bin/clang++ on macOS). CXX="${CXX:-clang++}" -for target in fuzz_scan fuzz_esp32_part fuzz_esp32_nvs fuzz_lzx; do +for target in fuzz_scan fuzz_esp32_part fuzz_esp32_nvs fuzz_lzx fuzz_reiserfs; do "$CXX" -std=c++20 -O1 -g -fsanitize=fuzzer,address -I src -I third_party \ -DFT_FUZZ_SIGDIR="\"$PWD/signatures\"" \ "${SRCS[@]}" "fuzz/${target}.cpp" -o "$target" diff --git a/fuzz/fuzz_reiserfs.cpp b/fuzz/fuzz_reiserfs.cpp new file mode 100644 index 0000000..95a6619 --- /dev/null +++ b/fuzz/fuzz_reiserfs.cpp @@ -0,0 +1,65 @@ +// fuzz_reiserfs.cpp — libFuzzer entry point for the ReiserFS extractor. +// Drives the parse-only tree/leaf-item walk directly: the fuzzer bytes are the +// tree region, spliced after a fixed valid superblock so the walk is always +// reached, with the superblock tried at both the 3.6 (64 KiB) and 3.5 (8 KiB) +// placements. Also feeds the raw bytes to the full scan pipeline so the +// identify/validator path sees adversarial input (issue #63). +// +// Build via fuzz/build.sh. Run: ./fuzz_reiserfs -max_total_time=60 +#include +#include +#include +#include +#include + +#include "extract/reiserfs.hpp" +#include "reader.hpp" +#include "scan.hpp" +#include "sigload.hpp" + +#ifndef FT_FUZZ_SIGDIR +#define FT_FUZZ_SIGDIR "signatures" +#endif + +namespace { +constexpr uint64_t BS = 4096; +constexpr uint64_t SB_BLK = 16; // 64 KiB / 4096 + +// A minimal valid superblock so locate_base/root_block_ok accept block SB_BLK+1 +// as the tree root; the fuzzer owns everything from the root block onward. +void write_sb(std::vector& img, uint32_t nblocks, uint32_t root_block, + const char* magic) { + const size_t o = SB_BLK * BS; + auto put32 = [&](size_t off, uint32_t v) { std::memcpy(img.data() + o + off, &v, 4); }; + auto put16 = [&](size_t off, uint16_t v) { std::memcpy(img.data() + o + off, &v, 2); }; + put32(0, nblocks); // s_block_count + put32(8, root_block); // s_root_block + put16(44, BS); // s_blocksize + std::memcpy(img.data() + o + 52, magic, std::strlen(magic)); // s_magic + put16(68, 3); // s_tree_height +} +} // namespace + +extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) { + static const ft::LoadResult sigs = ft::load_signatures(FT_FUZZ_SIGDIR); + + // Build an image: [zeros .. SB block .. fuzzer bytes as the tree region]. + // Cap so a pathological size field can't make us allocate unboundedly here. + const size_t tree_bytes = size > (8u << 20) ? (8u << 20) : size; + const uint32_t nblocks = static_cast((SB_BLK + 1) + (tree_bytes + BS - 1) / BS + 1); + std::vector img(static_cast(nblocks) * BS, 0); + const uint32_t root_block = SB_BLK + 1; + std::memcpy(img.data() + static_cast(root_block) * BS, data, tree_bytes); + + for (const char* magic : {"ReIsEr2Fs", "ReIsErFs"}) { + write_sb(img, nblocks, root_block, magic); + ft::Reader reader(std::span(img.data(), img.size())); + ft::reiserfs_probe(reader, SB_BLK * BS); + } + + // Via the pipeline: magic-driven identification on the raw bytes. + ft::Reader raw(std::span(data, size)); + auto findings = ft::scan(raw, sigs.signatures); + (void)findings; + return 0; +} diff --git a/moria.1 b/moria.1 index 2767826..6d363b9 100644 --- a/moria.1 +++ b/moria.1 @@ -13,7 +13,7 @@ embedded formats (filesystems, containers, bootloaders, executables, compression, media) with byte offsets and confidence tiers. It is identification-first; with .B \-\-extract -it also unpacks SquashFS, ext2/3/4, F2FS, JFFS2, UBIFS, romfs, YAFFS2, cramfs, FAT, exFAT, NTFS, HFS+, XFS, btrfs, EROFS, and ISO9660 filesystems, ZIP/tar/cpio/MS-CAB archives, CFBF compound files (Windows Installer .msi), U-Boot uImage/FIT containers, Windows CE XIP ROM images and registry hives, RAE Systems/Honeywell RFP firmware packages (LZARI-decompressing their sections), and Ingenic jzlzma-compressed kernel/rootfs images (hardware LZ77) +it also unpacks SquashFS, ext2/3/4, F2FS, JFFS2, UBIFS, romfs, YAFFS2, cramfs, FAT, exFAT, NTFS, HFS+, XFS, btrfs, EROFS, ReiserFS, and ISO9660 filesystems, ZIP/tar/cpio/MS-CAB archives, CFBF compound files (Windows Installer .msi), U-Boot uImage/FIT containers, Windows CE XIP ROM images and registry hives, RAE Systems/Honeywell RFP firmware packages (LZARI-decompressing their sections), and Ingenic jzlzma-compressed kernel/rootfs images (hardware LZ77) internally (no sudo). Every other format is still identified and located by offset, ready for another tool such as binwalk. .PP Output is human-readable by default. A single file shows a findings tree @@ -55,7 +55,7 @@ List tar/cpio/zip/MS-CAB member names and sizes, or a Windows CE ROM's modules a .SS Extract .TP .BR \-e ", " \-\-extract -Unpack identified SquashFS, ext2/3/4, F2FS, JFFS2, UBIFS (incl. UBI), romfs, YAFFS2, cramfs, FAT, exFAT, NTFS, EROFS, and ISO9660 filesystems, ZIP/tar/cpio/MS-CAB archives, CFBF compound files such as Windows Installer .msi packages, U-Boot uImage/FIT containers, Android boot/sparse images, Windows CE XIP ROM images, Windows CE registry hives, and RAE Systems/Honeywell RFP firmware packages into +Unpack identified SquashFS, ext2/3/4, F2FS, JFFS2, UBIFS (incl. UBI), romfs, YAFFS2, cramfs, FAT, exFAT, NTFS, EROFS, ReiserFS, and ISO9660 filesystems, ZIP/tar/cpio/MS-CAB archives, CFBF compound files such as Windows Installer .msi packages, U-Boot uImage/FIT containers, Android boot/sparse images, Windows CE XIP ROM images, Windows CE registry hives, and RAE Systems/Honeywell RFP firmware packages into .IR file .extracted/ (one subdir per region, named .IR 0x\- ). diff --git a/src/extract/manifest.cpp b/src/extract/manifest.cpp index 44e490c..c9972c8 100644 --- a/src/extract/manifest.cpp +++ b/src/extract/manifest.cpp @@ -15,6 +15,7 @@ #include "extract/ext.hpp" #include "extract/exfat.hpp" #include "extract/hfsplus.hpp" +#include "extract/reiserfs.hpp" #include "extract/xfs.hpp" #include "extract/btrfs.hpp" @@ -56,6 +57,7 @@ Extractor find_extractor(const std::string& type) { if (type == "tar") return extract_tar; if (type == "romfs") return extract_romfs; if (type == "cramfs") return extract_cramfs; + if (type == "reiserfs") return extract_reiserfs; if (type == "erofs") return extract_erofs; if (type == "f2fs") return extract_f2fs; if (type == "android_sparse") return extract_android_sparse; diff --git a/src/extract/reiserfs.cpp b/src/extract/reiserfs.cpp new file mode 100644 index 0000000..bf66327 --- /dev/null +++ b/src/extract/reiserfs.cpp @@ -0,0 +1,508 @@ +// reiserfs.cpp — ReiserFS (3.5 / 3.6) read-only extraction. See reiserfs.hpp. +// +// Format reference: the kernel on-disk headers (fs/reiserfs/reiserfs.h) and the +// reiserfsprogs layout. Everything on disk is little-endian and packed. +// +// Layout recap: +// - Superblock at base+64 KiB (3.6, magic "ReIsEr2Fs"/"ReIsEr3Fs") or base+8 +// KiB (3.5, magic "ReIsErFs"). It records block size, block count, the tree +// height, and the root block number. Block N is at base + N*block_size. +// - The S+ tree: internal nodes (level > 1) hold nr_item keys followed by +// nr_item+1 disk_child pointers; leaves (level 1) hold nr_item 24-byte item +// headers at the front and item bodies packed at the back (located by +// ih_item_location). We traverse the tree by child pointer only and never +// interpret an internal key, so the v1/v2 key ambiguity never arises there. +// - A leaf item's key is (dir_id, objectid, offset, type). ih_version selects +// the key encoding: v1 (offset u32 + uniqueness u32) or v2 (u64: 60-bit +// offset + 4-bit type). Objects are the pair (dir_id, objectid); an object's +// items are its stat-data (type SD, offset 0), its directory entries (type +// DIRENTRY), and its file bodies (type DIRECT tail + INDIRECT block lists). +// - Reconstruction: collect all leaf items, index by (dir_id, objectid), then +// recurse from the root directory key (1, 2), reading each child's stat-data +// for its mode and rebuilding files from their DIRECT/INDIRECT items. +#include "extract/reiserfs.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "extract/safepath.hpp" + +namespace ft { + +namespace { + +// Superblock location within the partition. +constexpr uint64_t DISK_OFFSET_36 = 64 * 1024; +constexpr uint64_t DISK_OFFSET_35 = 8 * 1024; + +// Node levels. +constexpr uint16_t LEAF_LEVEL = 1; +constexpr uint16_t MAX_LEVEL = 16; // real trees are <= ~5 deep + +// Header sizes. +constexpr uint64_t BLKH_SIZE = 24; +constexpr uint64_t KEY_SIZE = 16; +constexpr uint64_t IH_SIZE = 24; +constexpr uint64_t DC_SIZE = 8; +constexpr uint64_t DEH_SIZE = 16; + +// stat_data sizes. +constexpr uint64_t SD_V1_SIZE = 32; +constexpr uint64_t SD_V2_SIZE = 44; + +// i_mode file-type bits (same encoding as POSIX / ext). +constexpr uint16_t S_IFMT = 0xF000; +constexpr uint16_t S_IFREG = 0x8000; +constexpr uint16_t S_IFDIR = 0x4000; +constexpr uint16_t S_IFLNK = 0xA000; + +// Root directory object: (dir_id, objectid) = (ROOT_PARENT, ROOT). +constexpr uint32_t ROOT_PARENT_OBJECTID = 1; +constexpr uint32_t ROOT_OBJECTID = 2; + +// v1 key uniqueness values. +constexpr uint32_t V1_SD = 0; +constexpr uint32_t V1_DIRENTRY = 500; +constexpr uint32_t V1_INDIRECT = 0xfffffffe; +constexpr uint32_t V1_DIRECT = 0xffffffff; + +// Normalized item type. +enum class IType { SD, Direct, Indirect, DirEntry, Other }; + +// Guardrails against hostile / corrupt images. +constexpr size_t MAX_NODES = 4000000; +constexpr size_t MAX_ITEMS = 8000000; +constexpr size_t MAX_FILES = 2000000; +constexpr size_t MAX_DEPTH = 128; +constexpr uint64_t MAX_FILE_BYTES = uint64_t(8) << 30; // 8 GiB per file +constexpr uint64_t MAX_DIR_ENTRIES = 1u << 20; // per directory item + +struct Super { + uint64_t base = 0; // absolute file offset of block 0 (partition start) + uint64_t block_size = 0; + uint64_t block_count = 0; + uint32_t root_block = 0; + uint16_t tree_height = 0; +}; + +// One leaf item, kept as a reference into the image (body read on demand). +struct Item { + uint32_t dir_id = 0; + uint32_t objectid = 0; + uint64_t offset = 0; // object byte-offset key (1-based for file bodies) + IType type = IType::Other; + bool v2 = false; + uint16_t entry_count = 0; // directory items only + uint64_t body_off = 0; // absolute file offset of the item body + uint32_t body_len = 0; +}; + +struct Ctx { + const Reader& r; + Super sb; + SafeRoot* root; // null in the fuzz probe (parse-only, no writes) + std::string subdir; + Extracted& out; + bool truncated = false; + size_t nodes_seen = 0; + size_t files_seen = 0; + std::vector items{}; + // (dir_id,objectid) -> indices into `items`. + std::unordered_map> by_object{}; + std::unordered_set visited_blocks{}; // tree cycle guard + std::set stack{}; // directory recursion cycle guard +}; + +std::optional rd16(const Reader& r, uint64_t off) { + return r.at(static_cast(off), Endian::Little); +} +std::optional rd32(const Reader& r, uint64_t off) { + return r.at(static_cast(off), Endian::Little); +} +std::optional rd64(const Reader& r, uint64_t off) { + return r.at(static_cast(off), Endian::Little); +} + +uint64_t block_off(const Ctx& c, uint64_t blk) { return c.sb.base + blk * c.sb.block_size; } +uint64_t okey(uint32_t dir_id, uint32_t objectid) { + return (uint64_t(dir_id) << 32) | objectid; +} + +// Read the superblock fields at absolute offset `sbo`. +bool parse_super_at(const Reader& r, uint64_t sbo, Super& sb) { + auto bcount = rd32(r, sbo + 0); + auto rootb = rd32(r, sbo + 8); + auto bsize = rd16(r, sbo + 44); + auto height = rd16(r, sbo + 68); + if (!bcount || !rootb || !bsize || !height) return false; + uint64_t bs = *bsize; + if ((bs & (bs - 1)) != 0 || bs < 512 || bs > 8192) return false; + if (*bcount == 0 || *rootb == 0) return false; + sb.block_size = bs; + sb.block_count = *bcount; + sb.root_block = *rootb; + sb.tree_height = *height; + return true; +} + +// A root block is plausible if its block header has a sane level and item count. +bool root_block_ok(const Reader& r, const Super& sb) { + const uint64_t bo = sb.base + uint64_t(sb.root_block) * sb.block_size; + auto level = rd16(r, bo + 0); + auto nr = rd16(r, bo + 2); + if (!level || !nr) return false; + if (*level < LEAF_LEVEL || *level > MAX_LEVEL) return false; + // An internal node needs nr keys + nr+1 children; a leaf nr item headers. + const uint64_t need_internal = BLKH_SIZE + uint64_t(*nr) * KEY_SIZE + (uint64_t(*nr) + 1) * DC_SIZE; + const uint64_t need_leaf = BLKH_SIZE + uint64_t(*nr) * IH_SIZE; + return need_internal <= sb.block_size || need_leaf <= sb.block_size; +} + +// Decode one leaf item header at absolute offset `iho` into `it`. `block_base` +// is the containing leaf's absolute start, used to resolve ih_item_location. +bool decode_item(const Reader& r, uint64_t iho, uint64_t block_base, uint64_t block_size, + Item& it) { + auto dir_id = rd32(r, iho + 0); + auto objectid = rd32(r, iho + 4); + auto ucnt = rd16(r, iho + 16); + auto ilen = rd16(r, iho + 18); + auto iloc = rd16(r, iho + 20); + auto iver = rd16(r, iho + 22); + if (!dir_id || !objectid || !ucnt || !ilen || !iloc || !iver) return false; + + it.dir_id = *dir_id; + it.objectid = *objectid; + it.v2 = (*iver != 0); + it.entry_count = *ucnt; + + if (it.v2) { + auto v = rd64(r, iho + 8); + if (!v) return false; + it.offset = *v & ((uint64_t(1) << 60) - 1); + switch (static_cast(*v >> 60)) { + case 0: it.type = IType::SD; break; + case 1: it.type = IType::Indirect; break; + case 2: it.type = IType::Direct; break; + case 3: it.type = IType::DirEntry; break; + default: it.type = IType::Other; break; + } + } else { + auto off = rd32(r, iho + 8); + auto uniq = rd32(r, iho + 12); + if (!off || !uniq) return false; + it.offset = *off; + switch (*uniq) { + case V1_SD: it.type = IType::SD; break; + case V1_DIRECT: it.type = IType::Direct; break; + case V1_INDIRECT: it.type = IType::Indirect; break; + case V1_DIRENTRY: it.type = IType::DirEntry; break; + default: it.type = IType::Other; break; + } + } + + // Body must lie inside the block, past the header area. + if (*iloc < BLKH_SIZE || uint64_t(*iloc) + *ilen > block_size) return false; + it.body_off = block_base + *iloc; + it.body_len = *ilen; + return true; +} + +// Depth-first walk of the S+ tree, collecting every leaf item into c.items. +void walk_tree(Ctx& c, uint64_t blk, size_t depth) { + if (depth > MAX_LEVEL || c.nodes_seen++ > MAX_NODES) { c.truncated = true; return; } + if (blk == 0 || blk >= c.sb.block_count) { c.truncated = true; return; } + if (!c.visited_blocks.insert(blk).second) { c.truncated = true; return; } // cycle + + const uint64_t bo = block_off(c, blk); + auto level = rd16(c.r, bo + 0); + auto nr = rd16(c.r, bo + 2); + if (!level || !nr) { c.truncated = true; return; } + if (*level < LEAF_LEVEL || *level > MAX_LEVEL) { c.truncated = true; return; } + + if (*level == LEAF_LEVEL) { + // nr item headers, each IH_SIZE, starting after the block header. + const uint64_t max_items = (c.sb.block_size - BLKH_SIZE) / IH_SIZE; + uint64_t n = *nr; + if (n > max_items) { n = max_items; c.truncated = true; } + for (uint64_t i = 0; i < n; ++i) { + if (c.items.size() >= MAX_ITEMS) { c.truncated = true; return; } + Item it; + if (!decode_item(c.r, bo + BLKH_SIZE + i * IH_SIZE, bo, c.sb.block_size, it)) { + c.truncated = true; + continue; + } + c.by_object[okey(it.dir_id, it.objectid)].push_back( + static_cast(c.items.size())); + c.items.push_back(it); + } + return; + } + + // Internal node: nr keys then nr+1 child pointers. + const uint64_t children = bo + BLKH_SIZE + uint64_t(*nr) * KEY_SIZE; + const uint64_t last = children + (uint64_t(*nr) + 1) * DC_SIZE; + if (last > c.sb.block_size + bo) { c.truncated = true; return; } + for (uint64_t j = 0; j <= *nr; ++j) { + auto child = rd32(c.r, children + j * DC_SIZE); // dc_block_number + if (!child) { c.truncated = true; continue; } + walk_tree(c, *child, depth + 1); + } +} + +// The stat-data item for an object, if present. +const Item* find_sd(Ctx& c, uint32_t dir_id, uint32_t objectid) { + auto it = c.by_object.find(okey(dir_id, objectid)); + if (it == c.by_object.end()) return nullptr; + for (uint32_t idx : it->second) + if (c.items[idx].type == IType::SD) return &c.items[idx]; + return nullptr; +} + +bool sd_mode_size(Ctx& c, const Item& sd, uint16_t& mode, uint64_t& size) { + auto m = rd16(c.r, sd.body_off + 0); + if (!m) return false; + mode = *m; + if (sd.v2) { + if (sd.body_len < SD_V2_SIZE) return false; + auto s = rd64(c.r, sd.body_off + 8); + if (!s) return false; + size = *s; + } else { + if (sd.body_len < SD_V1_SIZE) return false; + auto s = rd32(c.r, sd.body_off + 8); + if (!s) return false; + size = *s; + } + return true; +} + +// Reconstruct a regular file / symlink target for an object into `out` (capped). +void read_content(Ctx& c, uint32_t dir_id, uint32_t objectid, uint64_t size, + std::vector& out) { + if (size > MAX_FILE_BYTES) { size = MAX_FILE_BYTES; c.truncated = true; } + // A file's bytes all come from blocks in the image, so a declared size larger + // than the image is corrupt (or a huge sparse file); clamp to avoid honoring + // it with a giant zero-filled allocation. + if (size > c.r.size()) { size = c.r.size(); c.truncated = true; } + out.assign(static_cast(size), 0); + auto found = c.by_object.find(okey(dir_id, objectid)); + if (found == c.by_object.end()) return; + + // Gather body items (direct tail + indirect block lists) in file order. + std::vector bodies; + for (uint32_t idx : found->second) { + const Item& it = c.items[idx]; + if (it.type == IType::Direct || it.type == IType::Indirect) bodies.push_back(&it); + } + std::sort(bodies.begin(), bodies.end(), + [](const Item* a, const Item* b) { return a->offset < b->offset; }); + + const uint64_t bs = c.sb.block_size; + for (const Item* it : bodies) { + if (it->offset == 0) { c.truncated = true; continue; } // body keys are 1-based + const uint64_t pos = it->offset - 1; + if (it->type == IType::Direct) { + auto span = c.r.bytes(static_cast(it->body_off), it->body_len); + if (!span) { c.truncated = true; continue; } + for (uint64_t k = 0; k < it->body_len && pos + k < size; ++k) + out[static_cast(pos + k)] = (*span)[static_cast(k)]; + } else { // Indirect: array of u32 unformatted-block numbers + const uint64_t nptr = it->body_len / 4; + for (uint64_t k = 0; k < nptr; ++k) { + auto blk = rd32(c.r, it->body_off + k * 4); + const uint64_t dst = pos + k * bs; + if (dst >= size) break; + const uint64_t want = std::min(bs, size - dst); + if (!blk || *blk == 0) continue; // sparse hole -> leave zeros + if (*blk >= c.sb.block_count) { c.truncated = true; continue; } + auto span = c.r.bytes(static_cast(block_off(c, *blk)), + static_cast(want)); + if (!span) { c.truncated = true; continue; } + std::memcpy(out.data() + static_cast(dst), span->data(), + static_cast(want)); + } + } + } +} + +struct Entry { + std::string name; + uint32_t dir_id; + uint32_t objectid; +}; + +// Parse one directory item's packed entries into `out` (pure; no writes). +// deh array is at the item start; names are packed toward the item end. Entry +// i's name runs from deh[i].location to the next-higher boundary: deh[i-1]'s +// location for i>0, else the item length (deh[0] holds the highest location). +// All reads are bounded by body_len. +void collect_entries(Ctx& c, const Item& it, std::vector& out) { + uint64_t nent = it.entry_count; + if (nent > MAX_DIR_ENTRIES) { nent = MAX_DIR_ENTRIES; c.truncated = true; } + if (nent * DEH_SIZE > it.body_len) { + nent = it.body_len / DEH_SIZE; + c.truncated = true; + } + uint64_t prev_end = it.body_len; + for (uint64_t i = 0; i < nent; ++i) { + const uint64_t deh = it.body_off + i * DEH_SIZE; + auto cdir = rd32(c.r, deh + 4); + auto cobj = rd32(c.r, deh + 8); + auto loc = rd16(c.r, deh + 12); + if (!cdir || !cobj || !loc) { c.truncated = true; break; } + const uint64_t name_start = *loc; + const uint64_t name_end = prev_end; + prev_end = name_start; // the next (higher-index) entry ends where this one starts + if (name_start < nent * DEH_SIZE || name_end > it.body_len || name_end <= name_start) + continue; // corrupt location + auto span = c.r.bytes(static_cast(it.body_off + name_start), + static_cast(name_end - name_start)); + if (!span) { c.truncated = true; continue; } + std::string name(reinterpret_cast(span->data()), span->size()); + if (auto z = name.find('\0'); z != std::string::npos) name.resize(z); // strip NUL pad + out.push_back({std::move(name), *cdir, *cobj}); + } +} + +// Every directory entry of an object, across all its directory items (key order). +std::vector dir_entries(Ctx& c, uint32_t dir_id, uint32_t objectid) { + std::vector out; + auto found = c.by_object.find(okey(dir_id, objectid)); + if (found == c.by_object.end()) return out; + std::vector dir_items; + for (uint32_t idx : found->second) + if (c.items[idx].type == IType::DirEntry) dir_items.push_back(&c.items[idx]); + std::sort(dir_items.begin(), dir_items.end(), + [](const Item* a, const Item* b) { return a->offset < b->offset; }); + for (const Item* it : dir_items) collect_entries(c, *it, out); + return out; +} + +void walk_dir(Ctx& c, uint32_t dir_id, uint32_t objectid, const std::string& rel, size_t depth) { + if (depth > MAX_DEPTH) { c.truncated = true; return; } + if (c.files_seen++ > MAX_FILES) { c.truncated = true; return; } + const uint64_t k = okey(dir_id, objectid); + if (!c.stack.insert(k).second) return; // directory cycle (corrupt image) + + for (const Entry& e : dir_entries(c, dir_id, objectid)) { + const std::string& name = e.name; + if (name.empty() || name == "." || name == "..") continue; + if (name.find('/') != std::string::npos || name.find('\0') != std::string::npos) continue; + const Item* sd = find_sd(c, e.dir_id, e.objectid); + if (!sd) { c.truncated = true; continue; } + uint16_t mode = 0; + uint64_t size = 0; + if (!sd_mode_size(c, *sd, mode, size)) { c.truncated = true; continue; } + + const std::string child_rel = rel.empty() ? name : rel + "/" + name; + const std::string full = c.subdir + "/" + child_rel; + const uint16_t type = mode & S_IFMT; + + if (type == S_IFDIR) { + if (c.root && c.root->make_dir(full)) c.out.dirs++; + walk_dir(c, e.dir_id, e.objectid, child_rel, depth + 1); + } else if (type == S_IFLNK) { + std::vector buf; + read_content(c, e.dir_id, e.objectid, std::min(size, 4096), buf); + std::string target(buf.begin(), buf.end()); + if (auto z = target.find('\0'); z != std::string::npos) target.resize(z); + if (!target.empty() && c.root && c.root->make_symlink(full, target)) c.out.symlinks++; + } else if (type == S_IFREG) { + std::vector data; + read_content(c, e.dir_id, e.objectid, size, data); + if (c.root && c.root->write_file(full, data, mode & 0777)) { + c.out.files++; + c.out.bytes += data.size(); + } else if (c.root) { + c.out.warnings.push_back("write failed: " + child_rel); + } + } + // Other types (fifo/chr/blk/sock): skipped. + } + c.stack.erase(k); +} + +// Recover the partition base for a superblock at `sb_off`, filling sb.base. +// Returns false if neither the 3.6 nor 3.5 placement yields a sane root block. +bool locate_base(const Reader& r, uint64_t sb_off, Super& sb) { + for (uint64_t disk_off : {DISK_OFFSET_36, DISK_OFFSET_35}) { + if (sb_off < disk_off) continue; + sb.base = sb_off - disk_off; + if (root_block_ok(r, sb)) return true; + } + return false; +} + +} // namespace + +bool extract_reiserfs(const Reader& r, const Finding& f, SafeRoot& root, const std::string& subdir, + Extracted& out) { + out.offset = f.offset; + out.type = "reiserfs"; + out.root = subdir; + + Super sb; + if (!parse_super_at(r, f.offset, sb)) { + out.status = "error:bad-superblock"; + return true; + } + // The superblock sits at a fixed offset inside the partition; the tree's + // block numbers count from the partition start, so recover that base. The + // 3.6 superblock is 64 KiB in, the 3.5 one 8 KiB in. Pick the base whose + // root block has a sane header (handles an image that is a bare partition + // starting at 0, or one carved with the superblock at its real offset). + if (!locate_base(r, f.offset, sb)) { + out.status = "error:root-block"; + return true; + } + if (!root.make_dir(subdir)) { + out.status = "error:mkdir"; + return true; + } + + Ctx c{r, sb, &root, subdir, out}; + walk_tree(c, sb.root_block, 0); + walk_dir(c, ROOT_PARENT_OBJECTID, ROOT_OBJECTID, "", 0); + out.status = c.truncated ? "partial" : "ok"; + return true; +} + +// Parse-only driver for fuzzing: recover the base from a superblock at `sb_off`, +// walk the whole S+ tree, then exercise the byte-sensitive paths on every object +// (content reconstruction and directory-entry parsing) with no SafeRoot writes. +// Returns the number of items collected (unused; keeps the call from being +// optimized away). All reads are bounds-checked, so any input is safe. +size_t reiserfs_probe(const Reader& r, size_t sb_off) { + Super sb; + if (!parse_super_at(r, sb_off, sb)) return 0; + if (!locate_base(r, sb_off, sb)) return 0; + Extracted out; + Ctx c{r, sb, nullptr, "", out}; + walk_tree(c, sb.root_block, 0); + uint64_t budget = 64u << 20; // bound total content read so fuzzing stays fast + for (const auto& kv : c.by_object) { + const uint32_t dir_id = static_cast(kv.first >> 32); + const uint32_t objectid = static_cast(kv.first & 0xffffffff); + if (const Item* sd = find_sd(c, dir_id, objectid)) { + uint16_t mode = 0; + uint64_t size = 0; + if (sd_mode_size(c, *sd, mode, size) && budget > 0) { + std::vector buf; + read_content(c, dir_id, objectid, std::min(size, budget), buf); + budget -= std::min(buf.size(), budget); + } + } + auto ents = dir_entries(c, dir_id, objectid); + (void)ents; + } + return c.items.size(); +} + +} // namespace ft diff --git a/src/extract/reiserfs.hpp b/src/extract/reiserfs.hpp new file mode 100644 index 0000000..60b411b --- /dev/null +++ b/src/extract/reiserfs.hpp @@ -0,0 +1,31 @@ +// reiserfs.hpp — ReiserFS (3.5 / 3.6) read-only extractor. +// +// Walks the S+ tree from the superblock's root block, collects every leaf item, +// and rebuilds the directory hierarchy under a SafeRoot: regular files (direct +// tails + indirect block lists, holes honored), directories, and symlinks. Item +// key format (v1 "old" / v2 "new") is read per item from ih_version, so a 3.6 +// image that mixes a v2 stat-data item with a v1 directory item in the same +// object is handled. No journal replay, no write support, no mount, no sudo +// (unlike mount/debugreiserfs). Every read goes through the bounds-checked +// Reader; a truncated or corrupt image yields a partial result, never OOB. +#pragma once + +#include + +#include "extract/manifest.hpp" +#include "finding.hpp" +#include "reader.hpp" + +namespace ft { + +class SafeRoot; + +bool extract_reiserfs(const Reader& r, const Finding& f, SafeRoot& root, const std::string& subdir, + Extracted& out); + +// Parse-only driver for fuzzing (fuzz/fuzz_reiserfs.cpp): walks the S+ tree from +// a superblock at `sb_off` and exercises the item/dir/content byte parsing with +// no filesystem writes. Every read is bounds-checked, so any input is safe. +size_t reiserfs_probe(const Reader& r, size_t sb_off); + +} // namespace ft diff --git a/tests/mkreiserfs_synth.py b/tests/mkreiserfs_synth.py new file mode 100644 index 0000000..47767ac --- /dev/null +++ b/tests/mkreiserfs_synth.py @@ -0,0 +1,266 @@ +#!/usr/bin/env python3 +"""Hand-built ReiserFS images for the extraction regression. + +ReiserFS has no populate-from-directory mkfs mode and the kernel driver was +removed in 6.13, so a real round-trip image cannot be produced unprivileged. +Instead we assemble images byte-for-byte from the on-disk layout, modeled on +exactly what mkreiserfs 3.6/3.5 writes (verified against debugreiserfs output): + + - superblock at 64 KiB (3.6, magic "ReIsEr2Fs") or 8 KiB (3.5, "ReIsErFs"); + - an S+ tree of formatted blocks: internal nodes (level > 1) = block header + + N keys + N+1 disk_child pointers; leaves (level 1) = block header + N item + headers at the front + item bodies packed at the back; + - per-item key format via ih_version (0 = v1 offset+uniqueness, 1 = v2 60-bit + offset + 4-bit type), matching the real images where a 3.6 object mixes a v2 + stat-data item with a v1 directory item; + - objects keyed (dir_id, objectid); a child's dir_id is its parent's objectid. + +Everything is little-endian and packed. Block numbers count from the partition +start (block 0), and block N is at base + N*block_size. +""" +import struct + +BS = 4096 # block size used by both images + +# Item type values (v2 key top-nibble; v1 maps through the uniqueness field). +T_SD, T_INDIRECT, T_DIRECT, T_DIRENTRY = 0, 1, 2, 3 +V1_UNIQ = {T_SD: 0, T_DIRENTRY: 500, T_INDIRECT: 0xFFFFFFFE, T_DIRECT: 0xFFFFFFFF} + +S_IFDIR, S_IFREG, S_IFLNK = 0o040000, 0o100000, 0o120000 + + +def key(dir_id, objectid, offset, itype, v2): + """16-byte on-disk key.""" + k = struct.pack("=2). Keys are ignored + by the extractor (it follows every child), so the separating key is zeros.""" + b = bytearray(BS) + nr = len(child_blocks) - 1 + struct.pack_into(" descriptor + matching tests/test_extract.py's tree_manifest format.""" + nblocks = 64 + img = bytearray(nblocks * BS) + SB, ROOT, LEAFA, LEAFB = 16, 17, 18, 19 + DA, DB, DD = 20, 21, 22 # data blocks + + blkA = bytes([0xA5]) * BS + blkB = bytes(range(256)) * 16 # 4096 bytes, known pattern + blkD = bytes([0x5A]) * BS + tail = b"TAILDATA" * 12 + b"X" * 4 # 100 bytes + place(img, DA, blkA) + place(img, DB, blkB) + place(img, DD, blkD) + + big = blkA + blkB + tail # 8292 bytes + sparse = (b"\x00" * BS) + blkD # 8192 bytes, leading hole + + V2 = True + # Objects: root(1,2); small(2,3); big(2,4); sparse(2,5); link(2,6); sub(2,7); nested(7,8) + root_entries = [(".", 1, 2), ("..", 0, 1), ("small.txt", 2, 3), ("big.bin", 2, 4), + ("sparse.bin", 2, 5), ("link", 2, 6), ("sub", 2, 7)] + sub_entries = [(".", 2, 7), ("..", 1, 2), ("nested.txt", 7, 8)] + rbody, rcnt = dir_body(root_entries, pad8=True) + sbody, scnt = dir_body(sub_entries, pad8=True) + + # Leaf A: root SD + root dir item + small SD/direct + big SD/indirect/tail + leafA_items = [ + ({"dir_id": 1, "objectid": 2, "offset": 0, "itype": T_SD, "v2": V2}, sd_v2(S_IFDIR | 0o755, len(rbody))), + ({"dir_id": 1, "objectid": 2, "offset": 1, "itype": T_DIRENTRY, "v2": False, "u16": rcnt}, rbody), + ({"dir_id": 2, "objectid": 3, "offset": 0, "itype": T_SD, "v2": V2}, sd_v2(S_IFREG | 0o644, 6)), + ({"dir_id": 2, "objectid": 3, "offset": 1, "itype": T_DIRECT, "v2": V2}, b"hello\n"), + ({"dir_id": 2, "objectid": 4, "offset": 0, "itype": T_SD, "v2": V2}, sd_v2(S_IFREG | 0o644, len(big))), + ({"dir_id": 2, "objectid": 4, "offset": 1, "itype": T_INDIRECT, "v2": V2}, struct.pack(" block 2 + + root_entries = [(".", 1, 2), ("..", 0, 1), ("file.txt", 2, 3), ("d", 2, 4)] + d_entries = [(".", 2, 4), ("..", 1, 2), ("inner.bin", 4, 5)] + rbody, rcnt = dir_body(root_entries, pad8=False) + dbody, dcnt = dir_body(d_entries, pad8=False) + inner = b"inner-content-0123456789\n" + + items = [ + ({"dir_id": 1, "objectid": 2, "offset": 0, "itype": T_SD, "v2": False}, sd_v1(S_IFDIR | 0o755, len(rbody))), + ({"dir_id": 1, "objectid": 2, "offset": 1, "itype": T_DIRENTRY, "v2": False, "u16": rcnt}, rbody), + ({"dir_id": 2, "objectid": 3, "offset": 0, "itype": T_SD, "v2": False}, sd_v1(S_IFREG | 0o600, len(b"abc\n"))), + ({"dir_id": 2, "objectid": 3, "offset": 1, "itype": T_DIRECT, "v2": False}, b"abc\n"), + ({"dir_id": 2, "objectid": 4, "offset": 0, "itype": T_SD, "v2": False}, sd_v1(S_IFDIR | 0o755, len(dbody))), + ({"dir_id": 2, "objectid": 4, "offset": 1, "itype": T_DIRENTRY, "v2": False, "u16": dcnt}, dbody), + ({"dir_id": 4, "objectid": 5, "offset": 0, "itype": T_SD, "v2": False}, sd_v1(S_IFREG | 0o644, len(inner))), + ({"dir_id": 4, "objectid": 5, "offset": 1, "itype": T_DIRECT, "v2": False}, inner), + ] + place(img, SB, superblock(nblocks, ROOT, b"ReIsErFs", 2)) + place(img, ROOT, leaf(items)) + + import hashlib + h = lambda d: hashlib.sha256(d).hexdigest() + expected = { + "file.txt": ("file", h(b"abc\n")), + "d": ("dir",), + "d/inner.bin": ("file", h(inner)), + } + return bytes(img), expected + + +if __name__ == "__main__": + import sys + which = sys.argv[1] if len(sys.argv) > 1 else "36" + img, exp = (build_36() if which == "36" else build_35()) + out = sys.argv[2] if len(sys.argv) > 2 else f"synth{which}.img" + with open(out, "wb") as f: + f.write(img) + print(f"wrote {out} ({len(img)} bytes); expected {len(exp)} entries") diff --git a/tests/run.sh b/tests/run.sh index 83a7a75..fab8013 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -34,6 +34,8 @@ python3 tests/test_jzlzma_raw.py python3 tests/test_esp32_nvs.py # Legacy block filesystems: NILFS2/Minix/ReiserFS/UFS/APFS/LogFS identify (+ real mkfs.minix). 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 # 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_reiserfs_extract.py b/tests/test_reiserfs_extract.py new file mode 100644 index 0000000..ceac60a --- /dev/null +++ b/tests/test_reiserfs_extract.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +"""ReiserFS extraction regression (issue #63). + +ReiserFS has no populate-from-directory mkfs mode, and the kernel driver was +removed in 6.13, so a populated image cannot be produced unprivileged. Layers: + + 1. Synthetic, always-on: hand-built 3.6 (2-level tree, v2 stat-data + v1 + directory items, indirect items, a tail, a leading hole, a symlink, a + nested dir) and 3.5 (single leaf, all-v1, 8 KiB superblock) images from + tests/mkreiserfs_synth, extracted and compared byte-for-byte. The layouts + are modeled on real mkreiserfs output (verified with debugreiserfs). + 2. Real empty image: mkreiserfs builds an empty filesystem (no mount needed); + moria must identify and extract it cleanly (just the root dir, 0 entries). + Self-skips if mkreiserfs is absent. This checks the parser against genuine + on-disk bytes. + 3. Corruption robustness: mangled superblock / tree bytes must never crash and + must yield a sane status (ASan build is the out-of-bounds oracle). + +Run: python3 tests/test_reiserfs_extract.py [path/to/moria] +""" +import hashlib +import json +import os +import shutil +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") +sys.path.insert(0, HERE) +import mkreiserfs_synth as M # noqa: E402 + + +def tree_manifest(root): + out = {} + for dirpath, dirnames, filenames in os.walk(root): + for name in list(dirnames) + list(filenames): + full = os.path.join(dirpath, name) + rel = os.path.relpath(full, root) + if os.path.islink(full): + out[rel] = ("link", os.readlink(full)) + elif os.path.isdir(full): + out[rel] = ("dir",) + else: + with open(full, "rb") as f: + out[rel] = ("file", hashlib.sha256(f.read()).hexdigest()) + return out + + +def extract(work, img_bytes, label): + """Write bytes, run moria --extract, return (entry, outdir) or (None, None).""" + img = os.path.join(work, f"{label}.img") + with open(img, "wb") as f: + f.write(img_bytes) + outdir = os.path.join(work, f"{label}.out") + r = subprocess.run([MORIA, "-j", "--extract", "-C", outdir, img], capture_output=True) + if r.returncode != 0: + print(f"FAIL [{label}]: moria exit {r.returncode}\n{r.stderr.decode()[-800:]}") + return None, None + try: + entry = json.loads(r.stdout.decode())["extraction"]["extracted"][0] + except Exception as e: + print(f"FAIL [{label}]: no extraction manifest ({e})\n{r.stdout[:200]}") + return None, None + return entry, outdir + + +def test_synthetic(work): + failures = 0 + for which, builder in (("3.6", M.build_36), ("3.5", M.build_35)): + img_bytes, expected = builder() + entry, outdir = extract(work, img_bytes, f"synth{which}") + if entry is None: + failures += 1 + continue + if entry["status"] != "ok": + print(f"FAIL [synth {which}]: status={entry['status']}") + failures += 1 + continue + got = tree_manifest(os.path.join(outdir, entry["root"])) + if got == expected: + print(f"PASS [synth {which}]: {entry['files']} files, {entry['dirs']} dirs, " + f"{entry['symlinks']} symlinks, round-trips byte-for-byte") + else: + failures += 1 + only_exp = set(expected) - set(got) + only_got = set(got) - set(expected) + mism = {k for k in expected.keys() & got.keys() if expected[k] != got[k]} + print(f"FAIL [synth {which}]: missing={sorted(only_exp)} extra={sorted(only_got)} " + f"mismatched={sorted(mism)}") + return failures + + +def test_real_empty(work): + """mkreiserfs makes an empty fs (no mount); moria must extract it cleanly.""" + if not shutil.which("mkreiserfs"): + print("SKIP [real]: mkreiserfs (reiserfsprogs) not installed") + return 0 + failures = 0 + for fmt in ("3.6", "3.5"): + img = os.path.join(work, f"real{fmt}.img") + with open(img, "wb") as f: + f.write(b"\x00" * (33 << 20)) + r = subprocess.run(["mkreiserfs", "-q", "-f", "--format", fmt, img], + capture_output=True) + if r.returncode != 0: + print(f"SKIP [real {fmt}]: mkreiserfs failed") + continue + outdir = os.path.join(work, f"real{fmt}.out") + r = subprocess.run([MORIA, "-j", "--extract", "-C", outdir, img], capture_output=True) + try: + entry = json.loads(r.stdout.decode())["extraction"]["extracted"][0] + except Exception: + print(f"FAIL [real {fmt}]: not identified/extracted\n{r.stdout[:200]}") + failures += 1 + continue + # An empty reiserfs has only the root directory (., ..), so nothing is + # written, but extraction must succeed on the genuine bytes. + if entry["type"] == "reiserfs" and entry["status"] == "ok" and entry["files"] == 0: + print(f"PASS [real {fmt}]: empty image identified + extracted clean (status ok)") + else: + print(f"FAIL [real {fmt}]: type={entry['type']} status={entry['status']} " + f"files={entry['files']}") + failures += 1 + return failures + + +def test_corruption(work): + """Mangled images must not crash and must report a sane status.""" + base, _ = M.build_36() + cases = { + "truncated-mid-tree": base[:20 * 4096], # cut inside the tree + "truncated-at-sb": base[:16 * 4096 + 100], # SB header only + "zeroed-root-block": bytearray(base), # root block wiped + "garbage-tree": bytearray(base), # random tree bytes + "huge-sizes": bytearray(base), # stat-data size fields maxed + } + import struct + z = cases["zeroed-root-block"] + z[17 * 4096:18 * 4096] = b"\x00" * 4096 + g = cases["garbage-tree"] + for i in range(17 * 4096, 23 * 4096): + g[i] = (i * 37) & 0xFF + h = cases["huge-sizes"] + # leaf A item 0 (root SD, v2) size field at body; just flip many bytes in the + # leaf bodies to 0xFF so size/location fields are maximal. + for i in range(18 * 4096 + 2048, 19 * 4096): + h[i] = 0xFF + + failures = 0 + for label, data in cases.items(): + img = os.path.join(work, f"corrupt_{label}.img") + with open(img, "wb") as f: + f.write(bytes(data)) + outdir = os.path.join(work, f"corrupt_{label}.out") + r = subprocess.run([MORIA, "-j", "--extract", "-C", outdir, img], + capture_output=True, timeout=60) + if r.returncode != 0: + print(f"FAIL [corrupt {label}]: moria exit {r.returncode} (crash/sanitizer?)\n" + f"{r.stderr.decode()[-600:]}") + failures += 1 + continue + # Identified-but-unreadable is fine; we only require no crash and, when a + # reiserfs extraction ran, a recognized status. + try: + entry = json.loads(r.stdout.decode()).get("extraction", {}).get("extracted", [{}])[0] + status = entry.get("status", "") + except Exception: + status = "" + ok = (status == "" or status == "ok" or status == "partial" + or status.startswith("error:")) + print(f"{'PASS' if ok else 'FAIL'} [corrupt {label}]: no crash, status={status!r}") + failures += 0 if ok else 1 + return failures + + +def main(): + if not os.path.exists(MORIA): + print("moria not built", file=sys.stderr) + return 1 + with tempfile.TemporaryDirectory() as work: + failures = 0 + failures += test_synthetic(work) + failures += test_real_empty(work) + failures += test_corruption(work) + if failures: + print(f"\n{failures} reiserfs extraction check(s) failed") + return 1 + print("\nall reiserfs extraction checks passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main())