Skip to content

fix(heap): validate the block header in eos_realloc(), not just eos_free() - #77

Merged
srpatcha merged 1 commit into
embeddedos-org:masterfrom
Kartikey1306:fix/heap-realloc-validation
Aug 28, 2026
Merged

fix(heap): validate the block header in eos_realloc(), not just eos_free()#77
srpatcha merged 1 commit into
embeddedos-org:masterfrom
Kartikey1306:fix/heap-realloc-validation

Conversation

@Kartikey1306

Copy link
Copy Markdown
Contributor

The architectural problem

The kernel allocator has two entry points that take a caller-supplied pointer and derive allocator metadata from it. One validates that pointer; the other does not.

eos_free() range-checks its argument against the heap. eos_realloc() takes the same kind of pointer and trusts whatever it finds:

block_header_t *block = (block_header_t *)((uint8_t *)ptr - HEADER_SIZE);
size_t current_usable = block->size - HEADER_SIZE;

if (new_size <= current_usable) return ptr;   /* "fits in current block" */

Two consequences, both reproduced against the unmodified allocator:

1. A foreign pointer is read out of bounds, then handed back

realloc(non-heap ptr, 16) = 0x102a0a020  (input was 0x102a0a020)
  -> returned the foreign pointer unchanged: YES

The bytes before ptr are read as a block header, and the pointer is returned as if it had been resized. eos_free() rejects exactly that pointer.

2. A short header underflows the size check into an unbounded overflow

block->size - HEADER_SIZE is unsigned. A header whose size field is below HEADER_SIZE underflows current_usable to nearly SIZE_MAX, so every growth request looks already satisfied:

realloc(ptr with size=8 header, 4096) = 0x102a0a010
  -> claimed a 64-byte buffer can hold 4096: YES

realloc returns the original short buffer and the caller writes new_size bytes into it. That turns a single corrupted header byte — precisely what a prior small overflow leaves behind — into an unbounded overflow, which is the opposite of what an allocator's metadata checks are for.

The change

Both paths go through one block_from_ptr() helper:

  • the pointer lies inside the heap,
  • its header is 8-byte aligned relative to the heap base,
  • the size field is at least MIN_BLOCK_SIZE,
  • and the block does not claim to extend past the end of the heap.

eos_free() uses it too and gains the size checks it did not have. An unrecognised pointer makes realloc return NULL and free a no-op — which is what each already did for the cases it did check, so nothing that worked before changes behaviour.

Verification

Check Result
ctest --no-tests=error 24/24 pass
pytest tests/ 10 passed

tests/test_heap.c gains coverage for both paths plus a grow-and-preserve case (contents survive a move) and a check that the new validation does not reject legitimate pointers. Against the unmodified heap.c it fails on realloc of a non-heap pointer must be rejected.

Notes for review

🤖 Generated with Claude Code

…ree()

The kernel allocator has a validation boundary in one of its two pointer-taking
entry points. eos_free() range-checks its argument against the heap;
eos_realloc() takes the same kind of pointer, derives a header from it, and
trusts whatever it finds:

    block_header_t *block = (block_header_t *)((uint8_t *)ptr - HEADER_SIZE);
    size_t current_usable = block->size - HEADER_SIZE;
    if (new_size <= current_usable) return ptr;

Two consequences, both reproduced against the unmodified allocator:

1. A pointer that never came from this heap is read out of bounds for its
   "header" and then handed straight back, as if it had been resized. eos_free()
   rejects exactly that pointer.

2. `block->size - HEADER_SIZE` is unsigned. A header whose size field is below
   HEADER_SIZE underflows current_usable to nearly SIZE_MAX, so every growth
   request looks already satisfied and realloc returns the original short
   buffer. The caller then writes new_size bytes into it. That turns a single
   corrupted header byte -- the thing a prior small overflow leaves behind --
   into an unbounded overflow, which is the opposite of what an allocator's
   metadata checks are for.

Both paths now go through one block_from_ptr() helper: the pointer must lie
inside the heap, its header must be 8-byte aligned relative to the heap base,
and the size field must be at least MIN_BLOCK_SIZE and not claim to extend past
the end of the heap. eos_free() uses it too and gains the size checks it did
not have. An unrecognised pointer makes realloc return NULL and free a no-op,
which is what each already did for the cases it did check.

Verified: ctest 24/24, pytest 10 passed. tests/test_heap.c gains coverage for
both paths plus a grow-and-preserve case; against the unmodified heap.c it
fails on "realloc of a non-heap pointer must be rejected".

