Skip to content

Exclude __stack_chk_guard where the loader owns it, and finish the records - #39

Merged
Samueru-sama merged 14 commits into
mainfrom
fix/loader-owned-exports
Sep 13, 2026
Merged

Samueru-sama merged 14 commits into
mainfrom
fix/loader-owned-exports

Conversation

@Samueru-sama

Copy link
Copy Markdown
Member

Supersedes #38. This branch carries #38's five commits unchanged and adds three.

The core fix is unchanged: the generated shim no longer defines
__stack_chk_guard on aarch64, riscv64 and loongarch64, where the dynamic
loader exports it as the process-wide stack canary; the generator decides
FUNC versus OBJECT from the merged kind table rather than the x86-64
target's; scripts/verify-artifacts.sh refuses any build whose preload
exports a name the target's own libc family also exports; and E102 is the
suite case.

Added on top of #38

  • docs: carry the 65/65 and 61/61 totals into every record — report 01,
    section 9 and section 10 still asserted the old totals as fact, and the
    report index still called section 3 six defects after it became seven. The
    one-home gate cannot see this because it only checks the current numbers.
  • verify-artifacts: the name-collision check is the fourth property — the
    script header still said THREE PROPERTIES.
  • gen_forward_shim: spell the loongarch64 gate as the tree does — the guard
    now uses defined(__loongarch64), the spelling every other architecture
    test in src/ uses. GCC defines both __loongarch__ and __loongarch64;
    this makes the comment that claims the spellings match true. Regenerated.

Verification

  • make builds all five artefacts clean.
  • sh scripts/verify-artifacts.sh src: all checks pass, and
    cross-libc-dlopen.so: no name reexported from the target libc family.
  • Against a real bullseye aarch64 libc.so.6 + ld-linux-aarch64.so.1 via
    CLD_SYSROOT, the gate flags __stack_chk_guard when the object exports
    it (the pre-fix shape) and prints the loader's defining symbol.
  • sh scripts/check-drift.sh and sh scripts/check-charset.sh pass.
  • make shim regenerates src/forward-shim.c and the manifest
    byte-identically.
  • E102's comparison logic against this machine's libc plus loader reports no
    collisions.
  • Author's fork CI on the inherited commits,
    run 34748021128:
    all six build rows green and both evidence tables green, E102 MATCH on both.

Not changed, and why

  • The follow-up note that src/cross-libc-dlopen.c (CLD_TRIPLET) and
    src/runtime-select.c (RS_LDSO) gate on __loongarch64__ does not match
    the tree: both use defined(__loongarch64) with no trailing underscores,
    which GCC defines (gcc/config/loongarch/loongarch-c.cc), so both branches
    fire. The only __loongarch64__ in the tree is the generator comment
    explaining GCC never defines it. No follow-up branch is needed; changing
    those two lines would introduce the bug the note claims to fix.
  • shim_covered[] still lists __stack_chk_guard on the architectures where
    it is no longer defined. forward_shim_covered() has no in-tree consumer,
    so this is cosmetic and is left as-is.

Closes #37. Supersedes #38.

talaria0101 and others added 8 commits September 13, 2026 08:02
The generated shim emitted __stack_chk_guard as a SHIM(void) stub on every
architecture, because the generator picked FUNC versus OBJECT from the x86-64
target inventory and the name is musl-only there. On aarch64, riscv64 and
loongarch64 the dynamic loader exports that name as the process-wide stack
canary, glibc's lookup lets an unversioned preload definition win for the
versioned reference, and every __stack_chk_guard GOT slot in the process,
libc's own included, bound the stub in read-only text. Chromium built as an
anylinux AppImage died on aarch64 with SIGSEGV in its GPU process under the
preload with CROSS_LIBC_DLOPEN=0, because the damage is done at relocation
time; x86-64 and ppc64le were unaffected, their loaders keep the canary in
thread storage and never export the name. Issue #37; report 3.7 has the
per-architecture measurement table.

