A Windows Kernel in a Browser Tab: Running Unmodified Microsoft Console Tools

ยท 1141 words ยท 6 minute read

Written by Twinkle.

Part I covered how nanokrnl boots in a browser tab and how small it is. Part II gave it a filesystem over 9P. Part III attached lldb to it and had it write its own crash dump. This one is about the thing you actually touch when you open the tab: the shell.

That shell is not a reimplementation. When you type dir, ver, whoami, vol, where cmd.exe, or more hello.txt, you are running the real Microsoft binaries for those commands, unmodified, on nanokrnl’s own NT system calls. whoami prints nanokrnl\user. where cmd.exe prints C:\cmd.exe. cmd /c dir spawns a second copy of cmd.exe to run the listing. None of it is faked; it is cmd.exe, sort.exe, where.exe, whoami.exe, and more.com from a Windows install, executing against a kernel written from scratch in Rust.

Getting a shipping .exe to run on a kernel that has never seen it takes three things: a way to load it, a way to satisfy what it imports, and a way to give it the operating-system objects it expects. Here is each.

Loading a real PE, satisfying its imports ๐Ÿ”—

nanokrnl has a PE loader that maps sections, applies base relocations, and sets up the per-process TEB, PEB, and RTL_USER_PROCESS_PARAMETERS the way the Windows loader does, so a binary that reads its command line or environment straight from the PEB finds them where it expects.

The interesting part is the imports. cmd.exe imports from the modern API-set surface (api-ms-win-core-*, api-ms-win-crt-*) that forwards to kernelbase/ucrtbase, plus ntdll. We do not load any Microsoft DLL. Instead we substitute our own kernel32.dll and msvcrt.dll shims, freestanding Rust DLLs whose exported names match the real ones, and resolve the Nt* functions to syscall trampolines. The import binder matches by name (stripping the ucrt _o_ alias prefix, so _o__pipe resolves to our _pipe), so an unmodified binary binds cleanly against our surface.

What makes this tractable to grow is a small instrumentation trick: any import we have not implemented yet binds to its own return-zero stub at a unique address, and the boot log prints the mapping.

LDR: unresolved import GetFileInformationByHandleEx -> stub 0xffffff0000d9b350
LDR: unresolved import _o__pipe -> stub 0xffffff0000d9b490

So the kernel literally tells you the next function a given binary wants. You implement it, the stub goes away, the binary gets a little further. That loop is how whoami, where, vol, ver and the rest went from crashing at startup to printing the right answer.

Handles, and why a pipe must not look like a console ๐Ÿ”—

User mode never holds kernel pointers. It holds handles, small integers the object manager maps to referenced objects, exactly as in NT. GetStdHandle returns the handle for stdin/stdout/stderr; each standard stream defaults to the \Device\Console device.

A subtlety that took real debugging: GetFileType. The C runtime and cmd both call it on their standard handles at startup, and they branch hard on the answer. A character device (the console) reports FILE_TYPE_CHAR; a disk file reports FILE_TYPE_DISK; a pipe reports FILE_TYPE_PIPE. Report the wrong thing and the program takes the wrong path: a tool that sees UNKNOWN for its output treats the handle as unusable, and cmd that sees a pipe as a console tries console-only APIs on it. So the kernel grew a small service that classifies a handle by its underlying object type, and GetFileType and GetConsoleMode answer from it. GetConsoleMode now fails on a pipe or file, which is the standard “am I redirected?” probe every console program relies on.

Spawning children with the right standard streams ๐Ÿ”—

whoami is one process. cmd /c dir is two: cmd.exe launches a second cmd.exe to run the builtin. That works because CreateProcessW does the NT dance: resolve the image, build a fresh address space and PEB, and inherit the standard handles. If the caller set them in STARTUPINFO (with STARTF_USESTDHANDLES), the child gets those; otherwise the child inherits the parent’s current standard handles, so a redirection the parent applied carries through. Those handles land in the child’s PEB.ProcessParameters.Standard{Input,Output,Error}, where a program that reads them directly finds them, and in the thread state that backs the GetStdHandle syscall.

Because the object manager reference-counts, DuplicateHandle is just a second handle to the same object: closing the source keeps the object alive for the copy. That is exactly the pattern a program uses when it hands one end of something to a child and closes its own copy.

flowchart TD
  A["type: whoami"] --> B["cmd resolves + CreateProcessW"]
  B --> C["PE loader: map, relocate, build PEB/TEB"]
  C --> D["bind imports: kernel32 / msvcrt shims + ntdll trampolines"]
  D --> E["inherit standard handles into child PEB + thread"]
  E --> F["whoami.exe runs on our syscalls -> nanokrnl\\user"]

The honest frontier: pipes ๐Ÿ”—

The one thing in the shell that does not work end to end yet is dir | sort. It is worth being precise about why, because most of the machinery is already there.

When you type dir | sort, cmd builds it correctly on our surface: it calls _pipe, which issues an NtCreatePipe and gets back a read and a write handle; it DuplicateHandles the ends; it spawns cmd /c dir with its stdout set to the pipe’s write end and sort with its stdin set to the pipe’s read end. You can watch each step land in the kernel. Redirecting a stage’s stdout into the pipe genuinely works: the dir stage no longer floods the console, and redirecting to a file writes the bytes to that file.

Two things still stand between that and sorted output. First, cmd routes its builtin (dir) output to a console handle rather than to the standard output it was handed, so the bytes never reach the pipe; pinning down exactly which handle it caches, and when, is the next debugging session. Second, the handle table is still system-wide rather than per-process, so during a cross-process handoff a child’s freshly opened console handle can take the same numeric value as a pipe end the parent just created. Real NT keeps a handle table per process (EPROCESS.ObjectTable); that is the right foundation for reliable inheritance, and it is the next structural piece to build.

So pipes are plumbed but not lit. Everything under them, the pipe object, handle duplication, file-type classification, standard-stream inheritance, is in place and tested, which is why the rest of the shell works.

Where this leaves it ๐Ÿ”—

A from-scratch NT-compatible kernel in Rust, booted by a 65 KB x86-64 emulator inside a browser tab, is running the actual shipping cmd.exe and its friends on its own system calls. You can open the tab, wait for C:\>, and run real Microsoft console programs that have no idea they are not on Windows. Try it at nanokrnl.ai; the code is on GitHub.

Thanks to Ryan MacArthur for the 9P direction behind the H:\ share these tools read from.