zupt/ROOT_CAUSE_ANALYSIS.md
Cristian Cezar Moisés 06c877ec86 feat: add Jasmin assembly integration for crypto acceleration
- Integrated `zupt_mac_verify_ct` in `zupt_decrypt_buffer()` to replace C XOR loop for HMAC-SHA256
- Integrated `zupt_ct_select_32` in `zupt_mlkem768_decaps()` to replace C `cmov()` for FO transformation
- Added `include/zupt_jasmin.h` with extern declarations and ABI docs
- Added `#ifdef ZUPT_USE_JASMIN` guards with clean C fallbacks in `zupt_crypto.c` and `zupt_mlkem.c`
- Makefile now auto-detects `jasmin/*.s`, assembles and links with `-DZUPT_USE_JASMIN`

Closes #3
2026-03-28 23:03:04 -03:00

10 KiB
Raw Blame History

Zupt — Root Cause Analysis

Summary

All compression/decompression round-trip tests pass across every file type, compression level (19), codec (Store, Zupt-LZ, Zupt-LZH, Zupt-LZHP), and mode (normal, solid, encrypted, encrypted-solid). No data corruption was reproduced on Linux x86-64. Address Sanitizer and Undefined Behavior Sanitizer detected zero memory errors.

The codebase does contain 16 concrete defects in five categories: data corruption (3 critical), memory safety (2 critical), security hardening, portability, robustness, and code quality.


BUG 1 — Endian-unsafe archive I/O (Portability: CRITICAL on BE targets)

Where: zupt_format.c:198202 (w16, w64, r16, r64)

What: These helpers use raw fwrite(&v, N, 1, f) which writes the host's native byte order. On little-endian (x86, ARM64, Apple Silicon) this produces LE archives. On big-endian (SPARC, s390x, MIPS-BE), archives are incompatible.

The same issue affects the central index serialization at lines 444450 and 718724, where memcpy(buf, &field, 8) writes native-endian 64-bit values.

Root cause: Missing explicit LE serialization layer.

Fix: Replace w16/w64/r16/r64 with byte-level LE serialization. Replace all raw memcpy of multi-byte index fields with le64_put/le64_get helpers.


BUG 2 — realloc return not checked (Robustness: CRASH on OOM)

Where: zupt_format.c:130131 (zupt_filelist_add)

What: realloc can return NULL if memory is exhausted. The code assigns the result directly to fl->paths and fl->arc_paths, losing the original pointer (memory leak) and then dereferencing NULL on the next access (segfault).

Root cause: Missing NULL check after realloc.

Fix: Assign realloc result to a temporary, check for NULL, return error or fall back.


BUG 3 — Non-regular files not detected (File type support)

Where: zupt_format.c:148192 (zupt_collect_files)

What: The function checks is_dir() and treats everything else as a regular file. Symlinks, FIFOs, block/character devices, and sockets are all treated as regular files. Reading from /dev/zero or a FIFO could hang indefinitely; device files may produce unbounded data.

Root cause: Missing S_ISREG() / FILE_ATTRIBUTE_NORMAL check.

Fix: Add is_regular_file() check. Skip non-regular files with a warning to stderr.


BUG 4 — Cryptographic random fallback uses rand() (Security: CRITICAL)

Where: zupt_crypto.c:2730 (Unix fallback) and zupt_crypto.c:2024 (Windows fallback)

What: If /dev/urandom fails to open (or RtlGenRandom fails to load), the code falls back to srand(time(NULL)) + rand(), which is trivially predictable. Salt and nonce generated this way would collapse all security guarantees.

Root cause: Defensive fallback written for "should never happen" case, but the fallback silently destroys security rather than failing loudly.

Fix: Remove the rand() fallback entirely. If the OS CSPRNG is unavailable, abort with an error message. On modern Linux, also try getrandom(2) before /dev/urandom.


BUG 5 — Sensitive key material wiping may be optimized out (Security)

Where: zupt_crypto.c — multiple memset(material, 0, N) calls, zupt_main.c:168 memset(opts.password, 0, ...).

What: The C standard allows compilers to eliminate stores to memory that is never read again. memset of key material followed by free() or function return is a classic case where -O2 can (and does) remove the wipe.

Root cause: No use of explicit_bzero, SecureZeroMemory, or a volatile-based wipe.

Fix: Add zupt_secure_wipe() using explicit_bzero (glibc), SecureZeroMemory (MSVC), or a volatile-pointer trick as fallback.


BUG 6 — MAC comparison is not constant-time (Security: timing oracle)

Where: zupt_crypto.c:155157 (zupt_decrypt_buffer)

What: ok &= (expected_mac[i] == stored_mac[i]) is an attempt at constant-time comparison, but the compiler may optimize it. More importantly, if ok becomes 0, the loop still runs (which is correct), but the compiler may short-circuit the &= operation.

Root cause: C semantics don't guarantee constant-time execution of bitwise operations.

Fix: Use XOR accumulation: diff |= (expected[i] ^ stored[i]), then check diff == 0. This is the standard pattern used by libsodium and OpenSSL.


BUG 7 — PBKDF2 mutates salt length parameter (Correctness)

Where: zupt_crypto.c:80if (slen > 252) slen = 252;

What: The local slen parameter is clamped destructively. In the current code this only runs once per derivation, so no multi-iteration issue exists. But if the function were called with slen > 252, the salt would be silently truncated with no warning, weakening the KDF.

Root cause: Safety clamp placed inside the loop body instead of documented at the API level.

