Skip to content

Latest commit

 

History

34 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

(This documentation was made with the help of Claude)

HoneyOS

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.


Toolchain and build

  • clang targeting x86_64-unknown-none-elf, freestanding (-ffreestanding -nostdlib -mno-red-zone -fno-stack-protector) for the kernel.
  • User programs additionally use -mcmodel=large because they link at 0x8000000000, above the small code model's ±2 GiB reach.
  • ld.lld for linking, nasm for assembly, qemu-system-x86 to run, python3 for the filesystem packer.
make          # build build/os.img
make run      # QEMU: RTL8139 NIC, host port 4444 forwarded to guest telnet (23)
make clean

make run uses -netdev user,id=net0,hostfwd=tcp::4444-:23 -device rtl8139,netdev=net0. Connect with telnet localhost 4444.


Disk image layout (build/os.img)

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
18 4 KiB Stage-2 bootloader
9208 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

Address-space layout

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

Boot sequence

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.

Stage 1 — boot/bootstage1.asm

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.

Stage 2 — boot/bootstage2.asm

  1. E820 memory map — queries the BIOS for the physical memory map, storing it at MMAP_BUFFER = 0xA000 and the entry count in a boot_info structure at BOOT_INFO = 0x9000.
  2. A20 line — enables and then verifies A20 so addresses above 1 MiB are reachable.
  3. 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].
  4. Long mode — sets PAE, loads the page tables, sets EFER.LME, enables paging, and far-jumps into 64-bit code.
  5. ELF loader — parses the kernel ELF's program headers and copies each PT_LOAD segment to its physical address (p_paddr), then jumps to e_entry.
  6. Chunked disk read — the kernel is read in 32 KiB chunks (64 sectors), advancing the destination real-mode segment by 0x800 paragraphs 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.


Kernel

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.

Serial console and logging — console.c / console.h

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.

Segmentation and the TSS — gdt.c / gdt.h

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.

Interrupts — idt.c/idt.h, isr.asm, interrupts.c/interrupts.h

  • idt.c installs 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.asm contains the assembly entry stubs for all 48 vectors. A shared irq_common saves registers, calls the C dispatcher, and sends EOI.
  • interrupts.c remaps the 8259 PIC so IRQs arrive at 0x200x2F (off the exception vectors), routes each IRQ through irq_dispatch to a registered handler, and exposes irq_set_tick_hook (used by the scheduler) plus pic_unmask/mask/eoi.

Timer — timer.c / timer.h

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.

Freestanding memory routines — mem.c

memset, memcpy, memmove, memcmp. A freestanding compiler emits calls to these for aggregate copies and initializers, so they must be provided.

Physical memory — pmm.c / pmm.h

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.

Virtual memory — vmm.c / vmm.h

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. .text is mapped read + execute; .rodata read-only + NX; .data/.bss read/write + NX. No page is simultaneously writable and executable.
  • CR0.WP is 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_addrspace allocates a fresh PML4 (sharing the kernel's higher-half entries), vmm_map_to/walk_root map into a specific space, and vmm_frame_in looks up an existing mapping (used by the ELF loader).
  • SMEP (CR4.SMEP) is enabled when CPUID.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).

Kernel heap — kheap.c / kheap.h

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.

Scheduling — thread.c/thread.h, switch.asm, spinlock.c/spinlock.h

  • Scheduler: preemptive round-robin, driven by the timer tick (sched_init installs schedule as the tick hook). Each thread record holds its saved rsp, cr3, kernel stack top, pid, is_user, done, and a policy bitmask. On a switch to a user thread, schedule installs the new cr3, updates tss.rsp0 and syscall_kstack, and sets the globals current_pid and current_policy.
  • switch.asm: context_switch saves/restores callee-saved registers and swaps stacks; thread_trampoline starts a kernel thread; process_user_trampoline uses iretq to drop a new thread into ring 3.
  • spinlock.c: an xchg-based lock with an interrupt-saving variant.

