← board

libc-free link: unresolved external symbol patched to 0 instead of a link error

Symptom

A libc-free C program (0 DT_NEEDED) that references a libc function the crtl does NOT provide links cleanly and then SIGSEGVs at runtime with a call to address 0 the first time that symbol's code path runs. No compile error, no link error, no warning. gdb shows call 0x0; the binary has no section headers so nm/objdump are empty, making it maximally opaque.

This cost a full session on sqlite's file VFS: pread (and behind it geteuid/fchown) were missing from the crtl, so aSyscall[] slots resolved to 0 and os_unix.c null-called inside seekAndRead/fillInUnixFile. The fix there was to add the syscalls (commit d65d95d8) — but the compiler should have said undefined symbol: pread at link time instead of shipping a null-call.

Root cause

cparser.inc marks any undefined C function ProcExternal[i] := True with ProcLibrary := 'libc.so.6' (or libm.so.6 for the math names) — see ~7109 / 7136, and extLib at ~6925. RegisterExternal (symtab.inc:5271) gives it an 8-byte GOT slot zero-initialised. EmitExternalProcAddr / the external call path load/call through that slot. The slot is filled at runtime only by the dynamic linker via R_X86_64_GLOB_DAT + the DT_NEEDED for its library.

In a libc-free link (forceSystemExternal false — no --system-libs, no explicit external 'lib'), the auto-pull ([[project_c_crtl_autopull_link_model]]) is expected to satisfy every external from crtl sources. Anything it does NOT satisfy stays external against libc.so.6 but no DT_NEEDED / GLOB_DAT is emitted (the whole point is 0 NEEDED), so the GOT slot is never written → stays 0. cparser.inc:6464 even documents the hazard verbatim: "the prototype is marked external against libc, and in a libc-free link the call silently does nothing."

Direct calls to an unresolved internal proc already hard-error (ApplyCallFixups: Error('unresolved forward: ...'), symtab.inc:5579). The gap is only the external-symbol path (call-through-GOT and address-of): it has no equivalent finalize check.

Fix (proposed)

At ELF finalize (writeELF* in elfwriter.inc, after CPullCrtlForPrototypes and after DynamicNeededCount is computed), fail on any external that will NOT be resolved:

Guards / do-not-break:

Acceptance

Notes

RESOLVED 2026-07-10 — real root cause was C-side, not the linker

The filed hypothesis (unresolved external patched to 0 in a libc-free link) is wrong. Minimal repros proved the external/GOT path is safe: a declared extern that stays unresolved always gets a DT_NEEDED and the dynamic loader reports it (symbol lookup error: undefined symbol: X, exit 127) — never a silent 0. A direct call to an undeclared function is already a hard compile error (call to undeclared function, cparser.inc:971).

The genuinely silent path is narrower and lives in cfront, not elfwriter: an undeclared identifier used as a VALUE decays to integer 0 (cparser.inc ~991, "best-effort leniency"). sqlite's os_unix.c references its syscalls only as addresses in the file-scope static ... aSyscall[] initializer ((sqlite3_syscall_ptr)pread, (...)geteuid, (...)fchown); the crtl headers didn't declare them, so each decayed to a 0 slot — a null fn-pointer that faulted only when os_unix later called through the slot (the "unixRead segfault"). Three distinct decay sites, all now warned:

Fix shipped (Track C, cfront)

Emit a warning (not a hard error) at all three sites: undeclared identifier 'X' ... (treated as 0 / null slot). Reserved __-prefixed names stay silent (predefined __LINE__/__FILE__/__func__ legitimately decay and are __-named). A hard error was tried first and rejected: it broke sqlite's use of __LINE__ (undeclared in cfront) — the leniency is load-bearing for predefined macros cfront doesn't model. -Werror promotes the warning for anyone who wants it fatal.

A speculative link-time ValidateLinkage (flagging internal procs with BodyAddr<0 referenced by ProcAddrFix/MethodFixups) was also tried and removed: it false-positived on legitimate Pascal abstract/virtual VMT slots (TB.G), and it did not even cover the actual sqlite class (a data-0, not a proc fixup).

Validation

Deferred (optional hardening, not needed for this bug)

Log