Fix: Move the check before the loop, use a local copy, and assert(slen <= ZUPT_SALT_SIZE).


BUG 8 — Huffman code-length limiting uses unreliable heuristic (Compression)

Where: zupt_lzh.c:228240 (huff_build)

What: When code lengths exceed LZH_MAX_CODELEN (15), the code attempts a Kraft inequality fix using floating-point arithmetic and a heuristic "shorten the most frequent long code" approach. This can produce invalid Huffman codes where the Kraft sum exceeds 1.0, leading to ambiguous decoding. The floating-point precision loss compounds for large alphabets.

Root cause: Ad-hoc fix instead of a proper package-merge or iterative length-limiting algorithm.

Fix: Replace with the standard iterative bit-length limiting: count symbols per length, then redistribute excess codes from max length downward until the Kraft inequality is satisfied, using integer arithmetic only.


BUG 9 — file_hash for multi-block files uses XOR (Integrity: weak)

Where: zupt_format.c:317file_hash ^= checksum;

What: For multi-block files, the per-file content hash is block_1_xxh64 XOR block_2_xxh64 XOR .... XOR is commutative, so reordered blocks produce the same hash. Identical blocks cancel out (two copies of the same block produce hash 0).

Root cause: Quick implementation that doesn't compose hashes properly.

Fix: Use incremental XXH64 across the entire file contents, or chain: hash = xxh64(&prev_hash_concat_data).


BUG 10 — Write errors silently ignored (Robustness)

Where: zupt_format.c — All w8/w16/w64/fwrite calls in compress paths ignore return values.

What: If the output disk is full or the filesystem encounters an error, compressed data blocks are silently truncated. The footer may still be written, producing a corrupt archive that appears valid until extraction.

Root cause: No error propagation from low-level write helpers.

Fix: Accumulate an error flag in a write context, check it before writing the footer.


BUG 11 — ftello return not checked (Robustness)

Where: zupt_format.c:293,299,432,706 etc.

What: ftello returns -1 on error. Storing -1 as uint64_t produces 0xFFFFFFFFFFFFFFFF, which would corrupt archive offsets.

Fix: Check for -1 and propagate error.


BUG 12 — Version string inconsistency

Where: zupt.h:33 says ZUPT_VERSION_STRING "0.5.1", README says "Version 0.3.0", Makefile says "v0.4.0".

Fix: Synchronize all version references to 0.5.1.


BUG 13 — Heap-buffer-overflow in LZH match finder quick-rejection (CRITICAL)

Where: zupt_lzh.c:465 (find_match, quick-rejection check)

What: The comparison src[ref + best] == src[ip + best] reads past the allocated buffer when ip + best >= slen. ASAN reports: heap-buffer-overflow READ of size 1 at the exact boundary. The bug is data-dependent — it triggers when ip is near the end of the input and a previous match set best to a length that extends past the buffer.

Root cause: Missing bounds check before the quick-rejection optimization.

Fix: Added (size_t)best < slen - ip guard before the comparison.

Also affects: zupt_lz.c:55 — identical pattern in the Zupt-LZ codec's match finder. Same fix applied.


BUG 14 — Heap-buffer-overflow in LZ hash function (CRITICAL)

Where: zupt_lz.c:43-44 (lz_find_match entry guard + lz_hash4)

What: lz_hash4 reads 4 bytes via memcpy(&v, p, 4), but the entry guard only checks ip + LZ_MIN_MATCH > src_len where LZ_MIN_MATCH = 3. When exactly 3 bytes remain, the 4-byte hash read goes 1 byte past the buffer.

Root cause: Guard mismatch: 3-byte minimum match vs 4-byte hash function.

Fix: Changed guard to ip + 4 > src_len.


BUG 15 — Huffman code-length limiting produces over-subscribed codes (CRITICAL, data corruption)

Where: zupt_lzh.c:227-240 (original huff_build code-length limiter)

What: tree_depths() clamps depths at LZH_MAX_CODELEN via dp[nd] = min(depth, 15). The original code tried to detect this by checking if (dp[i] > MAX_CODELEN) — but since tree_depths already clamped the values, the check never triggers. Result: the Kraft sum exceeds 2^15 (measured: 32770 vs target 32768), producing ambiguous Huffman codes. The decompressor then misinterprets symbols, causing data corruption or premature termination.

Manifestation: LZH decompression returns wrong size or wrong data on inputs >~100KB with skewed frequency distributions. Specifically affects: solid mode with ELF+random mixed data, prediction-transformed data, any compression level ≥ 2.

Root cause: The limiter detects overflow by checking code lengths, but tree_depths() already clamped them. The correct detection is computing the Kraft sum directly.

Fix: Replaced with Kraft-sum-based detection: compute sum of 2^(MAX-len) for all symbols, then iteratively fix by splitting shorter codes while absorbing MAX-length excess (same algorithm as zlib's gen_bitlen).


BUG 16 — Empty file checksum failure in solid extract

Where: zupt_format.c:1171-1173 (solid extract checksum verification)

What: Empty files (0 bytes) have content_hash = 0 (never set during compression). On extraction, zupt_xxh64(buf, 0, 0) returns the XXH64 seed value (not 0), causing a checksum mismatch for every empty file in solid archives.

Root cause: Hash of zero-length data is not identity; the compress path sets content_hash only if (sz > 0) but the extract path unconditionally hashes.

Fix: Skip checksum verification for empty files in the solid extract path (matching the solid test path which already had this guard).