Add Phase 9C security hardening

Generate exact-hash SDK source overrides without modifying dependencies.
Harden
SSH allocation and algorithm policy, tighten web authentication cleanup,
and add
focused host contract tests and documentation.
This commit is contained in:
2026-09-15 22:12:57 +02:00
parent 751dfb9ddb
commit cdc9c7335a
41 changed files with 3597 additions and 89 deletions
+59
View File
@@ -0,0 +1,59 @@
# SSH memory hook tests
Run from the repository root:
```sh
CCACHE_DISABLE=1 python3 tests/ssh_memory/run.py
```
Optionally verify the installed, audited SDK source contract too (no downloads):
```sh
CCACHE_DISABLE=1 python3 tests/ssh_memory/run.py --idf-path /home/mscholz/.platformio/packages/framework-espidf
```
The runner compiles the actual `src/ssh_memory.c` and extracts the actual volatile
`secure_wipe()` body from `src/secure_random.c`. Only SDK headers and heap calls
are doubled. Temporary build files stay outside the repository. `CC` and `CFLAGS`
are supported; the runner also forces `CCACHE_DISABLE=1` for child processes.
Coverage:
- NULL free, malloc/realloc NULL and zero-size delegation, secure zero-size free.
- Rounded usable capacity larger than the original request; whole-capacity wipe
checked **before** the fake heap actually frees the backing allocation.
- Equal-capacity and shrink pointer retention, discarded-tail wiping, unchanged
prefix, retained capacity and logical regrowth without allocation.
- Growth copies every byte of the old usable extent, including rounding, without
over-copying into the new suffix. Both allocations are live during growth.
- PSRAM-first/internal-fallback capability order on every allocation; successful
fallback and migration back to preferred PSRAM on a later growth.
- Failed allocation/growth leaves the old pointer and full contents live and
unchanged, with no SDK realloc fallback (none is supplied by the test).
- Base-pointer-only extent queries, live-pointer checks, aligned payloads and
prefix/suffix guards, request sizes unchanged including `SIZE_MAX`.
- Six rejected poisoning configurations, explicit-zero inactive options, and
four rejected IDF versions. The supported profile is unpoisoned IDF 5.5.0.
- Optional exact normalized function-body contracts for installed heap extent
queries/TLSF size accessor, public declaration and implementation alias; compile
the module with the installed IDF version header. This is a narrow source
contract check, not execution of the target SDK heap or a complete heap audit.
## Integration and limits
The parent must add `ssh_memory.c` to its build and register these three hooks
before wolfSSH/wolfSSL allocations begin. This change does not integrate them.
There are no production headers preceding allocations, metadata tables, locks or
additional tasks. Allocator alignment and allocation-size failure semantics are
preserved by passing the size straight to `heap_caps_malloc_prefer()`. A shrink
retains capacity rather than reclaiming heap; growth temporarily needs old plus
new allocations. PSRAM remains preferred, but internal fallback can transiently
need the full new allocation while the old one is still live. No runtime reserve
or hardware performance claim follows from these host tests.
Heap poisoning is deliberately unsupported: its canary layout is not compatible
with blindly wiping a rounded extent. The version guard requires re-audit on SDK
updates. Cleanup covers retired allocations and explicit realloc tails, not
still-live library buffers, parser spans, stack temporaries or all library
secrets. Hardware validation is deferred to whole-phase testing.
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""Build actual ssh_memory.c with a guarded heap double; no device/network work."""
import argparse
import os
from pathlib import Path
import re
import shlex
import subprocess
import tempfile
ROOT = Path(__file__).resolve().parents[2]
ENV = dict(os.environ, CCACHE_DISABLE="1")
def function(source, name):
match = re.search(r"\b" + re.escape(name) + r"\s*\([^;{}]*\)\s*\{", source)
if not match:
raise AssertionError(f"Missing function {name}")
start = source.index("{", match.start())
depth = 1
end = start + 1
while depth:
depth += (source[end] == "{") - (source[end] == "}")
end += 1
return source[match.start():end]
def normalized(text):
text = re.sub(r"/\*.*?\*/|//[^\n]*", "", text, flags=re.S)
return re.sub(r"\s+", "", text)
def sdk_contract(sdk):
heap = sdk / "components/heap"
contracts = [
("heap_caps.c", "heap_caps_get_allocated_size", """
heap_caps_get_allocated_size(void *ptr) {
ptr = MULTI_HEAP_REMOVE_BLOCK_OWNER_OFFSET(ptr);
heap_t *heap = find_containing_heap(ptr);
assert(heap);
size_t size = multi_heap_get_allocated_size(heap->heap, ptr);
return MULTI_HEAP_REMOVE_BLOCK_OWNER_SIZE(size);
}"""),
("multi_heap.c", "multi_heap_get_allocated_size_impl", """
multi_heap_get_allocated_size_impl(multi_heap_handle_t heap, void *p) {
return tlsf_block_size(p);
}"""),
("tlsf/tlsf.c", "tlsf_block_size", """
tlsf_block_size(void* ptr) {
size_t size = 0;
if (ptr) {
const block_header_t* block = block_from_ptr(ptr);
size = block_size(block);
}
return size;
}"""),
]
for path, name, expected in contracts:
actual = function((heap / path).read_text(), name)
assert normalized(actual) == normalized(expected), f"Reaudit {path}:{name}"
multi = normalized((heap / "multi_heap.c").read_text())
assert normalized('size_t multi_heap_get_allocated_size(multi_heap_handle_t heap, void *p) '
'__attribute__((alias("multi_heap_get_allocated_size_impl")));') in multi
header = (heap / "include/esp_heap_caps.h").read_text()
assert "size_t heap_caps_get_allocated_size(void *ptr);" in header
print("PASS installed SDK source contract: extent query, multi_heap alias, TLSF size accessor")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--idf-path", type=Path, help="also check installed IDF extent source and compile with its version header")
args = parser.parse_args()
cc = shlex.split(ENV.get("CC", "cc"))
with tempfile.TemporaryDirectory(prefix="ssh-memory-") as temporary:
directory = Path(temporary)
(directory / "esp_err.h").write_text("typedef int esp_err_t;\n")
(directory / "esp_heap_caps.h").write_text("""
#pragma once
#include <stddef.h>
#define MALLOC_CAP_SPIRAM (1U << 10)
#define MALLOC_CAP_INTERNAL (1U << 11)
#define MALLOC_CAP_8BIT (1U << 2)
void *heap_caps_malloc_prefer(size_t size, size_t count, ...);
size_t heap_caps_get_allocated_size(void *pointer);
void heap_caps_free(void *pointer);
""")
version = """
#define ESP_IDF_VERSION_VAL(a,b,c) (((a) << 16) | ((b) << 8) | (c))
#define ESP_IDF_VERSION ESP_IDF_VERSION_VAL(5,5,0)
"""
version_header = directory / "esp_idf_version.h"
version_header.write_text(version)
config = directory / "sdkconfig.h"
config.write_text("#define CONFIG_HEAP_POISONING_DISABLED 1\n")
# Use the real volatile wipe body, without pulling in unrelated DRBG/IDF.
wipe = function((ROOT / "src/secure_random.c").read_text(), "secure_wipe")
(directory / "wipe.c").write_text("#include <stddef.h>\n#include <stdint.h>\nvoid " + wipe + "\n")
common = cc + ["-std=c11", "-Wall", "-Wextra", "-Werror", "-pedantic",
*shlex.split(ENV.get("CFLAGS", "-O2")),
"-I", str(directory), "-I", str(ROOT / "src")]
source = str(ROOT / "src/ssh_memory.c")
binary = directory / "test"
subprocess.run(common + [source, str(ROOT / "tests/ssh_memory/test.c"),
str(directory / "wipe.c"), "-o", str(binary)], env=ENV, check=True)
subprocess.run([str(binary)], env=ENV, check=True)
def compile_only(expected_error=None):
result = subprocess.run(common + [source, "-c", "-o", str(directory / "memory.o")],
env=ENV, text=True, capture_output=True)
if expected_error is None:
assert result.returncode == 0, result.stderr
else:
assert result.returncode != 0 and expected_error in result.stderr, result.stderr
for flags in ("", "#define CONFIG_HEAP_POISONING_DISABLED 0\n",
"#define CONFIG_HEAP_POISONING_LIGHT 1\n",
"#define CONFIG_HEAP_POISONING_COMPREHENSIVE 1\n",
"#define CONFIG_HEAP_POISONING_DISABLED 1\n#define CONFIG_HEAP_POISONING_LIGHT 1\n",
"#define CONFIG_HEAP_POISONING_DISABLED 1\n#define CONFIG_HEAP_POISONING_COMPREHENSIVE 1\n"):
config.write_text(flags)
compile_only("SSH memory requires heap poisoning disabled")
config.write_text("#define CONFIG_HEAP_POISONING_DISABLED 1\n"
"#define CONFIG_HEAP_POISONING_LIGHT 0\n"
"#define CONFIG_HEAP_POISONING_COMPREHENSIVE 0\n")
compile_only()
for unsupported in ("5,4,0", "5,5,1", "5,6,0", "6,0,0"):
version_header.write_text(version.replace("5,5,0", unsupported))
compile_only("Reaudit SSH memory usable extent contract for this IDF")
version_header.write_text(version)
print("PASS compile guards: 6 invalid poisoning profiles, explicit disabled profile, 4 unsupported IDF versions")
if args.idf_path:
sdk_contract(args.idf_path)
version_header.write_text((args.idf_path / "components/esp_common/include/esp_idf_version.h").read_text())
compile_only()
print("PASS actual module compile with installed IDF version header")
else:
print("SKIP installed SDK source contract (provide --idf-path)")
if __name__ == "__main__":
main()
+214
View File
@@ -0,0 +1,214 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "ssh_memory.h"
#include "esp_heap_caps.h"
#include <assert.h>
#include <stdarg.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* All payloads have guarded rounded capacity and normal malloc alignment. */
#define ALIGNMENT _Alignof(max_align_t)
#define GUARD (2U * sizeof(max_align_t))
#define BLOCKS 8U
#define LIMIT 4096U
typedef struct {
unsigned char *raw;
unsigned char *base;
size_t capacity;
bool internal;
} block_t;
static block_t blocks[BLOCKS];
static bool fail_psram;
static bool fail_internal;
static unsigned allocations, releases, queries, psram_attempts, internal_attempts;
static unsigned live, peak_live;
static size_t last_request;
static block_t *lookup(void *pointer)
{
assert(pointer != NULL);
for (size_t i = 0; i < BLOCKS; ++i) {
if (blocks[i].base == pointer) return &blocks[i];
}
assert(!"not a live allocation base");
abort();
}
static void bytes_are(const unsigned char *p, size_t size, unsigned char value)
{
for (size_t i = 0; i < size; ++i) assert(p[i] == value);
}
static void guards(const block_t *block)
{
bytes_are(block->raw, GUARD, 0xD3);
bytes_are(block->base + block->capacity, GUARD, 0xD3);
}
void *heap_caps_malloc_prefer(size_t size, size_t count, ...)
{
va_list args;
va_start(args, count);
assert(count == 2U);
assert(va_arg(args, unsigned int) == (MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT));
assert(va_arg(args, unsigned int) == (MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT));
va_end(args);
++allocations;
last_request = size;
/* The module must pass size unchanged, including zero and SIZE_MAX. */
if (size == 0U) return NULL;
++psram_attempts;
bool internal = fail_psram || size > LIMIT;
if (internal) {
++internal_attempts;
if (fail_internal || size > LIMIT) return NULL;
}
size_t capacity = ((size + ALIGNMENT - 1U) / ALIGNMENT) * ALIGNMENT;
for (size_t i = 0; i < BLOCKS; ++i) {
block_t *block = &blocks[i];
if (block->base != NULL) continue;
block->raw = malloc(GUARD + capacity + GUARD);
assert(block->raw != NULL);
block->base = block->raw + GUARD;
block->capacity = capacity;
block->internal = internal;
memset(block->raw, 0xD3, GUARD + capacity + GUARD);
memset(block->base, 0xA5, capacity);
assert((uintptr_t)block->base % ALIGNMENT == 0U);
++live;
if (live > peak_live) peak_live = live;
return block->base;
}
assert(!"fake heap exhausted");
return NULL;
}
size_t heap_caps_get_allocated_size(void *pointer)
{
++queries;
block_t *block = lookup(pointer);
guards(block);
return block->capacity;
}
void heap_caps_free(void *pointer)
{
block_t *block = lookup(pointer);
guards(block);
/* Inspect BEFORE real free: no reads through dangling pointers. */
bytes_are(block->base, block->capacity, 0);
free(block->raw);
memset(block, 0, sizeof(*block));
++releases;
--live;
}
static void test_null_zero(void)
{
unsigned before = queries;
ssh_memory_free(NULL);
assert(queries == before && releases == 0U);
assert(ssh_memory_malloc(0) == NULL && last_request == 0U);
unsigned calls = allocations;
assert(ssh_memory_realloc(NULL, 0) == NULL);
assert(allocations == calls + 1U && queries == before);
void *p = ssh_memory_realloc(NULL, 7);
assert(p != NULL && last_request == 7U);
assert(ssh_memory_realloc(p, 0) == NULL && live == 0U);
}
static void test_retained_capacity(void)
{
unsigned char *p = ssh_memory_malloc(17);
block_t *block = lookup(p);
size_t capacity = block->capacity;
assert(capacity > 17U);
memset(p, 0x71, capacity);
unsigned calls = allocations;
assert(ssh_memory_realloc(p, capacity) == p);
bytes_are(p, capacity, 0x71);
assert(ssh_memory_realloc(p, 17) == p);
bytes_are(p, 17, 0x71);
bytes_are(p + 17, capacity - 17, 0);
assert(ssh_memory_realloc(p, 5) == p);
bytes_are(p, 5, 0x71);
bytes_are(p + 5, capacity - 5, 0);
/* Logical regrowth within retained capacity allocates nothing. */
assert(ssh_memory_realloc(p, capacity - 1U) == p);
bytes_are(p, 5, 0x71);
bytes_are(p + 5, capacity - 5, 0);
assert(block->capacity == capacity && allocations == calls);
guards(block);
ssh_memory_free(p);
}
static void test_growth_and_failure(void)
{
unsigned char *p = ssh_memory_malloc(17);
size_t capacity = lookup(p)->capacity;
for (size_t i = 0; i < capacity; ++i) p[i] = (unsigned char)(i + 1U);
fail_psram = fail_internal = true;
unsigned freed = releases;
assert(ssh_memory_realloc(p, capacity + 1U) == NULL);
assert(releases == freed && live == 1U);
for (size_t i = 0; i < capacity; ++i) assert(p[i] == (unsigned char)(i + 1U));
guards(lookup(p));
fail_internal = false;
unsigned char *q = ssh_memory_realloc(p, capacity + 1U);
assert(q != NULL && lookup(q)->internal);
assert(last_request == capacity + 1U && releases == freed + 1U);
assert(live == 1U && peak_live == 2U);
for (size_t i = 0; i < capacity; ++i) assert(q[i] == (unsigned char)(i + 1U));
/* The new suffix isn't promised zero; ensure no over-copy either. */
bytes_are(q + capacity, lookup(q)->capacity - capacity, 0xA5);
fail_psram = false;
size_t old_capacity = lookup(q)->capacity;
memset(q, 0x69, old_capacity);
unsigned char *r = ssh_memory_realloc(q, old_capacity + 19U);
assert(r != NULL && !lookup(r)->internal);
bytes_are(r, old_capacity, 0x69);
ssh_memory_free(r);
}
static void test_sizes_alignment_and_preference(void)
{
for (size_t size = 1; size <= 129; ++size) {
fail_psram = (size % 2U) != 0U;
unsigned external_before = psram_attempts;
unsigned internal_before = internal_attempts;
void *p = ssh_memory_malloc(size);
assert(last_request == size && lookup(p)->internal == fail_psram);
assert(psram_attempts == external_before + 1U);
assert(internal_attempts == internal_before + (fail_psram ? 1U : 0U));
assert((uintptr_t)p % ALIGNMENT == 0U);
ssh_memory_free(p);
}
fail_psram = fail_internal = true;
assert(ssh_memory_malloc(33) == NULL);
fail_psram = fail_internal = false;
assert(ssh_memory_malloc(SIZE_MAX) == NULL && last_request == SIZE_MAX);
unsigned char *p = ssh_memory_malloc(9);
size_t capacity = lookup(p)->capacity;
memset(p, 0x81, capacity);
assert(ssh_memory_realloc(p, SIZE_MAX) == NULL && last_request == SIZE_MAX);
bytes_are(p, capacity, 0x81);
guards(lookup(p));
ssh_memory_free(p);
}
int main(void)
{
test_null_zero();
test_retained_capacity();
test_growth_and_failure();
test_sizes_alignment_and_preference();
assert(live == 0U);
puts("PASS ssh_memory: null/zero, rounded extent, retained shrink/equal, growth, failure, caps, alignment, guards");
return 0;
}