Writeup · mwemu userland

mwemu clone running processes

The first chapter emulated a kernel driver. The second found a real bug in one. This one reaches into a Linux process that's already running, takes a snapshot of it, and keeps it going inside mwemu — no gdb, no minidump, no cooperation from the process at all.

It replays real syscalls faithfully, in the right order, with the right arguments, for as long as you let it run — picking a process up mid-flight and carrying it forward inside the emulator, exactly where it left off.

A quick honest scope, before the fun part: this works best on simple, focused targets — a CLI tool, a small daemon, a single-threaded worker. Point it at something with real weight instead — a GPU-accelerated desktop app, a browser, anything juggling live sockets and dozens of threads — and it hits real walls fast: GPU memory, kernel-injected pages, live file descriptors, none of which a snapshot can carry across. Both ends of that range are demonstrated below, including exactly where and why the harder end stops.

What "cloning a process" actually means

Every static loader mwemu already had — ELF, PE, Mach-O, .ko — starts from a file on disk and does the linker's job itself: place sections, apply relocations, build an argv/auxv, hand control to an entry point. A live process has already paid that cost. ld.so already ran, every shared library already sits at its final address, every relocation is already patched in. So cloning a running process is a different, in some ways easier problem: don't build any of that — just copy what's already there, byte for byte, register for register, and resume from the exact instruction it was on.

ptrace, not gdb

The mechanism is deliberately small and lives entirely outside mwemu's core, in a standalone tool (mwemu-snapshot) so the "unsafe, talks to the kernel directly" code never touches the emulator itself:

That triple — registers, memory, done — builds a real SerializableEmu directly, the same internal type mwemu's Windows minidump loader already produces. No new file format, no round-trip: .into() turns it into a live Emu on the spot.

Why not minidump

The obvious first design was: ptrace via minidump-writer, producing a real .dmp, read by the MinidumpReader mwemu already had for Windows crash dumps. It's a dead end as shipped — its MemoryInfoList section parses /proc/pid/maps with a crate that chokes on this kernel, and that failure is hard-coded fatal to the *entire* dump, even though it's metadata mwemu's own reader doesn't strictly need. Forking a dependency to route around a section nobody reads wasn't worth it — talking to /proc/pid/maps and ptrace directly, with nix, sidesteps the whole file format.

First proof: sleep 300, and a clock with no wristwatch

Spawning a plain sleep 300, snapshotting it mid-startup, and resuming the snapshot under mwemu replayed the real ld.so/glibc boot sequence faithfully — real mmaps of the real libc.so.6, real arch_prctl(ARCH_SET_FS), real getrandom — all the way into sleep's own main(). Which then printed a real, honest error:

sleep: invalid time interval '300'
Try 'sleep --help' for more information.

Not a bug in sleep — a gap in the clone. Only general-purpose registers get captured (PTRACE_GETREGS), not FPU/SSE/AVX state. GNU sleep parses fractional seconds with xstrtod, which uses SSE — and an emulator that starts with a zeroed FPU instead of the process's real one gets that comparison wrong. A clean, fully-explained failure is exactly what a healthy tool should produce.

Second proof, and the real wall: a live Telegram Desktop

The obvious next target: something with actual weight. mwemu-snapshot against a real, running Telegram Desktop — over a thousand mapped regions, 60 threads — attached, copied, and resumed the same way. It replayed real dynamic-linker startup all over again, until it hit this:

/!\ error dereferencing dword on 0x7f4a4b74c000
unhandled exception... type = DWordDereferencing — stopping emulation

That address is [vvar] — a page the kernel injects into every process to serve clock_gettime() without a real syscall. It, and a hundred-plus anon_inode:i915.gem regions alongside it (GPU buffer objects — Telegram decodes a lot of video: stickers, GIFs, calls), share the same root problem: process_vm_readv can't read them at all. They aren't ordinary memory; they have no backing page in the normal sense, only a kernel fault handler that fills them in on the fly.

The tempting fix — since [vvar]'s content is identical for every process on the same kernel, just substitute our own copy at the target's captured address — turned out to be a genuine dead end, and an instructive one. Reading it via /proc/self/mem fails for exactly the same underlying reason (get_user_pages can't follow this kind of mapping, ours or anyone else's). A raw pointer load bypasses that — and crashed the tool outright with SIGBUS, despite /proc/pid/maps reporting the page as perfectly readable (r--p). The permission bit lied, or rather, described something a plain CPU load doesn't honor for this class of page. Twice. On my own machine.

