From 8636e442ec144086d54dd5fdd2c1c681e0af0931 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Sat, 26 Sep 2026 18:53:16 +0300 Subject: [PATCH 1/2] feat(otra): identify and extract Artosyn OTRA firmware images Added support for the Artosyn OTRA firmware upgrade container used by AR8030 / VT4 radios and ground units (HGLRC, BetaFPV, arlink and more popular fpv vendors). moria now verifies the image's own SHA-256(body) integrity gate, lists its partitions and segments in the identify tree, and unpacks it recursively. Two body layouts exist and the validator tells them apart: * segmented (gnd/air units): a partition table + a segment table, each partition being the concatenation of its LZO1X-compressed segments. The extractor decompresses them (via moria's own bounds-checked LZO1X decoder, byte-identical to liblzo2) into one raw image per partition, which moria then recurses into (userapp0 -> UBIFS -> squashfs, kernel0 -> uImage, dtb, uboot, env, ...). * flat (arlink VT4 radios): a raw dual-slot flash image with no populated tables; the whole body is written out as flash.bin for downstream carving. A correct body SHA-256 yields the verified tier; the RSA-2048 signature is noted but not checked (identification/extraction only, no signing). Tests: new tests/test_otra.py covers the segmented (2-partition LZO byte-exact round-trip) and flat (flash.bin) paths, both verified via SHA-256; a flat fixture is added to gen_samples.py to satisfy the signature coverage gate. --- CMakeLists.txt | 3 + README.md | 2 +- signatures/otra.toml | 62 ++++++++++++ src/extract/manifest.cpp | 2 + src/extract/otra.cpp | 137 ++++++++++++++++++++++++++ src/extract/otra.hpp | 23 +++++ src/otra_format.hpp | 179 ++++++++++++++++++++++++++++++++++ src/validators/otra.cpp | 83 ++++++++++++++++ src/validators/otra.hpp | 10 ++ src/validators/registry.cpp | 2 + tests/gen_samples.py | 26 +++++ tests/test_otra.py | 188 ++++++++++++++++++++++++++++++++++++ 12 files changed, 716 insertions(+), 1 deletion(-) create mode 100644 signatures/otra.toml create mode 100644 src/extract/otra.cpp create mode 100644 src/extract/otra.hpp create mode 100644 src/otra_format.hpp create mode 100644 src/validators/otra.cpp create mode 100644 src/validators/otra.hpp create mode 100644 tests/test_otra.py diff --git a/CMakeLists.txt b/CMakeLists.txt index aa896eb..802524d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -67,6 +67,7 @@ add_executable(moria src/extract/uboot_env.cpp src/extract/esp32_nvs.cpp src/esp32_nvs_parse.cpp + src/extract/otra.cpp src/extract/rae_rfp.cpp src/extract/lzari.cpp src/extract/vbf.cpp @@ -112,6 +113,7 @@ add_executable(moria src/validators/jffs2.cpp src/validators/zip.cpp src/validators/jpeg.cpp + src/validators/otra.cpp src/validators/rae_rfp.cpp src/validators/vbf.cpp src/validators/verity.cpp @@ -235,6 +237,7 @@ if(Python3_Interpreter_FOUND) test_luks test_lzma test_rae_rfp + test_otra test_vbf test_uboot_env test_vbmeta diff --git a/README.md b/README.md index 44645b8..a68ceed 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ 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 - **Archives and images:** ZIP, tar, cpio, ISO 9660, Android sparse, Android boot - **Kernels and wrappers:** U-Boot uImage, U-Boot FIT, standalone gzip / xz / zstd / lz4 streams -- **Firmware packages:** RAE Systems / Honeywell RFP (section table; LZARI-decompresses each section) +- **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) ## Signatures diff --git a/signatures/otra.toml b/signatures/otra.toml new file mode 100644 index 0000000..8f823e9 --- /dev/null +++ b/signatures/otra.toml @@ -0,0 +1,62 @@ +name = "otra" +category = "firmware" + +# Artosyn "OTRA" firmware upgrade image (AR8030 / VT4 / VR04 goggles + air units, +# HGLRC / BetaFPV / arlink). 256-byte header, then a 32-byte SHA-256 of the body +# and a 256-byte RSA-2048 signature, then the body (bytes 0x220..EOF): +# +# 0x000 256 B header (magic 'OTRA', ver@4, compress@5, hashsize@0xa=0x20, +# siglen@0xe=0x100, body_size@0x10 = filesize-0x220, +# region1_size@0x18, region2_size@0x1c, +# npart@0x20 u16, nseg@0x22 u16) +# 0x100 32 B SHA-256 of the body +# 0x120 256 B RSA-2048 signature +# 0x220 ... body +# +# Two body layouts occur in the wild and the validator tells them apart: +# * segmented (VR04 goggles, 14 parts / 10 segs): a partition table (npart x +# 0x34: name[0x20], u64 flash_off, u64 capacity, u32 flags) then a segment +# table (nseg x 0x20: u64 file_off, u64 flash_off, u64 data_len, u64 flash_len), +# then LZO1X-compressed payloads. Each partition is the concat of its segments. +# * flat (arlink VT4 radio, small ~1 MB images): the body is a raw dual-slot +# flash image with no populated tables. +# Integrity is a SHA-256 over the whole body plus a raw RSA-2048 signature; the +# validator recomputes the SHA-256 (Verified tier on a match). +struct = """ + bytes[4] magic; + u8 ver; + u8 compress; + u8 sub_a; + u8 sub_b; + u16 rsvd0; + u8 hashsize; + u8 rsvd1; + u16 rsvd2; + u16 siglen; + u32 body_size; + u32 rsvd3; + u32 region1; + u32 region2; + u16 npart; + u16 nseg; +""" + +constraints = [ + "ver == 1", + "hashsize == 32", + "siglen == 256", + "npart <= 64", + "nseg <= 512", + "body_size >= 1", + "body_size <= _avail", +] + +validator = "otra" # tells segmented from flat, lists partitions, verifies SHA-256 + +[[magic]] +ascii = "OTRA" +endian = "little" + +[doc] +description = "Artosyn OTRA firmware upgrade image (AR8030 / VT4 / VR04; HGLRC / BetaFPV / arlink). SHA-256 + RSA-2048 signed; segmented images LZO-decompress into per-partition raw images." +vendor = "Artosyn" diff --git a/src/extract/manifest.cpp b/src/extract/manifest.cpp index 91d38be..d216ae1 100644 --- a/src/extract/manifest.cpp +++ b/src/extract/manifest.cpp @@ -22,6 +22,7 @@ #include "extract/iso9660.hpp" #include "extract/jffs2.hpp" #include "extract/ntfs.hpp" +#include "extract/otra.hpp" #include "extract/rae_rfp.hpp" #include "extract/spiffs.hpp" #include "extract/squashfs.hpp" @@ -64,6 +65,7 @@ Extractor find_extractor(const std::string& type) { if (type == "uboot_env") return extract_uboot_env; if (type == "esp32_nvs") return extract_esp32_nvs; if (type == "rae_rfp") return extract_rae_rfp; + if (type == "otra") return extract_otra; if (type == "vbf") return extract_vbf; if (type == "vbmeta") return extract_vbmeta; if (type == "upx") return extract_upx; diff --git a/src/extract/otra.cpp b/src/extract/otra.cpp new file mode 100644 index 0000000..af21d33 --- /dev/null +++ b/src/extract/otra.cpp @@ -0,0 +1,137 @@ +// otra.cpp — Artosyn OTRA firmware image extraction. See otra.hpp. +// +// Segmented: one raw image per partition = concat of its LZO1X-decompressed +// segments (data_len compressed -> flash_len decompressed, no per-segment CRC). +// Flat: the body (0x220..EOF) is a raw flash image; write it whole. Either way the +// output is re-identified when moria recurses into the extraction directory. +#include "extract/otra.hpp" + +#include +#include +#include +#include + +#include "extract/lzo1x.hpp" +#include "extract/safepath.hpp" +#include "otra_format.hpp" + +namespace ft { + +namespace { + +constexpr uint64_t kMaxPart = uint64_t(64) << 20; // 64 MiB cap on one decoded partition + +std::string sanitize(const std::string& name) { + std::string s; + for (char c : name) { + unsigned char u = static_cast(c); + s += (u == '.' || u == '_' || u == '-' || (u >= '0' && u <= '9') || + (u >= 'A' && u <= 'Z') || (u >= 'a' && u <= 'z')) + ? c + : '_'; + } + return s; +} + +} // namespace + +bool extract_otra(const Reader& r, const Finding& f, SafeRoot& root, const std::string& subdir, + Extracted& out) { + out.offset = f.offset; + out.type = "otra"; + out.root = subdir; + + auto ho = otra::parse_otra(r, f.offset); + if (!ho) { + out.status = "error:header"; + return true; + } + const otra::Header& h = *ho; + if (!root.make_dir(subdir)) { + out.status = "error:mkdir"; + return true; + } + + auto tables = otra::parse_otra_tables(r, f.offset, h); + + // Flat subtype: no populated tables — the body is a raw flash image. Write it + // whole so downstream carving / recursion can work on it. + if (!tables.segmented) { + const size_t body_off = f.offset + otra::kHeaderEnd; + auto body = r.bytes(body_off, r.size() - body_off); + if (!body) { + out.status = "error:body"; + return true; + } + std::vector data(body->begin(), body->end()); + if (!root.write_file(subdir + "/flash.bin", data, 0644)) { + out.status = "error:write"; + return true; + } + out.files++; + out.bytes += data.size(); + out.consumed = r.size() - f.offset; + out.status = "ok"; + return true; + } + + // Segmented subtype: one raw partition image per partition that carries segments. + bool any_fail = false; + size_t part_idx = 0; + for (const auto& p : tables.parts) { + const size_t idx = part_idx++; + auto sel = otra::segments_of(p, tables.segs); + if (sel.empty()) continue; // inactive B-slot / no payload in this image + + uint64_t total = 0; + for (const auto* s : sel) total += s->flash_len; + if (total == 0 || total > kMaxPart) { + any_fail = true; + out.warnings.push_back(p.name + ": implausible size, skipped"); + continue; + } + + std::vector blob; + blob.reserve(static_cast(total)); + bool ok = true; + for (const auto* s : sel) { + auto src = r.bytes(s->file_off, s->data_len); + if (!src) { ok = false; break; } + std::vector dec(static_cast(s->flash_len)); + size_t got = 0; + if (!lzo1x_decompress_safe(src->data(), src->size(), dec.data(), dec.size(), &got) || + got != s->flash_len) { + ok = false; + break; + } + blob.insert(blob.end(), dec.begin(), dec.end()); + } + + std::string name = sanitize(p.name); + if (name.empty()) name = "part"; + char pfx[8]; + std::snprintf(pfx, sizeof(pfx), "%02zu_", idx); + std::string fname = std::string(pfx) + name + ".bin"; + + if (!ok) { + any_fail = true; + out.warnings.push_back(p.name + ": LZO segment decode failed"); + continue; // don't write a truncated/corrupt partition image + } + if (!root.write_file(subdir + "/" + fname, blob, 0644)) { + out.status = "error:write"; + return true; + } + out.files++; + out.bytes += blob.size(); + } + + out.consumed = r.size() - f.offset; + if (out.files == 0) + out.status = "error:no-partitions"; + else + out.status = any_fail ? "partial" : "ok"; + return true; +} + +} // namespace ft diff --git a/src/extract/otra.hpp b/src/extract/otra.hpp new file mode 100644 index 0000000..c170ca4 --- /dev/null +++ b/src/extract/otra.hpp @@ -0,0 +1,23 @@ +// otra.hpp — Artosyn OTRA firmware image extractor. +// +// Segmented images (VR04): each partition is the concatenation of its LZO1X +// segments; the extractor decompresses them into one raw partition image per +// partition (e.g. "05_userapp0.bin"), which moria then recurses into (UBI -> +// squashfs, uImage, dtb, ...). Flat images (arlink VT4): the body has no tables, +// so the whole flash image is written out as "flash.bin" for downstream carving. +#pragma once + +#include + +#include "extract/manifest.hpp" +#include "finding.hpp" +#include "reader.hpp" + +namespace ft { + +class SafeRoot; + +bool extract_otra(const Reader& r, const Finding& f, SafeRoot& root, const std::string& subdir, + Extracted& out); + +} // namespace ft diff --git a/src/otra_format.hpp b/src/otra_format.hpp new file mode 100644 index 0000000..ccad054 --- /dev/null +++ b/src/otra_format.hpp @@ -0,0 +1,179 @@ +// otra_format.hpp — Artosyn OTRA firmware image parser (header + partition/segment +// tables), shared by the validator and the extractor. Header-only, no I/O beyond +// the bounds-checked Reader. See signatures/otra.toml for the on-disk layout. +// +// Two body layouts exist. `parse_otra` decodes the header for both; `parse_otra_tables` +// additionally walks the partition and segment tables and reports whether the image +// is the "segmented" subtype (populated tables + a contiguous LZO payload chain that +// spans the body) or the "flat" subtype (a raw flash image with empty tables). +#pragma once + +#include +#include +#include +#include + +#include "reader.hpp" + +namespace ft { + +namespace otra { + +constexpr size_t kHeaderEnd = 0x220; // header + hash + signature; body starts here +constexpr size_t kHashOff = 0x100, kHashLen = 0x20; +constexpr size_t kSigOff = 0x120, kSigLen = 0x100; +constexpr size_t kPartEntry = 0x34; // name[0x20] + u64 flash_off + u64 cap + u32 flags +constexpr size_t kSegEntry = 0x20; // u64 file_off + u64 flash_off + u64 data_len + u64 flash_len +constexpr size_t kMaxParts = 64; +constexpr size_t kMaxSegs = 512; + +struct Header { + uint8_t ver = 0; + uint8_t compress = 0; + uint8_t hashsize = 0; + uint16_t siglen = 0; + uint64_t body_size = 0; // field @0x10; should equal filesize - 0x220 + uint64_t region1 = 0; + uint64_t region2 = 0; + uint16_t npart = 0; + uint16_t nseg = 0; + std::string version; // ASCII build string at 0x80 (e.g. "0.00.00") + size_t part_off = 0, seg_off = 0, pay_off = 0; // absolute offsets in the image +}; + +struct Partition { + std::string name; + uint64_t flash_off = 0; + uint64_t capacity = 0; + uint32_t flags = 0; +}; + +struct Segment { + uint64_t file_off = 0; // absolute offset of the (compressed) payload in the image + uint64_t flash_off = 0; // on-flash target, used to match a segment to its partition + uint64_t data_len = 0; // compressed size + uint64_t flash_len = 0; // decompressed / on-flash size +}; + +struct Tables { + bool segmented = false; // populated tables + valid payload chain + bool chain_spans_body = false; // last payload ends exactly at EOF + std::vector parts; + std::vector segs; +}; + +// Decode the fixed header. `base` is the offset of the 'OTRA' magic (0 in practice). +inline std::optional
parse_otra(const Reader& r, size_t base) { + auto magic = r.bytes(base, 4); + if (!magic || (*magic)[0] != 'O' || (*magic)[1] != 'T' || (*magic)[2] != 'R' || + (*magic)[3] != 'A') + return std::nullopt; + + auto ver = r.at(base + 0x04, Endian::Little); + auto compress = r.at(base + 0x05, Endian::Little); + auto hashsize = r.at(base + 0x0a, Endian::Little); + auto siglen = r.at(base + 0x0e, Endian::Little); + auto body_size = r.at(base + 0x10, Endian::Little); + auto region1 = r.at(base + 0x18, Endian::Little); + auto region2 = r.at(base + 0x1c, Endian::Little); + auto npart = r.at(base + 0x20, Endian::Little); + auto nseg = r.at(base + 0x22, Endian::Little); + if (!ver || !compress || !hashsize || !siglen || !body_size || !region1 || !region2 || + !npart || !nseg) + return std::nullopt; + + Header h; + h.ver = *ver; + h.compress = *compress; + h.hashsize = *hashsize; + h.siglen = *siglen; + h.body_size = *body_size; + h.region1 = *region1; + h.region2 = *region2; + h.npart = *npart; + h.nseg = *nseg; + + // ASCII build string at 0x80 (16 bytes max), when present and printable. + if (auto vs = r.bytes(base + 0x80, 16)) { + std::string s; + for (uint8_t c : *vs) { + if (c == 0) break; + if (c < 0x20 || c >= 0x7f) { s.clear(); break; } + s += static_cast(c); + } + h.version = s; + } + + // Table offsets: region1 precedes region2, tables follow both (RE'd pointer math). + h.part_off = base + kHeaderEnd + h.region1 + h.region2; + h.seg_off = h.part_off + size_t(h.npart) * kPartEntry; + h.pay_off = h.seg_off + size_t(h.nseg) * kSegEntry; + return h; +} + +// Walk the partition + segment tables and classify the subtype. Never throws; +// on a flat/opaque image it returns Tables{segmented=false} with empty vectors. +inline Tables parse_otra_tables(const Reader& r, size_t base, const Header& h) { + Tables t; + if (h.npart == 0 || h.npart > kMaxParts || h.nseg == 0 || h.nseg > kMaxSegs) return t; + if (h.pay_off > r.size()) return t; + + std::vector parts; + for (size_t i = 0; i < h.npart; ++i) { + const size_t o = h.part_off + i * kPartEntry; + auto name = r.bytes(o, 0x20); + auto flash_off = r.at(o + 0x20, Endian::Little); + auto capacity = r.at(o + 0x28, Endian::Little); + auto flags = r.at(o + 0x30, Endian::Little); + if (!name || !flash_off || !capacity || !flags) return t; + Partition p; + for (uint8_t c : *name) { + if (c == 0) break; + p.name += static_cast(c); + } + p.flash_off = *flash_off; + p.capacity = *capacity; + p.flags = *flags; + parts.push_back(std::move(p)); + } + + std::vector segs; + uint64_t chain = h.pay_off; // payloads are laid out contiguously from here + for (size_t i = 0; i < h.nseg; ++i) { + const size_t o = h.seg_off + i * kSegEntry; + auto file_off = r.at(o + 0x00, Endian::Little); + auto flash_off = r.at(o + 0x08, Endian::Little); + auto data_len = r.at(o + 0x10, Endian::Little); + auto flash_len = r.at(o + 0x18, Endian::Little); + if (!file_off || !data_len || !flash_len) return t; + // A populated segment must sit inside the file, after the tables, and follow + // the previous one with no gap (the flat subtype fails here: file_off == 0). + if (*file_off != chain) return t; + if (*data_len > r.size() - *file_off) return t; + Segment s; + s.file_off = *file_off; + s.flash_off = flash_off ? *flash_off : 0; + s.data_len = *data_len; + s.flash_len = *flash_len; + chain = *file_off + *data_len; + segs.push_back(s); + } + + t.segmented = true; + t.chain_spans_body = (chain == r.size()); + t.parts = std::move(parts); + t.segs = std::move(segs); + return t; +} + +// Segments whose flash_off falls within a partition's [flash_off, flash_off+capacity). +inline std::vector segments_of(const Partition& p, + const std::vector& segs) { + std::vector sel; + for (const auto& s : segs) + if (s.flash_off >= p.flash_off && s.flash_off < p.flash_off + p.capacity) sel.push_back(&s); + return sel; +} + +} // namespace otra +} // namespace ft diff --git a/src/validators/otra.cpp b/src/validators/otra.cpp new file mode 100644 index 0000000..a3571c4 --- /dev/null +++ b/src/validators/otra.cpp @@ -0,0 +1,83 @@ +// otra.cpp — Artosyn OTRA firmware image validator. See src/otra.hpp for layout. +// +// Header + constraints already matched (magic 'OTRA', ver/hashsize/siglen sane). +// Here we: (1) confirm body_size == filesize-0x220, (2) recompute SHA-256 over the +// body and compare to the stored digest (a match is the firmware's own integrity +// gate -> Verified tier), and (3) classify the body as segmented (populated +// partition/segment tables + contiguous LZO payload chain; partitions surface as +// members) or flat (a raw dual-slot flash image). +#include "validators/otra.hpp" + +#include +#include +#include +#include + +#include "deobfuscate/cipher.hpp" +#include "otra_format.hpp" + +namespace ft { + +namespace { +std::string hex(uint64_t v) { + char buf[19]; + std::snprintf(buf, sizeof(buf), "0x%llx", static_cast(v)); + return buf; +} +} // namespace + +bool validate_otra(ValidatorCtx& ctx) { + const Reader& r = ctx.reader; + const size_t base = ctx.offset; + + auto ho = otra::parse_otra(r, base); + if (!ho) return false; + const otra::Header& h = *ho; + + // The container runs from the header to EOF (body_size counts from 0x220). + if (base + otra::kHeaderEnd > r.size()) return false; + const size_t avail_body = r.size() - (base + otra::kHeaderEnd); + if (h.body_size != avail_body) return false; // trailing junk / truncated -> not a clean OTRA + + ctx.out.size = r.size() - base; + if (!h.version.empty()) ctx.out.version = h.version; + + auto tables = otra::parse_otra_tables(r, base, h); + ctx.out.compression = tables.segmented ? "lzo1x" : "none"; + + if (tables.segmented) { + for (const auto& p : tables.parts) { + auto sel = otra::segments_of(p, tables.segs); + if (sel.empty()) continue; // inactive slot / no payload in this image + Member m; + m.name = p.name.empty() ? "part" : p.name; + m.offset = static_cast(sel.front()->file_off); + uint64_t comp = 0, decomp = 0; + for (const auto* s : sel) { comp += s->data_len; decomp += s->flash_len; } + m.size = static_cast(comp); // compressed span in the file + m.note = std::to_string(sel.size()) + (sel.size() == 1 ? " seg -> " : " segs -> ") + + hex(decomp) + ((p.flags & 1u) ? " · active" : ""); + ctx.out.members.push_back(std::move(m)); + } + } + + // SHA-256 over the body is the firmware's own integrity check; recompute it. + auto body = r.bytes(base + otra::kHeaderEnd, avail_body); + auto stored = r.bytes(base + otra::kHashOff, otra::kHashLen); + bool sha_ok = false; + if (body && stored && h.hashsize == otra::kHashLen) { + auto calc = sha256(*body); + sha_ok = stored->size() == calc.size() && + std::equal(calc.begin(), calc.end(), stored->begin()); + } + + if (sha_ok) + ctx.out.set_confidence(Confidence::Verified, "SHA-256(body) matches stored digest"); + else if (tables.segmented && tables.chain_spans_body) + ctx.out.set_confidence(Confidence::Consistent, "segment payload chain spans the body"); + else + ctx.out.set_confidence(Confidence::Structural, "OTRA header consistent"); + return true; +} + +} // namespace ft diff --git a/src/validators/otra.hpp b/src/validators/otra.hpp new file mode 100644 index 0000000..f11f78e --- /dev/null +++ b/src/validators/otra.hpp @@ -0,0 +1,10 @@ +#pragma once +#include "signature.hpp" + +namespace ft { +// Validate an Artosyn OTRA firmware image: decode the header, recompute the +// SHA-256 over the body (0x220..EOF) and compare it to the stored digest +// (Verified on a match), and classify the body as segmented (lists partitions as +// members) or flat. See src/otra.hpp for the layout. +bool validate_otra(ValidatorCtx& ctx); +} // namespace ft diff --git a/src/validators/registry.cpp b/src/validators/registry.cpp index bb94c74..30b1a18 100644 --- a/src/validators/registry.cpp +++ b/src/validators/registry.cpp @@ -14,6 +14,7 @@ #include "validators/littlefs.hpp" #include "validators/luks.hpp" #include "validators/lzma.hpp" +#include "validators/otra.hpp" #include "validators/partition.hpp" #include "validators/rae_rfp.hpp" #include "validators/spiffs.hpp" @@ -69,6 +70,7 @@ Validator find_validator(const std::string& name) { if (name == "littlefs") return validate_littlefs; if (name == "spiffs") return validate_spiffs; if (name == "lzma") return validate_lzma; + if (name == "otra") return validate_otra; return nullptr; } diff --git a/tests/gen_samples.py b/tests/gen_samples.py index a025f17..a50b32b 100755 --- a/tests/gen_samples.py +++ b/tests/gen_samples.py @@ -684,6 +684,31 @@ def section(name, flags, data): return hdr + section(b"IniFile", 0, ini) + section(b"SIGN", 0, bytes(96)) +def otra(): + # Artosyn OTRA firmware image: 0x220-byte header (magic 'OTRA', ver@4, + # compress@5, hashsize@0xa=0x20, siglen@0xe=0x100, body_size@0x10, + # region1@0x18, region2@0x1c, npart@0x20, nseg@0x22), a 32-byte SHA-256 of the + # body @0x100, a 256-byte RSA signature @0x120, then the body. This is the flat + # subtype (raw flash body, no usable tables); the correct body SHA-256 makes it + # verified. (test_otra.py covers the segmented + LZO-decompress path.) + import hashlib + b = bytearray(0x220) + b[0:4] = b"OTRA" + b[0x04] = 1 # version byte + b[0x05] = 0 # compress flag (flat: 0) + b[0x0a] = 0x20 # hashsize + struct.pack_into(" flat subtype. npart/nseg are set but the + # segment chain does not validate (file_off 0), so moria treats the body as raw. + body_extra = bytes((i * 13 + 7) & 0xFF for i in range(0x400)) + b = _header(1, 1, region1=0x40, compress=0, version=b"9.9.9") + b += bytes(0x54) # one zeroed part entry (0x34) + one zeroed seg entry (0x20) + b += body_extra + return _finalize(b), body_extra + + +def moria_json(path): + r = subprocess.run([MORIA, "-j", path], capture_output=True, timeout=60) + if r.returncode != 0: + raise RuntimeError(f"moria exited {r.returncode}: {r.stderr.decode(errors='replace')}") + return json.loads(r.stdout) + + +def main(): + fails = [] + with tempfile.TemporaryDirectory() as tmp: + # ---- segmented ---- + seg_img = os.path.join(tmp, "seg.img") + with open(seg_img, "wb") as f: + f.write(build_segmented()) + j = moria_json(seg_img) + top = j["findings"][0] if j["findings"] else {} + if top.get("type") != "otra": + fails.append(f"segmented: type={top.get('type')} (want otra)") + if top.get("confidence_tier") != "verified": + fails.append(f"segmented: tier={top.get('confidence_tier')} (want verified; SHA-256)") + if top.get("compression") != "lzo1x": + fails.append(f"segmented: compression={top.get('compression')} (want lzo1x)") + if len(top.get("members", [])) != 2: + fails.append(f"segmented: members={len(top.get('members', []))} (want 2)") + + outdir = os.path.join(tmp, "seg.out") + subprocess.run([MORIA, "-e", seg_img, "-C", outdir], capture_output=True, timeout=60) + root = os.path.join(outdir, "0x0-otra") + expect = {"00_P0.bin": P0_PLAIN, "01_P1.bin": P1_PLAIN} + for fname, want in expect.items(): + p = os.path.join(root, fname) + if not os.path.exists(p): + fails.append(f"segmented: missing extracted {fname}") + continue + got = open(p, "rb").read() + if got != want: + fails.append(f"segmented: {fname} not byte-exact " + f"({len(got)} bytes, sha {hashlib.sha256(got).hexdigest()[:12]})") + + # ---- flat ---- + flat_img = os.path.join(tmp, "flat.img") + img_bytes, body = build_flat() + with open(flat_img, "wb") as f: + f.write(img_bytes) + j = moria_json(flat_img) + top = j["findings"][0] if j["findings"] else {} + if top.get("type") != "otra": + fails.append(f"flat: type={top.get('type')} (want otra)") + if top.get("confidence_tier") != "verified": + fails.append(f"flat: tier={top.get('confidence_tier')} (want verified)") + if top.get("compression") != "none": + fails.append(f"flat: compression={top.get('compression')} (want none)") + + outdir = os.path.join(tmp, "flat.out") + subprocess.run([MORIA, "-e", flat_img, "-C", outdir], capture_output=True, timeout=60) + fb = os.path.join(outdir, "0x0-otra", "flash.bin") + if not os.path.exists(fb): + fails.append("flat: flash.bin missing") + else: + got = open(fb, "rb").read() + if got != img_bytes[HDR:]: + fails.append("flat: flash.bin does not equal the image body") + elif got[-len(body):] != body: + fails.append("flat: flash.bin body tail mismatch") + + if fails: + print("FAIL:") + for m in fails: + print(" -", m) + return 1 + print("PASS: OTRA segmented (2 partitions, LZO byte-exact) + flat (flash.bin) round-trip; " + "both verified via SHA-256") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 703667c3548e76100ced9fb7bf8126a5585ab32f Mon Sep 17 00:00:00 2001 From: Matt Brown Date: Mon, 5 Oct 2026 11:09:22 -0400 Subject: [PATCH 2/2] fix(otra): use ASCII separator in member note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The active-partition marker used a UTF-8 middle dot. moria's JSON emitter escapes it byte-wise (·), so JSON consumers see mojibake. Use an ASCII [active] marker instead; the JSON output is the tool-calling surface. --- src/validators/otra.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/validators/otra.cpp b/src/validators/otra.cpp index a3571c4..4591fb8 100644 --- a/src/validators/otra.cpp +++ b/src/validators/otra.cpp @@ -56,7 +56,7 @@ bool validate_otra(ValidatorCtx& ctx) { for (const auto* s : sel) { comp += s->data_len; decomp += s->flash_len; } m.size = static_cast(comp); // compressed span in the file m.note = std::to_string(sel.size()) + (sel.size() == 1 ? " seg -> " : " segs -> ") + - hex(decomp) + ((p.flags & 1u) ? " · active" : ""); + hex(decomp) + ((p.flags & 1u) ? " [active]" : ""); ctx.out.members.push_back(std::move(m)); } }