Rambles around computer science
Rambles around computer science
Diverting trains of thought, wasting precious time
13 07 2026<br>--><br>Mon, 13 Jul 2026
System call instrumentation on Linux/x86-64 using memory-indirect calls (in vain?), part two
In my last post I identified some<br>approaches to system call instrumentation on x86-64 Linux<br>that combine instruction punning<br>with the memory-indirect call and (most eccentrically)<br>lcall (“far call”) instructions.<br>I mentioned these have some interesting differences with<br>the direct relative jmp or register-indirect call<br>used by straightforward instruction punning or zpoline approaches.
These differences appear to be fractionally useful if you care about<br>saving a small amount of memory<br>(relative to E9Patch),<br>keeping the memory map simple for debugging purposes<br>(cf. an incidental downside of E9Patch that might bite when debugging low-level<br>code),<br>about minimising introduction of new instructions for security reasons<br>(an advantage of the memory-indirect approach over conventional trampolines), and/or<br>about keeping the standard hardware null-pointer trapping in place<br>even for function pointers,<br>while avoiding any need for special low memory-mapping privileges or not-so-ubiquitous CPU features<br>(advantages over zpoline).
In this post I'll cover more details about how to make this work,<br>especially around the segmentation features on which far calls rely.<br>I've implemented one variant of the idea (the<br>%rax-relative lcall one) on the use-lcall branch of my libsystrap<br>repository.<br>“Details” is the word; if you're not into details you probably<br>don't need this post, and I'm putting this here partly so that<br>anyone searching on related topics might benefit from what I've learned.
Use of the Local Descriptor Table from Linux userspace
Let's recap from last time the picture we need for our %rax-relative<br>far calls.
:____________________:<br>| x x |<br>| patched text | the loaded binary whose text is patched<br>|x x x |<br>|____________________| (data segment somewhere around here too, not shown)<br>: :<br>~ ~ ~ ~ ~
~ ~ ~ ~ ~ ... a long way down...
~ ~ ~ ~ ~<br>:_ _ _ _ _ _ _ _ _ _ :<br>|1f1f1f1f1f1f1f1f1f1f| the bottom 2GB of the linear address space<br>|1f1f1f1f1f1f1f1f1f1f| ... 0x1f bytes in ranges identifiable as %rax-relative pun targets<br>|1f1f1f1f1f1f1f1f1f1f| of the patched instructions, or we can just fill the whole thing<br>|1f1f1f1f1f1f1f1f1f1f|<br>|1f1f1f1f1f1f1f1f1f1f| since there is a spread of possible %rax values, there is a<br>|..1f1f1f1f1f1f1f1f1f| spread of possible landing addresses, but two adjacent addresses can work fine<br>.____________________.
How does this work?<br>For any system call that we patched to instead be a far call<br>indirecting %rax-relativewise via the low 2GB,<br>our lcall *0xnnnn(%rax) instruction we will read the six-byte far address<br>0x1f1f:0x1f1f1f1f.<br>The 16-bit selector 0x1f1f denotes an LDT entry, number 0x3e3 (discard the bottom three bits of 0x1f1f).<br>So if we put our handler code at 0x1f1f1f1f,<br>and install an LDT entry at slot number 0x3e3, with base address zero,<br>we can start to handle these calls.<br>(The base address doesn't have to be zero... I will come back to this.<br>And we don't have to pick 0x1f as our repeating byte—if our LDT is<br>otherwise empty, then any selector ending 7 or f will map to an LDT entry we can use,<br>so we have 32 byte values to play with.<br>0x1f is good because it's always an illegal instruction<br>in x86—just in case this memory gets executed by mistake somehow.)
Let's look at the modify_ldt system call.<br>It's a very confusing beast for a number of reasons.<br>Firstly, despite the name, it can be used to read the LDT as well as writing it.<br>Secondly, there are two flavours of write operation.<br>Thirdly, it uses different data formats for reading and writing.<br>The manual page gives it the following signature:
int modify_ldt(int func, void *ptr, unsigned long bytecount);
... while noting that<br>glibc provides no wrapper, so in reality we have to use syscall().<br>We get the read function if passing func equal to 0,<br>and two different write functions<br>if passing func equal to 1 or 17 (0x11).
Although the manual page is not clear on this,<br>the read function (func == 0) will read raw LDT entries in the hardware-defined format.<br>This is C-ified by Linux, inside the kernel, as follows<br>(from Linux 6.18).
struct desc_struct {<br>u16 limit0;<br>u16 base0;<br>u16 base1: 8, type: 4, s: 1, dpl: 2, p: 1;<br>u16 limit1: 4, avl: 1, l: 1, d: 1, g: 1, base2: 8;<br>} __attribute__((packed));
One entry is eight bytes in size, and decoding its idiosyncratic narrow-width fields requires<br>reading<br>the Intel documentation.
By contrast, the write function updates a single entry, at the given index, where the entry is<br>described by the user_desc structure<br>as seen in the manual page.
struct user_desc {<br>unsigned int entry_number;<br>unsigned int base_addr;<br>unsigned int limit;<br>unsigned int seg_32bit:1;<br>unsigned int contents:2;<br>unsigned int read_exec_only:1;<br>unsigned int limit_in_pages:1;<br>unsigned...