Skip to content

Fix atomvm:read_priv and detect truncated packs - #2340

Open
bettio wants to merge 6 commits into
atomvm:release-0.7from
bettio:fix-read_priv
Open

Fix atomvm:read_priv and detect truncated packs#2340
bettio wants to merge 6 commits into
atomvm:release-0.7from
bettio:fix-read_priv

Conversation

@bettio

@bettio bettio commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator

The packbeam (.avm) scanners were unbounded and trusted section sizes,
so a lookup miss (e.g. atomvm:read_priv/2 for an unpacked file) ran off
the pack into erased flash and crashed the VM. A truncated pack failed
the same way.

Bound and validate every scan, detect truncation at load, and add a
build-time guard for the one pack known at build time. No on-disk
format change.

Also user visible:

  • scans stop only at the end marker, so sections after a data file
    are found
  • atomvm:read_priv/2 raises invalid_avm on a corrupt entry instead
    of reporting a missing file
  • rejecting an invalid .avm no longer leaks the file mapping

These changes are made under both the "Apache 2.0" and the "GNU Lesser
General Public License 2.1 or later" license terms (dual license).

SPDX-License-Identifier: Apache-2.0 OR LGPL-2.1-or-later

@petermm

This comment was marked as outdated.

@petermm

This comment was marked as outdated.

@bettio
bettio force-pushed the fix-read_priv branch 2 times, most recently from 22d6864 to 2a6c79b Compare July 22, 2026 11:41
@petermm

This comment was marked as outdated.

bettio added 6 commits July 22, 2026 14:53
The packbeam scanners were unbounded, so a lookup miss (e.g. read_priv
for an unpacked file) ran off the pack into erased flash and crashed.
Bound and validate every scan against the stored pack size.

The walk now stops only at the end marker: the old scanners stopped at
the first data file (flags 0), hiding every section after it.

Signed-off-by: Davide Bettio <davide@uninstall.it>
Reject truncated or incomplete packs (e.g. an image flashed to a
too-small partition) at load instead of failing late, validating by
tail check or bounded walk.

Signed-off-by: Davide Bettio <davide@uninstall.it>
Fail at configure time when boot.avm does not fit its partition.

Signed-off-by: Davide Bettio <davide@uninstall.it>
A priv entry whose length prefix does not fit its own section was
skipped, so a corrupt pack was indistinguishable from a missing file.
Raise invalid_avm instead. A genuine miss still returns undefined.

Read the length prefix unaligned: an in-place pack may sit at any
byte offset and the direct load faults Xtensa/Cortex-M0+.

Signed-off-by: Davide Bettio <davide@uninstall.it>
The generic_unix invalid-pack rejection in sys_open_avm_from_file
returned without releasing the mapping, leaking it and its file
descriptor. Pre-existing, but truncated packs now take this path.

Signed-off-by: Davide Bettio <davide@uninstall.it>
The XIP_SRAM_BASE bound spans past the flash, so a corrupt section
size could steer bounded header reads into unmapped address space.

Signed-off-by: Davide Bettio <davide@uninstall.it>
@petermm

petermm commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Review of the last 6 commits

Range: HEAD~6..HEAD (e495aebb3 through 67020086e)
Verdict: Changes requested
Scope: bounded AVM-pack parsing, truncation detection, read_priv/2 validation, ESP32 image sizing, and JIT flash integration.

The core parser rewrite is directionally sound: regular section scans are now bounded, unaligned pack bases are supported, section payload sizes are reported correctly, read_priv/2 validates its embedded length, and the mapped-file rejection leak is fixed. However, four issues should be resolved before merge. The first can make JIT flash operations target AVM resource data rather than the real pack end.

Findings

1. Blocker — JIT mistakes ordinary data sections for the end marker

Files: src/libAtomVM/avmpack.c:125-146, src/libAtomVM/jit_stream_flash.c:136-160, src/libAtomVM/jit_stream_flash.c:177-222

avmpack_find_section_by_flag() returns the first section whose flags satisfy the requested mask. JIT asks for END_OF_FILE (0) under END_OF_FILE_MASK (255), but ordinary resource sections also have flags 0. The new AVM-pack test explicitly creates such sections in tests/test-avmpack.c:172-181.