Note for review: PR embeddedos-org#60 also touches tests/test_heap.c (it does not touch
kernel/src/mem/heap.c). If that lands first this needs a trivial rebase in the
test file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@srpatcha srpatcha left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified locally against current master: merges clean, builds with no new errors, ctest 24/24.

The asymmetry this fixes is the real bug — eos_free range-checked its argument and eos_realloc did not, so the same pointer was trusted in one path and validated in the other. Routing both through block_from_ptr is the right shape, and it means the two cannot drift apart again.

The size check earns its place independently of the range check. block->size - HEADER_SIZE is unsigned, so a header claiming a size below HEADER_SIZE underflows to near SIZE_MAX, every growth request compares as already satisfied, and realloc hands back the short buffer — one corrupted header byte becoming an unbounded overflow in the caller. Good catch, and the test pins exactly that case rather than only the easy foreign-pointer one.

Also checked the tests fail for the right reasons and that the added validation does not reject legitimate pointers — the eos_malloc/eos_free round trip after the realloc cases covers that.

@Kartikey1306

Copy link
Copy Markdown
Contributor Author

Hi EmbeddedOS Team,

I wanted to follow up regarding my application and the technical assessment. Since completing the initial assessment, I’ve continued working across eOS, eBoot, and ebuild and have contributed substantially beyond the initial requested PRs.

My GitHub profile is Kartikey1306, where the complete contribution history is available:

eOS

#50 — Merged: Device Tree parser bounds/safety fixes and fuzzing integration
#75 — Merged: Fixed OTA authentication so the update’s own supplied hash cannot be treated as proof of authenticity
#77 — Merged: Fixed eos_realloc() header/range validation
#79 — Merged: Removed accidentally tracked .coverage database
#80 — Merged: Fixed the simulation workflow failure
#102 — Open: Build-system/HAL backend selection improvements
#104 — Open: Keep assertions active in test binaries
#105 — Open: Fixed incorrect flat-digest substitution for a dm-verity Merkle root
#106 — Open: Pin EoSim to an existing tag

eBoot

#38 — Merged: Fail-closed CRC verification when flash reads fail
#39 — Merged: Fixed boot_log dependency/linking for Stage-0
#40 — Merged: Strengthened image-header Ed25519 signature coverage
#58 — Closed: Build restoration work; changes were subsequently incorporated through master
#59 — Merged: Fail-closed behavior when the OTP trust anchor cannot be read
#77 — Open: Test macro/build correction
#80 — Open: Removed an unsuitable hardware-crypto GCM shortcut
#81 — Open: Pin EoSim to an existing tag
#82 — Open: Ensure a failed debug lock cannot report successful secure boot

ebuild

#48 — Merged: Track C/C++ header dependencies to prevent stale builds
#66 — Open: Ninja path escaping and CI improvements
#67 — Open: Make third-party dependencies and checksums verifiable
#94 — Open: Fix S-expression parsing and provide a runnable board example
#95 — Open: Pin EoSim to an existing tag

Overall, this is 23 PRs and 83 commits across the three repositories, with 10 PRs merged, 12 currently open, and 1 closed. The open PRs are currently showing 12/12 green CI, and I’ve received detailed maintainer reviews and verification throughout the work.

The work has covered security, embedded systems, parser safety, cryptographic verification, OTA security, boot integrity, build systems, testing/fuzzing, CI, dependency verification, and reliability. Several of the findings were security-sensitive issues on trust paths where failure could incorrectly result in a successful verification or boot decision.

Given the amount of work I’ve now contributed and the feedback from the maintainers, I wanted to ask about the actual role and next steps in the hiring process.

Could you please let me know:

What is the role/title and primary responsibility?
What technical areas and projects would I be working on?
What level of ownership are you expecting from the person joining?
Is the position full-time, part-time, or contract?
What are the expected working hours/time-zone and collaboration requirements?
What is the compensation/engagement structure?
What are the remaining interview or selection stages?

Most importantly, I’d appreciate knowing whether, based on my contributions and the maintainer feedback, my profile aligns with what you’re looking for and whether we can proceed to an interview or technical discussion.

I’m happy to walk through any of the PRs, security findings, implementation decisions, testing methodology, or the reasoning behind the issues I identified.

GitHub: Kartikey1306

Best regards,
Kartikeydheer Srivastava

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