D400 - The payload builds its own syscall entry, and this hands it something with no inside

measured - 2026-08-30

Past the entry-argument wall (D399), elfldr faults reading 0x2001 inside an unrelated stub. That looked like a bad handle being dereferenced. It is not: it is a jump landing in the middle of the thunk table, and the mechanism is legible in the payload's own instructions.

What is measured

There is a trampoline that shifts every argument down one register - rsi to rdi, rdx to rsi, rcx to rdx, r8 to r10, r9 to r8, the stack slot to r9 - and then calls through one global. That is the shape of a syscall shim: argument zero is the number, and r10 rather than rcx is where the fourth argument belongs, which is the kernel convention and not the C one.

That global is built like this:

4ab2:  mov  %rax,0x5cb20     ; a pointer the runtime has just obtained
4ae9:  mov  0x5cb20,%rax
4af5:  add  $0xa,%rax        ; ten bytes into it
4af9:  mov  %rax,0x5cb20     ; and that is the syscall entry from here on

So the payload does not ask for a syscall gadget. It takes a pointer it already has, skips ten bytes into it, and calls that for every system call it will ever make. On the target that is a function whose eleventh byte begins a syscall instruction - the ordinary trick of reaching the instruction inside a wrapper rather than the wrapper's prologue.

Why it fails here

Every address this project hands a guest is a thunk, and a thunk has no inside. Ten bytes into one is either the middle of its own body or the start of the next one, so the payload's first system call enters an arbitrary stub with a syscall number in rdi. The stub it happened to land in reads its first argument as a pointer, and that argument was 0x2001 - a module handle, which is why the fault looked like a handle problem.

This is the same class as D378, where the runtime wanted a syscall gadget in a named global and got one. The difference is that this payload never names anything: it derives the entry from a pointer, so there is no name to serve.

Which pointer it is, settled by watching the write

The disassembly could not say: the instruction reads the handoff's field zero, and a comparison against a freshly resolved address sits between the read and the store. Either answer fitted, and they want different fixes - so it was left open rather than guessed at, and a watchpoint on the global answered it in one run.

Three writes arrive, in order:

written by value
image+0x4743 0x0 - the runtime clearing its own .bss
a host address, inside this project's own resolver 0x7000000015a0
image+0x4b00 0x7000000015aa

The second is this project writing an answer into the guest's out-parameter, and the value is the thunk for getpid. The third is the payload's own add $0xa. So the pointer is a resolved symbol, not the resolver, and the offset is applied to whatever address this project answered a name with.

That is the harder of the two answers. It is not one field to fill: it is a property of every thunk a guest might resolve, because the guest picks the name.

Why it lands in a particular wrong function

A stub is twenty-three bytes of instructions in a thirty-two byte slot:

[0..2]    mov r10, imm64      the index the dispatcher switches on
[2..10]   the index
[10..12]  mov r11, imm64      <- ten bytes in is exactly here
[12..20]  the dispatch trampoline
[20..23]  jmp r11

Ten bytes in is already a valid instruction boundary, and what sits there is mov r11, trampoline; jmp r11. So the payload does not jump into the middle of an instruction and crash on garbage - it jumps into the dispatcher correctly, having skipped only the part that loads the index. The dispatcher then switches on whatever r10 happened to hold.

That is worth knowing because it changes what the failure is. It is not corruption and it is not random: it is a well-formed call to an arbitrary function, chosen by a stale register. The stub it reached read its first argument as a pointer and that argument was 0x2001, which is how a module handle ended up looking like the cause twice over.

It also means a guest could get anywhere this way, quietly, if the stale index happened to name something that returned plausibly instead of faulting.

What that means for the fix

A thunk must have something usable ten bytes in. On the target, getpid is a wrapper whose syscall instruction sits at that offset, and the payload knows it - which is why it resolves an ordinary function rather than asking for a gadget.

This project cannot answer with a real syscall: guest code runs natively on the host, so the instruction would trap to the host's kernel rather than to this one. What it can do is place its own gadget - the one D378 already builds, which calls the dispatcher and returns - at that offset in every thunk, so the resolve-and-offset convention lands somewhere real whichever name a guest picks.

Built, and what it changed

A stub is now sixty-four bytes: a short jump over a landing zone, the zone a sled of one-byte nops falling into mov r11, gadget; jmp r11, then the dispatch path. Entering at any offset from two to sixteen slides into the gadget, so eight, ten and twelve all arrive - the number is not this payload's ten, because the offset belongs to whichever C library build a guest was written against and serving exactly one of them would work once and mislead after.

jmp and not call into the gadget: the guest reached the stub by calling, so its return address is already on the stack and the gadget's own ret takes it home.

With it, elfldr resolves getpid, builds its entry from that thunk plus ten, and makes three real system calls through it:

0    649  nothing here implements it
1     20  getpid
2     20  getpid

Two of them are served. That is the whole path working end to end - resolve, offset, land, dispatch, return - where before it was a jump into an arbitrary stub.

The payload then stops at its own ud2, the trap it places after a call that must not return: it reads a status through handoff field five, calls its terminal exit, and this project's stub returns from it. So the next wall is syscall 649, which is a named thing to implement rather than a mystery, and after it a notion of a call that does not come back.

The fallback path this decision started from is never taken: handle 1 succeeds, so 0x2001 is only ever reached when it fails. It was a red herring three times over - first as a supposed constant, then as a handle being dereferenced, and finally as a number that simply never comes up on the path a working run takes.