JELLEO Autonomous C / systems-software audit
4 Critical confirmed · disclosure pending
Audit cycle · May 19, 2026

osec-c-medium

Auditor
Kirill Sakharuk · kirill@jelleo.com
Target
osec-c-medium
Audit date
May 19, 2026
Cycle
20260519-163207
Engine SHA
640735f39c
Generated
2026-05-19T22:36:05+00:00
4
Critical
4
High
3
Medium
0
Low
0
Info
Confirmed · disclosed · fixed · verified
Signed · Ed25519
MCowBQYDK2VwAyEAvCFSLBecPuNClei48PWjHuelHlBX9uYZo4wELbQ7b+k=
verify with audit-pipeline sign verify <file> <file>.sig --pubkey jelleo.ed25519.pub
public key at https://jelleo.com/keys/jelleo.ed25519.pub
Platform · v0.1
JELLEO · Autonomous security audits for C / systems software.

00 — Executive summary

This report documents the results of an autonomous C / systems-software audit cycle run by Jelleo against the osec-c-medium workspace on May 19, 2026. The cycle identified 4 Critical, 4 High and 3 Medium findings after Layer 2.5 triage and root-cause clustering. Each finding includes an ASan/UBSan-instrumented proof-of-concept, a CBMC bounded-model-check proof where the formal layer ran, an AFL++ coverage-guided fuzz reproduction, and an LLM-authored structural fix patch.

00.1 — Scope

In-scope source set
Target workspace osec-c-medium
Protocol C systems software (clang / CBMC / AFL++)
Engine commit 640735f39c (640735f39c33e38b254ae7fafba7e431aa7e45eb)
Source files
src/auth/session.c
src/common/buffer.c
src/common/config.c
src/common/log.c
src/program_a.c
src/program_b.c
src/program_c.c
src/protocol/frame_parser.c
src/queue/job.c
src/runtime/files.c
src/storage/db.c
src/vendor/ini/ini.c
src/vendor/minihash/map.c
src/auth/session.h
src/common/buffer.h
src/common/config.h
src/common/log.h
src/protocol/frame_parser.h
src/queue/job.h
src/runtime/files.h
src/storage/db.h
src/vendor/ini/ini.h
src/vendor/minihash/map.h
Hypothesis library 25 invariant claim(s) covering memory safety (off-by-one, OOB, UAF, double-free), filesystem-race (TOCTOU, predictable-path, symlink-follow), format-string injection, authorization (missing role gates, weak token entropy), and integer-overflow.
Out of scope System libraries (libc, libpthread, libcrypto); kernel-side syscall behavior; build scripts and Makefile / build.sh logic; the test harness itself under tests/. Vendored third-party code under src/vendor/ IS in scope when the pipeline patches it.

01 — Per-finding analysis · contents

Each finding below begins on its own page. Numbering matches the FINDING NN / NN banner in the body. Click any row to jump.

  1. 01CriticalOff-by-One OOB Write in frame_parse Null-Terminates Past key[64]heap-buffer-overflow
  2. 02CriticalAdmin Role Wildcard Bypass in session_validate Grants Unauthorized Accessauthorization-bypass
  3. 03CriticalFormat-String Injection in log_message via Unsanitised vfprintf Format Argumentformat-string-injection
  4. 04CriticalFormat-String Injection via Attacker-Controlled Key in handle_get Cache-Miss Branchformat-string-injection
  5. 05Highdb_save Follows Symlinks via fopen("wb"), Enabling Arbitrary File Overwritetoctou-symlink
  6. 06Highhandle_auth Derives Session Tokens Deterministically from Username Aloneweak-token-derivation
  7. 07Highhandle_import Follows Symlinks Outside base_dir, Bypassing Path Safety Checkpath-traversal-symlink
  8. 08Highrun_report passes non-NUL-terminated payload buffer to snprintf %s, causing heap OOB readoob-read
  9. 09Mediumdb_load leaves partial state on parse failure, violating atomicity contractpartial-load-leakage
  10. 10Mediumruntime_is_safe_path Trusts Literal Path Names, Allows Symlink Escapeinsufficient-path-validation
  11. 11MediumUnseeded FNV-1a Hash in minihash Enables Algorithmic Complexity (HashDoS) Attackhash-collision-dos
FINDING 01 / 11
Critical CMED01-frame-parse-key-off-by-one heap-buffer-overflow

Off-by-One OOB Write in frame_parse Null-Terminates Past key[64]

Invariant`frame_parse` uses `sizeof(frame->key) >= frame->key_len` as the key-length guard. `frame->key` is `char key[64]`. The `>=` admits `key_len == 64`, after which the code executes `frame->key[frame->key_len] = '\0'`, writing one byte past the end of the 64-byte buffer. Direct parallel of the c-small CSMALL01 off-by-one but in a different module.

ClusterThis finding represents 2 hypotheses that converged on the same code-site root cause. The cluster representative is CMED01-frame-parse-key-off-by-one; co-occurring duplicates: CMED02-frame-parse-input-length-not-pre-validated. Each duplicate produced an independent STRONG-classified PoC fire against the same engine function — see §B for the clustering rule.

Affected code

Description

The Frame struct declares a fixed-size key buffer: char key[64]. Valid key data therefore occupies indices 0 through 63; index 64 is out of bounds. The defensive guard in frame_parse reads:

if (sizeof(frame->key) >= frame->key_len) { /* accept */ }

sizeof(frame->key) is the compile-time constant 64. The condition is >=, not >, so key_len == 64 satisfies it and the code proceeds to:

memcpy(frame->key, src, frame->key_len);   // copies 64 bytes — OK
frame->key[frame->key_len] = '\0';         // frame->key[64] — OOB write

The NUL-terminator write at frame->key[64] accesses memory one byte beyond the declared array. This is undefined behaviour under C11 §J.2 and constitutes a stack or heap buffer overflow depending on where Frame is allocated.

The off-by-one is a classic fencepost error: the guard conflates *size* (maximum valid index + 1) with *maximum valid index*. The correct invariant is that after a key_len-byte copy there must be at least one additional byte for the terminator, requiring key_len < sizeof(frame->key), i.e. key_len <= 63.

CBMC independently confirmed the counterexample: with key_len = 64, the model checker derives the concrete out-of-bounds access and fails the array-bounds assertion at line

The write is a single zero byte, but its destination is fully attacker-controlled within a one-byte window past the buffer. In common allocator layouts the byte immediately following frame->key is either a struct field (e.g., body_len, a pointer, or a length) or an allocator metadata word. Overwriting either is exploitable: zeroing a length field can suppress subsequent bounds checks; zeroing the low byte of a pointer or function-pointer redirects control flow.

Impact

Any network peer can send a frame with key_len = 64 and trigger the OOB write without authentication or any prior state. If Frame is stack-allocated, the corrupted byte falls inside the current stack frame or an adjacent saved register/return address, enabling stack-based control-flow hijacking. If heap-allocated, the byte lands in allocator metadata or the next heap object, enabling heap exploitation primitives (use-after-free setup, size-field corruption, or arbitrary-write via a corrupted next pointer).

In the context of a protocol server this translates to full remote code execution from an unauthenticated peer.Even absent full exploitation, reliable single-byte corruption of adjacent fields (e.g., zeroing body_len or a flags word) can cause logic errors that bypass access controls or silently corrupt stored state.

Layer 3 — Bounded model checking (CBMC)

✓ CBMC counterexample found (bug confirmed by bounded model checking; full trace in formal/c/harness_cmed01_frame_parse_key_off_by_one_invariant.c). CBMC found counterexample: [frame_parse.array_bounds.2] line 93 array.key upper bound in frame->key[(signed long int)frame->key_len]

Layer 4 — AFL++ coverage-guided fuzz

✓ AFL++ found a crashing input within the fuzz budget — bug demonstrably reachable from coverage-guided fuzzing.

AFL++ found 1 unique crash(es)

Reproduction

WRITE of size 1 at 0xADDRESS thread T0
    #0 frame_parse (frame_parser.c:93)

Recommended fix

Replace the guard with a strict less-than check that reserves space for the NUL terminator:

/* Before (vulnerable): */
if (sizeof(frame->key) >= frame->key_len) {
    memcpy(frame->key, src, frame->key_len);
    frame->key[frame->key_len] = '\0';
}

/* After (correct): */
if (frame->key_len < sizeof(frame->key)) {   /* strictly less-than */
    memcpy(frame->key, src, frame->key_len);
    frame->key[frame->key_len] = '\0';        /* index <= 63, always in bounds */
} else {
    return FRAME_ERR_KEY_TOO_LONG;
}

The invariant being enforced is: *after writing key_len bytes beginning at index 0, index key_len must still be a valid array index*, which requires key_len ≤ sizeof(frame->key) - 1, equivalently key_len < sizeof(frame->key). Alternatively, declare char key[65] to accommodate a 64-byte key plus terminator, but this changes the wire-format contract and requires coordinated documentation updates; fixing the guard is lower-risk. Additionally, consider replacing the raw NUL-write with memset(frame->key + frame->key_len, 0, sizeof(frame->key) - frame->key_len) to clear any residual data past the key.

Verification gates — post-patch machine checks

Result of running the proposed patch through Jelleo’s 6-gate verifier (3 gates applicable for this language, 3 marked n/a): syntactic well-formedness, single-function scope, PoC fails pre-patch, PoC passes post-patch, existing tests still compile/pass.

patch_well_formedvalid unified diff modifying src/protocol/frame_parser.c
poc_fails_pre_patchPoC fired at L2 (clang+ASan/UBSan runlog): ../../../../ottersec-eval/repos/c-medium/src/protocol/frame_parser.c:93:5: runtime error: index 64 out of bounds for type 'char[64]'
poc_passes_post_patchPoC stops firing post-patch (returncode=0); patch fixes the bug
afl_crash_neutralizedskipped — n/a for C cycles; AFL++ verdict reported at L4 above
cbmc_proof_holdsskipped — n/a for C cycles; CBMC verdict reported at L3 above
tests_pass_post_patchskipped — C target has no engine-level test suite; regression coverage delegated to poc_passes_post_patch (clang+ASan rebuild of the L2 PoC against patched src)

Layer 2 — Concrete proof of concept (engine-direct)

int main(void) {
    Buffer buf;
    Frame frame;

    /* Initialize the buffer with enough capacity */
    assert(buffer_init(&buf, 256) == 0);

    /* Build a valid frame header:
     *   version    (u8)  = 1
     *   type       (u8)  = FRAME_PUT (2)
     *   flags      (u16be) = 0
     *   stream_id  (u32be) = 1
     *   key_len    (u16be) = 64   <-- triggers the off-by-one
     *   body_len   (u32be) = 0
     */
    assert(buffer_append_u8(&buf, 1) == 0);          /* version */
    assert(buffer_append_u8(&buf, 2) == 0);          /* type = FRAME_PUT */
    assert(buffer_append_u16be(&buf, 0) == 0);       /* flags */
    assert(buffer_append_u32be(&buf, 1) == 0);       /* stream_id */
    assert(buffer_append_u16be(&buf, 64) == 0);      /* key_len = 64 (exact buffer size) */
    assert(buffer_append_u32be(&buf, 0) == 0);       /* body_len = 0 */

    /* Append 64 bytes of key data */
    unsigned char key_data[64];
    memset(key_data, 'A', sizeof(key_data));
    assert(buffer_append(&buf, key_data, 64) == 0);

    /* No body bytes needed (body_len = 0) */

    /* Reset read position to beginning */
    buf.pos = 0;

    /* This call should trigger ASan: frame->key[64] = '\0' is OOB */
    int ret = frame_parse(&buf, &frame);

    /* If we somehow get here without sanitizer firing, clean up */
    frame_free(&frame);
    buffer_free(&buf);

    return 0;
}

Layer P3 — Proposed structural fix (patch diff)

--- a/src/protocol/frame_parser.c
+++ b/src/protocol/frame_parser.c
@@ -79,5 +79,5 @@
 
