ldls: the same loader, on SchOS

docs/plan/ldls.md milestone L3b. ldls/ko.c, btf.c and stub.c were written
freestanding so they would build unchanged on both sides; cmd/ldlsd collects
that promise -- the same three files, linked against lib/ instead of a libc,
in an ordinary SchOS process.

    # ldlsd /lib/modules/8139too.ko /lib/modules/btf
      module  8139too      license GPL      wants 94 symbols
      defused 6 %gs stack-guard reference(s)
      calling init_module
        1  _printk(...)
            | 8139too: 8139too Fast Ethernet driver 0.9.28
        2  __pci_register_driver(...)
      btf     122279 types
      layout  pci_driver.probe +16   pci_dev 2680 bytes
      driver  "8139too"    devices it claims: 10ec:8139 ... and 18 more
      calling probe
        1  alloc_etherdev_mqs(0x368, 0x1)
      probe returned -12 after 1 stub call(s)

Byte for byte the host harness's behaviour, which is what the shared source
was for. Two earlier predictions came good with no work: a process's mmap area
starts at 0x40000000, inside the window R_X86_64_32S needs, and the kernel
never sets EFER.NXE so every mapping is executable. The second is a gap rather
than a feature and is now recorded as one -- LDLS is relying on the absence of
a protection an OS should have.

One thing did not just work. The first run faulted before probe's first call,
err 5 -- present, read, user -- at a kernel address, with the rip resolving to

    mov %gs:0x0(%rip),%rax      # rtl8139_init_one+0x26

Modules are built -mstack-protector-guard-reg=gs, so the canary comes through
the GS segment, and that is an ABI between the kernel and its own per-CPU area
which a host that is not that kernel cannot provide. ko_defuse_gs() rewrites
those references to xor %reg,%reg.

There are two forms and the second is easy to miss: the prologue loads the
guard with mov (8b) and the epilogue compares it with sub (2b) before a jne to
__stack_chk_fail. xor is right for both -- it zeroes the saved canary and sets
ZF, so the epilogue's branch falls through exactly as a passing check does. Six
sites in 8139too.

It checks every site before patching any and refuses the whole operation on
anything it does not recognise. That refusal is what found the sub form, by
declining to guess -- a loader that half-understands an instruction should not
be editing it.

make ldls-stage/ldls-unstage put a module and its kernel's BTF into the local
sysroot and take them out again. Not part of `all`, and .gitignore refuses
*.ko, so an image only carries somebody else's GPL code between two commands
you typed on purpose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
This commit is contained in:
Nexoniarz 2026-09-14 09:10:01 +02:00
commit 3fd1cc5279
7 changed files with 738 additions and 3 deletions

View file

@ -108,7 +108,7 @@ KERN_NOENTRY = $(filter-out $(BUILD)/kernel/arch/x86_64/entry.o, $(KERNOBJS))
# Third-party software does NOT go here — see ports/.
# -----------------------------------------------------------------------
ULIB = $(addprefix $(BUILD)/lib/, start.o glue.o usys.o)
CMDS = schinit schinitd fbinfo shmtest socktest inputtest nettest reflink schattr ping chvt pcitest vgpud schdbg schmode netecho
CMDS = schinit schinitd fbinfo shmtest socktest inputtest nettest reflink schattr ping chvt pcitest vgpud schdbg schmode netecho ldlsd
CMDBINS = $(addprefix $(BUILD)/cmd/, $(CMDS))
# -----------------------------------------------------------------------
@ -798,6 +798,23 @@ $(BUILD)/lib/%.o: lib/%.S
@mkdir -p $(BUILD)/lib
$(CC) $(CFLAGS) -c $< -o $@
# ldlsd links the LDLS core as well as its own main, so it needs a rule of its
# own -- a specific rule beats the pattern one below. ldls/ko.c, btf.c and
# stub.c are freestanding and build unchanged for either side, which is the
# whole point of having written them that way.
LDLS_GUEST = $(addprefix $(BUILD)/ldls/guest/, ko.o btf.o stub.o)
$(BUILD)/ldls/guest/%.o: ldls/%.c ldls/ko.h ldls/btf.h ldls/stub.h
@mkdir -p $(BUILD)/ldls/guest
$(CC) $(CFLAGS) -c $< -o $@
$(BUILD)/cmd/ldlsd: cmd/ldlsd/main.c $(ULIB) $(LDLS_GUEST)
@mkdir -p $(BUILD)/cmd
$(CC) $(CFLAGS) -I./ldls -c $< -o $(BUILD)/cmd/ldlsd.o
$(LD) $(LDFLAGS) $(LD_LOWBASE) -e _start -Ttext 0x4000 -o $@ \
$(BUILD)/cmd/ldlsd.o $(LDLS_GUEST) $(ULIB)
$(OBJCOPY) -S $@
$(BUILD)/cmd/%: cmd/%/main.c $(ULIB)
@mkdir -p $(BUILD)/cmd
$(CC) $(CFLAGS) -c $< -o $(BUILD)/cmd/$*.o
@ -960,6 +977,29 @@ $(LDLS_TYPES): ldls/host/types.c ldls/btf.c ldls/btf.h
ldls: $(LDLS_INSPECT) $(LDLS_RUN) $(LDLS_TYPES)
# Put a module and its kernel's BTF into the *local* sysroot, for testing ldlsd
# in QEMU.
#
# make ldls-stage KO=/path/to/8139too.ko BTF=/sys/kernel/btf/vmlinux
#
# This is the model docs/plan/ldls.md describes, carried out on the machine
# that is doing the testing: the user obtains a module and loads it on their
# own machine. Deliberately **not** a dependency of `all`, so an ordinary build
# never picks it up, and `.gitignore` refuses `*.ko` so it cannot be committed
# by accident. An image built after this has somebody else's GPL code in it and
# must not be redistributed -- which is exactly what licensing.md §6 says about
# the combination, and why it is one command you have to type on purpose.
ldls-stage:
@test -n "$(KO)" || { echo "usage: make ldls-stage KO=<module.ko> [BTF=<vmlinux-btf>]"; exit 1; }
@mkdir -p $(SYSROOT)/lib/modules
cp "$(KO)" $(SYSROOT)/lib/modules/
@test -z "$(BTF)" || cp "$(BTF)" $(SYSROOT)/lib/modules/btf
@echo "staged into $(SYSROOT)/lib/modules -- this image is now yours alone"
ldls-unstage:
rm -rf $(SYSROOT)/lib/modules
@echo "removed"
# Load our own object and check the loader got it right. No network, no QEMU,
# no module from anywhere.
ldls-check: $(LDLS_INSPECT) $(LDLS_RUN) $(LDLS_TYPES) $(LDLS_REFMOD)