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:
@@ -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.
|
||||
@@ -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()
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user