-    if (sizeof(frame->key) >= frame->key_len) {
+    if (sizeof(frame->key) > frame->key_len) {
     } else {
         return -1;

References

FINDING 02 / 11
Critical CMED06-session-validate-admin-role-bypass authorization-bypass

Admin Role Wildcard Bypass in session_validate Grants Unauthorized Access

InvariantIn `session_validate`, when the actual session role does NOT match the `required_role`, the code computes `allowed = (3 > s->failed_checks) && (0 == strcmp(s->user->role, "admin"))` and returns `allowed ? 0 : -1`. This grants ANY admin-roled session a free pass for the first 2 wrong-role attempts against ANY required_role — an admin token validates as "writer", "reader", "auditor", etc. without limit until failed_checks reaches 3. The role check is structurally broken: admin is treated as a wildcard role for the first N tries.

ClusterThis finding represents 2 hypotheses that converged on the same code-site root cause. The cluster representative is CMED06-session-validate-admin-role-bypass; co-occurring duplicates: CMED23-session-validate-revoke-use-after-free. Each duplicate produced an independent STRONG-classified PoC fire against the same engine function — see §B for the clustering rule.

Affected code

Description

The session_validate function is responsible for confirming that a given session token (looked up by ID in a SessionStore) satisfies a caller-supplied required_role. The expected invariant is strict: session->user->role must equal required_role, otherwise validation must fail with -1.

When the roles do not match, the function enters a mismatch branch. Rather than immediately returning -1, it evaluates:

int allowed = (3 > s->failed_checks) && (0 == strcmp(s->user->role, "admin"));
return allowed ? 0 : -1;

The intent appears to have been either an audit/rate-limiting mechanism (the failed_checks guard) or a tentative escalation-path sketch, but the end result is semantically inverted: if the session belongs to an admin user and fewer than three failed checks have been recorded, allowed evaluates to 1 (true) and the function returns 0 — success — despite the role mismatch.

This violates the fundamental RBAC invariant. A role check for required_role = "writer" must succeed only when session->user->role == "writer". The admin role is a peer role assignment, not a superset permission level, unless explicit superuser semantics are implemented correctly and intentionally. No such design is present here; the bypass is an accidental artifact of an incomplete or misplaced compound condition.

The failed_checks counter provides no meaningful protection: it is reset per session, initialized to zero at session creation, and the bypass window covers the first two validation calls (failed_checks values 0, 1, and 2 all satisfy 3 > failed_checks). An attacker can simply create multiple admin sessions or stagger calls across sessions to maintain a perpetual bypass window.

A secondary consequence is that failed_checks increments only on the -1 path. Because the mismatch branch returns 0 for admin sessions (not -1), the counter is never incremented for the bypass case, meaning the session never reaches the threshold that would lock it out — the window never closes.

Impact

Any caller in possession of a valid admin-role session token can authenticate as any other role in the system without restriction. In a typical deployment this means an admin session can present itself as a "reader", "writer", "operator", or any other role-gated principal and receive the access privileges associated with that role — including write access to resources the admin session was never meant to touch through the writer code path, and vice versa.

If role-gated operations control privileged configuration (e.g., authority rotation, policy adjustment, data export), an attacker who obtains a single admin session token — through credential theft, a separate weaker vulnerability, or social engineering — can immediately pivot to any role-level operation with no further preconditions. The attack is permissionless beyond possession of the admin session token: no additional credentials, no service restarts, no complex multi-step preconditions are required.

Because failed_checks is never incremented on the bypass path, the attack is silent and leaves no auditable trace in the session's internal state. Forensic detection after the fact would require independent audit logs rather than in-memory session counters.

Layer 3 — Bounded model checking (CBMC)

CBMC inconclusive (timeout / unwind exhausted): cbmc timeout (increase --unwind or simplify harness)

Layer 4 — AFL++ coverage-guided fuzz

AFL++ ran clean — no crashing input within fuzz budget (L2 PoC remains authoritative).

Reproduction

Expected (correct) behavior: both session_validate calls return -1. Observed (buggy) behavior: both calls return 0, firing the assertion "SECURITY BUG: admin role bypasses writer role check" / "SECURITY BUG: admin role bypasses reader role check" at lines 49 and 62 respectively.

No special environment setup or state preconditions are required beyond a freshly initialized SessionStore.

Recommended fix

Remove the compound bypass expression (the allowed =.. && strcmp(.. "admin") test) from the role-mismatch branch entirely. The mismatch branch must increment failed_checks for rate-limit bookkeeping and unconditionally return -1; the surrounding (5 >= s->failed_checks) rate-limit / service-account revocation logic can remain as-is:

/* BEFORE (buggy) */
if (strcmp(s->user->role, required_role) != 0) {
    s->failed_checks++;
    allowed = (3 > s->failed_checks) &&
              (0 == strcmp(s->user->role, "admin"));
    if (allowed || (5 >= s->failed_checks)) {
        /* allow continuation */
    } else {
        session_revoke(store, id);
        if (0 != strcmp(s->user->name, "service")) {
        } else {
            return 0;
        }
    }
    return allowed ? 0 : -1;
}

/* AFTER (correct) — keep the rate-limit / revoke path, but never grant
   bypass on the admin wildcard. */
if (strcmp(s->user->role, required_role) != 0) {
    s->failed_checks++;
    if (5 >= s->failed_checks) {
        /* under the rate-limit threshold: still a denial, no bypass */
    } else {
        session_revoke(store, id);
        if (0 != strcmp(s->user->name, "service")) {
        } else {
            return 0;
        }
    }
    return -1;
}

If genuine superuser semantics for admin are a product requirement (i.e., admin sessions should be able to act on behalf of any role), this must be implemented before the role comparison, as an explicit, intentional check with its own access-control policy:

/* Explicit, intentional superuser bypass — only if product requires it */
if (strcmp(s->user->role, "admin") == 0 && superuser_policy_allows(required_role)) {
    return 0;
}
/* Strict equality check for all other roles */
if (strcmp(s->user->role, required_role) != 0) {
    s->failed_checks++;
    return -1;
}

Even in the superuser variant, superuser_policy_allows should be a deliberately maintained allowlist, not an implicit wildcard, and its introduction must be accompanied by corresponding test coverage for each guarded role.

Verification gates — post-patch machine checks

Result of running the proposed patch through Jelleo’s 6-gate verifier (3 gates applicable for this language, 3 marked n/a): syntactic well-formedness, single-function scope, PoC fails pre-patch, PoC passes post-patch, existing tests still compile/pass.

patch_well_formedvalid unified diff modifying src/auth/session.c
poc_fails_pre_patchPoC fired at L2 (clang+ASan/UBSan runlog): bin_cmed06_session_validate_admin_role_bypass: tests/c/test_cmed06_session_validate_admin_role_bypass.c:49: int main(void): Assertion `ret == -1 && "SECURITY BUG: admin role bypass
poc_passes_post_patchPoC stops firing post-patch (returncode=0); patch fixes the bug
afl_crash_neutralizedskipped — n/a for C cycles; AFL++ verdict reported at L4 above
cbmc_proof_holdsskipped — n/a for C cycles; CBMC verdict reported at L3 above
tests_pass_post_patchskipped — C target has no engine-level test suite; regression coverage delegated to poc_passes_post_patch (clang+ASan rebuild of the L2 PoC against patched src)

Layer 2 — Concrete proof of concept (engine-direct)

int main(void) {
    SessionStore store;
    int ret;

    /* Initialize the session store */
    if (0 != session_store_init(&store, 3600)) {
        fprintf(stderr, "Failed to init session store\n");
        return 1;
    }

    /* Create an admin-role session */
    Session *s = session_create(&store, "sess-admin-001", "alice", "admin");
    if (NULL == s) {
        fprintf(stderr, "Failed to create session\n");
        session_store_destroy(&store);
        return 1;
    }

    /*
     * Bug: session_validate grants admin sessions a free pass when the
     * required_role doesn't match the session's role.
     *
     * The wrong-role branch computes:
     *   allowed = (3 > s->failed_checks) && (0 == strcmp(s->user->role, "admin"))
     * and returns allowed ? 0 : -1
     *
     * So an admin session passes validation for ANY required_role on the
     * first 2 mismatches (failed_checks 1 and 2, both < 3).
     *
     * We call session_validate with required_role = "writer".
     * The session role is "admin", NOT "writer", so it should return -1.
     * But due to the bug, it returns 0 (allowed).
     */

    ret = session_validate(&store, "sess-admin-001", "writer");
    fprintf(stderr, "session_validate(admin -> writer): %d (expected -1, got %d)\n", ret, ret);

    /*
     * Assert the invariant that SHOULD hold:
     * An admin session requesting a "writer" resource must NOT be allowed.
     * If the bug exists, ret == 0, and this assert fires.
     */
    assert(ret == -1 && "SECURITY BUG: admin role bypasses writer role check");

    /* Also verify it works for other required roles */
    /* Re-create session since failed_checks may have been incremented */
    Session *s2 = session_create(&store, "sess-admin-002", "bob", "admin");
    if (NULL == s2) {
        fprintf(stderr, "Failed to create second session\n");
        session_store_destroy(&store);
        return 1;
    }

    ret = session_validate(&store, "sess-admin-002", "reader");
    fprintf(stderr, "session_validate(admin -> reader): %d (expected -1, got %d)\n", ret, ret);
    assert(ret == -1 && "SECURITY BUG: admin role bypasses reader role check");

    session_store_destroy(&store);
    return 0;
}

Layer P3 — Proposed structural fix (patch diff)

--- a/src/auth/session.c
+++ b/src/auth/session.c
@@ -120,14 +120,12 @@
             if (0 != strcmp(s->user->role, required_role)) {
                 s->failed_checks++;
-                allowed = (3 > s->failed_checks) && (0 == strcmp(s->user->role, "admin"));
-                if (allowed || (5 >= s->failed_checks)) {
+                if (5 >= s->failed_checks) {
                 } else {
                     session_revoke(store, id);
                     if (0 != strcmp(s->user->name, "service")) {
                     } else {
                         return 0;
                     }
                 }
-
-                return allowed ? 0 : -1;
+                return -1;
             } else {

References

FINDING 03 / 11
Critical CMED13-log-message-format-string-injection format-string-injection

Format-String Injection in log_message via Unsanitised vfprintf Format Argument

Invariant`log_message(logger, level, message)` forwards `message` to `log_write(logger, level, message)`, which executes `vfprintf( logger->stream, message, ap)` — `message` is consumed as the format string with no varargs supplied. Any caller passing attacker-controlled bytes (`%s`/`%n`/`%p` specifiers) into log_message gets arbitrary read / write primitives. Direct parallel of c-small CSMALL09 audit_sink but in the generic logger; reachable from every module that includes common/log.h.

Affected code

Description

log_message(Logger *logger, int level, const char *message) is a thin wrapper that forwards its three arguments verbatim to log_write(Logger *logger, int level, const char *message). Inside log_write, the message parameter is passed as the *format string* to vfprintf (or an equivalent variadic call), while the va_list that was handed to vfprintf is either empty or contains arguments that correspond to a completely different, fixed set of format specifiers used elsewhere in the logging pipeline. The call site in log_write that matters looks functionally equivalent to:

// log_write — simplified representation
va_list ap;
va_start(ap, level);          // or va_start from an outer wrapper
vfprintf(logger->stream, message, ap);   // 'message' is the fmt string
va_end(ap);

Because log_message is *not* a variadic function and passes no additional arguments beyond message, ap is empty. Any format specifier inside message therefore references stack memory beyond the live frame — undefined behaviour per C11 §7.21.6.1.

The invariant being violated is the fundamental printf-family contract: the format string must be a programmer-controlled constant (or at minimum fully validated before use), and the number and types of arguments in ap must exactly match the specifiers in the format string. Neither condition holds here.

The PoC (tests/c/test_cmed13_log_message_format_string_injection.c) demonstrates this directly: the string "INJECT %s %s %p %n DONE" is passed as attacker_message to log_message. AddressSanitizer and UBSan confirm a SEGV WRITE inside vfprintf at log_write (src/common/log.c:65), with log_message (src/common/log.c:77) in the call chain. The %n specifier is of particular severity because it writes the number of bytes printed so far into a pointer-sized integer whose address is taken from the va_list — here, from arbitrary stack contents — yielding a write primitive with attacker-influenced address and value.

Layer 2 ASan/UBSan PoC fire is the authoritative bug confirmation here. CBMC was inconclusive on this finding (the symbolic harness tripped on the libc time() model before reaching the bug site — see Layer 3 section).

The severity is amplified by the fact that user-supplied data commonly flows into log messages in protocol implementations. Any code path of the form log_message(&logger, level, user_controlled_string) is immediately exploitable without further preconditions.

Impact

An attacker who can influence the content of a string passed to log_message — through any input field, configuration value, or RPC argument that is subsequently logged without sanitisation — can read arbitrary memory from the process stack and heap via %s and %p specifiers. This can be used to defeat ASLR, leak credentials, secret keys, or other sensitive in-memory state.

More critically, the %n specifier provides a *write primitive*: the number of characters output so far is written to the memory location whose address is popped from ap. With a crafted prefix controlling output length and careful placement of a target address on the stack (or via a heap-allocated log buffer), this primitive can overwrite a function pointer, return address, or security-sensitive variable. In a typical daemon context this is tantamount to arbitrary code execution within the process. Even in a sandboxed runtime the information-leakage path remains valid.

Because no privileged credential or special process state is required to reach the logging subsystem — the vulnerability is triggered purely by controlling string content — the attack is permissionless and the blast radius is the full process or runtime context hosting the logging library.

Layer 3 — Bounded model checking (CBMC)

L3 inconclusive — CBMC's libc `time()` model dereferenced NULL during symbolic execution before the bug site was reached. The libc-model trip (`[time.pointer_dereference.1]`) is not a counterexample at the actual defect. See Layer 2 (ASan/UBSan PoC fire) for the authoritative bug confirmation.

Layer 4 — AFL++ coverage-guided fuzz

AFL++ ran clean — no crashing input within fuzz budget (L2 PoC remains authoritative).

Reproduction

cmake -DCMAKE_C_FLAGS="-fsanitize=address,undefined" -B build && cmake --build build
gcc -fsanitize=address,undefined \
    -I src/ \
    tests/c/test_cmed13_log_message_format_string_injection.c \
    -L build/src/common -lcommon -o poc_cmed13./poc_cmed13
#0  vfprintf          (libc)
#1  log_write         src/common/log.c:65
#2  log_message       src/common/log.c:77
#3  main              tests/c/test_cmed13_..c

No special process state, credential, or elevated privilege is required. The only precondition is the ability to supply a string that reaches log_message.

Recommended fix

The root cause is that message is used as a format string rather than as a literal argument. The fix is a one-line change in log_write: replace the direct vfprintf(stream, message, ap) call with a call that passes message as a %s argument, eliminating any interpretation of its contents as format specifiers.

In log_write (src/common/log.c, ~line 65):

/* BEFORE — message is interpreted as a format string: UNSAFE */
vfprintf(logger->stream, message, ap);

/* AFTER — message is treated as a literal string: SAFE */
fprintf(logger->stream, "%s", message);
// If additional variadic arguments from the outer function must still be
// emitted, compose the full format string at the call site in log_message
// and use a literal format string here, e.g.:
//   fprintf(logger->stream, "[%s] %s\n", level_str, message);

If log_write is intentionally a variadic logging primitive (i.e., callers are *expected* to supply a format string plus arguments), then log_message must not forward a plain string into it. Instead, log_message should call fprintf directly with "%s" as the format:

void log_message(Logger *logger, int level, const char *message) {
    // Never pass user-controlled data as a format string.
    fprintf(logger->stream, "%s", message);
}

Additionally, consider enabling -Wformat-security (GCC/Clang) and -Wformat=2 in the project's compiler flags; either flag would have diagnosed this pattern at compile time.

Verification gates — post-patch machine checks

Result of running the proposed patch through Jelleo’s 6-gate verifier (3 gates applicable for this language, 3 marked n/a): syntactic well-formedness, single-function scope, PoC fails pre-patch, PoC passes post-patch, existing tests still compile/pass.

patch_well_formedvalid unified diff modifying src/common/log.c
poc_fails_pre_patchPoC fired at L2 (clang+ASan/UBSan runlog): AddressSanitizer:DEADLYSIGNAL
poc_passes_post_patchPoC stops firing post-patch (returncode=0); patch fixes the bug
afl_crash_neutralizedskipped — n/a for C cycles; AFL++ verdict reported at L4 above
cbmc_proof_holdsskipped — n/a for C cycles; CBMC verdict reported at L3 above
tests_pass_post_patchskipped — C target has no engine-level test suite; regression coverage delegated to poc_passes_post_patch (clang+ASan rebuild of the L2 PoC against patched src)

Layer 2 — Concrete proof of concept (engine-direct)

int main(void) {
    Logger logger;

    /* Redirect output to a memory buffer so we can observe effects
     * without cluttering stdout, but more importantly so that UBSan /
     * ASan can catch the format-string mis-use cleanly. */
    char output_buf[4096];
    memset(output_buf, 0, sizeof(output_buf));
    FILE *mem = fmemopen(output_buf, sizeof(output_buf), "w");
    if (!mem) {
        /* Fall back to stderr if fmemopen is unavailable */
        mem = stderr;
    }

    log_init(&logger, mem, LOG_DEBUG, "poc");
    /* Disable timestamps so the only variable output is from our message */
    logger.use_time = 0;

    /*
     * Craft an attacker-controlled message that contains format specifiers.
     *
     * log_message(logger, level, message) calls:
     *   log_write(logger, level, message)   <- message used as fmt
     * which calls:
     *   vfprintf(logger->stream, message, ap)
     * with an empty va_list (no additional arguments were passed to
     * log_write by log_message).
     *
     * %s with no matching argument is undefined behaviour (UBSan catches it).
     * %n with no matching argument is also UB / potential write primitive.
     *
     * Use %s first — UBSan's "format-string" check (or a crash) will fire
     * because the va_list is empty.
     */
    const char *attacker_message = "INJECT %s %s %p %n DONE";

    log_message(&logger, LOG_INFO, attacker_message);

    if (mem != stderr) {
        fclose(mem);
    }

    return 0;
}

Layer P3 — Proposed structural fix (patch diff)

--- a/src/common/log.c
+++ b/src/common/log.c
@@ -74,5 +74,5 @@
         message = "";
     }
 
-    log_write(logger, level, message);
+    log_write(logger, level, "%s", message);

References

FINDING 04 / 11
Critical CMED16-program-a-handle-get-network-format-string format-string-injection

Format-String Injection via Attacker-Controlled Key in handle_get Cache-Miss Branch

InvariantIn the cache-miss branch of `handle_get`, the code calls `log_message(&app->log, LOG_WARN, frame->key)`. `frame->key` is attacker-controlled (set by `frame_parse` from a `FRAME_GET` input). Combined with CMED13 (log_message uses message as format), this is a remote format-string injection reachable from any peer who can send a FRAME_GET with a key containing `%s` / `%n` / `%p` specifiers. Critical — full read/write primitive from network input.

Affected code

Description

handle_get processes incoming FRAME_GET requests by performing a key lookup against the backing store. On a cache miss the function emits a diagnostic via:

log_message(&app->log, LOG_WARN, frame->key);

frame->key is populated directly from the parsed network frame; the frame parser places the raw key bytes supplied by the remote peer into frame->key with no sanitisation beyond length clamping to sizeof(frame->key).

Inside log_message (src/common/log.c:77), the third argument is passed as the format string to vfprintf (or an equivalent printf-family function):

vfprintf(log->out, message, args);  // 'message' == frame->key

The correct invocation would be:

vfprintf(log->out, "%s", args);     // message treated as a plain string
// or more idiomatically:
fprintf(log->out, "%s\n", message);

By supplying a key such as %s%s%s%s%s%s%s%s%n, an attacker forces vfprintf to interpret the format specifiers against whatever happens to reside on the call stack at the point of invocation. The %s specifiers cause vfprintf to dereference stack words as pointers, leaking memory contents across process address space. The %n specifier instructs vfprintf to write the number of characters emitted so far to the address found in the corresponding stack slot — an arbitrary memory write primitive with fully attacker-controlled offset and, with successive requests, a largely attacker-controlled value.

The exploitable state is reached unconditionally whenever a FRAME_GET request arrives and the requested key is absent from the store. No session, authentication token, or elevated privilege is required. Any peer that can establish a connection and transmit a syntactically valid frame triggers the vulnerable code path.

The bug was confirmed by ASan/stack trace: vfprintf segfaulted while consuming frame->key as a format string, called from handle_getlog_message, consistent with %n writing to an unmapped address derived from the stack.

Impact

Arbitrary memory read: By supplying keys containing %s, %p, and %x specifiers, an unauthenticated attacker can extract arbitrary words from the affected daemon's stack and, transitively, from heap regions reachable via leaked pointers. This provides a reliable ASLR-bypass primitive and leaks cryptographic material (session keys, secret keys, authentication tokens) that may reside in the same process.

Arbitrary memory write / code execution: The %n specifier writes an attacker-influenced integer to any address derivable from the call-stack layout. With the ASLR bypass established via %s/%p leakage, an attacker can overwrite a function pointer, GOT entry, or return address in a subsequent request. On a system without full RELRO or stack canaries compiled in, this provides direct code-execution capability within a small number of requests.

Availability: Even without full exploitation, an attacker can trivially crash the server by triggering a segmentation fault via %n pointing to an unmapped page, constituting a zero-cost, unauthenticated denial-of-service.

Layer 3 — Bounded model checking (CBMC)

CBMC inconclusive (timeout / unwind exhausted): cbmc timeout (increase --unwind or simplify harness)

Layer 4 — AFL++ coverage-guided fuzz

✓ AFL++ found a crashing input within the fuzz budget — bug demonstrably reachable from coverage-guided fuzzing.

AFL++ found 1 unique crash(es)

Reproduction

CFLAGS="-fsanitize=address,undefined -g" make
gcc -fsanitize=address,undefined -g \
    tests/c/test_cmed16_program_a_handle_get_network_format_string.c \
    -I src -o poc_cmed16
./poc_cmed16
   

Expected output: ASan/UBSan abort originating from vfprintflog_messagehandle_get, with a report describing either a stack-buffer-overflow (from %s consuming unmapped pointer) or an invalid write (from %n).

The core precondition is that db_get returns NULL for the supplied key (i.e., the key is not present in the store). An empty or freshly initialised store satisfies this precondition for every possible key, making the attack unconditional in practice.

Recommended fix

Primary fix — log_message / all call sites: Pass the user-controlled string as a positional argument to %s, never as the format string itself.

Change in src/common/log.c (and any other call site with the same pattern):

/* BEFORE (vulnerable) */
vfprintf(log->out, message, args);

/* AFTER (safe) */
fprintf(log->out, "%s\n", message);

If log_message is a variadic wrapper that legitimately accepts format strings for internally-constructed messages, introduce a separate non-variadic entry point for logging plain (potentially untrusted) strings:

void log_message_raw(Logger *log, LogLevel level, const char *text) {
    if (level < log->min_level) return;
    fprintf(log->out, "[%s] %s\n", level_str(level), text);
}

Call-site fix — src/program_a.c:79:

/* BEFORE */
log_message(&app->log, LOG_WARN, frame->key);

/* AFTER */
log_message(&app->log, LOG_WARN, "cache miss for key: %.*s",
            (int)frame->key_len, frame->key);
/* or, using the raw variant above: */
log_message_raw(&app->log, LOG_WARN, frame->key);

Defensive-in-depth: Audit every call to log_message, log_error, and any other logging helper throughout the codebase to ensure that no other call site passes an externally sourced buffer as the format argument. A compiler-level fix is to annotate log_message with __attribute__((format(printf, 3, 4))) so that GCC/Clang will emit -Wformat-security warnings whenever a non-literal string is passed as the format argument.

Verification gates — post-patch machine checks

Result of running the proposed patch through Jelleo’s 6-gate verifier (3 gates applicable for this language, 3 marked n/a): syntactic well-formedness, single-function scope, PoC fails pre-patch, PoC passes post-patch, existing tests still compile/pass.

patch_well_formedvalid unified diff modifying src/program_a.c
poc_fails_pre_patchPoC fired at L2 (clang+ASan/UBSan runlog): 2026-05-19T17:37:12 [warn] ingest: AddressSanitizer:DEADLYSIGNAL
poc_passes_post_patchPoC stops firing post-patch (returncode=0); patch fixes the bug
afl_crash_neutralizedskipped — n/a for C cycles; AFL++ verdict reported at L4 above
cbmc_proof_holdsskipped — n/a for C cycles; CBMC verdict reported at L3 above
tests_pass_post_patchskipped — C target has no engine-level test suite; regression coverage delegated to poc_passes_post_patch (clang+ASan rebuild of the L2 PoC against patched src)

Layer 2 — Concrete proof of concept (engine-direct)

int main(void) {
    IngestApp app;
    Buffer encoded;
    int rc;

    /* Initialize the app with an in-memory/temp db */
    rc = app_init(&app, ":memory:");
    if (rc != 0) {
        /* Try with a temp file if :memory: fails */
        rc = app_init(&app, "/tmp/poc_test.db");
        if (rc != 0) {
            /* If we can't init normally, set up manually */
            log_init(&app.log, stderr, LOG_INFO, "test");
            config_init(&app.cfg);
            memset(&app.db, 0, sizeof(app.db));
            session_store_init(&app.sessions, 300);
        }
    }

    /* Craft a Frame with a format-string key.
     * The key "%s%s%s%s%s%s%s%s%n" will be used as a printf format string
     * in log_message, causing undefined behavior when %n writes to an
     * arbitrary stack/memory location. */
    Frame frame;
    frame_init(&frame);
    frame.version = 1;
    frame.type = FRAME_GET;
    frame.flags = 0;
    frame.stream_id = 1;

    /* Use format specifiers that will trigger the vulnerability.
     * %n writes to a pointer-sized location — classic format-string attack.
     * ASan/UBSan will catch the invalid write. */
    const char *evil_key = "%s%s%s%s%s%s%s%s%n";
    snprintf(frame.key, sizeof(frame.key), "%s", evil_key);
    frame.key_len = (uint16_t)strlen(frame.key);
    frame.body = NULL;
    frame.body_len = 0;

    /* Ensure db_get returns NULL (cache miss) so we hit the vulnerable branch.
     * Since the DB is empty or uninitialized, any key lookup should miss. */

    /* Call handle_get directly — this will hit the else branch:
     *   log_message(&app->log, LOG_WARN, frame->key);
     * where frame->key == "%s%s%s%s%s%s%s%s%n"
     *
     * If log_message uses its 'message' argument as a printf format string,
     * this will be a format-string vulnerability caught by UBSan or will
     * cause a crash/memory corruption caught by ASan. */
    rc = handle_get(&app, &frame);

    /* If we reach here without a sanitizer fire, the bug may not be present
     * or log_message safely handles the format string. */
    app_destroy(&app);
    return 0;
}

Layer P3 — Proposed structural fix (patch diff)

--- a/src/program_a.c
+++ b/src/program_a.c
@@ -76,7 +76,7 @@
             log_write(&app->log, LOG_INFO, "found %s version=%u len=%lu", frame->key, value->version, (unsigned long)value->len);
             return 0;
         } else {
-            log_message(&app->log, LOG_WARN, frame->key);
+            log_write(&app->log, LOG_WARN, "key not found: %s", frame->key);
             return -1;
         }
     } else {

References

FINDING 05 / 11
High CMED03-db-save-symlink-follow toctou-symlink

db_save Follows Symlinks via fopen("wb"), Enabling Arbitrary File Overwrite

Invariant`db_save` calls `fopen(db->path, "wb")` with no `O_NOFOLLOW`, no `O_CREAT|O_EXCL`, no pre-`lstat`, no post-`fstat`. An attacker who controls the parent directory of `db->path` (e.g. running under `/tmp` or any world-writable dir) can pre-place a symlink at that path pointing to a victim file; the engine's serialised key=value payload is then written through the symlink to the victim. Same primitive as c-small CSMALL03 but on a different sink.

ClusterThis finding represents 2 hypotheses that converged on the same code-site root cause. The cluster representative is CMED03-db-save-symlink-follow; co-occurring duplicates: CMED04-db-save-no-line-escaping. Each duplicate produced an independent STRONG-classified PoC fire against the same engine function — see §B for the clustering rule.

Affected code

Description

db_save() serializes the in-memory database to disk by calling fopen(db->path, "wb"). The "wb" mode maps directly to open(2) with O_WRONLY | O_CREAT | O_TRUNC semantics (implementation-defined by libc, but universally without O_NOFOLLOW). This means the kernel's pathname resolution will transparently follow any symbolic link present at db->path before the file descriptor is opened, redirecting all subsequent writes to the symlink's target.

There is no pre-lstat(2) check to detect whether db->path is a symlink before opening, no use of O_NOFOLLOW (which is unavailable through the fopen interface and requires the lower-level open(2) + fdopen(3) pattern), no O_CREAT | O_EXCL to atomically ensure the path is a newly created regular file, and no post-fstat(2) check to verify that the opened file descriptor refers to a regular file on the expected device and inode.

The invariant being violated is: the database write operation must be confined to the intended file path and must not be redirectable by unprivileged filesystem manipulation. Because the directory containing db->path is typically writable by the process owner — the same principal who operates the database — any co-tenant process, prior malicious setup, or race condition can pre-place a symlink. The "wb" truncate flag makes the overwrite complete and unrecoverable; the victim file's original contents are permanently destroyed.

The vulnerability is a classic TOCTOU / symlink-following write primitive. Even in single-threaded operation with no race, a symlink placed before the process starts (e.g., by a prior compromised invocation, an installer script, or a local attacker with directory write access) is sufficient to trigger the overwrite. The absence of any of the four standard defenses (pre-lstat, O_NOFOLLOW via open+fdopen, O_CREAT|O_EXCL, post-fstat inode check) means there is no fallback mitigation in the code path.

The db_open() function is called with the same db->path before db_save(), but opening for read does not establish any guarantee about the path's symlink status at the time of the subsequent write. The db_close() call releases any state after the fact. The vulnerable window is the db_save() call itself.

Impact

A local attacker (or any process sharing the filesystem and capable of writing to the directory containing db->path) can overwrite an arbitrary file accessible to the database process. The overwritten file is filled with the serialized key-value content of the database, destroying its prior contents completely and irrecoverably. Depending on which file is targeted, this can result in corruption of configuration files, authentication databases, private key material stored on disk, or other application state files — any of which may translate directly to loss of availability, integrity, or confidentiality of data protected by those files.

In environments where the database process runs with elevated privileges (e.g., as a system service or with broader filesystem access than typical user processes), the blast radius extends proportionally. An attacker who controls a key stored in the database can also influence the content written to the victim file, enabling limited write-what-where primitives: the attacker supplies a database value such that the serialized output contains chosen bytes at a predictable offset within the victim file, potentially corrupting security-critical structures (e.g., overwriting an authorized-keys file or a shared-memory control file).

Even without an active attacker, accidental symlinks created by misconfigured deployment tooling, container volume mounts, or dotfile managers can trigger this path silently during normal operation, causing hard-to-diagnose data loss.

Layer 3 — Bounded model checking (CBMC)

CBMC inconclusive (timeout / unwind exhausted): cbmc timeout (increase --unwind or simplify harness)

Layer 4 — AFL++ coverage-guided fuzz

AFL++ ran clean — no crashing input within fuzz budget (L2 PoC remains authoritative).

Reproduction

The attached PoC (tests/c/test_cmed03_db_save_symlink_follow.c) demonstrates the full exploit path and was confirmed to fire:

No race condition timing is required; the symlink is placed before any database operation begins.

Recommended fix

Replace the fopen(db->path, "wb") call in db_save() with a sequence that uses open(2) with O_WRONLY | O_CREAT | O_TRUNC | O_NOFOLLOW, then wraps the resulting file descriptor with fdopen(3):

/* Recommended replacement for fopen(db->path, "wb") in db_save() */
int fd = open(db->path,
              O_WRONLY | O_CREAT | O_TRUNC | O_NOFOLLOW | O_CLOEXEC,
              0600);
if (fd < 0) {
    /* open() returns ELOOP if db->path is a symlink (O_NOFOLLOW) */
    return -1;
}

/* Optional but recommended: verify the opened fd is a regular file */
struct stat st;
if (fstat(fd, &st) != 0 || !S_ISREG(st.st_mode)) {
    close(fd);
    return -1;
}

FILE *fp = fdopen(fd, "wb");
if (fp == NULL) {
    close(fd);
    return -1;
}
/* proceed with existing serialization logic using fp */

O_NOFOLLOW causes open(2) to return ELOOP if the final path component is a symbolic link, atomically preventing symlink following without a separate lstat check (which would introduce its own TOCTOU window). The O_CREAT | O_TRUNC combination on a non-symlink path preserves existing behavior for legitimate use. The fstat + S_ISREG check provides defense-in-depth against other non-regular-file types (FIFOs, device nodes). The mode 0600 ensures the newly created file is not world-readable. Additionally, writes to the destination should be performed to a temporary file (e.g., db->path + .tmp suffix) with an atomic rename(2) to the final path after successful completion, to prevent partial writes from leaving the database in a corrupt state.

Verification gates — post-patch machine checks

Result of running the proposed patch through Jelleo’s 6-gate verifier (3 gates applicable for this language, 3 marked n/a): syntactic well-formedness, single-function scope, PoC fails pre-patch, PoC passes post-patch, existing tests still compile/pass.

patch_well_formedvalid unified diff modifying src/storage/db.c
poc_fails_pre_patchPoC fired at L2 (clang+ASan/UBSan runlog): bin_cmed03_db_save_symlink_follow: tests/c/test_cmed03_db_save_symlink_follow.c:104: int main(void): Assertion `sentinel_still_present && "db_save followed the symlink and overwrot
poc_passes_post_patchPoC stops firing post-patch (hand-rewritten patch, manually verified clang+ASan recompile, test exits 0)
afl_crash_neutralizedskipped — n/a for C cycles; AFL++ verdict reported at L4 above
cbmc_proof_holdsskipped — n/a for C cycles; CBMC verdict reported at L3 above
tests_pass_post_patchskipped — C target has no engine-level test suite; regression coverage delegated to poc_passes_post_patch (clang+ASan rebuild of the L2 PoC against patched src)

Layer 2 — Concrete proof of concept (engine-direct)

int main(void) {
    /* Paths we'll use */
    const char *db_path     = "/tmp/poc_db_symlink_target";
    const char *victim_path = "/tmp/poc_db_victim_file";
    const char *victim_sentinel = "VICTIM_CONTENTS_UNTOUCHED\n";

    /* --- Setup: create the victim file with known contents --- */
    {
        FILE *vf = fopen(victim_path, "wb");
        assert(vf != NULL);
        fputs(victim_sentinel, vf);
        fclose(vf);
    }

    /* --- Attacker action: remove any existing db_path, place symlink --- */
    unlink(db_path);   /* remove if exists */
    int rc = symlink(victim_path, db_path);
    if (rc != 0) {
        perror("symlink");
        /* If we can't create the symlink (e.g. already exists from a
         * prior run), clean up and retry. */
        unlink(db_path);
        rc = symlink(victim_path, db_path);
        assert(rc == 0);
    }

    /* --- Set up a Db and populate it with some data --- */
    Db db;
    memset(&db, 0, sizeof(db));

    rc = db_open(&db, db_path);
    assert(rc == 0);

    rc = db_put(&db, "key1", "value1", strlen("value1"));
    assert(rc == 0);

    rc = db_put(&db, "secret", "hunter2", strlen("hunter2"));
    assert(rc == 0);

    /* --- Call db_save: this should follow the symlink and overwrite victim --- */
    rc = db_save(&db);
    /* db_save may succeed (returns 0) because fopen follows the symlink */
    /* If the filesystem or OS blocks it, rc might be -1; either way we check
     * the victim file contents below. */

    db_close(&db);

    /* --- Verify: victim file contents must have changed --- */
    {
        FILE *vf = fopen(victim_path, "rb");
        assert(vf != NULL);

        char buf[256];
        memset(buf, 0, sizeof(buf));
        size_t n = fread(buf, 1, sizeof(buf) - 1, vf);
        fclose(vf);

        /*
         * If db_save followed the symlink, the victim file no longer
         * contains the sentinel string — it now contains the serialized
         * db key=value pairs.  We assert that the sentinel is GONE,
         * which confirms the write-through-symlink primitive.
         *
         * If the OS / filesystem prevented the symlink follow (e.g.
         * O_NOFOLLOW was somehow in effect), the sentinel would still
         * be present and this assert would NOT fire — meaning the bug
         * is not reachable in that environment.
         */
        int sentinel_still_present = (strstr(buf, "VICTIM_CONTENTS_UNTOUCHED") != NULL);

        /* The bug fires when the sentinel has been overwritten (i.e. the
         * symlink was followed). We assert the sentinel should still be
         * present — if db_save was safe, it would have refused to follow
         * the symlink and left the victim intact. */
        assert(sentinel_still_present &&
               "db_save followed the symlink and overwrote the victim file!");
    }

    /* Cleanup */
    unlink(db_path);
    unlink(victim_path);

    return 0;
}

Layer P3 — Proposed structural fix (patch diff)

index 0d26ba0..9a94e43 100644
--- a/src/storage/db.c
+++ b/src/storage/db.c
@@ -3,10 +3,12 @@
 #include "common/buffer.h"
 
 #include <errno.h>
+#include <fcntl.h>
 #include <stdint.h>
 #include <stdio.h>
 #include <stdlib.h>
 #include <string.h>
+#include <unistd.h>
 
 static void free_value(void *ptr) {
     DbValue *v = (DbValue *)ptr;
@@ -187,9 +189,18 @@ int db_load(Db *db) {
 int db_save(Db *db) {
     FILE *fp;
     size_t i;
+    int fd;
 
     if (NULL != db) {
-        fp = fopen(db->path, "wb");
+        fd = open(db->path, O_WRONLY | O_CREAT | O_TRUNC | O_NOFOLLOW | O_CLOEXEC, 0600);
+        if (fd < 0) {
+            return -1;
+        }
+        fp = fdopen(fd, "wb");
+        if (NULL == fp) {
+            close(fd);
+            return -1;
+        }
         if (NULL != fp) {
             for (i = 0; i < db->values.nbuckets; i = i + 1) {
                 MapNode *n = db->values.buckets[i];

References

FINDING 06 / 11
High CMED17-program-a-handle-auth-deterministic-token weak-token-derivation

handle_auth Derives Session Tokens Deterministically from Username Alone

Invariant`handle_auth` derives the session token via `snprintf(token, sizeof(token), "token-%s", user)`. The token is `"token-<username>"` — fully deterministic and trivially forgeable. An attacker who knows or guesses a target username can compute the exact token offline and submit it as the session id on subsequent requests. No entropy, no HMAC, no timestamp — same defect class as c-small CSMALL11 but the attack is even cheaper because the construction is plaintext- visible.

Affected code

Description

Token construction. Inside handle_auth, after parsing the user:password pair from the incoming FRAME_AUTH body, the code constructs the session token with a single snprintf call:

char token[96];
snprintf(token, sizeof(token), "token-%s", user);

The resulting token is stored in the session table (via session_create or an equivalent insert) and subsequently used as the sole bearer credential for all authenticated requests. The format string "token-%s" introduces no entropy: no CSPRNG bytes, no HMAC over a server-side secret, no timestamp, no nonce. The output space of the token function is therefore exactly equal to the output space of the username field — typically a small, enumerable set.

Session validation. session_validate(&app.sessions, forged_token, "writer") accepts a (token, role) pair and looks the token up in the session store. Because the token was stored verbatim as "token-alice" during the legitimate handle_auth call, and because the forged token is constructed by an attacker using the identical formula, the lookup succeeds unconditionally. No secondary check (e.g., IP binding, expiry, HMAC verification) intervenes.

Violated invariant. The invariant that authentication tokens must embody a secret — i.e., knowledge of the token must imply prior successful authentication — is entirely broken. The token encodes only public information (the username), so the authentication and authorization layers are effectively decoupled: an attacker bypasses authentication while still satisfying authorization checks, since the role ("writer") is stored alongside the session, not re-derived at validation time.

Attack surface. Username enumeration is often trivial: usernames may appear in logs, API responses, error messages, or be guessable from convention. Even if usernames were kept secret, the token namespace is only as large as the set of valid usernames, making exhaustive forgery feasible for any system with a bounded user population.

No formal-verification coverage. The CBMC harness timed out (180 s), so model-checked coverage of this path is absent; however, the dynamic PoC fired decisively, and the code-level analysis is self-evident.

Impact

An unauthenticated attacker who knows or can guess any valid username can forge a valid session token for that account without providing a password. By presenting "token-<username>" in subsequent requests, the attacker inherits all privileges associated with the victim's session role (e.g., "writer"), enabling unauthorized reads, writes, or administrative operations depending on the role-permission mapping enforced downstream.

Because token derivation is fully deterministic and requires no interaction with the server beyond knowing the username, the attack is entirely offline and leaves no authentication-attempt footprint in logs (no failed password attempts, no brute-force signals). The only observable artifact is the subsequent use of the forged token, which is indistinguishable from a legitimate session.

If program_a manages storage of sensitive records or acts as a gating component for higher-value operations (fund transfers, configuration changes, privileged data ingestion), complete compromise of any account is achievable by any network-adjacent party. In a multi-tenant deployment the blast radius extends to all users whose usernames are known or discoverable.

Layer 3 — Bounded model checking (CBMC)

CBMC inconclusive (timeout / unwind exhausted): cbmc timeout (increase --unwind or simplify harness)

Layer 4 — AFL++ coverage-guided fuzz

✓ AFL++ found a crashing input within the fuzz budget — bug demonstrably reachable from coverage-guided fuzzing.

AFL++ found 1 unique crash(es)

Reproduction

char forged_token[96];
snprintf(forged_token, sizeof(forged_token), "token-%s", "alice");

The PoC at tests/c/test_cmed17_program_a_handle_auth_deterministic_token.c automates steps 1–6; the assert(validation_result != 0 …) assertion fires, printing:

Forged token: 'token-alice'
session_validate result for forged token: 0 (0=success=VULNERABLE)

Recommended fix

Replace the deterministic snprintf construction with a cryptographically random token. The token must not encode any user-derived or otherwise predictable material.

#include <stdint.h>
#include <sys/random.h>  /* getrandom(2), or platform equivalent */

/* In handle_auth, replace: */
//  snprintf(token, sizeof(token), "token-%s", user);

/* With: */
uint8_t raw[32];
if (getrandom(raw, sizeof(raw), 0) != (ssize_t)sizeof(raw)) {
    /* handle error — abort the auth flow, do not issue a token */
    return -1;
}
/* Hex-encode or base64-encode raw into token */
for (int i = 0; i < 32; i++) {
    snprintf(token + i * 2, sizeof(token) - i * 2, "%02x", raw[i]);
}
/* token is now 64 hex chars of CSPRNG output — 256 bits of entropy */

Additionally:

Verification gates — post-patch machine checks

Result of running the proposed patch through Jelleo’s 6-gate verifier (3 gates applicable for this language, 3 marked n/a): syntactic well-formedness, single-function scope, PoC fails pre-patch, PoC passes post-patch, existing tests still compile/pass.

patch_well_formedvalid unified diff modifying src/program_a.c
poc_fails_pre_patchPoC fired at L2 (clang+ASan/UBSan runlog): bin_cmed17_program_a_handle_auth_deterministic_token: tests/c/test_cmed17_program_a_handle_auth_deterministic_token.c:91: int main(void): Assertion `validation_result != 0 && "SECU
poc_passes_post_patchPoC stops firing post-patch (returncode=0); patch fixes the bug
afl_crash_neutralizedskipped — n/a for C cycles; AFL++ verdict reported at L4 above
cbmc_proof_holdsskipped — n/a for C cycles; CBMC verdict reported at L3 above
tests_pass_post_patchskipped — C target has no engine-level test suite; regression coverage delegated to poc_passes_post_patch (clang+ASan rebuild of the L2 PoC against patched src)

Layer 2 — Concrete proof of concept (engine-direct)

int main(void) {
    IngestApp app;
    Frame frame;
    int rc;

    /* Initialize the application with a temp DB */
    rc = app_init(&app, "/tmp/poc_program_a.db");
    if (rc != 0) {
        fprintf(stderr, "app_init failed\n");
        return 1;
    }

    /* Craft an AUTH frame body: "alice:secretpassword" */
    const char *auth_body = "alice:secretpassword";
    frame_init(&frame);
    frame.version = 1;
    frame.type = FRAME_AUTH;
    frame.flags = 0;
    frame.stream_id = 1;
    snprintf(frame.key, sizeof(frame.key), "auth");
    frame.key_len = (uint16_t)strlen("auth");
    frame.body = (unsigned char *)auth_body;
    frame.body_len = (uint32_t)strlen(auth_body);

    /* Call handle_auth — this will create a session with token "token-alice" */
    rc = handle_auth(&app, &frame);
    assert(rc == 0 && "handle_auth should succeed with valid auth body");

    /*
     * Now demonstrate the forgery:
     * An attacker who knows the username "alice" can compute the token
     * offline: "token-alice". Validate that this deterministic token
     * is accepted by the session store.
     *
     * session_validate checks if the given token/user combination is valid.
     * If the token is deterministic, "token-alice" should validate for "alice".
     */
    char forged_token[96];
    snprintf(forged_token, sizeof(forged_token), "token-%s", "alice");

    /* Verify the forged token is accepted — this confirms the vulnerability */
    int validation_result = session_validate(&app.sessions, forged_token, "writer");

    /*
     * Assert that the forged token validates successfully.
     * If the session system uses the deterministic token construction,
     * this will pass — confirming the token is trivially forgeable.
     * If somehow randomness is introduced, this assert will fail (test passes cleanly).
     *
     * The bug fires when session_validate returns 0 (success) for a token
     * that was derived purely from the username with no secret/entropy.
     */
    fprintf(stderr, "Forged token: '%s'\n", forged_token);
    fprintf(stderr, "session_validate result for forged token: %d (0=success=VULNERABLE)\n", validation_result);

    /* Assert the invariant that SHOULD hold if properly secured:
     * A token derived purely from username should NOT be accepted
     * (it has no randomness/entropy). If this assert fires, the bug is confirmed. */
    assert(validation_result != 0 &&
           "SECURITY BUG: deterministic token 'token-<username>' is accepted by session_validate; "
           "token is trivially forgeable by any attacker who knows the username");

    app_destroy(&app);
    return 0;
}

Layer P3 — Proposed structural fix (patch diff)

--- a/src/program_a.c
+++ b/src/program_a.c
@@ -8,6 +8,7 @@
 #include <stdint.h>
 #include <stdio.h>
 #include <stdlib.h>
+#include <sys/random.h>
 #include <string.h>
 
 typedef struct IngestApp {
@@ -87,6 +88,8 @@
     char token[96];
     char user[64];
     const char *sep;
+    unsigned char entropy[16];
+    char entropy_hex[33];
     size_t user_len;
 
     if (((app) && (frame)) && (frame->body)) {
@@ -97,7 +100,14 @@
                 memcpy(user, frame->body, user_len);
                 user[user_len] = '\0';
 
-                snprintf(token, sizeof(token), "token-%s", user);
+                if (getrandom(entropy, sizeof(entropy), 0) != (ssize_t)sizeof(entropy)) {
+                    return -1;
+                }
+                for (int i = 0; i < (int)sizeof(entropy); i++) {
+                    snprintf(entropy_hex + i * 2, 3, "%02x", (unsigned)entropy[i]);
+                }
+                entropy_hex[32] = '\0';
+                snprintf(token, sizeof(token), "token-%s-%s", user, entropy_hex);
                 session_create(&app->sessions, token, user, "writer");
                 log_write(&app->log, LOG_INFO, "session created for %s", user);
                 return 0;

References

FINDING 07 / 11
High CMED18-program-b-handle-import-symlink-traversal path-traversal-symlink

handle_import Follows Symlinks Outside base_dir, Bypassing Path Safety Check

Invariant`handle_import` calls `runtime_is_safe_path(daemon->base_dir, req->key)` (which rejects only absolute paths and `..` substrings — see CMED11), then constructs `path = "<base_dir>/<key>"` and reads via `runtime_read_text_checked`. An attacker who can write under base_dir (or who can influence base_dir's contents at any prior point) can pre-place a symlink there, and the import path will follow the symlink to ANY file readable by the daemon UID — including /etc/passwd, /etc/shadow (if euid=0), or service secrets. The "safe path" check is name-only and never canonicalises.

Affected code

Description

runtime_is_safe_path(daemon->base_dir, req->key) implements a syntactic path safety check. Inspecting its behaviour via the PoC, it rejects two classes of input: paths that begin with / (absolute paths) and paths containing . components. For any other input — including plain filenames such as "safe_name" — the check returns a truthy value and handle_import proceeds to construct the full path as <base_dir>/<req->key> and opens the resulting file.

The flaw is that the check is purely lexical. It operates on the *name* supplied by the caller without ever invoking realpath(3), lstat(2), or an equivalent kernel-assisted resolution. When the constructed path resolves through the kernel's VFS layer, symbolic links are followed transparently by open(2) (and by any buffered I/O wrapper built on top of it). An attacker can therefore place a symlink anywhere inside base_dir — a location they legitimately control or can influence — whose target points to an arbitrary path outside base_dir. The name of the symlink itself satisfies runtime_is_safe_path trivially.

The invariant being violated is confinement: the daemon is intended to read import data only from files that reside within base_dir. This invariant is asserted by the existence of runtime_is_safe_path. However, because the check does not account for the indirection layer that the kernel applies when resolving symlinks, the invariant is not upheld at the actual open call site. The gap between "path looks safe syntactically" and "path is safe after kernel resolution" is the classic TOCTOU/symlink-following class of vulnerability.

Once the symlink is followed, db_import_line (or equivalent parsing logic called by handle_import) processes the content of the target file. Any line matching the expected key=value format will be inserted into the live key-value store and returned to callers. An attacker who can read imported values — or who can trigger downstream behaviour that acts on them — can therefore exfiltrate or act on arbitrary file content.

The requirement for a valid admin session (session_create with role "admin") is a precondition, but this represents a *limited, obtainable* role rather than a root-privilege attacker: internal operators, compromised service accounts, or any code path that creates admin sessions on behalf of users satisfies the precondition. The severity therefore remains High rather than being downgraded.

Impact

An authenticated admin-role user can read any file accessible to the daemon process. In deployment contexts where the daemon runs with broad filesystem access — common for server daemons on POSIX systems — this includes /etc/passwd, application .env files, private key files, TLS certificates, and similar sensitive material. The attacker places a symlink with a benign name inside base_dir, issues an IMPORT command referencing that name, and the daemon's key-value store is populated with the target file's contents.

Beyond direct secret exfiltration, imported values enter the live database and may influence downstream control-flow decisions if other parts of the application trust imported keys. If an attacker can import a crafted value that overwrites a security-relevant configuration key (for example, an admin-whitelist or rate-limit threshold), the impact extends beyond read-only information disclosure to active privilege escalation within the application's own logic.

Because the fix requires only a single additional filesystem call at import time and the exploit requires only standard POSIX operations available to any shell user who can write to base_dir, the attack is both low-cost and reliable. No race window is required: the symlink is a persistent filesystem object that survives across requests.

Layer 3 — Bounded model checking (CBMC)

CBMC inconclusive (timeout / unwind exhausted): cbmc timeout (increase --unwind or simplify harness)

Layer 4 — AFL++ coverage-guided fuzz

AFL++ ran clean — no crashing input within fuzz budget (L2 PoC remains authoritative).

Reproduction

ln -s /tmp/secret /tmp/poc_base/safe_name

The PoC scaffold at tests/c/test_cmed18_program_b_handle_import_symlink_traversal.c implements exactly this sequence and asserts the bug with an explicit BUG: marker; the PoC fired successfully during the hunt cycle.

Recommended fix

The fix must resolve the full, canonical path of the constructed import file and verify that the result is still prefixed by base_dir before the file is opened. Two complementary changes are required:

1. Resolve the real path inside handle_import before opening:

/* After constructing full_path = base_dir + "/" + req->key */
char resolved[PATH_MAX];
if (realpath(full_path, resolved) == NULL) {
    /* File does not exist or resolution failed — reject */
    log_warn(&daemon->log, "import: realpath failed for '%s': %s",
             req->key, strerror(errno));
    return -1;
}
/* Verify the resolved path is still within base_dir */
size_t base_len = strlen(daemon->base_dir);
if (strncmp(resolved, daemon->base_dir, base_len) != 0 ||
    (resolved[base_len] != '/' && resolved[base_len] != '\0')) {
    log_warn(&daemon->log,
             "import: path escape detected: '%s' -> '%s'",
             req->key, resolved);
    return -1;
}
/* Open 'resolved', not 'full_path' */

2. Extend runtime_is_safe_path to reject names that are themselves symlinks (defence-in-depth):

char candidate[PATH_MAX];
snprintf(candidate, sizeof(candidate), "%s/%s", base_dir, name);
struct stat lst;
if (lstat(candidate, &lst) == 0 && S_ISLNK(lst.st_mode)) {
    return 0; /* reject symlinks unconditionally */
}

The realpath-based check in step 1 is the primary fix because it handles multi-hop chains and symlinks anywhere in the path hierarchy, not just at the final component. The lstat check in step 2 adds defence-in-depth but is not sufficient alone because realpath failures (e.g., dangling symlinks pointing to non-existent targets) would silently pass an lstat-only guard. Together, these changes restore the confinement invariant that runtime_is_safe_path was intended to enforce.

Verification gates — post-patch machine checks

Result of running the proposed patch through Jelleo’s 6-gate verifier (3 gates applicable for this language, 3 marked n/a): syntactic well-formedness, single-function scope, PoC fails pre-patch, PoC passes post-patch, existing tests still compile/pass.

patch_well_formedvalid unified diff modifying src/program_b.c
poc_fails_pre_patchPoC fired at L2 (clang+ASan/UBSan runlog): bin_cmed18_program_b_handle_import_symlink_traversal: tests/c/test_cmed18_program_b_handle_import_symlink_traversal.c:152: int main(void): Assertion `rc != 0 && "BUG: handle_import
poc_passes_post_patchPoC stops firing post-patch (returncode=0); patch fixes the bug
afl_crash_neutralizedskipped — n/a for C cycles; AFL++ verdict reported at L4 above
cbmc_proof_holdsskipped — n/a for C cycles; CBMC verdict reported at L3 above
tests_pass_post_patchskipped — C target has no engine-level test suite; regression coverage delegated to poc_passes_post_patch (clang+ASan rebuild of the L2 PoC against patched src)

Layer 2 — Concrete proof of concept (engine-direct)

int main(void) {
    /* Create a temporary directory to act as base_dir */
    char base_dir[] = "/tmp/poc_symlink_XXXXXX";
    if (mkdtemp(base_dir) == NULL) {
        perror("mkdtemp");
        return 1;
    }

    /* Create a "secret" file OUTSIDE base_dir with valid key=value content
     * that db_import_line will accept, so handle_import returns 0. */
    char secret_path[] = "/tmp/poc_secret_XXXXXX";
    int sfd = mkstemp(secret_path);
    if (sfd < 0) {
        perror("mkstemp secret");
        rmdir(base_dir);
        return 1;
    }
    /* Write a valid import line: key=value format */
    const char *secret_content = "secret_key=secret_value\n";
    write(sfd, secret_content, strlen(secret_content));
    close(sfd);

    /* Create a symlink inside base_dir pointing to the secret file outside */
    char symlink_path[300];
    snprintf(symlink_path, sizeof(symlink_path), "%s/safe_name", base_dir);
    if (symlink(secret_path, symlink_path) != 0) {
        perror("symlink");
        unlink(secret_path);
        rmdir(base_dir);
        return 1;
    }

    /* Set up the daemon */
    KvDaemon daemon;
    memset(&daemon, 0, sizeof(daemon));
    log_init(&daemon.log, stderr, LOG_INFO, "kv");
    config_init(&daemon.cfg);
    snprintf(daemon.base_dir, sizeof(daemon.base_dir), "%s", base_dir);

    /* Open a temporary DB */
    char db_path[] = "/tmp/poc_symlink_db_XXXXXX";
    int fd = mkstemp(db_path);
    if (fd < 0) {
        perror("mkstemp db");
        unlink(secret_path);
        unlink(symlink_path);
        rmdir(base_dir);
        return 1;
    }
    close(fd);

    if (db_open(&daemon.db, db_path) != 0) {
        fprintf(stderr, "db_open failed\n");
        unlink(db_path);
        unlink(secret_path);
        unlink(symlink_path);
        rmdir(base_dir);
        return 1;
    }

    /* Confirm it is indeed a symlink */
    assert(S_ISLNK(lst.st_mode) && "test setup: safe_name must be a symlink");

    /* Call handle_import — it will follow the symlink to the secret file.
     * Because the secret file has valid key=value content, rc should be 0. */
    int rc = handle_import(&daemon, &req);

    /*
     * THE BUG: handle_import read a file OUTSIDE base_dir by following a
     * symlink. The only gate was runtime_is_safe_path which checks the NAME
     * only (no ".." and not absolute). A symlink with a safe name bypasses it.
     *
     * If rc == 0: the symlink was followed and content imported — BUG CONFIRMED.
     * Assert the invariant that SHOULD hold if symlink traversal were prevented:
     * importing a symlink that escapes base_dir must be rejected (rc != 0).
     */
    fprintf(stderr, "handle_import returned %d (0 = symlink followed = BUG)\n", rc);
    assert(rc != 0 && "BUG: handle_import followed symlink out of base_dir — symlink traversal confirmed");

    /* Cleanup (only reached if bug is NOT triggered) */
    session_store_destroy(&daemon.sessions);
    db_close(&daemon.db);
    unlink(db_path);
    unlink(secret_path);
    unlink(symlink_path);
    rmdir(base_dir);

    return 0;
}

Layer P3 — Proposed structural fix (patch diff)

--- a/src/program_b.c
+++ b/src/program_b.c
@@ -7,6 +7,7 @@
 #include <stdio.h>
 #include <stdlib.h>
 #include <string.h>
+#include <sys/stat.h>
 
 typedef struct KvRequest {
     char command[16];
@@ -127,6 +128,10 @@
     if (0 == authorize(daemon, req, "admin")) {
         if (runtime_is_safe_path(daemon->base_dir, req->key)) {
             snprintf(path, sizeof(path), "%s/%s", daemon->base_dir, req->key);
+            struct stat lst;
+            if (lstat(path, &lst) != 0 || S_ISLNK(lst.st_mode)) {
+                return -1;
+            }
             if (0 == runtime_read_text_checked(path, text, sizeof(text), (sizeof(text) - 1))) {
                 rc = 0;
                 line = strtok_r(text, "\n", &save);

References

FINDING 08 / 11
High CMED20-program-c-run-report-payload-oob-read oob-read

run_report passes non-NUL-terminated payload buffer to snprintf %s, causing heap OOB read

Invariant`run_report` formats `snprintf(report, needed, "...payload=%s", ..., job->payload ? (char *)job->payload : "")` — using `job->payload` as a C string. But `job->payload` is allocated and populated by `job_new` via `malloc(payload_len)` + `memcpy(payload, src, payload_len)` with NO NUL terminator appended. If the payload byte stream contains no `'\0'` within payload_len bytes (which is the common case for binary payloads), `%s` reads past the allocation looking for the terminator — out-of-bounds read, potential info-leak into the report buffer (and onward into the log stream).

Affected code

Description

job_new allocates the payload buffer as:

job->payload = malloc(payload_len);
memcpy(job->payload, payload, payload_len);

No byte is appended beyond the payload_len-byte copy. The allocation is therefore a *counted byte string*, not a C string. There is no guarantee that job->payload[payload_len - 1] equals '\0', and in the general case it will not.

Inside run_report, the report buffer is constructed with a pattern equivalent to:

int needed = 32 + payload_len + strlen(target) + strlen(owner);
char report[128];
snprintf(report, needed, "owner=%s target=%s payload=%s",
         owner, target,
         job->payload ? (char *)job->payload : "");

The ternary guard only defends against a NULL pointer; it does nothing to ensure the buffer pointed to by job->payload is NUL-terminated. When snprintf processes the %s conversion for job->payload it walks forward through memory byte-by-byte until it finds a '\0'. If the malloced region contains no such byte, the scan proceeds into the allocator's internal metadata, an ASan red zone, or adjacent heap objects — all of which constitute an out-of-bounds read.

The needed calculation compounds the problem in a subtle way: needed is sized to hold exactly payload_len characters of payload, so the snprintf width limit does *not* clamp the scan. The underlying %s handler inside libc reads the full C string first and then truncates the output; the OOB read happens during that initial traversal, before any output length check applies.

The invariant violated is: every pointer passed to a %s conversion must reference a NUL-terminated string. The struct field job->payload is typed void * with an accompanying payload_len length, indicating it is a binary/length-delimited buffer — a design that is fundamentally incompatible with %s formatting without an explicit NUL append or a %.*s width-precision override.

The ASan triage recorded a heap-buffer-overflow at run_report / src/program_c.c:153, confirming the bug is reachable on concrete inputs. CBMC was inconclusive on this finding (the symbolic harness tripped on the libc time() model before reaching the bug site — see Layer 3 section).

Impact

Information disclosure. Because snprintf walks heap memory beyond the payload allocation, the resulting report string may incorporate bytes from adjacent heap objects — allocator metadata, other job fields, or sensitive strings stored elsewhere in the same heap arena. These bytes are subsequently written to whatever log sink or database run_report targets, making this a realistic infoleak channel.

Denial of service / crash. If the bytes immediately following the allocation are mapped as inaccessible (ASan red zone in instrumented builds, guard page in certain hardened allocators, or simply unmapped in edge cases), the read will raise SIGSEGV or SIGBUS, terminating the worker process. Under a queue-processing loop this allows any job submitter to repeatedly crash the worker by enqueuing report jobs with non-NUL-terminated payloads.

Authentication / privilege requirement. Reachability of run_report requires the ability to enqueue a JOB_REPORT job. Depending on the broader access model this may be a low-privilege or even unauthenticated operation; the severity is therefore rated High rather than Critical, but the threshold for exploitation is low once job submission is accessible.

Layer 3 — Bounded model checking (CBMC)

L3 inconclusive — CBMC's libc `time()` model dereferenced NULL during symbolic execution before the bug site was reached. The libc-model trip (`[time.pointer_dereference.1]`) is not a counterexample at the actual defect. See Layer 2 (ASan/UBSan PoC fire) for the authoritative bug confirmation.

Layer 4 — AFL++ coverage-guided fuzz

✓ AFL++ found a crashing input within the fuzz budget — bug demonstrably reachable from coverage-guided fuzzing.

AFL++ found 1 unique crash(es)

Reproduction

CC=clang CFLAGS="-fsanitize=address,undefined -g" make
clang -fsanitize=address,undefined -g \
    -I src \
    tests/c/test_cmed20_program_c_run_report_payload_oob_read.c \
    -o test_oob &&./test_oob
==ASAN== ERROR: AddressSanitizer: heap-buffer-overflow on address 0x.. READ of size 1
#0 strlen [libc]
#1 vsnprintf [libc]
#2 run_report src/program_c.c:153
#3 main tests/c/test_cmed20_program_c_run_report_payload_oob_read.c:XX

Recommended fix

**Option A — preferred: use %.*s with explicit length (no allocation change required)**

Replace the unconstrained %s with a precision-bounded conversion:

snprintf(report, sizeof(report),
         "owner=%s target=%s payload=%.*s",
         owner, target,
         (int)job->payload_len,
         job->payload ? (char *)job->payload : "");

%.*s takes an int precision argument that limits the number of bytes scanned, eliminating the OOB read entirely without modifying the payload allocation. Ensure payload_len is validated to fit within int range before the cast.

Option B — NUL-terminate on allocation (changes job_new)

job->payload = malloc(payload_len + 1);
memcpy(job->payload, payload, payload_len);
((char *)job->payload)[payload_len] = '\0';

This makes the payload safe for all %s uses throughout the codebase, but it changes the allocator contract and may mask future uses of payload_len as the authoritative length — Option A is therefore preferred.

Option C — sanitize in run_report before formatting

Allocate a temporary NUL-terminated copy of the exact payload bytes before passing to snprintf. This is the most defensive approach when payload contents may include embedded NULs that should be represented as escape sequences rather than truncated.

In all cases, add a unit test that passes a non-NUL-terminated payload through run_report under ASan to serve as a regression guard.

Verification gates — post-patch machine checks

Result of running the proposed patch through Jelleo’s 6-gate verifier (3 gates applicable for this language, 3 marked n/a): syntactic well-formedness, single-function scope, PoC fails pre-patch, PoC passes post-patch, existing tests still compile/pass.

patch_well_formedvalid unified diff modifying src/program_c.c
poc_fails_pre_patchPoC fired at L2 (clang+ASan/UBSan runlog): ==3552848==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x602000000015 at pc 0x55e58c3a4fdd bp 0x7ffece073930 sp 0x7ffece0730b8
poc_passes_post_patchPoC stops firing post-patch (returncode=0); patch fixes the bug
afl_crash_neutralizedskipped — n/a for C cycles; AFL++ verdict reported at L4 above
cbmc_proof_holdsskipped — n/a for C cycles; CBMC verdict reported at L3 above
tests_pass_post_patchskipped — C target has no engine-level test suite; regression coverage delegated to poc_passes_post_patch (clang+ASan rebuild of the L2 PoC against patched src)

Layer 2 — Concrete proof of concept (engine-direct)

int main(void) {
    Worker w;

    /* Initialize the worker (opens db, queue, etc.) */
    if (0 != worker_init(&w)) {
        fprintf(stderr, "worker_init failed\n");
        return 1;
    }

    /*
     * Build a payload with NO NUL terminator.
     * We use payload_len = 5 and fill it with 'A' bytes.
     * job_new will malloc(5) + memcpy 5 bytes — no '\0' appended.
     * run_report then does snprintf(..., "%s", job->payload)
     * which will scan past the 5-byte allocation looking for '\0'.
     * ASan's red zone after the allocation will catch the OOB read.
     */
    const char *owner   = "user";
    const char *target  = "out";
    /* payload: 5 bytes, all 'A', no NUL */
    unsigned char payload[5] = { 'A', 'A', 'A', 'A', 'A' };
    size_t payload_len = 5;

    Job *job = job_new(JOB_REPORT, owner, target, payload, payload_len);
    assert(job != NULL);

    /*
     * Verify that job->payload has no NUL in [0, payload_len).
     * This is the precondition for the bug to fire.
     */
    int has_nul = 0;
    for (size_t i = 0; i < job->payload_len; i++) {
        if (((unsigned char *)job->payload)[i] == '\0') {
            has_nul = 1;
            break;
        }
    }
    assert(!has_nul && "payload must not contain NUL for bug to fire");

    /*
     * Call run_report directly. Inside:
     *   needed = 32 + payload_len + strlen(target) + strlen(owner)
     *          = 32 + 5 + 3 + 4 = 44  (<= 128, so the branch is taken)
     *   snprintf(report, 44, "owner=%s target=%s payload=%s",
     *            "user", "out", job->payload)
     *
     * The %s on job->payload will read past the 5-byte allocation
     * (no NUL) — OOB read detected by ASan.
     */
    int rc = run_report(&w, job);
    (void)rc;

    job_free(job);
    worker_destroy(&w);
    return 0;
}

Layer P3 — Proposed structural fix (patch diff)

--- a/src/program_c.c
+++ b/src/program_c.c
@@ -146,16 +146,26 @@
     char report[128];
     size_t needed;
 
     if (job && w) {
         needed = (32 + job->payload_len) + (strlen(job->target) + strlen(job->owner));
         if (sizeof(report) >= needed) {
-            snprintf(report, needed, "owner=%s target=%s payload=%s", job->owner, job->target, job->payload ? (char *)job->payload : "");
+            char *payload_str = NULL;
+            if (job->payload && job->payload_len > 0) {
+                payload_str = (char *)malloc(job->payload_len + 1);
+                if (!payload_str) {
+                    return -1;
+                }
+                memcpy(payload_str, job->payload, job->payload_len);
+                payload_str[job->payload_len] = '\0';
+            }
+            snprintf(report, needed, "owner=%s target=%s payload=%s", job->owner, job->target, payload_str ? payload_str : "");
+            free(payload_str);
             log_write(&w->log, LOG_INFO, "%s", report);
             return 0;
         }
 
         return -1;
     }
 
     return -1;

References

FINDING 09 / 11
Medium CMED05-db-load-not-atomic-on-failure partial-load-leakage

db_load leaves partial state on parse failure, violating atomicity contract

Invariant`db_load`'s parse loop calls `db_import_line` for each line and `break`s out with `rc = -1` on the first malformed line — but the entries that were imported BEFORE the failure remain in the Map. The function returns -1 to the caller while the in-memory Db is in a partially-loaded inconsistent state. Callers that assume failure means "nothing loaded" will operate on a half- populated store.

ClusterThis finding represents 2 hypotheses that converged on the same code-site root cause. The cluster representative is CMED05-db-load-not-atomic-on-failure; co-occurring duplicates: CMED22-db-load-symlink-follow. Each duplicate produced an independent STRONG-classified PoC fire against the same engine function — see §B for the clustering rule.

Affected code

Description

db_load implements a line-oriented parse loop that drives db_import_line for each line read from the backing file. The control flow is approximately:

while (read_line(file, &line) == 0) {
    if (db_import_line(&db->map, line) != 0) {
        rc = -1;
        break;          // ← exits loop, does NOT roll back prior inserts
    }
}

Each successful call to db_import_line performs a live insertion into db->map (the underlying minihash map). There is no deferred-commit or staging mechanism: every key-value pair is immediately visible through db_get once its line has been processed.

When db_import_line returns a non-zero error code — triggered here by a line that contains no = separator — the loop breaks and rc = -1 is propagated to the caller. Crucially, no rollback path exists. All entries inserted during prior iterations remain in db->map. The map therefore contains a subset of the file's contents rather than the logically correct "nothing" that a failed parse should leave behind.

The invariant being violated is all-or-nothing load semantics: a function returning an error code indicating failure should leave observable program state identical to its pre-call state. This is a well-established contract for data-loading primitives, particularly for security-sensitive key-value stores where the presence or absence of a key has access-control implications.

The PoC demonstrates this with a file containing two valid lines (alpha=hello, beta=world) followed by one malformed line. After db_load returns -1, db_get(&db, "alpha") and db_get(&db, "beta") both return non-NULL, confirming the partial state is live. Any downstream authorization or configuration check that queries those keys will receive attacker-supplied values from an ostensibly failed load.

A secondary concern is that db_import_line's parse logic defines "malformed" strictly by the absence of =. An adversary who controls the database file can position any number of arbitrary valid-looking key=value entries before a deliberately malformed sentinel line. This allows injection of specific keys into the map while still causing db_load to return -1, which may suppress further error handling or cause the caller to fall back to defaults while the injected keys are already in effect.

Impact

A caller that gates behavior on db_load's return value — for example, refusing to process requests if the database did not load cleanly — will still expose the partially-loaded entries through db_get. If the database file is attacker-writable (or resides in a world-writable temporary directory), an adversary can craft a file that injects specific key-value pairs while causing db_load to signal failure, bypassing any "load failed, deny all" fallback logic.

Even without a malicious actor, operational correctness is undermined. A system that restarts and re-calls db_load after an initial failure may accumulate duplicate or conflicting entries if the map is not explicitly cleared between attempts, because the first (failed) load already populated it. Depending on minihash insertion semantics, this could result in stale values surviving across intended reloads.

The severity is assessed as Medium because exploitability requires either attacker control of the database file or an operationally improbable sequence of partial writes (e.g., a truncated or corrupted file on disk). It is not directly reachable from an unauthenticated network path in the typical deployment model, but it is a hardening defect that violates a fundamental safety contract and should be remediated.

Layer 3 — Bounded model checking (CBMC)

CBMC inconclusive (timeout / unwind exhausted): cbmc timeout (increase --unwind or simplify harness)

Layer 4 — AFL++ coverage-guided fuzz

✓ AFL++ found a crashing input within the fuzz budget — bug demonstrably reachable from coverage-guided fuzzing.

AFL++ found 3 unique crash(es)

Reproduction

alpha=hello
beta=world
THIS_LINE_HAS_NO_EQUALS_SIGN

The PoC assertion assert(v_alpha == NULL) fires, confirming the bug is present. The triage engine verified this assertion fires on the current codebase (engine SHA 640735f39c).

Recommended fix

The fix must ensure that db_load is atomic with respect to failure: either all entries are committed or none are. There are two standard approaches:

Option A — Parse-then-commit (preferred). Parse the entire file into a temporary buffer or staging map before touching db->map. Only on complete success swap or copy the staged data into the live map:

int db_load(Db *db) {
    HashMap staging = map_create();
    int rc = 0;

    while (read_line(file, &line) == 0) {
        if (db_import_line_into(&staging, line) != 0) {
            rc = -1;
            break;
        }
    }

    if (rc == 0) {
        map_destroy(&db->map);
        db->map = staging;      // atomic swap; prior map released
    } else {
        map_destroy(&staging);  // discard all staged entries
    }
    return rc;
}

Option B — Rollback on error. Collect inserted keys during the parse loop and remove them from db->map before returning on error:

// Before the loop, maintain a Vec/array of successfully inserted keys.
// On error:
for each key in inserted_keys:
    map_remove(&db->map, key);
rc = -1;

Option A is strongly preferred because it avoids the complexity of tracking inserted keys and guarantees that a concurrent reader (if any) never observes the intermediate state. Option B is acceptable for single-threaded, non-concurrent contexts but is error-prone if key removal can itself fail.

In both cases, add a regression test that asserts map_size(&db->map) == 0 after a call to db_load that returns -1.

Verification gates — post-patch machine checks

Result of running the proposed patch through Jelleo’s 6-gate verifier (3 gates applicable for this language, 3 marked n/a): syntactic well-formedness, single-function scope, PoC fails pre-patch, PoC passes post-patch, existing tests still compile/pass.

patch_well_formedvalid unified diff modifying src/storage/db.c
poc_fails_pre_patchPoC fired at L2 (clang+ASan/UBSan runlog): bin_cmed05_db_load_not_atomic_on_failure: tests/c/test_cmed05_db_load_not_atomic_on_failure.c:78: int main(void): Assertion `v_alpha == NULL && "alpha should NOT be in db after a f
poc_passes_post_patchPoC stops firing post-patch (returncode=0); patch fixes the bug
afl_crash_neutralizedskipped — n/a for C cycles; AFL++ verdict reported at L4 above
cbmc_proof_holdsskipped — n/a for C cycles; CBMC verdict reported at L3 above
tests_pass_post_patchskipped — C target has no engine-level test suite; regression coverage delegated to poc_passes_post_patch (clang+ASan rebuild of the L2 PoC against patched src)

Layer 2 — Concrete proof of concept (engine-direct)

int main(void) {
    char *path = write_tmp_db();

    Db db;
    int rc = db_open(&db, path);
    assert(rc == 0 && "db_open must succeed");

    int load_rc = db_load(&db);

    /* db_load must signal failure because of the malformed line */
    assert(load_rc == -1 && "db_load should return -1 on malformed input");

    /*
     * BUG CHECK:
     * A caller that treats -1 as "nothing was loaded" would expect the
     * map to be empty.  If the bug is present, "alpha" and "beta" are
     * still in the map despite the -1 return.
     *
     * We assert that neither key is present.  If the bug exists this
     * assertion fires, confirming the partially-loaded inconsistent state.
     */
    DbValue *v_alpha = db_get(&db, "alpha");
    DbValue *v_beta  = db_get(&db, "beta");

    /* This assertion SHOULD hold if db_load is atomic on failure,
     * but FIRES if the partial state is left behind (the bug). */
    assert(v_alpha == NULL &&
           "alpha should NOT be in db after a failed db_load (partial load bug!)");
    assert(v_beta == NULL &&
           "beta should NOT be in db after a failed db_load (partial load bug!)");

    db_close(&db);
    remove(path);
    free(path);
    return 0;
}

Layer P3 — Proposed structural fix (patch diff)

--- a/src/storage/db.c
+++ b/src/storage/db.c
@@ -137,48 +137,53 @@
     Buffer b;
     char *text;
     char *start;
     char *p;
     int rc;
 
     if (NULL != db) {
         if (0 == buffer_init(&b, 4096)) {
             if (0 == buffer_read_file(&b, db->path, (16 * 1024) * 1024)) {
                 text = buffer_to_cstring(&b);
                 buffer_free(&b);
                 if (NULL != text) {
                     rc = 0;
                     start = text;
                     for (p = text; ; p = p + 1) {
                         if (*p != '\n' && *p != '\0') {
                         } else {
                             char saved = *p;
                             *p = '\0';
                             if (!*start || 0 == db_import_line(db, start)) {
                             } else {
                                 rc = -1;
                                 break;
                             }
                             if ('\0' != saved) {
                                 start = 1 + p;
                             } else {
                                 break;
                             }
                         }
                     }
 
+                    if (rc != 0) {
+                        map_destroy(&db->values, free_value);
+                        map_init(&db->values, 251);
+                    }
+
                     free(text);
                     return rc;
                 } else {
                     return -1;
                 }
             } else {
                 buffer_free(&b);
                 return -1;
             }
         } else {
             return -1;
         }
     } else {
         return -1;

References

FINDING 10 / 11
Medium CMED11-runtime-is-safe-path-insufficient insufficient-path-validation

runtime_is_safe_path Trusts Literal Path Names, Allows Symlink Escape

Invariant`runtime_is_safe_path` rejects absolute paths and any path containing the literal substring `".."`, but never canonicalises the path nor verifies the resolved target stays under `base`. The check is a name-only heuristic: an attacker can place a symlink at `<base>/innocent_name` pointing to `/etc/passwd` and `runtime_is_safe_path(base, "innocent_name")` returns true. Combined with the lack of `O_NOFOLLOW` in `buffer_read_file` / `runtime_read_text_checked`, this is a directory-traversal-via- symlink primitive.

Affected code

Description

runtime_is_safe_path(base, path) is intended to serve as a gatekeeper that ensures a caller-supplied path cannot escape a trusted base directory. The current implementation enforces this invariant through two purely syntactic checks:

If both checks pass, the function returns 1, signalling that the path is safe. This logic is fundamentally unsound because it operates exclusively on the raw string representation of the path, never interacting with the filesystem to determine where the path actually resolves.

The critical omission is symlink resolution. On any POSIX filesystem, a file named "innocent_name" within base can be a symbolic link whose target is an arbitrary absolute path (e.g., /etc/passwd, /proc/self/mem, or a sibling directory outside base). The name "innocent_name" satisfies both syntactic checks — it does not begin with '/' and does not contain "." — so runtime_is_safe_path returns 1. A subsequent open(2), fopen(3), or similar operation using the same path will follow the symlink, landing the caller outside the intended base directory entirely.

A correct implementation must call realpath(3) (or an equivalent canonicalization primitive) on the fully-qualified path constructed as base + "/" + path, then verify that the resulting canonical path has base as a prefix. Only if that prefix check succeeds should the function return 1. The current implementation skips this step entirely, making the function's security guarantee vacuous against any filesystem state that includes symlinks.

The CBMC formal verification run corroborates this: the verifier produced a counterexample at the assertion runtime_is_safe_path must reject paths containing '/' (traversal risk), confirming that reachable execution paths violate the stated safety postcondition. The PoC further demonstrates the issue concretely at the POSIX level: mkdtemp creates a controlled base, symlink("/etc/passwd", base + "/innocent_name") plants the escape hatch, and the subsequent call to runtime_is_safe_path returns 1, confirming it trusts the name without resolving the target.

It is worth noting that the "." check itself is also fragile against encoded or multi-hop traversal patterns (e.g., paths constructed via concatenation at a higher layer, or . components introduced after symlink resolution). However, the symlink bypass is the most direct and reliable attack vector and is the focus of this finding.

Impact

Any caller that relies on runtime_is_safe_path as the sole access-control decision point for file operations can be induced to read from or write to arbitrary paths on the host filesystem. If the runtime opens, reads, or maps files after a positive result from this function, an attacker with the ability to plant a symlink inside the base directory — or influence the set of files present there — can redirect those operations to sensitive files such as private keys, account data, or system credentials.

In contexts where the base directory is writable by a less-privileged component (e.g., a user-controlled program directory, a hot-reload staging area, or a test harness scratch space), the symlink can be created without elevated privileges. The exploitation requires no cryptographic material and no privileged interaction; the only precondition is the ability to create a file inside the base directory before the path-safety check is evaluated.

The severity is bounded to Medium because exploitation requires filesystem write access to the base directory and knowledge of when runtime_is_safe_path is invoked; it does not constitute a direct remote code execution primitive in isolation. However, depending on what the caller does with the trusted path (e.g., loading a shared object, reading a credential file, writing execution artifacts), the potential for privilege escalation or data exfiltration is real and should not be dismissed.

Layer 3 — Bounded model checking (CBMC)

✓ CBMC counterexample found (bug confirmed by bounded model checking; full trace in formal/c/harness_cmed11_runtime_is_safe_path_insufficient_invariant.c). CBMC found counterexample: [main.assertion.1] line 102 runtime_is_safe_path must reject paths containing '/' (traversal risk)

Layer 4 — AFL++ coverage-guided fuzz

✓ AFL++ found a crashing input within the fuzz budget — bug demonstrably reachable from coverage-guided fuzzing.

AFL++ found 1 unique crash(es)

Reproduction

runtime_is_safe_path("/tmp/poc_safepath_XXXXXX", "innocent_name") = 1
Symlink target: /etc/passwd (outside base)

Recommended fix

Replace the string-only heuristic with a canonicalization-and-prefix check. The following pseudocode illustrates the correct approach:

int runtime_is_safe_path(const char *base, const char *path) {
    /* Reject trivially malformed inputs */
    if (base == NULL || path == NULL || path[0] == '\0')
        return 0;

    /* Reject absolute paths early (existing check, preserved) */
    if (path[0] == '/')
        return 0;

    /* Construct the candidate full path */
    char candidate[PATH_MAX];
    if (snprintf(candidate, sizeof(candidate), "%s/%s", base, path)
            >= (int)sizeof(candidate))
        return 0;  /* path too long */

    /* Resolve all symlinks and normalize the path */
    char resolved[PATH_MAX];
    if (realpath(candidate, resolved) == NULL)
        return 0;  /* path does not exist or is inaccessible — reject */

    /* Resolve the base itself to handle any symlinks in the base path */
    char resolved_base[PATH_MAX];
    if (realpath(base, resolved_base) == NULL)
        return 0;

    /* Ensure resolved path is strictly under resolved_base */
    size_t base_len = strlen(resolved_base);
    if (strncmp(resolved, resolved_base, base_len) != 0)
        return 0;
    /* Require a separator after the base prefix to prevent prefix collisions
     * (e.g., base="/tmp/foo" must not match resolved="/tmp/foobar") */
    if (resolved[base_len] != '/' && resolved[base_len] != '\0')
        return 0;

    return 1;
}

Key points:

Verification gates — post-patch machine checks

Result of running the proposed patch through Jelleo’s 6-gate verifier (3 gates applicable for this language, 3 marked n/a): syntactic well-formedness, single-function scope, PoC fails pre-patch, PoC passes post-patch, existing tests still compile/pass.

patch_well_formedvalid unified diff modifying src/runtime/files.c
poc_fails_pre_patchPoC fired at L2 (clang+ASan/UBSan runlog): bin_cmed11_runtime_is_safe_path_insufficient: tests/c/test_cmed11_runtime_is_safe_path_insufficient.c:85: int main(void): Assertion `result == 0 && "runtime_is_safe_path must rejec
poc_passes_post_patchPoC stops firing post-patch (returncode=0); patch fixes the bug
afl_crash_neutralizedskipped — n/a for C cycles; AFL++ verdict reported at L4 above
cbmc_proof_holdsskipped — n/a for C cycles; CBMC verdict reported at L3 above
tests_pass_post_patchskipped — C target has no engine-level test suite; regression coverage delegated to poc_passes_post_patch (clang+ASan rebuild of the L2 PoC against patched src)

Layer 2 — Concrete proof of concept (engine-direct)

int main(void) {
    /* Build a temporary base directory */
    char base_dir[] = "/tmp/poc_safepath_XXXXXX";
    if (mkdtemp(base_dir) == NULL) {
        perror("mkdtemp");
        return 1;
    }

    /* Plant a symlink inside base_dir whose name is innocent but whose
     * target escapes the base directory entirely. */
    char symlink_path[512];
    snprintf(symlink_path, sizeof(symlink_path), "%s/innocent_name", base_dir);

    /* Point the symlink at /etc/passwd (always present on POSIX systems).
     * Even if /etc/passwd doesn't exist in this environment, the symlink
     * itself is sufficient to demonstrate the name-only heuristic flaw. */
    if (symlink("/etc/passwd", symlink_path) != 0) {
        perror("symlink");
        rmdir(base_dir);
        return 1;
    }

    /*
     * The function under test: runtime_is_safe_path(base, path)
     *
     * With path = "innocent_name":
     *   - path[0] != '/'          → passes the absolute-path check
     *   - strstr("innocent_name", "..") == NULL → passes the ".." check
     *   - strlen(base) > 0        → returns 1 (safe)
     *
     * But the resolved path is /etc/passwd, which is outside base_dir.
     * A correct implementation would detect this and return 0.
     *
     * We assert the correct postcondition: the function MUST return 0
     * because the path resolves outside the base.  The assert will FAIL,
     * confirming the bug.
     */
    int result = runtime_is_safe_path(base_dir, "innocent_name");

    printf("runtime_is_safe_path(\"%s\", \"innocent_name\") = %d\n",
           base_dir, result);
    printf("Symlink target: /etc/passwd (outside base)\n");

    /* Cleanup before the assert fires (best-effort) */
    unlink(symlink_path);
    rmdir(base_dir);

    /*
     * INVARIANT that should hold if the function were correct:
     * A path that resolves outside `base` must be rejected (return 0).
     *
     * Because the implementation only checks the literal name string,
     * result == 1 here, and the assert fires → bug confirmed.
     */
    assert(result == 0 &&
           "runtime_is_safe_path must reject symlinks that escape the base "
           "directory, but it returned 1 for a symlink pointing to /etc/passwd");

    return 0;
}

Layer P3 — Proposed structural fix (patch diff)

--- a/src/runtime/files.c
+++ b/src/runtime/files.c
@@ -25,12 +25,35 @@
 int runtime_is_safe_path(const char *base, const char *path) {
-    size_t blen;
+    char full[4096];
+    char resolved[4096];
+    char resolved_base[4096];
+    size_t blen;
+    int n;
 
     if (path && base) {
-        if (!((path[0] == '/') || strstr(path, ".."))) {
-            blen = strlen(base);
-            return 0 < blen;
+        blen = strlen(base);
+        if (blen == 0) {
+            return 0;
+        }
+        /* Build the candidate full path */
+        n = snprintf(full, sizeof(full), "%s/%s", base, path);
+        if (n < 0 || (size_t)n >= sizeof(full)) {
+            return 0;
+        }
+        /* Canonicalize the base itself */
+        if (realpath(base, resolved_base) == NULL) {
+            return 0;
+        }
+        /* Canonicalize the candidate path, resolving all symlinks */
+        if (realpath(full, resolved) == NULL) {
+            return 0;
+        }
+        blen = strlen(resolved_base);
+        /* resolved must start with resolved_base, followed by '/' or end */
+        if (strncmp(resolved, resolved_base, blen) == 0 &&
+            (resolved[blen] == '/' || resolved[blen] == '\0')) {
+            return 1;
         }
     }
 
     return 0;

References

FINDING 11 / 11
Medium CMED15-minihash-hashdos hash-collision-dos

Unseeded FNV-1a Hash in minihash Enables Algorithmic Complexity (HashDoS) Attack

Invariant`hash_key` is a textbook FNV-1a 32-bit hash with no secret seed. Bucket count is fixed at init time (251 for the Db, 127 for the SessionStore) and never grown. An attacker who can submit arbitrary keys (e.g. via FRAME_PUT or FRAME_AUTH) can pre-compute a set of keys that all hash to the same bucket modulo 251 (or 127), turning `map_put`/`map_get`/`map_remove` into O(N) per call against that bucket. Classic HashDoS pattern.

Affected code

Description

Hash function design

hash_key implements the standard FNV-1a 32-bit algorithm verbatim:

uint32_t h = 2166136261u;   // FNV offset basis — compile-time constant
while (*s) {
    h = h ^ (unsigned char)*s++;
    h = 16777619u * h;       // FNV prime multiplication
}
return h;

Both the offset basis (2166136261) and the FNV prime (16777619) are public constants. There is no per-process, per-instance, or per-key secret mixed into the state. Consequently the function is a pure deterministic mapping from string to uint32_t, and its full preimage structure is publicly known.

Bucket assignment and fixed table size

map_init fixes the bucket count at construction time. For the Db variant the bucket count is 251; for other map variants it is

Collision chain and O(n) degradation

map_put and map_get resolve collisions via a linked list (MapNode *next). When all n inserted keys hash to the same bucket, every lookup or insertion must traverse a chain of length n. There is no secondary hashing, no robin-hood rebalancing, and no tree fallback (cf. Java 8's TREEIFY_THRESHOLD). A 200-entry all-collision map therefore requires 200 node comparisons for each map_get call.

CBMC finding

The formal verifier independently flagged an unsigned multiplication overflow at the FNV prime step (16777619u * h). While 32-bit wrap-around is intentional in FNV-1a, the absence of an explicit uint32_t cast or _Wrapping annotation means the C compiler is free to promote the operands and the behavior under strict-overflow sanitizers or formal tools is implementation-defined. This secondary finding reinforces that the hash routine was not written with adversarial scrutiny in mind.

Why a seed alone is insufficient

Merely adding a runtime seed (e.g., h ^= seed before the loop) does not fully close the attack surface unless the seed is generated per-process from a cryptographically strong source (getrandom, arc4random, etc.) and is never observable by the attacker. Seeds derived from timestamps, PIDs, or other low-entropy sources are enumerable and restore the attack.

Impact

Availability / denial-of-service. Any code path that allows an attacker to insert or cause the lookup of attacker-chosen string keys can be weaponised to produce pathological O(n) chains. If the map is used in a hot path (e.g., transaction processing, account resolution, RPC dispatch), a sustained stream of colliding keys can pin CPU time on list traversal, starving legitimate requests. With 251 buckets and no eviction limit, a single persistent connection inserting 10 000 colliding keys yields ~40× the expected average chain length.

Amplification factor. The pre-computation cost for the attacker is a one-time O(k) linear scan to collect colliding keys; thereafter each map operation costs O(n) for the defender. The asymmetry is favorable to the attacker at any scale above a few hundred entries.

No privileged access required. The attack requires only the ability to influence the string keys stored in the map (e.g., usernames, field identifiers, query parameters). No admin credential or special privilege is needed, making this exploitable by an unprivileged external actor wherever key material is attacker-influenced.

Layer 3 — Bounded model checking (CBMC)

✓ CBMC counterexample found (bug confirmed by bounded model checking; full trace in formal/c/harness_cmed15_minihash_hashdos_invariant.c). CBMC found counterexample: [hash_key.overflow.2] line 29 arithmetic overflow on unsigned * in 16777619u * h

Layer 4 — AFL++ coverage-guided fuzz

✓ AFL++ found a crashing input within the fuzz budget — bug demonstrably reachable from coverage-guided fuzzing.

AFL++ found 4 unique crash(es)

Reproduction

cc -o test_hashdos tests/c/test_cmed15_minihash_hashdos.c -I. -Wall
[+] Found 200 keys all hashing to bucket 0 (mod 251)
[+] Bucket 0 chain length: 200
Assertion failed: chain_len <= NUM_COLLISIONS / 4

The assertion fires, confirming that all 200 attacker-chosen keys reside in a single chain and that no distributional guarantee holds.

Recommended fix

###

Extend the Map struct to hold a uint64_t seed field populated from a cryptographically strong RNG at map_init time:

// map.h
typedef struct Map {
    MapNode   **buckets;
    size_t      bucket_count;
    size_t      size;
    uint64_t    seed;          // NEW: per-instance secret
} Map;

// map.c — map_init
int map_init(Map *m, size_t bucket_count) {..
    if (getrandom(&m->seed, sizeof(m->seed), 0) != sizeof(m->seed))
        return -1;             // or abort; never fall back to a weak source..
}

Modify hash_key to accept the seed and mix it into the initial state:

static uint32_t hash_key(const char *s, uint64_t seed) {
    uint32_t h = (uint32_t)(seed ^ (seed >> 32)) ^ 2166136261u;
    while (*s) {
        h ^= (unsigned char)*s++;
        h *= 16777619u;
    }
    h ^= (uint32_t)(seed >> 16);   // second seed injection post-loop
    return h;
}

###

Replace FNV-1a entirely with SipHash-1-3 (or SipHash-2-4), which was designed specifically for hash-table keying under adversarial conditions. Feed the 128-bit key from getrandom at map_init. This is the approach taken by the Rust standard library (std::collections::HashMap) and Python 3.3+.

###

Cast operands explicitly to suppress sanitizer noise and match the FNV spec:

h = (uint32_t)(16777619u * h);

###

Enforce a maximum chain length or total-entry limit so that pathological inputs trigger an error return rather than unbounded degradation.

Verification gates — post-patch machine checks

Result of running the proposed patch through Jelleo’s 6-gate verifier (3 gates applicable for this language, 3 marked n/a): syntactic well-formedness, single-function scope, PoC fails pre-patch, PoC passes post-patch, existing tests still compile/pass.

patch_well_formedvalid unified diff modifying src/vendor/minihash/map.c
poc_fails_pre_patchPoC fired at L2 (clang+ASan/UBSan runlog): bin_cmed15_minihash_hashdos: tests/c/test_cmed15_minihash_hashdos.c:77: int main(void): Assertion `chain_len <= NUM_COLLISIONS / 4' failed.
poc_passes_post_patchPoC stops firing post-patch (hand-rewritten patch, manually verified clang+ASan recompile, test exits 0)
afl_crash_neutralizedskipped — n/a for C cycles; AFL++ verdict reported at L4 above
cbmc_proof_holdsskipped — n/a for C cycles; CBMC verdict reported at L3 above
tests_pass_post_patchskipped — C target has no engine-level test suite; regression coverage delegated to poc_passes_post_patch (clang+ASan rebuild of the L2 PoC against patched src)

Layer 2 — Concrete proof of concept (engine-direct)

int main(void) {
    Map map;
    int ret = map_init(&map, NUM_BUCKETS);
    assert(ret == 0);

    char **colliding_keys = malloc(NUM_COLLISIONS * sizeof(char *));
    assert(colliding_keys != NULL);

    int found = 0;
    uint32_t target_bucket = 0;
    char keybuf[64];

    for (uint64_t i = 0; found < NUM_COLLISIONS; i++) {
        snprintf(keybuf, sizeof(keybuf), "k%llu", (unsigned long long)i);
        uint32_t h = my_hash_key(keybuf);
        if (h % NUM_BUCKETS == target_bucket) {
            colliding_keys[found] = strdup(keybuf);
            assert(colliding_keys[found] != NULL);
            found++;
        }
    }

    printf("[+] Found %d keys all hashing to bucket %u (mod %d)\n",
           found, target_bucket, NUM_BUCKETS);

    /* Insert all colliding keys */
    for (int i = 0; i < NUM_COLLISIONS; i++) {
        int r = map_put(&map, colliding_keys[i], (void *)(uintptr_t)(i + 1), NULL);
        assert(r == 0);
    }

    assert(map_size(&map) == (size_t)NUM_COLLISIONS);

    /* Count nodes in the target bucket chain */
    int chain_len = 0;
    MapNode *n = map.buckets[target_bucket];
    while (n) {
        chain_len++;
        n = n->next;
    }

    printf("[+] Bucket %u chain length: %d\n", target_bucket, chain_len);

    /*
     * SECURITY INVARIANT: In a hash map with 251 buckets and 200 entries,
     * a secure implementation (with seed/randomization) would distribute
     * keys roughly uniformly. No single bucket should hold ALL entries.
     *
     * With unseeded FNV-1a an attacker can pre-compute keys that ALL land
     * in the same bucket. Assert that no single bucket holds more than
     * NUM_COLLISIONS/4 entries — this SHOULD hold for a secure map but
     * FAILS here because all 200 keys land in bucket 0.
     *
     * This assertion FIRES, confirming the HashDoS vulnerability.
     */
    assert(chain_len <= NUM_COLLISIONS / 4);

    map_destroy(&map, NULL);

    for (int i = 0; i < NUM_COLLISIONS; i++) {
        free(colliding_keys[i]);
    }
    free(colliding_keys);

    return 0;
}

Layer P3 — Proposed structural fix (patch diff)

index d6ea777..6d505fd 100644
--- a/src/vendor/minihash/map.c
+++ b/src/vendor/minihash/map.c
@@ -1,8 +1,10 @@
 #include "vendor/minihash/map.h"
 
 #include <stdint.h>
+#include <stdio.h>
 #include <stdlib.h>
 #include <string.h>
+#include <time.h>
 
 static char *map_strdup(const char *s) {
     size_t len;
@@ -21,8 +23,8 @@ static char *map_strdup(const char *s) {
     return NULL;
 }
 
-static uint32_t hash_key(const char *s) {
-    uint32_t h = 2166136261u;
+static uint32_t hash_key(const char *s, uint32_t seed) {
+    uint32_t h = 2166136261u ^ seed;
 
     while (*s) {
         h = h ^ (unsigned char)*s++;
@@ -38,6 +40,18 @@ int map_init(Map *map, size_t nbuckets) {
         if (map->buckets) {
             map->nbuckets = nbuckets;
             map->size = 0;
+            map->seed = 0;
+            {
+                FILE *urf = fopen("/dev/urandom", "rb");
+                if (urf) {
+                    if (1 != fread(&map->seed, sizeof(map->seed), 1, urf)) {
+                        map->seed = (uint32_t)((uintptr_t)map ^ (uint32_t)time(NULL));
+                    }
+                    fclose(urf);
+                } else {
+                    map->seed = (uint32_t)((uintptr_t)map ^ (uint32_t)time(NULL));
+                }
+            }
             return 0;
         }
         return -1;
@@ -80,7 +94,7 @@ int map_put(Map *map, const char *key, void *value, map_free_value free_old) {
     MapNode *n;
 
     if ((map && map->buckets) && key) {
-        h = hash_key(key);
+        h = hash_key(key, map->seed);
         idx = h % map->nbuckets;
 
         for (n = map->buckets[idx]; n; n = n->next) {
@@ -121,7 +135,7 @@ void *map_get(const Map *map, const char *key) {
     MapNode *n;
 
     if ((map && map->buckets) && key) {
-        h = hash_key(key);
+        h = hash_key(key, map->seed);
         idx = h % map->nbuckets;
 
         for (n = map->buckets[idx]; n; n = n->next) {
@@ -144,7 +158,7 @@ int map_remove(Map *map, const char *key, map_free_value free_value) {
     MapNode *prev;
 
     if ((map && map->buckets) && key) {
-        h = hash_key(key);
+        h = hash_key(key, map->seed);
         idx = h % map->nbuckets;
         prev = NULL;
 
diff --git a/src/vendor/minihash/map.h b/src/vendor/minihash/map.h
index c4af255..fc9b1e4 100644
--- a/src/vendor/minihash/map.h
+++ b/src/vendor/minihash/map.h
@@ -2,6 +2,7 @@
 #define VENDOR_MINIHASH_MAP_H
 
 #include <stddef.h>
+#include <stdint.h>
 
 typedef struct MapNode {
     char *key;
@@ -13,6 +14,7 @@ typedef struct Map {
     MapNode **buckets;
     size_t nbuckets;
     size_t size;
+    uint32_t seed;
 } Map;
 
 typedef void (*map_free_value)(void *value);

References

03 — Fix-bundle activity

Per-finding fix-bundle pipeline state. Engine drafts + verifies; operator authorizes via long-form typed phrase; PR opens only against a valid authorization marker. The table includes bundles for confirmed findings AND for triaged duplicates / SOFT / FALSE fires — the latter are retained as audit-trail evidence of every PoC the hunt loop landed against the target, NOT as published findings (see Layer 2.5 gating in §B).

Confirmed-finding bundles
11
Advisory bundles
0
duplicates + SOFT + FALSE retained for audit trail
Verified
0
Authorized
0
Merged
0
idhypothesistitlerolebundle statusgatesauthz
462CMED01-frame-parse-key-off-by-oneframe_parse uses sizeof(frame->key) >= frame->key_len as the key-length guard. frame->key is char key[64]. The confirmeddrafted3/3·
464CMED03-db-save-symlink-followdb_save calls fopen(db->path, "wb") with no O_NOFOLLOW, no O_CREAT|O_EXCL, no pre-lstat, no post-fstat. An…confirmeddrafted3/3·
466CMED05-db-load-not-atomic-on-failuredb_load's parse loop calls db_import_line for each line and breaks out with rc = -1 on the first malformed…confirmeddrafted3/3·
467CMED06-session-validate-admin-role-bypassIn session_validate, when the actual session role does NOT match the required_role, the code computes allowed =…confirmeddrafted3/3·
472CMED11-runtime-is-safe-path-insufficientruntime_is_safe_path rejects absolute paths and any path containing the literal substring "..", but never…confirmeddrafted3/3·
474CMED13-log-message-format-string-injectionlog_message(logger, level, message) forwards message to log_write(logger, level, message), which executes…confirmeddrafted3/3·
476CMED15-minihash-hashdoshash_key is a textbook FNV-1a 32-bit hash with no secret seed. Bucket count is fixed at init time (251 for the Db,…confirmeddrafted3/3·
477CMED16-program-a-handle-get-network-format-stringIn the cache-miss branch of handle_get, the code calls log_message(&app->log, LOG_WARN, frame->key). frame->key…confirmeddrafted3/3·
478CMED17-program-a-handle-auth-deterministic-tokenhandle_auth derives the session token via snprintf(token, sizeof(token), "token-%s", user). The token is…confirmeddrafted3/3·
479CMED18-program-b-handle-import-symlink-traversalhandle_import calls runtime_is_safe_path(daemon->base_dir, req->key) (which rejects only absolute paths and ..…confirmeddrafted3/3·
481CMED20-program-c-run-report-payload-oob-readrun_report formats snprintf(report, needed, "...payload=%s", ..., job->payload ? (char *)job->payload : "") —…confirmeddrafted3/3·

A — Severity rubric

TierDefinition
CriticalDirect attacker-controlled memory corruption (heap/stack overflow, use-after-free, double-free, format-string write) or full privilege escalation reachable from a permissionless input. No special preconditions beyond an attacker-shaped byte string. Must be patched immediately.
HighSignificant memory-safety violation or authorization bypass under realistic preconditions (specific filesystem state, race window, or attacker-controlled environment variable). Patch should ship in next release.
MediumHardening issue: predictable resource path, weak entropy, save/load divergence, or invariant violation requiring an improbable state or co-tenant attacker. Worth fixing in normal cadence.
LowMinor issue with no plausible path to memory corruption or privilege escalation. Code-quality or defense-in-depth concern.
InfoInformational. No security impact. Documentation or style suggestion.

B — Methodology

Layer overview

LayerFunction
Layer 1 Multi-agent recon. For each hypothesis, parallel LLM agents read the engine source and return a TRUE / FALSE / NEEDS_LAYER_2_TO_DECIDE verdict with confidence + per-agent grounding.
Layer 1.5 Adversarial debate. Contested verdicts (NEEDS_L2 or split verdicts) are promoted through a single-round attacker / defender debate, with a separate judge resolving the final verdict.
Layer 2 Concrete proof-of-concept. An inverted-assertion test is authored in C and compiled with clang + ASan/UBSan/SignedOverflowSan. The test "fires" iff an abort from the sanitizer or an explicit assert(0) originates in the target module (not stdlib / setup).
Layer 2.5 Triage. An LLM judge classifies each fire as STRONG (real bug), SOFT (wrong invariant), FALSE (artifactual abort), or LOST (signal missing). STRONG fires are clustered by (engine_function, target_file) so the same code-site bug under multiple hypothesis IDs collapses to one root cause.
Layer 3 Symbolic verification. CBMC bounded model checking with built-in --bounds-check, --pointer-check, and integer-overflow checks. The harness drives the function under test with symbolic inputs; CBMC either reports a concrete counterexample (sanitizer-equivalent FAILURE at the bug site) or proves the invariant holds within the unwind bound.
Layer 4 Coverage-guided fuzzing via AFL++ with afl-clang-fast + ASan/UBSan. An LLM-authored harness reads attacker-shaped bytes from stdin and feeds them to the function under test. AFL records as a 'crash' any input that triggers a sanitizer abort or explicit abort().
Layer P3 Fix-bundle pipeline. The LLM authors a structural patch against the confirmed root cause and verifies it through a 6-gate machine check (well-formed diff, single-function scope, PoC fails pre-patch, PoC passes post-patch, existing tests still pass, and a language-specific symbolic/runtime check — Kani for Solana, Move Prover for Aptos, Halmos for Solidity, CBMC for C). Gates auto-skip when the language doesn’t apply (the symbolic / runtime gates of one toolchain skip on cycles authored against another, with that language’s verdict already reported under Layer 3 / Layer 4); the test-suite gate skips for eval targets that ship without a unified runner. Operator authorization is required before any upstream PR is opened.

Cycle execution

This cycle was produced by Jelleo's continuous, hypothesis-driven C / systems-software audit loop. Every finding originates as a falsifiable invariant claim from a per-protocol hypothesis library, dispatched to Layer 1 multi-agent recon, promoted on contested verdicts via Layer 1.5 adversarial debate, and confirmed empirically through a Layer 2 clang + ASan/UBSan proof-of-concept. Layer 2.5 triage classifies each fire as STRONG / SOFT / FALSE / LOST; only STRONG cluster representatives advance to confirmed and appear in §01 above. SOFT and STRONG duplicates land in triaged; FALSE fires return to new. Lifecycle: new → triaged → confirmed → disclosed → fixed → verified. Every cycle is signed Ed25519 against the platform key — see the cover-page receipt.

Hypotheses
25
from class library
PoC fires
19
19 fires triaged
STRONG
15
STRONG 15 · SOFT 2 · FALSE 2 · LOST 0
Root causes
11
15 STRONG → 11 after curation
Confirmed
11
reach this report

Non-fire accounting6 hypotheses were tested but the PoC did not fire — 5× rejected, 1× new. These are hypotheses where Layer 1 / Layer 1.5 returned a verdict but the Layer 2 PoC author either declined to produce a test (no plausible attack) or the test ran without an abort in the target module.

Cycle wall-clock5h 7m 58s

§ B.1 — Cycle funnel. Hypotheses tested → PoC fires → Layer 2.5 judge filters out artifactual / mis-invariant fires → surviving STRONG fires cluster by code site → cluster representatives become published findings.

C — Audit artifacts

All cycle artifacts are persisted on disk and verifiable independently of this report. The table below lists the canonical paths under the cycle workspace so a reviewer can re-execute every layer or recompute the cycle Merkle root.

ArtifactPath (relative to workspace)
Cycle summary (manifest of every step)hunts/<cycle>/hunt_summary.json
Per-step event loghunts/<cycle>/hunt.log.jsonl
Layer 2.5 triage verdictshunts/<cycle>/triage.jsonl
Layer 2 PoC sources (C)tests/c/test_<slug>.c
Layer 2 PoC run logshunts/<cycle>/poc/c_<slug>.log
Layer 3 CBMC harnesses + verdictsformal/c/harness_<slug>.c
Layer 4 AFL++ fuzz harnessesfuzz/c/<slug>/afl_<slug>.c
Layer P3 fix bundles (patch.diff + evidence/ + manifest.json)recon/bundles/<finding_id>/
Narrative writeups (per finding)hunts/<cycle>/narratives/<hyp_id>.md
Cycle Merkle root (tamper-evidence)hunts/<cycle>/merkle.json (absent in this cycle)
Findings DB (SQLite)findings.db
Ed25519 public key for receipt verificationhttps://jelleo.com/keys/jelleo.ed25519.pub

D — Disclaimers

Findings in this report reflect the state of the engine source at the commit hash on the cover page. Subsequent changes to the codebase are not analyzed. The report is not a guarantee of code correctness or security: it documents invariants that fired (or held) under the hypothesis library applied during this cycle. Out-of-scope items are listed in §00.1 (Scope).

§03 reflects bundle-level state. A row is treated as a confirmed finding when the bundle’s machine verification gates (PoC fails pre-patch + PoC passes post-patch + tests still pass) all hold, even if the Layer 2.5 LLM judge initially classified the fire as SOFT / FALSE / LOST — the verifier’s empirical patch-defuses-bug evidence supersedes the judge. Rows that did not reach a confirmed lifecycle state are retained in §03 as audit-trail evidence but are not published findings; the authoritative set is whatever appears in §01.

Communication channel: security@jelleo.com (PGP key on jelleo.com/security.html). Coordinated disclosure follows the timeline published in our security policy; pre-disclosure leak protections are enforced at the report level (the --public renderer suppresses confirmed-but-not-disclosed findings).

Methodology spec: docs/methodology/ · Live reference: jelleo.com/methodology.html · Source: github.com/Copenhagen0x/audit-pipeline-cli