For a pack containing a priv resource before its terminator, JIT therefore receives the resource payload and name rather than the end marker. This has several consequences:

  • JIT placement is calculated from the resource payload's sector and can overlap later AVM-pack contents.
  • globalcontext_set_cache_valid() attempts to rewrite the resource name to END.
  • If the flash transition succeeds and the resource name is longer than three characters, the loop can repeatedly rediscover the same flags-0 resource.

This ambiguity existed in the old flag lookup, but the revised parser retains it and the JIT safety changes rely on the lookup to identify whether a terminator exists. A generic flag query cannot distinguish the terminator from a regular flags-0 section; JIT should locate the structurally recognized end marker instead.

Suggested fix

diff --git a/src/libAtomVM/jit_stream_flash.c b/src/libAtomVM/jit_stream_flash.c
@@
+static bool find_pack_end(const struct AVMPackData *pack,
+    const void **end_offset, const char **end_name)
+{
+    uint32_t real_size;
+    if (!avmpack_compute_size(pack->data, pack->size, &real_size)) {
+        return false;
+    }
+
+    *end_offset = (const uint8_t *) pack->data + real_size;
+    *end_name = (const char *) *end_offset - 4;
+    return true;
+}
@@
-        if (!avmpack_find_section_by_flag(avmpack_data->data, avmpack_data->size,
-                END_OF_FILE_MASK, END_OF_FILE, &end_offset, &end_size, &end_name)) {
+        if (!find_pack_end(avmpack_data, &end_offset, &end_name)) {
             valid_cache = false;
             continue;
         }
@@
-            if (!avmpack_find_section_by_flag(avmpack_data->data, avmpack_data->size,
-                    END_OF_FILE_MASK, END_OF_FILE, &end_offset, &end_size, &end_name)) {
+            if (!find_pack_end(avmpack_data, &end_offset, &end_name)) {
                 found_end = false;
                 valid_cache = false;
                 break;
             }

Remove the now-unused end_size locals. Add a JIT test with a flags-0 resource before the real terminator, ideally placing the terminator in a later sector; verify that the append position follows the real terminator and that only its end name is changed to END.

2. Blocker — a malformed zero-sized marker can trigger an out-of-bounds string read

File: src/libAtomVM/avmpack.c:74-97

For section_size == 0, read_section() guarantees that exactly 16 marker bytes are available, leaving exactly four bytes at name. It then calls strcmp(name, "end") and strcmp(name, "END") without first proving those four bytes contain a NUL.

A marker ending in endX or ENDX is not a C string within the supplied view. strcmp may read beyond avmpack_size, which is undefined behavior and can fault when the buffer ends at a guard page. This restores an out-of-bounds read in the parser's malformed-input path.

Suggested fix

diff --git a/src/libAtomVM/avmpack.c b/src/libAtomVM/avmpack.c
@@
     if (section_size == 0) {
-        // The min-size guard above keeps the terminator name bytes in bounds for strcmp.
-        if (flags == 0 && (strcmp(name, "end") == 0 || strcmp(name, "END") == 0)) {
+        // Exactly four name bytes are available in a terminator, including its NUL.
+        if (flags == 0
+            && (memcmp(name, "end", 4) == 0 || memcmp(name, "END", 4) == 0)) {

Add an exact-size test ending in endX/ENDX. The existing Valgrind job can then exercise this malformed marker safely.

3. Blocker — avmpack_is_complete() accepts a disconnected tail marker

Files: src/libAtomVM/avmpack.c:182-194, src/libAtomVM/avmpack.h:154-170

avmpack_is_complete() checks the magic header and interprets only the final 16 bytes as an end marker. It does not establish that walking the section sizes from offset 24 reaches that marker.

For example, a file with a corrupt or oversized first section and a syntactically valid marker in its final 16 bytes is accepted by exact-size loaders and atomvm:add_avm_pack_binary/2. Later scans remain bounded, but they stop at the corrupt section, silently hiding later entries and violating the new contract that corrupt/truncated packs are rejected at load.

Suggested fix

diff --git a/src/libAtomVM/avmpack.c b/src/libAtomVM/avmpack.c
@@
 bool avmpack_is_complete(const void *avmpack_binary, uint32_t exact_size)
 {
-    // The header and every section are multiples of 4, so a complete pack's size must be too.
-    if (!avmpack_is_valid(avmpack_binary, exact_size)
-        || exact_size < AVMPACK_SIZE + AVMPACK_END_MARKER_SIZE || (exact_size & 3) != 0) {
-        return false;
-    }
-
-    struct AVMPackSection section;
-
-    return read_section(avmpack_binary, exact_size, exact_size - AVMPACK_END_MARKER_SIZE, &section)
-        == AVMPackSectionEnd;
+    uint32_t real_size;
+    return avmpack_compute_size(avmpack_binary, exact_size, &real_size)
+        && real_size == exact_size;
 }

Update the declaration comment, which currently promises that this function does not traverse the section chain. Add a test with an invalid first section plus a valid final marker and require rejection.

4. Blocker on 64-bit Unix — exact file sizes are narrowed before validation

Files: src/platforms/generic_unix/lib/sys.c:434-449, src/platforms/emscripten/src/lib/sys.c:692-718, src/platforms/generic_unix/main.c:163-204

The generic Unix loader casts mapped->size from size_t to uint32_t before both validation and storage. A file of 2^32 + N bytes whose first N bytes form an accepted pack is therefore validated as an N-byte file; the greater-than-4-GiB tail is ignored despite the exact-size API contract.

atomvm:add_avm_pack_binary/2 already performs the necessary bin_size > UINT32_MAX check. Apply the same boundary at all wider exact-size sources. This is immediately relevant on 64-bit generic Unix and future-proofing on current wasm32 builds.

Suggested fix

diff --git a/src/platforms/generic_unix/lib/sys.c b/src/platforms/generic_unix/lib/sys.c
@@
-    if (UNLIKELY(!avmpack_is_complete(mapped->mapped, (uint32_t) mapped->size))) {
+    if (UNLIKELY(mapped->size > UINT32_MAX
+            || !avmpack_is_complete(mapped->mapped, (uint32_t) mapped->size))) {
         mapped_file_close(mapped);
         return AVM_OPEN_INVALID;
     }

Use the same pre-conversion check for Emscripten's data_size and generic Unix's embedded_size. If a 32-bit compiler diagnoses the comparison as always false, guard it with #if SIZE_MAX > UINT32_MAX.

Non-blocking hardening

Compare pack addresses as integers in JIT

File: src/libAtomVM/jit_stream_flash.c:155

end_offset > max_end_offset relationally compares pointers derived from different AVM-pack objects. That comparison is not portable C. The code uses these values as flash addresses, so compare uintptr_t values explicitly.

diff --git a/src/libAtomVM/jit_stream_flash.c b/src/libAtomVM/jit_stream_flash.c
@@
-        if (end_offset > max_end_offset) {
+        if ((uintptr_t) end_offset > (uintptr_t) max_end_offset) {
             max_end_offset = end_offset;
         }

Positive notes

  • read_section() now bounds regular section headers, names, and payloads and supports unaligned pack bases.
  • Returning payload size rather than total section size makes the new read_priv/2 length check meaningful.
  • read_priv/2 restores in_use, uses an unaligned big-endian read, and distinguishes a corrupt present resource from a missing resource.
  • The generic Unix invalid-pack path now closes its mapping.
  • The ESP32 configure-time partition-size check and RP2 end-of-flash scan bound look correct.
  • The new parser and JIT tests cover the principal happy paths, lookup misses, truncation, oversized sections, missing NULs in regular names, misalignment, corrupt resource lengths, and missing terminators.

Verification performed

  • Inspected every file changed by git diff HEAD~6..HEAD and traced all callers of the changed AVM-pack APIs.
  • git diff --check HEAD~6..HEAD — passed.
  • Built test-avmpack and test-jit_stream_flash from the existing build directory — passed.
  • Ran ./build/tests/test-avmpack — passed.
  • Ran ./build/tests/test-jit_stream_flash — passed.
  • Asked Oracle to independently stress-test the parser/JIT boundaries; its assessment agreed that findings 1–4 are real and merge-blocking for the stated behavior.

The passing tests do not exercise a flags-0 resource in the JIT path, a non-NUL four-byte terminator name, a disconnected final marker, or a greater-than-UINT32_MAX exact-size source; those are the missing regression cases identified above.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants