commit f8164e2795d7e8f74d270efd23968f1dace98db8
parent e51cf4693982cac804e78807d5e31a9293b67a66
Author: Ryan Sepassi <rsepassi@gmail.com>
Date: Tue, 9 Jun 2026 12:15:42 -0700
link: reject local-exec TLS against non-local thread-locals
kit emits local-exec TLS only: a thread-local's offset within the image's
TLS block is fixed at link time, valid only for a thread-local defined in
the image being linked. Nothing checked that, so a TLS access that bound to
a DSO import or (cross-TU) to a non-thread-local definition silently baked a
bogus tp-relative offset into the binary.
Add link_require_local_tls() and call it at the two points that commit a
tp-relative offset: the TLS-IE GOT-slot fill (link_emit_internal_tpoff64,
shared by the AOT writer and the JIT mapper via link_layout_got) and the
ELF apply pass (apply_all_relocs, for direct local-exec relocs). An imported
or non-thread-local target now produces a clear diagnostic instead of a
bogus offset; kit has no initial-exec/global-dynamic fallback to relax to.
test/link/bad/31_tls_non_local exercises a cross-TU TLS-vs-non-TLS mismatch
(rejected on both the E and J lanes). Teach bad_read_case the per-tuple
'targets' gating the cases reader already has, and scope the case to ELF
(the IE-GOT-slot premise has no Mach-O/COFF analogue).
Diffstat:
8 files changed, 76 insertions(+), 0 deletions(-)
diff --git a/src/link/link_internal.h b/src/link/link_internal.h
@@ -616,6 +616,17 @@ struct LinkImage {
* the relocation site. Panics on unsupported kinds. */
void link_reloc_apply(Compiler*, RelocKind, u8* P_bytes, u64 S, i64 A, u64 P);
+/* kit emits local-exec TLS only: a thread-local's offset within THIS image's
+ * TLS block is fixed at link time, so a local-exec access (or the TP-relative
+ * fill of a TLS initial-exec GOT slot) is valid only against a thread-local
+ * *defined in the image being linked*. Panic with a clear diagnostic if `tgt`
+ * is imported from a shared object (its TLS block belongs to another module,
+ * sized and placed by the dynamic loader) or resolved to a non-thread-local
+ * definition. There is no initial-exec/global-dynamic fallback to relax to,
+ * so the only alternative is a silently bogus tp-relative offset -- hence the
+ * hard error. A no-op when `tgt` is a thread-local defined here. */
+void link_require_local_tls(Compiler*, const LinkSymbol* tgt);
+
/* Public link_emit_image_writer dispatches by Compiler.target.obj. The
* ELF and Mach-O writers get architecture identity from LinkArchDesc;
* reloc application remains keyed by RelocKind. COFF arrives later. */
diff --git a/src/link/link_reloc_layout.c b/src/link/link_reloc_layout.c
@@ -520,6 +520,24 @@ void link_layout_jit_stubs(Linker* l, LinkImage* img, u32 map_size,
/* ---- pass 3c: GOT layout ---- */
+void link_require_local_tls(Compiler* c, const LinkSymbol* tgt) {
+ if (!tgt) return;
+ if (!tgt->imported && tgt->kind == SK_TLS) return;
+ {
+ Slice nm = tgt->name ? pool_slice(c->global, tgt->name) : SLICE_NULL;
+ const char* why = tgt->imported
+ ? "it is imported from a shared object"
+ : "it resolved to a definition that is not "
+ "thread-local";
+ compiler_panic(c, SRCLOC_NONE,
+ "link: cannot resolve thread-local access to '%.*s': %s. "
+ "kit emits local-exec TLS only, so every thread-local must "
+ "be defined in the image being linked (initial-exec / "
+ "global-dynamic against a shared object are not supported)",
+ (int)nm.len, nm.s ? nm.s : "", why);
+ }
+}
+
/* Fill a TLS Initial-Exec GOT slot with the target's TP-relative offset.
* Emitted as an internal raw-64-bit tpoff reloc so apply_all_relocs computes
* the offset in the same coordinate system it uses for ordinary local-exec
@@ -531,6 +549,11 @@ static void link_emit_internal_tpoff64(LinkImage* img, Linker* l,
u64 write_vaddr, LinkSymId target) {
LinkRelocApply rrec;
(void)l;
+ /* The slot holds a tp-relative offset, valid only for a thread-local
+ * defined in this image (see link_require_local_tls). A TLS-IE access
+ * against an imported or non-thread-local target would otherwise bake a
+ * bogus offset into the slot. */
+ link_require_local_tls(img->c, LinkSyms_at(&img->syms, target - 1));
memset(&rrec, 0, sizeof(rrec));
rrec.input_id = LINK_INPUT_NONE;
rrec.section_id = OBJ_SEC_NONE;
diff --git a/src/obj/elf/link.c b/src/obj/elf/link.c
@@ -496,6 +496,13 @@ static void apply_all_relocs(LinkImage* img, u64 img_base) {
continue;
}
seg = &img->segments[sec->segment_id - 1];
+ if (reloc_is_tlsle(r->kind, tls_vi) || reloc_is_x64_tlsle(r->kind, tls_vi))
+ /* Both the direct local-exec relocs and the internal R_TPOFF64 used to
+ * fill a TLS-IE GOT slot resolve to a tp-relative offset within THIS
+ * image's TLS block — only meaningful for a thread-local defined here.
+ * Reject an imported or non-thread-local target rather than emit a
+ * bogus offset (kit has no initial-exec/global-dynamic fallback). */
+ link_require_local_tls(img->c, tgt);
if (reloc_is_tlsle(r->kind, tls_vi)) {
/* S is the target's TP-relative offset: distance from the TLS image
* start plus the arch/OS TCB bias (see tls_tcb_bias). Both vaddrs are
diff --git a/test/link/bad/31_tls_non_local/a.c b/test/link/bad/31_tls_non_local/a.c
@@ -0,0 +1,13 @@
+/* Reference a thread-local symbol that is *not* a thread-local definition
+ * in the linked image. kit emits local-exec TLS only, so the symbol's
+ * tp-relative offset must be known at link time -- which holds only for a
+ * thread-local defined in this image. Here `shared` resolves to b.c's
+ * ordinary (non-TLS) global, so there is no valid TLS offset; the linker
+ * must reject this rather than bake in a bogus tp-relative offset.
+ *
+ * (clang lowers this external __thread reference to initial-exec on a
+ * non-PIC executable; kit's linker fills the IE GOT slot with the symbol's
+ * local-exec tp-offset, which is where the mismatch surfaces.) */
+extern _Thread_local int shared;
+
+int test_main(void) { return shared; }
diff --git a/test/link/bad/31_tls_non_local/b.c b/test/link/bad/31_tls_non_local/b.c
@@ -0,0 +1,3 @@
+/* Defines `shared` as an ordinary global -- NOT thread-local. The TLS
+ * reference in a.c therefore binds to a non-thread-local definition. */
+int shared = 5;
diff --git a/test/link/bad/31_tls_non_local/expect b/test/link/bad/31_tls_non_local/expect
@@ -0,0 +1 @@
+cannot resolve thread-local access
diff --git a/test/link/bad/31_tls_non_local/targets b/test/link/bad/31_tls_non_local/targets
@@ -0,0 +1,3 @@
+aa64-elf
+x64-elf
+rv64-elf
diff --git a/test/link/run.sh b/test/link/run.sh
@@ -642,6 +642,21 @@ bad_read_case() {
CASE_DIR="${KIT_SRC%/}"
NAME="bad/$KIT_BASE"
+ # Whole-case applicability via `targets` (same mechanism as link_read_case):
+ # a negative case may be format-specific (e.g. an ELF-only TLS scenario with
+ # no Mach-O/COFF analogue). Listed <arch>-<obj> tuples run; others SKIP-NA.
+ if [ -f "$CASE_DIR/targets" ]; then
+ applicable=0
+ for tuple in $(cat "$CASE_DIR/targets"); do
+ [ "$tuple" = "$CUR_TUPLE" ] && applicable=1
+ done
+ if [ $applicable -eq 0 ]; then
+ printf ' SKIP-NA %s — N/A on %s\n' "$NAME" "$CUR_TUPLE"
+ KIT_SKIP_NA_CASE=1
+ return
+ fi
+ fi
+
EXPECT_FILE="$CASE_DIR/expect"
if [ ! -f "$EXPECT_FILE" ]; then
kit_fail "$NAME (missing $EXPECT_FILE)"