Three layers. The generator excludes the name on the three architectures
whose loader exports it and emits zeroed data of the real size where musl
guests need it, and now decides the type from the merged kind table, which
also re-types six other musl objects from stubs to data. forward-shim.c and
its manifest are regenerated. scripts/verify-artifacts.sh refuses any build
whose preload exports a name the target's own libc family also exports,
beyond dlopen and version-compat.c's forwarders; CLD_SYSROOT names the
sysroot for verification outside a build, and the released v0.2.5 aarch64
object makes it refuse naming the symbol, measured on an x86-64 machine
against the real bullseye arm64 cross libc. E102 is the suite's case, run on
both rows: on the aarch64 runner it failed before this change and passes
after, and the total moves to 65/65 and 61/61, with every one-home record
moved with it.

The fixed x86-64 build exports the same 140 names as before, so the musl
bridge keeps every symbol it had, including __stack_chk_guard itself on the
architectures whose loader does not provide it.
The one-home gate refused the clean tree because this file had spelt the
new suite totals out. It is the gate working as designed and it is why the
totals live in report 08 alone.
E102's logic is proven against the floor cross libc on this machine; the
runner row is the PR's CI run, the same way E101's aarch64 number was
measured. The silicon run lives in the consumer's CI, which this repository
cannot cite as a file.
The loongarch64 exclusion spelled __loongarch64__, which GCC never
defines: the compiler's own loongarch-c.cc defines __loongarch__ and
__loongarch64, without the trailing underscores. The exclusion never
fired, the artefact still exported the canary, and the gate refused the
loongarch64 build, which is the gate's first live catch. Measured
against gcc-mirror's loongarch-c.cc, loongarch_define_unconditional_macros.

The gate's refusal now prints the shared names verbatim through IFS= read,
with the artefact's defining dynsym line beside each. The ppc64le build
was refused over a name that printed as 8], which word splitting can
produce and a verbatim print cannot, so this is the diagnostic that says
which it was.

PROGRESS: fix the report 03 link, relative to docs/todo/.
PPC64 ELFv2 readelf prints a st_other annotation, [<localentry>: 8],
between the visibility and the index. With the name shifted out of
column 8, every symbol on both sides of the comparison parsed as the two
characters 8], the intersection found it, and the gate refused the
ppc64le build over a name that is not a name. The bullseye cross
binutils prints the column and the newer x86-64 binutils does not for
the same file, which is why the local rehearsal saw nothing and the
runner did. Stripped in both parsers, the gate's and E102's, with the
measurement in the comment.

Re-proven after the change: the planted v0.2.5 aarch64 object is
refused naming __stack_chk_guard with its defining dynsym line printed
beside it.
E102 moved both evidence-table totals by one, and report 08 was the only
record updated. The summary, section 9 and section 10 still asserted the
old numbers as fact, and the report index still called section 3 six
defects after it became seven. The one-home gate cannot see this: it
checks only the current numbers.
The header still said THREE PROPERTIES while the new block introduced the
fourth. Spelled out beside the other three, with the __stack_chk_guard
measurement as the reason it exists.
Every other architecture test in src/cross-libc-dlopen.c,
src/runtime-select.c and src/gl-fwd.c uses __loongarch64, and GCC defines
it, so the comment that claims the spellings match is made true. The
generated guard is regenerated.
The E102 claim rested on a fork run and the consumer's silicon report.
The upstream matrix run is durable and green on all six builds and both
evidence rows, so it is the citable one.
@Samueru-sama

Copy link
Copy Markdown
Member Author

End-to-end check on the consumer, with the preload present rather than deleted.

Built this PR's artefacts (the green matrix run
34748810446),
then in pkgforge-dev/Helium-Browser-AppImage-Enhanced, branch debug, replaced

rm ./AppDir/lib/sharun-preload/cross-libc-dlopen.so

with a copy of this PR's cross-libc-dlopen.so for the running architecture
(pr39-preload/cross-libc-dlopen-$ARCH.so, commit 909afa8). The bundled buggy
preload is swapped for the fixed one instead of removed, so the AppImage test
exercises the fix.

