D366 - sceKernelDlsym is how a payload gets its C library, and the answer is a stub we already had

decided - 2026-08-29

D365 found that a payload's runtime is handed a resolver and asks it for names. This is what answering properly took.

The problem an import table cannot solve

A dynamic import is a name the linker resolved before the program started. The payloads barely use one: klogsrv carries vsnprintf, snprintf and sprintf as eight-byte objects in .bss, filled at startup by asking for one name at a time. Those names are in no import table and no relocation, so nothing indexed by symbol index can answer them.

This also settles what D359 half-saw. The null the payloads jump to is a .bss global - and it is .bss because the runtime was supposed to have filled it and never ran.

The answer is the stub that already existed

The thunk table grew a second population: after the guest's own imports, one stub per implemented name, with a name-to-address map published beside them. A lookup answers the same address the linker would have written, so a function reached by name and the same function reached by an import are one address, with one counter, one trace entry and one implementation. A resolver that minted its own answers would have created a second way for a call to behave, and the first divergence would have been unattributable.

One table rather than two, because dispatch is indexed by a single number and a second table would need a second trampoline, a second counter array and a second way to be wrong.

Three things that had to stay true

A stub count still means imports. len() is the guest's own; total() is every stub. A report saying "1,410 import stubs, 254 implemented" must go on meaning the guest's, not that number plus everything this emulator can answer - so implemented_count_within takes the import count and the summary passes it.

One list, walked twice. The binding says slot imports + n is resolvable()[n]; the call trace says the same thing in a different function. If the two orders could differ, a resolved call would be attributed to a different function than the one that ran - which is worse than no label, because it reads as evidence. There is now one list and a test that it is stable.

unknown is a label that cannot be looked into. Two different unlabelled stubs read as the same thing, so a trace showing both looks like one function called twice. It is unknown#<index> now, and that immediately distinguished two calls this session that had looked like one.

What the guest then said

the guest asked for the address of sceKernelDlsym - answered 0x7000000004a0
the guest asked for the address of getpid - answered 0x700000001580

Every distinct name, once, answered or not - the same shape as the sysctl report and for the same reason. A payload's resolution pass is the clearest statement it ever makes of what it needs, and it makes it before doing anything else, so printing only the failures would throw away the half that says what the runtime is built out of.

klogsrv now runs __crt_start into __kernel_init and stops there, on a field of the handoff structure that is still a marker. That is two walls further than this morning, and the next one is a measurement rather than a mystery.