diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f17593..e252e30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -332,7 +332,13 @@ All notable changes to EigenScript are documented here. that a damaged bundle invoked *with* arguments still behaves as an interpreter; the shipped form (`./myapp`) is the case that matters. `tests/test_bundle.sh` covers all four states including the - no-false-positive checks on the plain binary. + no-false-positive checks on the plain binary — plus a **fold guard** + that greps the built interpreter for the plaintext magic, because the + obfuscation is only as good as the compiler's willingness to leave it + alone: gcc -O2 did not fold it, **clang did**, and CI's macOS job went + red with every REPL check failing at exit 3. The key is `volatile` + now, and a coincidental magic match is additionally rejected unless a + well-formed entry header follows it. - **vm_run_bytecode/sandbox_run: an assembled chunk's temporal opcodes now turn history recording on themselves (#831).** `g_trace_hist` was diff --git a/src/bundle.c b/src/bundle.c index d23f720..22c74c7 100644 --- a/src/bundle.c +++ b/src/bundle.c @@ -50,6 +50,7 @@ #define TRAILER_SIZE 24 #define BUNDLE_HEAD_LEN 24 + /* #882: a head magic at the START of the archive region, so a bundle whose * trailer is gone is still recognizable AS a bundle. * @@ -72,9 +73,18 @@ static const unsigned char BUNDLE_HEAD_OBF[BUNDLE_HEAD_LEN] = { 0x77, 0x1b, 0x08, 0x19, 0x12, 0x13, 0x0c, 0x1f, 0x77, 0x2c, 0x68, 0x5a }; +/* The key is `volatile` on purpose. With a plain constant, an optimizer is + * free to constant-fold this whole loop and materialize the PLAINTEXT in + * rodata — which is exactly what it must not do, because then the runtime + * image contains the magic and every plain `eigenscript` start finds it + * inside itself and refuses to run. gcc -O2 did not fold it; clang did, and + * CI's macOS job caught it as "plain interpreter still starts the REPL" + * failing with exit 3. A volatile read cannot be folded away. */ static void bundle_head_magic(unsigned char out[BUNDLE_HEAD_LEN]) { + static volatile unsigned char key = 0x5A; + unsigned char k = key; for (size_t i = 0; i < BUNDLE_HEAD_LEN; i++) - out[i] = (unsigned char)(BUNDLE_HEAD_OBF[i] ^ 0x5A); + out[i] = (unsigned char)(BUNDLE_HEAD_OBF[i] ^ k); } /* Refuse loudly, exit 3 — the same contract the tape layer uses for a damaged @@ -98,6 +108,31 @@ static int own_exe_path(const char *argv0, char *out, size_t cap) { * interpreter start, which is interactive, so an ~800 KB read is invisible. * Takes the FIRST occurrence: the writer emits it immediately after the * runtime image, so anything matching later is inside the payload. */ +/* Does a plausible archive entry header start at `off`? The writer emits + * [u32 path_len][path bytes, no NUL][u64 size][bytes], so a real archive head + * is followed by a small length, a printable relative path, and a size that + * fits in the file. Used to reject a coincidental magic match. */ +static int bundle_entry_header_plausible(FILE *f, long off) { + if (fseek(f, 0, SEEK_END) != 0) return 0; + long fsz = ftell(f); + if (fsz < 0 || off < 0 || off >= fsz) return 0; + if (fseek(f, off, SEEK_SET) != 0) return 0; + + uint32_t plen; + if (fread(&plen, 4, 1, f) != 1) return 0; + if (plen == 0 || plen > 2048) return 0; + + char rel[2049]; + if (fread(rel, 1, plen, f) != plen) return 0; + for (uint32_t i = 0; i < plen; i++) + if (rel[i] < 0x20 || (unsigned char)rel[i] > 0x7e) return 0; + + uint64_t size; + if (fread(&size, 8, 1, f) != 1) return 0; + if (size > (uint64_t)fsz) return 0; + return 1; +} + static int bundle_scan_for_head(FILE *f) { unsigned char want[BUNDLE_HEAD_LEN]; bundle_head_magic(want); @@ -112,8 +147,20 @@ static int bundle_scan_for_head(FILE *f) { size_t have = carry + got; if (have >= BUNDLE_HEAD_LEN) { for (size_t i = 0; i + BUNDLE_HEAD_LEN <= have; i++) - if (buf[i] == want[0] && memcmp(buf + i, want, BUNDLE_HEAD_LEN) == 0) - return 1; + if (buf[i] == want[0] && memcmp(buf + i, want, BUNDLE_HEAD_LEN) == 0) { + /* Belt and braces after the clang fold above: a match is + * only believed if a well-formed first entry header + * follows it. A stray occurrence in some future rodata + * blob will not be followed by a plausible + * [u32 path_len][printable path][u64 size], so it cannot + * make the interpreter refuse itself. */ + long at = ftell(f); + if (at < 0) return 0; + long head_end = at - (long)(have - i) + BUNDLE_HEAD_LEN; + int believable = bundle_entry_header_plausible(f, head_end); + if (fseek(f, at, SEEK_SET) != 0) return 0; + if (believable) return 1; + } } if (got == 0) return 0; carry = (have >= BUNDLE_HEAD_LEN - 1) ? BUNDLE_HEAD_LEN - 1 : have; diff --git a/tests/run_all_tests.sh b/tests/run_all_tests.sh index e873a94..c61ddfe 100755 --- a/tests/run_all_tests.sh +++ b/tests/run_all_tests.sh @@ -1661,7 +1661,7 @@ echo "" # [42g] --bundle (#413): single-file distribution — script + eigs_modules + # stdlib in one executable; tape-attached bundles replay byte-identically. -echo "[42g] Bundle (8 checks)" +echo "[42g] Bundle (14 checks)" BN_OUTPUT=$(bash "$TESTS_DIR/test_bundle.sh" 2>&1) BN_PASS=$(echo "$BN_OUTPUT" | grep -c "PASS:" || true) BN_FAIL=$(echo "$BN_OUTPUT" | grep -c "FAIL:" || true) diff --git a/tests/test_bundle.sh b/tests/test_bundle.sh index 3a069b2..a6250c4 100644 --- a/tests/test_bundle.sh +++ b/tests/test_bundle.sh @@ -156,6 +156,27 @@ POUT=$("$EIGS" "$APP/plain.eigs" 2>&1); RC=$? && ok "plain interpreter runs a script normally" \ || fail "plain interpreter runs a script normally" "rc=$RC out=$POUT" +# ---- 10b. THE regression gate for the above. The head magic is stored +# XOR-obfuscated so its plaintext is never in the runtime image; if a compiler +# constant-folds that obfuscation away, the plaintext lands in rodata and every +# plain `eigenscript` start finds the magic inside itself and refuses with +# exit 3. That is not hypothetical: gcc -O2 did not fold it, clang did, and +# CI's macOS job went red with "plain interpreter still starts the REPL" +# failing. The key is `volatile` now, but assert the OUTCOME rather than trust +# the compiler — this catches it at test time on any toolchain. +if command -v python3 >/dev/null 2>&1; then + if python3 -c " +import sys +plain = b'\\x7fEIGS-BUNDLE-ARCHIVE-v2\\x00' +sys.exit(0 if plain not in open('$EIGS','rb').read() else 1) +"; then + ok "head magic plaintext is NOT in the interpreter image (fold guard)" + else + fail "head magic plaintext is NOT in the interpreter image (fold guard)" \ + "the obfuscation was constant-folded — every plain start will refuse itself" + fi +fi + ROUT=$(echo 'print of "repl-ok"' | "$EIGS" 2>&1); RC=$? case "$ROUT" in *repl-ok*) ok "plain interpreter still starts the REPL (no false refusal)" ;;