Workflow run
34749192626
is green on both rows:

  • Build AppImage (x86_64) — success
  • Build AppImage (aarch64) — success, including
    quick-sharun --test ./dist/*.AppImage --no-sandbox

The aarch64 row is the one that died with SIGSEGV before, with only the preload
present and CROSS_LIBC_DLOPEN=0. It now completes with the preload in place.
The aarch64 artefact of this PR is the one that does not export
__stack_chk_guard; the x86_64 one still does, which is correct there because
its loader keeps the canary in thread storage.

@QaidVoid

Copy link
Copy Markdown
Member

Reviewed by building and measuring both sides rather than reading. Cross-built aarch64 and loongarch64 and native x86-64 from this branch and from main, and re-measured the root cause against the floor cross libc packages independently.

The fix is correct and I found no regression. The issues below are all in the gate that is meant to stop this recurring, not in the fix itself.

Regression check, both paths

Every defined dynamic symbol, this branch versus main:

artefact aarch64 x86-64
cross-libc-dlopen.so 1 name removed (__stack_chk_guard), 6 re-typed FUNC to OBJECT, nothing else 0 names removed, 7 re-typed FUNC to OBJECT
gl-fwd.so / egl-fwd.so / gles-fwd.so identical identical
runtime-select byte-identical byte-identical

Undefined-import set unchanged on both. Export count 74 before and 74 after. make shim reproduces forward-shim.c and the manifest byte-identically, and check-charset, check-drift and verify-gates (14/14) pass.

The removal is safe on the excluded architectures because the reference still resolves. Measured against libc6-dev-arm64-cross:

ld-linux-aarch64.so.1:  33: ...31640  8 OBJECT GLOBAL DEFAULT 16 __stack_chk_guard@@GLIBC_2.17
libc.so.6:              11: ...00000  0 OBJECT GLOBAL DEFAULT UND __stack_chk_guard@GLIBC_2.17

That is 3.7 in one screenful. The loader defines it, libc.so.6 imports it, and the unversioned preload definition was winning that binding. Same shape confirmed on riscv64 and on loongarch64 (ld-linux-loongarch-lp64d.so.1 defines it at @@GLIBC_2.36, libc.so.6 imports it), and confirmed absent on ppc64le and x86-64. The measurement table in 3.7 is accurate on all five rows.

The loongarch64 cross build from this branch passes the new gate and exports zero __stack_chk_guard entries, so the exclusion fires with the final spelling.

Findings

1. The new gate can pass without measuring anything. scripts/verify-artifacts.sh:118 swallows readelf's errors with 2>/dev/null. With readelf broken I get:

cross-libc-dlopen.so: no name reexported from the target libc family

Green, on a check that read nothing. The file header two screens up says each property "fails silently if it is wrong rather than loudly, which is why they are checked rather than assumed". Suggest asserting that the target-side name list is non-empty, since a glibc that exports nothing is not a thing, and calling bad when it is.

2. name collisions unverified exits 0. verify-artifacts.sh:194 prints through say, so the run continues to all artefact checks passed with exit 0. Measured. PROGRESS.md calls this "a SKIP by name, not a pass", but nothing downstream can tell the difference: it is not recorded in build-manifest.json, so release-notes.sh cannot surface it either. For riscv64, ppc64le and loongarch64, which have no runner and are released on the strength of this script, that is the whole gate quietly evaporating.

3. CLD_SYSROOT only handles a flat sysroot. verify-artifacts.sh:159 globs $SYSROOT/lib and $SYSROOT/lib64 only, not $SYSROOT/usr/lib or $SYSROOT/lib/<triplet>. Point it at an extracted Debian rootfs, which is the obvious use and the way the refusal was proven, and it finds nothing and takes path 2 above.

4. The refusal diagnostic names innocent symbols. verify-artifacts.sh:216 uses grep -F " $h", which is a substring match. I planted a colliding puts and got:

puts
   so:  6: ...1100  7 FUNC   GLOBAL DEFAULT 11 puts
   so:  7: ...4010  8 OBJECT GLOBAL DEFAULT 21 puts_impl

Given this block exists because a mis-parsed name cost a debugging cycle, grep -E " $h(@|$)" is worth the two characters.

5. E102 prints nothing when it fires. experiments/30-run-tests.sh:560 writes /work/e102-collisions and then asserts the file is empty, so a failure is E102 MISMATCH with no names. verify-artifacts.sh was deliberately taught to print them verbatim in f2cb15f. Same lesson, applied on one side only.

Smaller

  • scripts/build-in-env.sh:139 still says the artefacts are checked "against the three properties". 44b17dc fixed the header in verify-artifacts.sh and missed the identical claim in the file that calls it.
  • docs/report/08-test-results.md:46 now reads "64 minus 4 is 60, and no case is missing for a reason nobody wrote down. 65 minus 4 is 61 under the same accounting." Two totals in one paragraph, and the first sentence is the stale one. The one-home gate cannot see it for the same reason 5f94c9f was needed.
  • PROGRESS.md:31 says the work is on branch no-loader-owned-exports. This PR is fix/loader-owned-exports.
  • ARCH_MACROS entries for x86_64, i386 and ppc64le (tools/gen_forward_shim.py:128-132) are never consulted, since only architectures named in ARCH_EXCLUDED are looked up, and i386 is not a build target at all.
  • .cld-exempt.$$ is written into the artefact directory that later gets packaged.
  • On x86-64 and ppc64le a musl guest reading __stack_chk_guard now gets a constant zero where it previously got a constant stub address. Neither is a real canary and musl uses the TLS canary on x86-64, so the practical impact is nil, but a zero canary is worth a sentence in 3.7 rather than "zeroed data of the real size" alone.
  • The six other re-typed symbols are a correctness improvement, since they are data and a FUNC there hands the reader code bytes. Worth noting that it also drops the loud-failure contract: a musl guest reading h_errno now silently sees 0 forever instead of aborting with the symbol named. The commit message does disclose the re-typing.

On the loongarch64 spelling

Settled by measurement, on the exact compiler the build uses (debian:trixie-slim, loongarch64-linux-gnu-gcc (Debian 14.2.0-19)):

#define __loongarch__ 1
#define __loongarch64 1
#define __loongarch_grlen 64

__loongarch64__ is not defined. Both __loongarch__ and __loongarch64 compile through the real cross preprocessor, so neither spelling was broken, and d968ce5 is the better of the two: ARCH_MACROS is keyed by the repo's architecture names, and __loongarch__ identifies loongarch in general rather than loongarch64, which would make it the odd one out beside the riscv64 entry directly above it. The only argument the other way is a hypothetical LA32 target with the same hazard, and that costs nothing here, because the guard is #if !(...) so a new architecture emits by default and the gate then refuses the build.

The correction in the PR description is also right, and I checked it rather than taking it on trust. src/cross-libc-dlopen.c:1479, src/runtime-select.c:83 and src/gl-fwd.c:124 all already use defined(__loongarch64), and GCC's loongarch-c.cc defines __loongarch__ unconditionally and __loongarch64 under TARGET_64BIT, and never __loongarch64__. A follow-up branch there would have introduced the bug it claimed to fix.

Verdict

The diagnosis is right, including the non-obvious half of it, which is why x86-64 and ppc64le were never affected. Finding 1 through 3 are what I would want fixed before this merges, because for the three architectures with no runner this script is the release criterion and it currently has three paths that pass without measuring anything. The rest are nits.

Review finding: readelf's errors were swallowed, so a broken readelf or a
wrong file set made both name lists empty, the intersection vacuously
empty, and the check print a clean result without measuring. The empty
target case returned through say() and exit 0, which for the three
architectures with no runner is the whole gate evaporating. Both are bad()
now, CLD_SYSROOT also looks in usr/lib and the multiarch directories of an
extracted rootfs, the diagnostic matches a name exactly instead of as a
substring, and the exemption file is a mktemp rather than a dotfile in the
artefact directory.
Review finding: the case asserted only that /work/e102-collisions was
empty, so a failure was E102 MISMATCH naming nothing. It now prints them
the way verify-artifacts.sh does, and echoes the needle only when the list
is empty.
… the re-typing

Review nits: 08 still carried '64 minus 4 is 60' beside the current
accounting; PROGRESS named the superseded branch; 3.7 now says what the
merged-kind-table fix did to the six other data symbols and to the canary
a musl guest reads on x86-64 and ppc64le.
@Samueru-sama

Copy link
Copy Markdown
Member Author

Thanks — all fair, and findings 1-3 are exactly the failure mode the file header claims to prevent. Pushed 13a7c8a, 8962480, a9ea4d5.

1. An empty measurement now refuses. Both name lists must be non-empty before the intersection runs; either one being empty is bad(), not a pass. Measured with a readelf that exits 1: it now prints

FAIL: cross-libc-dlopen.so: the name-collision check read no defined
      dynamic names from at least one side (...), so it measured nothing.

instead of no name reexported.

2. No target libc found now refuses. name collisions unverified is a bad(), so the run exits 1. The only caller is build-in-env.sh inside the floor container, where the cross sysroot exists, so the build stops rather than releasing three unverified architectures. Measured: empty CLD_SYSROOT with CLD_ARCH=aarch64 → one failed check, exit 1.

3. CLD_SYSROOT handles an extracted rootfs. It now tries usr/lib/<triplet>, lib/<triplet>, usr/<triplet>/lib, usr/lib, usr/lib64, lib, lib64. Measured against a rootfs-shaped layout (usr/lib/aarch64-linux-gnu/libc.so.6 plus the loader), it finds the libc and reports the planted collision; before it found nothing and took path 2.

4. Exact match. grep -E " ${h}(@|$)". Measured on a line set carrying puts and puts_impl, only puts matches.

5. E102 prints the names. It prints E102: the preload reexports names this libc family also has: and the list, and echoes the needle only on the empty path, so a collision cannot fall through to a pass.

Smaller: build-in-env.sh says four properties now; 08 carries only 65 minus 4 is 61; PROGRESS.md names fix/loader-owned-exports; the exemption file is a mktemp rather than a dotfile in the artefact directory; 3.7 records both the six re-typed data symbols and the canary a musl guest reads on x86-64 and ppc64le.

ARCH_MACROS keeps all six entries on purpose: it is the repo-architecture-to-preprocessor mapping, and only the names in ARCH_EXCLUDED are looked up. i386 is the one entry no current target uses; I left it so the table stays one complete mapping rather than a second list that can drift from it.

On loongarch64, agreed and thanks for measuring it on the exact cross compiler — d968ce5 keeps __loongarch64, so there is nothing to change.

Re-ran locally after the edits: sh -n on the changed scripts, the x86-64 gate green, and all three refusal paths above exit 1. Upstream CI re-runs on the new head.

The unverified path now refuses the build, so the notes that described it
as a skip and the build doc that counted three properties were both stale.
The script header says what the fourth property needs to run.
The Helium anylinux AppImages bundle their libc family, so they are the
runtime the preload lands in. aarch64 glibc 2.43 exports the canary and
x86-64 glibc 2.44 does not, and the gate refuses v0.2.5 against that
AppDir and accepts this branch's build.
@QaidVoid

Copy link
Copy Markdown
Member

Re-verified the five fixes on a9ea4d5 by measurement, then pushed b253b36 and 5a536fc for the four leftovers and one new measurement.

The five fixes hold

finding measured
silent pass on broken readelf now FAIL: ...read no defined dynamic names from at least one side, exit 1
unverified exited 0 now FAIL: no riscv64 libc family found, exit 1
CLD_SYSROOT flat-only works on usr/lib/<triplet>, usr/lib and the old flat lib/, so the layout that already worked did not regress
substring diagnostic planted puts beside puts_impl, only puts prints
E102 silent on failure prints the names, and still MISMATCHes, since run wants both the exit status and the needle and the needle is only echoed on the clean path

No regression from the two new refusals: x86_64, aarch64 and loongarch64 all build and verify green locally, sh -n clean on the changed scripts, and gates run 34749521492 is green across all six build rows and both evidence rows, so they were exercised against the real cross sysroots without false-failing.

Agreed on ARCH_MACROS. One complete mapping beats a second list that can drift from it, and i386 costs nothing.

Pushed: b253b36, the four leftovers

  • docs/todo/PROGRESS.md still said a machine without the cross libc "prints name collisions unverified and that is a SKIP by name, not a pass". That string is gone from the script and the path refuses now. The note that prompted the fix outlived it.
  • docs/building.md was the third instance of the THREE/FOUR drift, still "against three properties" over three bullets. It now counts four and carries the name-collision bullet beside the others.
  • The script header still promised standalone verification without qualification. Added the clause that says the fourth property needs the target's own libc family and takes CLD_SYSROOT when verifying another architecture's artefacts outside a build. The first three still need nothing but the object. Comment only, no behaviour change.
  • Report 3.7 cited run 34748810446, which is two commits behind. It now cites the run carrying the check in its current shape.

Pushed: 5a536fc, 3.7 measured against a real consumer

The table in 3.7 was measured on libc6-<target>-cross 2.31 and trixie's 2.41. The Helium-0.16.3.1-anylinux AppImages bundle their own libc family, so that bundle is the runtime the preload actually lands in, and it is a much newer glibc. Extracted both and measured:

aarch64 bundle, glibc 2.43
  ld-linux-aarch64.so.1:  11: ...3fc88  8 OBJECT GLOBAL DEFAULT 16 __stack_chk_guard@@GLIBC_2.17
  libc.so.6:              10: ...00000  0 OBJECT GLOBAL DEFAULT UND __stack_chk_guard@GLIBC_2.17

x86-64 bundle, glibc 2.44
  ld-linux-x86-64.so.2:   no such name
  libc.so.6:              no such name

So the split is a property of the architecture rather than of the 2.31 cross packages it was first measured on, which is the part 3.7 could not previously say.

A/B against that AppDir with CLD_SYSROOT, which is also a live test of the widened globs, since the AppDir uses a flat lib/:

released v0.2.5 aarch64:
  FAIL: cross-libc-dlopen.so exports names the target libc family also exports.
        __stack_chk_guard
   so:     96: 0000000000006744    20 FUNC    GLOBAL DEFAULT   11 __stack_chk_guard
  exit 1

this branch, same target:
  cross-libc-dlopen.so: no name reexported from the target libc family
  exit 0

The released object defines the canary as a 20-byte FUNC in read-only .text while the loader's is an 8-byte OBJECT it writes in security_init(), which is the interposition in two lines.

x86-64 runtime, both paths

Ran the real browser rather than only the objects, with a real GPU process:

preload --version headless screenshot, ANGLE SwiftShader
none exit 0 exit 0, f627178818240214997d0d78ad081711
released v0.2.5 exit 0 exit 0, f627178818240214997d0d78ad081711
this branch exit 0 exit 0, f627178818240214997d0d78ad081711

Byte-identical PNGs across all three. Confirmed the preload was genuinely in the process rather than silently skipped: under CROSS_LIBC_DLOPEN_DEBUG=1 it logs its dlopen interception for libm.so.6, libresolv.so.2 and libcrypt.so.1.

Not measured here: aarch64 at runtime. This machine has no ARM silicon and no qemu binfmt, so the aarch64 evidence stays the static A/B above plus the ARM runner's E102 row in CI.

@Samueru-sama

Copy link
Copy Markdown
Member Author

Thanks — reviewed both. b253b36 and 5a536fc are docs/comment only and accurate; the four leftovers are gone, the citation points at a run carrying the current-shaped check, and check-charset/check-drift pass on 5a536fc. CI is green on the head (gates, secret-sweep, release).

The consumer-glibc measurement is the piece 3.7 needed: it shows the split is architectural, not an artefact of the 2.31 cross packages, and the CLD_SYSROOT A/B against the real AppDir is a live test of the widened globs. Squash-merging now.

@Samueru-sama
Samueru-sama merged commit e783cd9 into main Sep 13, 2026
22 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

cross-libc-dlopen.so causes all chromium browsers to crash only in aarch64

3 participants