(This documentation was made with the help of Claude)
A from-scratch x86-64 operating system, written in C and NASM assembly with no external libraries, no cross-compiler runtime, and no borrowed kernel code. It boots on BIOS, brings the CPU from 16-bit real mode to 64-bit long mode, and runs a network honeypot: a ring-3 service that impersonates a compromised ASUS RT-AC66U router over telnet, admits any login, presents a fake BusyBox shell backed by planted "bait" files, and records every credential and command — with the attacker-facing code confined to an unprivileged, syscall-filtered sandbox so that compromising it reaches nothing of value.
This README is a complete technical reference: every subsystem, the exact data structures and constants it uses, how the pieces enforce the security boundary, and an annotated map of every file in the tree.
clangtargetingx86_64-unknown-none-elf, freestanding (-ffreestanding -nostdlib -mno-red-zone -fno-stack-protector) for the kernel.- User programs additionally use
-mcmodel=largebecause they link at0x8000000000, above the small code model's ±2 GiB reach. ld.lldfor linking,nasmfor assembly,qemu-system-x86to run,python3for the filesystem packer.
make # build build/os.img
make run # QEMU: RTL8139 NIC, host port 4444 forwarded to guest telnet (23)
make cleanmake run uses -netdev user,id=net0,hostfwd=tcp::4444-:23 -device rtl8139,netdev=net0.
Connect with telnet localhost 4444.
The image is assembled by concatenating the boot stages and the kernel, truncating to a fixed size, then writing the filesystem at a known sector. Nothing overlaps:
| Sector(s) | Size | Contents |
|---|---|---|
0 |
512 B | Stage-1 boot sector (MBR), loaded by BIOS at 0x7C00 |
1–8 |
4 KiB | Stage-2 bootloader |
9–208 |
up to 100 KiB | Kernel ELF (KERNEL_LBA=9, up to KERNEL_SECTORS=200) |
220+ |
~18 KiB | Telemetry session log (header + records), magic LOG1 |
256+ |
variable | simplefs filesystem, magic SFS1 |
KERNEL_OFFSET = 0xFFFFFFFF80000000. USER_LIMIT = 0x0000800000000000 (the boundary the
syscall layer uses to reject user pointers into kernel space).
| Region | Base | Protection |
|---|---|---|
| User program image | 0x8000000000 |
per-ELF-segment, USER bit set |
| User stack top | 0x8001000000 (4 pages below) |
RW + NX + USER |
| Kernel image (higher half) | 0xFFFFFFFF80000000 |
per-section W^X (see VMM) |
| Kernel heap | 0xFFFFFFFFC0000000 |
RW + NX |
| Low direct map (first 1 GiB) | identity, 2 MiB pages | RW + NX |
At power-on the machine is a 16-bit processor executing 512 bytes of our code at 0x7C00.
Two stages bring up a 64-bit environment and load the kernel.
The MBR boot sector. Uses BIOS INT 13h extended (LBA) reads to load stage 2 from sectors 1–8 into memory, then jumps to it. Kept minimal to fit in one 512-byte sector.
- E820 memory map — queries the BIOS for the physical memory map, storing it at
MMAP_BUFFER = 0xA000and the entry count in aboot_infostructure atBOOT_INFO = 0x9000. - A20 line — enables and then verifies A20 so addresses above 1 MiB are reachable.
- Page tables — builds initial tables that identity-map the first 1 GiB (2 MiB pages)
and also map the kernel into the higher half via
PML4[511]. - Long mode — sets PAE, loads the page tables, sets
EFER.LME, enables paging, and far-jumps into 64-bit code. - ELF loader — parses the kernel ELF's program headers and copies each
PT_LOADsegment to its physical address (p_paddr), then jumps toe_entry. - Chunked disk read — the kernel is read in 32 KiB chunks (64 sectors), advancing
the destination real-mode segment by
0x800paragraphs after each chunk, so no single BIOS read ever crosses a 64 KiB boundary. This allows the kernel to exceed 128 sectors without silent truncation.
Every step prints a [boot] ... line to the serial port for diagnosability.
Entry is kernel/entry.asm, which establishes a stack and calls kmain.
kernel/kmain.c initializes all subsystems in dependency order, then launches the
sandboxed honeypot process; the kernel then idles (hlt) while the scheduler runs user
processes.
16550 UART on COM1 (0x3F8), 115200 baud, 8N1. kprintf implements %c %s %d %u %x %p and the long forms %lx %lu %ld; output translates \n to \r\n. Input is
interrupt-driven: serial_input_init unmasks IRQ 4 and installs serial_irq, which
pushes received bytes into a ring buffer that kgetc drains (non-blocking). The serial
port is both the operator console and the sink for all human-readable logs.
The Global Descriptor Table defines four segments plus a Task State Segment:
| Selector | Descriptor | Ring |
|---|---|---|
0x08 |
kernel code | 0 |
0x10 |
kernel data | 0 |
0x18 |
user data | 3 |
0x20 |
user code | 3 |
The 64-bit TSS holds rsp0, the kernel stack the CPU switches to on a ring-3 → ring-0
transition (updated per-process by the scheduler via tss_set_rsp0). Its iomap_base is
set to sizeof(tss), meaning there is no I/O permission bitmap — ring 3 cannot execute
in/out port I/O at all.
idt.cinstalls 48 gates: the 32 CPU exception vectors and 16 hardware IRQ vectors (32–47). The exception handler prints a full register dump and halts.isr.asmcontains the assembly entry stubs for all 48 vectors. A sharedirq_commonsaves registers, calls the C dispatcher, and sends EOI.interrupts.cremaps the 8259 PIC so IRQs arrive at0x20–0x2F(off the exception vectors), routes each IRQ throughirq_dispatchto a registered handler, and exposesirq_set_tick_hook(used by the scheduler) pluspic_unmask/mask/eoi.
Programs the PIT to 100 Hz. timer_ticks() returns the monotonic tick count. The
single timer interrupt drives both preemptive scheduling (via the tick hook) and telemetry
timestamps.
memset, memcpy, memmove, memcmp. A freestanding compiler emits calls to these for
aggregate copies and initializers, so they must be provided.
A bitmap frame allocator over the E820 usable regions, its bitmap placed immediately
after the kernel image (_kernel_end). pmm_alloc_frame/pmm_free_frame hand out 4 KiB
physical frames; every page the kernel or a process ever gets comes from here. Physical
addresses are converted to kernel-virtual by adding KERNEL_OFFSET.
The kernel builds its own 4-level page tables (PML4/PDPT/PD/PT; index macros shift by 39/30/21/12). Protections:
- W^X, per section.
.textis mapped read + execute;.rodataread-only + NX;.data/.bssread/write + NX. No page is simultaneously writable and executable. CR0.WPis set, so the kernel itself cannot write through read-only mappings.- Low direct map of the first 1 GiB uses 2 MiB huge pages (
PTE_HUGE, bit 7) marked NX, giving the kernel access to physical RAM without executable low memory. - Per-process address spaces:
vmm_new_addrspaceallocates a fresh PML4 (sharing the kernel's higher-half entries),vmm_map_to/walk_rootmap into a specific space, andvmm_frame_inlooks up an existing mapping (used by the ELF loader). - SMEP (
CR4.SMEP) is enabled whenCPUID.7.EBX[7]reports support, so the CPU faults if the kernel ever executes a user-mode page (reported at boot as[cpu] SMEP enabled; a safe no-op on CPUs without it).
PTE flags: PTE_PRESENT, PTE_WRITABLE, PTE_USER, PTE_HUGE, PTE_NX (bit 63); the
physical frame occupies bits 12–51 (ADDR_MASK 0x000FFFFFFFFFF000).
A free-list allocator (kmalloc/kfree) serving a dedicated region at
HEAP_BASE = 0xFFFFFFFFC0000000, backed by PMM frames mapped RW+NX. Used for per-process
kernel stacks and bookkeeping.
- Scheduler: preemptive round-robin, driven by the timer tick (
sched_initinstallsscheduleas the tick hook). Each thread record holds its savedrsp,cr3, kernel stack top,pid,is_user,done, and apolicybitmask. On a switch to a user thread,scheduleinstalls the newcr3, updatestss.rsp0andsyscall_kstack, and sets the globalscurrent_pidandcurrent_policy. switch.asm:context_switchsaves/restores callee-saved registers and swaps stacks;thread_trampolinestarts a kernel thread;process_user_trampolineusesiretqto drop a new thread into ring 3.spinlock.c: anxchg-based lock with an interrupt-saving variant.
The security-critical doorway.
Fast syscalls are configured via MSRs — EFER.SCE (enable), STAR (0x08 kernel /
0x10 user segment bases), LSTAR (= syscall_entry), and SFMASK = 0x700, which
clears IF, TF, DF on entry. Clearing IF means a syscall runs with interrupts masked —
atomically, non-preemptibly, which is what lets the network be serviced inside syscalls
without locks. syscall_entry switches to the per-process kernel stack (syscall_kstack)
before touching anything.
Convention: syscall number in rax; arguments in rdi, rsi, rdx (dispatched as
a1, a2, a3); return in rax.
Three enforced protections on every call (syscall_dispatch):
- Syscall filter (checked first).
if (num >= 32 || !((current_policy >> num) & 1))→ log[sandbox] pid N KILLED: forbidden syscall Mandthread_exit(). A process may only make the syscalls its policy bitmask permits; anything else is fatal. - User-pointer validation.
valid_user_range(ptr, len)rejects null, any address>= USER_LIMIT(kernel space), and length overflow — so ring 3 cannot direct the kernel to read/write kernel memory. - Per-process file descriptors.
fdt[MAXPID=16][MAXFD=16]; fd 0 is serial stdin (non-blockingkgetc), fds 3+ are open bait files. One process cannot touch another's.
Syscall table:
| # | Name | Signature | Behavior |
|---|---|---|---|
| 0 | exit |
(code) |
log and terminate the process |
| 1 | write |
(fd, buf, len) |
write bytes to the console (kputc) |
| 2 | getpid |
() |
return current_pid |
| 3 | read |
(fd, buf, len) |
fd 0 = serial input; fd ≥ 3 = read a bait file, advancing its offset |
| 4 | open |
(path) |
open a bait file, allocate a fd |
| 5 | close |
(fd) |
release a fd |
| 6 | accept |
() |
pump the network; return 1 if a connection is ready |
| 7 | recv |
(buf, len) |
pump the network; copy received bytes from the socket buffer |
| 8 | send |
(buf, len) |
queue bytes to the TCP connection |
| 9 | sockclose |
() |
close the connection |
| 10 | connected |
() |
is the connection still established? |
| 11 | uptime |
() |
timer_ticks() |
| 12 | log |
(buf, len) |
append a record to the on-disk telemetry log |
| 13 | peerip |
() |
the current attacker's IPv4 address |
Process creation (process.c): process_create(blob, pid, policy) builds a fresh
address space and calls load_user_elf, which maps each PT_LOAD page with permissions
from its flags (PTE_USER always; PTE_WRITABLE if writable; PTE_NX if not executable),
so user space is W^X too. It maps a 4-page user stack below USER_STACK_TOP = 0x8001000000 and a 16 KiB kernel stack from kmalloc. The loader is hardened: before
allocating a page it calls vmm_frame_in and, if the page is already mapped (two segments
sharing it), reuses that frame and copies into it rather than allocating a fresh zeroed
frame — so no segment can blank another's page.
- ATA PIO driver (
ata.c): primary master at I/O base0x1F0, LBA28,ata_read/ata_writeof 512-byte sectors. Backs the filesystem and the telemetry log. - simplefs (
fs.c): a read-only flat filesystem. Sector 256 is the directory — astruct { uint32_t magic /* 'SFS1' = 0x53465331 */, count; }header followed by up to 7 entries ofstruct { char name[56]; uint32_t start_lba, size; }(64 bytes each).fs_openmatches a full path;fs_readreads at an arbitrary offset, clipped to file size, sector by sector. Read-only means an attacker who explores the "router" cannot modify or plant files. mkfs.py(host tool): packs the bait intobuild/fs.img—/etc/passwd(fake root + admin accounts),/etc/motd(ASUS RT-AC66U firmware banner),/www/index.html(fake web-admin page), and/readme.txt.
Built bottom-up. Because every inbound byte is attacker-controlled, each parser length-checks before reading fields.
Inline inb/outb/inw/outw/inl/outl.
Configuration space via the 0xCF8 address / 0xCFC data ports; pci_find scans for a
vendor:device match.
Driver for the Realtek RTL8139 (vendor 0x10EC, device 0x8139).
- Init: enable bus-mastering DMA + I/O in the PCI command register, read the I/O base
from BAR0, power on (
0x52), software reset (0x37 |= 0x10, wait for it to clear), program the RX buffer physical address (0x30), set the interrupt mask (0x3C), set the receive config (0x44 = 0x0F | WRAP), and enable RX+TX (0x37 = 0x0C). The MAC is read from the first six I/O bytes. - DMA: buffers are static kernel arrays; their physical address is
virtual − KERNEL_OFFSET. - Transmit: 4 descriptors (
tx_buf[4][2048]); write buffer physical address to0x20 + n*4, length to0x10 + n*4, poll theTOKbit (0x8000). - Receive: ring buffer
rx_buf[8192 + 16 + 1536]; the slack plus theWRAPbit let a packet near the end run past it instead of wrapping mid-packet.rtl8139_receivechecksBUFE(0x37 & 0x01), reads the per-packet 4-byte header (status + length), copies the payload minus the 4-byte CRC, advances the read offset 4-byte-aligned, and writes it back toCAPR(0x38) with the hardware's−0x10offset quirk.
- Ethernet:
struct eth_hdr { dst[6], src[6]; type; }.net_polldrains all pending frames and dispatches by ethertype —0x0806→ ARP,0x0800→ IPv4. - ARP: answers requests for our IP (
OUR_IP = 10.0.2.15) and resolves others (arp_resolve), used to reach the gateway (10.0.2.2). - IPv4: version/IHL, total length, TTL, protocol, and the internet checksum (one's-complement 16-bit-word sum, folded and inverted).
- ICMP:
net_pingsends echo requests; the inbound handler answers echo requests in place (swap MACs and IPs, type 8 → 0, recompute both checksums) so the host responds to a ping and looks alive to a scanner. - TCP: a single-connection server state machine with states
CLOSED, LISTEN, SYN_RCVD, ESTABLISHED, LAST_ACKand flagsFIN 0x01, SYN 0x02, RST 0x04, PSH 0x08, ACK 0x10. It performs the three-way handshake, tracksour_seq/their_seqper byte (SYN and FIN each consume one sequence number), computes the TCP checksum over the pseudo-header (src/dst IP, protocol 6, length) plus the segment, transfers data, and tears down cleanly back toLISTEN.tcp_listen(port)arms it;tcp_write/tcp_closeare the service-facing send/close. - Socket layer: the connection is exposed to the ring-3 service through an 8 KiB receive
ring (
sock_rx) and flags.net_acceptreports/consumes a pending connection;net_recvdrains the ring;net_sendcallstcp_write;net_sock_closecloses;net_connectedreports liveness;net_peer_ipreturns the remote address. The network is pumped from inside theaccept/recvsyscalls (which callnet_poll), so there is no separate network thread racing the connection state.
Runs unprivileged in ring 3, reaching the world only through syscalls.
- Presents the RT-AC66U telnet banner and
login:prompt after sending telnet option negotiationIAC WILL ECHO(FF FB 01) andIAC WILL SUPPRESS-GO-AHEAD(FF FB 03), so a real telnet client disables local echo and the typed password stays hidden. - Accepts any username/password, then presents a fake BusyBox shell supporting
ls,cat,whoami,pwd,id,uname, andexit/logout.catstreams real bytes off the bait filesystem viaopen/read, translating lone\nto\r\nso output isn't a staircase. Line editing handles CR/LF variants, NUL, backspace, and skips telnetIACsequences. - Records every event — service start, connection (with source IP from
peerip), captured credentials, each command, and disconnect — timestamped withuptimeand sent both to the operator console (writeto fd 1) and to the persistent log (log).
Because it is ring-3 code behind the validated syscall boundary and a restrictive policy, a memory-safety bug in this hostile-input parser cannot escalate into the kernel.
Layered confinement of the attacker-facing service:
- Ring 3 — no privileged instructions, no port I/O, its own address space.
- Syscall filtering — each process carries a policy bitmask (bit N = syscall N
allowed), installed by the scheduler as
current_policyand enforced first in the dispatcher. The honeypot's policy isPOLICY_HONEYPOT = 0x3FFB(syscalls 0,1,3–13 — everything it needs, but notgetpidand nothing beyond its set). A violation kills the process immediately. - Demonstration —
user/escape.asmis a standalone program givenPOLICY_MINIMAL = 0x003(onlyexit+write); it prints a line, attemptsgetpid(syscall 2), and is killed on the spot, proving the filter.kernel/escblob.asmembeds it into the kernel.
An append-only on-disk session log at LOG_LBA = 220. A header sector holds
struct { uint32_t magic /* 'LOG1' = 0x314C4F47 */, len; } followed by the raw log bytes
in subsequent sectors. telemetry_init (called at boot) reads the header, loads the log
into a 16 KiB buffer, and replays it to the console so prior sessions are visible after a
reboot. telemetry_append (behind syscall 12) copies a record into the buffer and flushes
header + data back to disk via ata_write. Captured credentials, commands, IPs, and
timestamps therefore survive reboots.
start.asm—_start: callmain, thenexitwith its return value.ulib.c/ulib.h— the minimal libc: syscall wrappers (write,read,open,close,exit,getpid,accept,recv,send,sockclose,connected,uptime,logmsg,peerip) and helpers (strlen,streq,puts,readline,utoa).user.ld— links user programs at0x8000000000and, crucially under-mcmodel=large, collects the large-model sections (.ltext,.lrodata,.ldata,.lbss) into the correct page-aligned output segments so they never overlap another segment's page.
.
├── Makefile Build orchestration: boot (nasm -f bin), kernel
│ (clang+lld), user programs (mcmodel=large), fs.img
│ (mkfs.py); assembles os.img; `run` launches QEMU.
├── mkfs.py Host tool: packs bait files into build/fs.img (SFS1).
├── boot
│ ├── bootstage1.asm 512-byte MBR; LBA-reads stage 2 and jumps to it.
│ └── bootstage2.asm E820, A20, page tables, long mode, ELF loader,
│ chunked kernel read.
├── kernel
│ ├── entry.asm Kernel entry; sets up stack, calls kmain.
│ ├── kmain.c Boot orchestration; launches the ring-3 honeypot.
│ ├── linker.ld Higher-half kernel layout; exports section symbols.
│ ├── bootinfo.h boot_info structure (boot drive, memory map).
│ ├── console.c / .h COM1 UART: kprintf + interrupt-driven serial input.
│ ├── mem.c memset / memcpy / memmove / memcmp.
│ ├── gdt.c / .h GDT (ring0/ring3 segments) + TSS (rsp0; no ring-3 I/O).
│ ├── idt.c / .h IDT: 32 exception + 16 IRQ gates; fault dump handler.
│ ├── isr.asm Assembly stubs for all 48 vectors; irq_common.
│ ├── interrupts.c / .h PIC remap (0x20-0x2F), IRQ dispatch, tick hook.
│ ├── timer.c / .h PIT at 100 Hz; timer_ticks().
│ ├── pmm.c / .h Physical frame allocator (bitmap over E820).
│ ├── vmm.c / .h 4-level paging, W^X/NX, CR0.WP, direct map, SMEP,
│ │ per-process address spaces, vmm_frame_in.
│ ├── kheap.c / .h Kernel heap (kmalloc/kfree) at 0xFFFFFFFFC0000000.
│ ├── thread.c / .h Preemptive scheduler; per-thread cr3 / pid / policy.
│ ├── switch.asm Context switch + ring-0/ring-3 trampolines.
│ ├── spinlock.c / .h xchg spinlock with interrupt-save.
│ ├── usermode.asm enter_user (iretq to ring 3); SYSCALL entry stub.
│ ├── syscall.c / .h SYSCALL/SYSRET setup; dispatcher; policy filter;
│ │ user-pointer validation; per-process fd table.
│ ├── process.c / .h process_create; ELF loader with page-merge hardening.
│ ├── ata.c / .h ATA PIO disk driver (LBA28).
│ ├── fs.c / .h simplefs: read-only flat filesystem (SFS1).
│ ├── io.h Port-I/O inline helpers.
│ ├── pci.c / .h PCI config-space access; device discovery.
│ ├── rtl8139.c / .h RTL8139 NIC driver (PCI, MAC, TX ring, RX ring).
│ ├── net.c / .h Ethernet, ARP, IPv4, ICMP, TCP state machine,
│ │ socket layer, net_poll.
│ ├── telemetry.c / .h Append-only on-disk session log; boot replay.
│ ├── userblob.asm Embeds the honeypot user ELF into the kernel.
│ ├── escblob.asm Embeds the escape-demo ELF into the kernel.
│ └── honeypot.c / .h [legacy] the original in-kernel deception service,
│ superseded by user/honeypot_user.c; not compiled.
└── user
├── honeypot_user.c The ring-3 honeypot: fake telnet login + BusyBox
│ shell + per-session telemetry.
├── escape.asm Sandbox demo: attempts a forbidden syscall, gets killed.
├── user.ld User linker script (links at 0x8000000000; collects
│ large-model .l* sections).
├── lib
│ ├── start.asm _start -> main -> exit.
│ ├── ulib.c Syscall wrappers + string/format helpers.
│ └── ulib.h libc declarations.
├── shell.c [legacy] interactive ring-3 shell; superseded.
└── user.asm [legacy] early raw-assembly ring-3 test program.
How an inbound attack is contained, layer by layer:
- Bounds-checked parsing at every network layer (Ethernet → ARP → IPv4 → ICMP → TCP).
- The deception service runs in ring 3, in an isolated address space, with no privileged instructions and no port I/O.
- A single, validated syscall doorway: syscalls run with interrupts masked (atomic),
every user pointer is validated against
USER_LIMIT, and file descriptors are per-process. - Least-privilege syscall filtering: the service is granted only the syscalls it uses
(
0x3FFB); any other call kills it. - W^X + NX + SMEP: no writable-executable pages, non-executable data, and the kernel cannot execute user pages — blocking code injection and ret-to-user at the hardware level.
- Loader hardening: overlapping ELF segments can never blank each other's pages.
- Read-only bait: the attacker can explore the fake router but cannot alter it.
- Persistent telemetry: credentials, commands, IPs, and timings are logged to disk and the console, surviving reboots.
Deliberate scope boundaries, not defects:
- One TCP connection at a time — no concurrent-connection table.
- No TCP retransmission, flow control, or reassembly — assumes a reliable local link.
- No SMAP — SMEP is enabled; SMAP would need
stac/clacaround every user-buffer access in the syscall layer. - No CPU or memory quotas — process memory is fixed at load (no allocation syscall); a CPU quota would require scheduler time-accounting.
- No filesystem chroot — moot while the filesystem contains only bait.
- No
fork/exec, no writable filesystem — process and storage models are minimal. - Single ATA disk (PIO); single NIC (RTL8139).