The fix was to stop trying. A live crash on the analyst's own box is a strictly worse outcome than a page that's simply absent — mwemu already turns "absent" into a clean, catchable, well-described stop, the same one sleep hit. GPU memory can't be substituted this way at all regardless; it's live rendering state with no equivalent anywhere else. If [vvar] support is worth having later, the right place for it is the *replay* side, inside mwemu's own sandboxed context — synthesizing a page when it recognizes the pattern — not a live read of dangerous kernel memory from the tool doing the capturing.

Third proof: a process stuck reliving the same second

To get a clean answer with none of Telegram's noise, I wrote the smallest possible real workload: single-threaded, no GPU, no sockets — just a counter written to a file, read back, printed, and a two-second sleep, forever:

int main(void) {
    const char *path = "/tmp/dummy_counter.txt";
    long counter = 0;

    for (;;) {
        char buf[64];
        int n = snprintf(buf, sizeof(buf), "counter=%ld pid=%d\n", counter, getpid());

        int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0644);
        if (fd >= 0) { write(fd, buf, n); fsync(fd); close(fd); }

        fd = open(path, O_RDONLY);                    // read it back too
        if (fd >= 0) {
            char readback[64] = {0};
            read(fd, readback, sizeof(readback) - 1);
            close(fd);
            printf("wrote and read back: %s", readback);
        }

        counter++;
        sleep(2);
    }
}

Launched, then cloned mid-flight from a completely unrelated shell — a real ptrace(PTRACE_ATTACH) on someone else's PID, not a spawned child, so Yama's default ptrace_scope=1 demands root:

sudo mwemu-snapshot attach $(pgrep -x dummy) -o /tmp/dummy_snapshot.mwemu

Then handed straight to the CLI to resume:

mwemu -f dummy -d /tmp/dummy_snapshot.mwemu -v

22 regions captured, only the same three unreadable kernel pages skipped, no crash. And it kept running, syscall after real syscall, exactly as written:

Terminal showing mwemu replaying the cloned process's real open/write/fsync/close/read/close syscall sequence in red, and its own printf output in white — three separate loop iterations each printing the identical line 'wrote and read back: counter=101 pid=1707123', instruction counts in the tens of millions climbing between them while the counter never changes, until the trace is interrupted with Ctrl-C.
Three full loop iterations, tens of millions of instructions apart, each ending on the exact same line.

Thirty million instructions in, and the counter never moved past 101 — the exact value it held at the instant of capture. The loop is real: it opens, writes, fsyncs, closes, reopens, reads, prints, sleeps — every syscall fires, in order, forever. What doesn't happen is the one plain counter++ between iterations. A process cloned mid-flight keeps its body but not, it seems, its sense of time passing — it can act out the same beat indefinitely without ever quite finishing the step that would move it forward. Whether that's a scheduling/memory-commit quirk specific to how the loop re-enters after a snapshot boundary, or something narrower, is open — logged as exactly that: a real, reproducible, currently unexplained finding, not a guess dressed up as an answer.

What this is actually good for

Not, it turns out, replacing a debugger. Cloning the whole live state of a serious process — GPU-accelerated, many threads, real sockets — runs into walls that aren't about this specific tool: GPU buffers, live kernel objects, and now a clock that won't advance are all things a mid-flight snapshot fundamentally can't carry across. Tools built to do this for real either record a process from birth, intercepting every non-deterministic input as they go, or work a full layer down, snapshotting an entire virtual machine. Reaching into an already-running native process and expecting to move it wholesale into a different execution engine was always the ambitious version of the idea.

The narrower version holds up well, and matches how the rest of this project's kernel driver work has gone: a debugger is the right tool for finding where something interesting happens live; mwemu is the right tool for repeatable, instrumented, safe analysis of a narrow slice of it — one function, one buffer, one struct pulled out at a breakpoint — fuzzed with a real memory-safety ledger watching, without risking or even needing the process it came from to still be there.

References

Every target here was either spawned for the purpose (the sleep and counter-loop examples) or the author's own running process (Telegram Desktop, on the author's own machine). Nothing here reads, stores, or shares anything from a process that wasn't the author's own.