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

@ -182,7 +182,7 @@ One device, one process, claimed and released, on the `/dev/pci` discipline.
| **L1** | **Stubs.** Every symbol resolves, to a function that logs its name. `init_module` runs, which yields the *call order* — the second thing you need and the other thing you cannot guess. | **done** — `ldls/stub.c`, `ldls-run` |
| **L2** | **The hardware.** `probe()` receives a working `struct pci_dev`, maps its BARs, allocates coherent DMA and registers an interrupt, all through `/dev/pci`. The driver reaches its device. | **done**, except `/dev/pci` — see below |
| **L3a** | **The class bridge.** `/dev/netdev`: a process claims it and becomes an interface. `ping` over a *process*. | **done** — `kernel/dev/netuser.c`, `cmd/netecho` |
| **L3b** | The SchOS-side host: `ldls-run`'s loader and BTF work, as a program on SchOS, reaching hardware through `/dev/pci`. | |
| **L3b** | The SchOS-side host: `ldls-run`'s loader and BTF work, as a program on SchOS. | **done** — `cmd/ldlsd`; `/dev/pci` is L3c's half |
| **L3c** | The fifteen symbols, implemented rather than stubbed, against a real rtl8139. | |
| **L3** | **A packet.** The two joined: **`ping` over a Linux driver.** This is the milestone that proves the whole idea and the one that cannot be skipped. | |
| **L4** | **Kill it.** The host process dies, the kernel clears bus mastering and reclaims the DMA, a new host starts and the interface comes back. | free — H2 built all of this |
@ -547,6 +547,94 @@ allowed to include lwIP's headers.
---
## L3b: the same loader, on SchOS
`ldls/ko.c`, `ldls/btf.c` and `ldls/stub.c` were written freestanding so they
would build unchanged on both sides. `cmd/ldlsd` is the collection of 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
/lib/modules/8139too.ko
module 8139too
license GPL
wants 94 symbols
defused 6 %gs stack-guard reference(s)
loaded 33988 bytes at 0x000000004001f000
calling init_module
1 _printk(...)
| 8139too: 8139too Fast Ethernet driver 0.9.28
2 __pci_register_driver(...)
init_module returned 0 after 2 stub call(s)
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 behaviour of the host harness, which is what the shared
source was for. Two predictions from earlier milestones came good without any
work: a process's mmap area starts at `0x40000000`, so the module lands inside
the window `R_X86_64_32S` needs, and the kernel never sets `EFER.NXE`, so every
mapping is executable.
That second one is **a gap, not a feature**. A process being able to execute
its own heap is normally a thing an operating system prevents, and LDLS is
quietly relying on its absence. It belongs on Gate 0's list.
### The one thing that did not just work
The first run on SchOS faulted before probe's first call:
```
pid 8 ldlsd: trap 14 err 5 on cpu 0 rip 0x4002138e addr 0xffff80007de5fc60
```
`err 5` is present, read, user mode, and the address is a *kernel* one. The rip
resolved to `.text+0x206e`, which is:
```
mov %gs:0x0(%rip),%rax # rtl8139_init_one+0x26
```
Linux builds modules with `-mstack-protector-guard-reg=gs`, so the stack canary
is fetched through the GS segment. That is an ABI between the kernel and its
own per-CPU area, and **a host that is not that kernel cannot provide it** — on
SchOS the GS base belongs to the kernel, so a user-mode read of it faults on a
kernel address.
`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`) followed by a `jne` to
`__stack_chk_fail`. `xor` is correct for both — it makes the saved canary zero,
and it 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. A loader that half-understands an instruction
should not be editing it — and that refusal is what found the `sub` form, by
declining to guess.
### Getting the files there
`make ldls-stage KO=… BTF=…` copies a module and its kernel's BTF into the
local sysroot. It is deliberately not part of `all`, `.gitignore` refuses
`*.ko`, and `make ldls-unstage` removes them again. An image built between
those two commands has somebody else's GPL code in it and must not be
redistributed — which is exactly what [../licensing.md](../licensing.md) §6
says about the combination, and why it is a thing you type on purpose.
---
## What it will not do
- **It is not a security boundary, and here that is worse than usual.** There
@ -592,6 +680,14 @@ allowed to include lwIP's headers.
- **`ldls-run` is a development harness, not the deliverable.** It runs on the
build machine so that loader failures cost a second instead of a boot. The
thing that ships is the SchOS-side host.
- **The stack protector is gone from a hosted driver.** `ko_defuse_gs()`
removes it because the ABI it depends on cannot be supplied here. That is a
hardening measure lost, on foreign code, in a host with no IOMMU — worth
saying plainly rather than filing as a detail.
- **LDLS relies on SchOS having no NX.** Every mapping is executable because
`EFER.NXE` is never set. If that is ever fixed — and it should be — `ldlsd`
needs a way to ask for executable memory, and nothing in `mmap` currently
offers one.
- **BTF has to come from somewhere.** On Linux it is `/sys/kernel/btf/vmlinux`.
SchOS has no such file and never will — so the user brings the BTF along with
the module, from the kernel the module was built for. Both are theirs,