User/kernel boundary — usermode.asm, syscall.c/syscall.h, process.c/process.h

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):

  1. Syscall filter (checked first). if (num >= 32 || !((current_policy >> num) & 1)) → log [sandbox] pid N KILLED: forbidden syscall M and thread_exit(). A process may only make the syscalls its policy bitmask permits; anything else is fatal.
  2. 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.
  3. Per-process file descriptors. fdt[MAXPID=16][MAXFD=16]; fd 0 is serial stdin (non-blocking kgetc), 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.

Storage — ata.c/ata.h, fs.c/fs.h, mkfs.py

  • ATA PIO driver (ata.c): primary master at I/O base 0x1F0, LBA28, ata_read/ata_write of 512-byte sectors. Backs the filesystem and the telemetry log.
  • simplefs (fs.c): a read-only flat filesystem. Sector 256 is the directory — a struct { uint32_t magic /* 'SFS1' = 0x53465331 */, count; } header followed by up to 7 entries of struct { char name[56]; uint32_t start_lba, size; } (64 bytes each). fs_open matches a full path; fs_read reads 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 into build/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.

Networking

Built bottom-up. Because every inbound byte is attacker-controlled, each parser length-checks before reading fields.

Port I/O — io.h

Inline inb/outb/inw/outw/inl/outl.

PCI — pci.c / pci.h

Configuration space via the 0xCF8 address / 0xCFC data ports; pci_find scans for a vendor:device match.

RTL8139 NIC — rtl8139.c / rtl8139.h

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 to 0x20 + n*4, length to 0x10 + n*4, poll the TOK bit (0x8000).
  • Receive: ring buffer rx_buf[8192 + 16 + 1536]; the slack plus the WRAP bit let a packet near the end run past it instead of wrapping mid-packet. rtl8139_receive checks BUFE (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 to CAPR (0x38) with the hardware's −0x10 offset quirk.

Protocol stack — net.c / net.h

  • Ethernet: struct eth_hdr { dst[6], src[6]; type; }. net_poll drains 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_ping sends 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_ACK and flags FIN 0x01, SYN 0x02, RST 0x04, PSH 0x08, ACK 0x10. It performs the three-way handshake, tracks our_seq/their_seq per 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 to LISTEN. tcp_listen(port) arms it; tcp_write/tcp_close are 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_accept reports/consumes a pending connection; net_recv drains the ring; net_send calls tcp_write; net_sock_close closes; net_connected reports liveness; net_peer_ip returns the remote address. The network is pumped from inside the accept/recv syscalls (which call net_poll), so there is no separate network thread racing the connection state.

The honeypot service — user/honeypot_user.c

Runs unprivileged in ring 3, reaching the world only through syscalls.

  • Presents the RT-AC66U telnet banner and login: prompt after sending telnet option negotiation IAC WILL ECHO (FF FB 01) and IAC 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, and exit/logout. cat streams real bytes off the bait filesystem via open/read, translating lone \n to \r\n so output isn't a staircase. Line editing handles CR/LF variants, NUL, backspace, and skips telnet IAC sequences.
  • Records every event — service start, connection (with source IP from peerip), captured credentials, each command, and disconnect — timestamped with uptime and sent both to the operator console (write to 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.

Sandbox / containment

Layered confinement of the attacker-facing service:

  1. Ring 3 — no privileged instructions, no port I/O, its own address space.
  2. Syscall filtering — each process carries a policy bitmask (bit N = syscall N allowed), installed by the scheduler as current_policy and enforced first in the dispatcher. The honeypot's policy is POLICY_HONEYPOT = 0x3FFB (syscalls 0,1,3–13 — everything it needs, but not getpid and nothing beyond its set). A violation kills the process immediately.
  3. Demonstrationuser/escape.asm is a standalone program given POLICY_MINIMAL = 0x003 (only exit + write); it prints a line, attempts getpid (syscall 2), and is killed on the spot, proving the filter. kernel/escblob.asm embeds it into the kernel.

Telemetry — telemetry.c / telemetry.h

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.

User-space runtime — user/lib/

  • start.asm_start: call main, then exit with 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 at 0x8000000000 and, 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.

File tree

.
├── 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.

Security model summary

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.

Known limitations

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/clac around 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).

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages