DEV Community

Higor Diego
Higor Diego

Posted on

Building a Process Honeypot with Zig to Detect Linux Process Injection

Who this article is for: security professionals, blue teamers, researchers, and engineers working with Linux internals and systems programming.


Why Process Injection Still Deserves Attention

When we talk about ransomware and APT groups targeting Linux environments, process injection is still an important technique. Instead of simply dropping a new binary on the server and executing it, an attacker attempts to place code inside a legitimate process that is already running.

In practice, this makes analysis much harder. The injected code may inherit the target process permissions, continue appearing under the name of a trusted application in commands such as ps and top, and remain unnoticed by solutions that only inspect the process name or executable signature.

Some of the best-known techniques on Linux include:

Technique How it works Related syscalls
ptrace injection Writes directly into another process memory ptrace(ATTACH), ptrace(POKEDATA)
/proc/mem write Uses the process memory pseudo-file open(/proc/[pid]/mem), write()
dlopen/LD_PRELOAD Loads a library into the process openat(), mmap(), dlopen()
shellcode via mmap Creates or changes an executable memory region mmap(PROT_EXEC), mprotect()
process_vm_writev Writes into another process through a Linux cross-process memory API process_vm_writev()

Even when attackers try to hide, these techniques leave traces. We can observe specific syscalls, changes in /proc/[pid]/maps, and modifications to the process state exposed through /proc/[pid]/status.

That is where the idea of a process honeypot came from: a process designed to look interesting as a target while monitoring, in real time, any attempt to manipulate its memory or behavior.


Why I Chose Zig

Zig may not be the first language that comes to mind when building this kind of defensive tool. Even so, it fits the problem very well for a few reasons:

  • No heavy runtime: there is no garbage collector, no hidden threads, and no difficult-to-predict runtime cost. This matters for a honeypot because it should consume few resources and interfere as little as possible with the environment.
  • Direct syscall access: through std.os.linux, we can work directly with kernel interfaces without relying on several libc abstraction layers.
  • More predictable memory behavior: it becomes easier to create controlled memory areas containing decoy data and monitor any changes made to them.
  • comptime support: we can generate different decoy process profiles at compile time without adding runtime overhead.
  • Cross-compilation: Zig's toolchain makes it easier to build binaries for different architectures.

How I Designed the Architecture

To keep the solution simple, I split the honeypot into two processes created from the same binary using fork():

┌─────────────────────────────────────────────────────────────┐
│                      proc-honeypot                          │
│                                                             │
│  ┌─────────────────────┐  fork()  ┌─────────────────────┐  │
│  │       Monitor       │ ──────►  │        Decoy        │  │
│  │                     │          │                     │  │
│  │  • ptrace loop      │◄─SIGTRAP─│  • simulates service   │  │
│  │  • /proc/maps poll  │          │  • in-memory data │  │
│  │  • alert engine     │          │  • self-scan        │  │
│  └──────────┬──────────┘          └─────────────────────┘  │
│             │                                               │
│             ▼                                               │
│       alert.jsonl   (stdout / file)                      │
└─────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Monitor: this process is responsible for observation. It uses ptrace(PTRACE_ATTACH) to track the syscalls executed by the Decoy. After a defined number of calls, it also compares /proc/[pid]/maps snapshots to find new memory regions or suspicious changes.

Decoy: this is the bait process. It simulates a high-value service, keeps fake information in the heap, and continuously performs work. The goal is to make it look like an interesting target inside a controlled environment.


Project Structure

proc-honeypot/
├── build.zig
└── src/
    ├── main.zig          # Fork + orchestration
    ├── decoy.zig         # Decoy process
    ├── monitor.zig       # ptrace loop + analysis
    ├── proc_scanner.zig  # /proc/[pid]/maps parser
    └── alert.zig         # JSON alert engine
Enter fullscreen mode Exit fullscreen mode

Implementation

build.zig

const std = @import("std");

pub fn build(b: *std.Build) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});

    const exe = b.addExecutable(.{
        .name = "proc-honeypot",
        .root_source_file = b.path("src/main.zig"),
        .target = target,
        .optimize = optimize,
    });

    // ptrace capability is required — make sure the binary
    // runs with CAP_SYS_PTRACE or as root in production.
    b.installArtifact(exe);

    const run_cmd = b.addRunArtifact(exe);
    run_cmd.step.dependOn(b.getInstallStep());

    const run_step = b.step("run", "Run the honeypot");
    run_step.dependOn(&run_cmd.step);
}
Enter fullscreen mode Exit fullscreen mode

src/alert.zig

I started with the alerting mechanism. Each event is written as JSON Lines, or JSONL, making it easy to send the output to tools such as Splunk, Elastic, or almost any SIEM capable of consuming structured logs.

const std = @import("std");

pub const AlertLevel = enum {
    info,
    warning,
    critical,

    pub fn toString(self: AlertLevel) []const u8 {
        return switch (self) {
            .info => "INFO",
            .warning => "WARNING",
            .critical => "CRITICAL",
        };
    }
};

pub const AlertEvent = struct {
    level: AlertLevel,
    technique: []const u8,
    syscall: ?[]const u8,
    pid: i32,
    details: []const u8,
    timestamp_ns: u64,
};

pub const AlertEngine = struct {
    writer: std.fs.File.Writer,
    mutex: std.Thread.Mutex,

    pub fn init(file: std.fs.File) AlertEngine {
        return .{
            .writer = file.writer(),
            .mutex = .{},
        };
    }

    pub fn emit(self: *AlertEngine, event: AlertEvent) void {
        self.mutex.lock();
        defer self.mutex.unlock();

        // Manual serialization to avoid a JSON library dependency
        self.writer.print(
            \\{{"timestamp_ns":{d},"level":"{s}","technique":"{s}","pid":{d},"syscall":"{s}","details":"{s}"}}
            \\
        , .{
            event.timestamp_ns,
            event.level.toString(),
            event.technique,
            event.pid,
            event.syscall orelse "none",
            event.details,
        }) catch {};
    }
};

pub fn now() u64 {
    var ts: std.os.linux.timespec = undefined;
    _ = std.os.linux.clock_gettime(std.os.linux.CLOCK.REALTIME, &ts);
    return @as(u64, @intCast(ts.tv_sec)) * std.time.ns_per_s +
        @as(u64, @intCast(ts.tv_nsec));
}
Enter fullscreen mode Exit fullscreen mode

src/proc_scanner.zig

The /proc/[pid]/maps parser is one of the central components of this solution. It allows us to understand how the process memory is organized and identify changes that may indicate an injection. Each line follows a structure similar to this:

7f1234560000-7f1234570000 rwxp 00000000 00:00 0
address_range            perms offset   dev  inode  [pathname]
Enter fullscreen mode Exit fullscreen mode

An anonymous region with execute permission, especially one displayed as rwx or --x, deserves attention. This kind of mapping often appears when shellcode has been placed in memory through mmap or when its permissions were changed afterward.

const std = @import("std");

pub const Permission = packed struct {
    read: bool,
    write: bool,
    execute: bool,
    shared: bool, // 'p' = private (false), 's' = shared (true)
};

pub const MemoryRegion = struct {
    addr_start: u64,
    addr_end: u64,
    perms: Permission,
    offset: u64,
    is_anonymous: bool,      // no pathname
    pathname: ?[]u8,         // heap-allocated, pode ser null

    pub fn isSuspicious(self: MemoryRegion) bool {
        // Anonymous executable region: classic shellcode pattern
        if (self.is_anonymous and self.perms.execute) return true;
        // Writable and executable region: shellcode staging pattern
        if (self.perms.write and self.perms.execute) return true;
        return false;
    }

    pub fn deinit(self: *MemoryRegion, allocator: std.mem.Allocator) void {
        if (self.pathname) |p| allocator.free(p);
    }
};

pub const MapsSnapshot = struct {
    regions: std.ArrayList(MemoryRegion),
    allocator: std.mem.Allocator,

    pub fn deinit(self: *MapsSnapshot) void {
        for (self.regions.items) |*r| r.deinit(self.allocator);
        self.regions.deinit();
    }
};

/// Parses /proc/[pid]/maps and returns a snapshot.
pub fn snapshot(pid: i32, allocator: std.mem.Allocator) !MapsSnapshot {
    var path_buf: [64]u8 = undefined;
    const path = try std.fmt.bufPrint(&path_buf, "/proc/{d}/maps", .{pid});

    const file = try std.fs.openFileAbsolute(path, .{});
    defer file.close();

    var snap = MapsSnapshot{
        .regions = std.ArrayList(MemoryRegion).init(allocator),
        .allocator = allocator,
    };

    var buf: [4096]u8 = undefined;
    var reader = file.reader();

    while (try reader.readUntilDelimiterOrEof(&buf, '\n')) |line| {
        const region = parseLine(line, allocator) catch continue;
        try snap.regions.append(region);
    }

    return snap;
}

fn parseLine(line: []const u8, allocator: std.mem.Allocator) !MemoryRegion {
    // Format: "start-end perms offset dev inode [pathname]"
    var it = std.mem.tokenizeScalar(u8, line, ' ');

    const addr_range = it.next() orelse return error.ParseError;
    const perms_str = it.next() orelse return error.ParseError;
    _ = it.next(); // offset (ignored for now)
    _ = it.next(); // dev
    _ = it.next(); // inode
    const pathname_raw = it.rest();

    // Parse the address range
    const dash = std.mem.indexOf(u8, addr_range, "-") orelse return error.ParseError;
    const addr_start = try std.fmt.parseInt(u64, addr_range[0..dash], 16);
    const addr_end = try std.fmt.parseInt(u64, addr_range[dash + 1 ..], 16);

    // Parse permissions (rwxp / r--p / etc.)
    if (perms_str.len < 4) return error.ParseError;
    const perms = Permission{
        .read    = perms_str[0] == 'r',
        .write   = perms_str[1] == 'w',
        .execute = perms_str[2] == 'x',
        .shared  = perms_str[3] == 's',
    };

    // Pathname: trim spaces and newlines
    const trimmed = std.mem.trim(u8, pathname_raw, " \t\r\n");
    const is_anon = trimmed.len == 0 or trimmed[0] == '[';

    var pathname: ?[]u8 = null;
    if (trimmed.len > 0) {
        pathname = try allocator.dupe(u8, trimmed);
    }

    return MemoryRegion{
        .addr_start   = addr_start,
        .addr_end     = addr_end,
        .perms        = perms,
        .offset       = 0,
        .is_anonymous = is_anon,
        .pathname     = pathname,
    };
}

/// Compares two snapshots and returns new suspicious regions.
/// The caller is responsible for freeing the returned list.
pub fn diffSuspicious(
    old: *const MapsSnapshot,
    new: *const MapsSnapshot,
    allocator: std.mem.Allocator,
) !std.ArrayList(MemoryRegion) {
    var new_suspicious = std.ArrayList(MemoryRegion).init(allocator);

    outer: for (new.regions.items) |new_region| {
        if (!new_region.isSuspicious()) continue;

        // Check whether this region already existed in the previous snapshot
        for (old.regions.items) |old_region| {
            if (old_region.addr_start == new_region.addr_start and
                old_region.addr_end == new_region.addr_end)
            {
                continue :outer; // Region already known
            }
        }

        // New suspicious region found
        try new_suspicious.append(new_region);
    }

    return new_suspicious;
}
Enter fullscreen mode Exit fullscreen mode

src/monitor.zig

This is the core of the honeypot. The Monitor uses ptrace to track the syscalls executed by the Decoy in real time and generate alerts whenever it identifies unexpected behavior.

const std = @import("std");
const linux = std.os.linux;
const alert = @import("alert.zig");
const proc_scanner = @import("proc_scanner.zig");

// Linux x86_64 syscall numbers
// Ref: /usr/include/asm/unistd_64.h
const SYS = struct {
    const mmap:             u64 = 9;
    const mprotect:         u64 = 10;
    const ptrace:           u64 = 101;
    const process_vm_readv: u64 = 310;
    const process_vm_writev:u64 = 311;
    const memfd_create:     u64 = 319;
};

// mmap/mprotect flags relevant to detection
const PROT_EXEC:  u64 = 0x4;
const PROT_WRITE: u64 = 0x2;
const MAP_ANON:   u64 = 0x20;

// x86_64 registers through ptrace(GETREGS)
// Manual definition for portability across Zig versions
const UserRegsStruct = extern struct {
    r15: u64, r14: u64, r13: u64, r12: u64,
    rbp: u64, rbx: u64, r11: u64, r10: u64,
    r9: u64,  r8: u64,  rax: u64, rcx: u64,
    rdx: u64, rsi: u64, rdi: u64,
    orig_rax: u64,  // Syscall number on entry
    rip: u64,  cs: u64, eflags: u64,
    rsp: u64,  ss: u64, fs_base: u64, gs_base: u64,
    ds: u64,   es: u64, fs: u64,     gs: u64,
};

pub const Monitor = struct {
    target_pid: linux.pid_t,
    alert_engine: *alert.AlertEngine,
    allocator: std.mem.Allocator,
    maps_snapshot: proc_scanner.MapsSnapshot,
    syscall_count: u64,
    // Diff /proc/maps after every SCAN_INTERVAL syscalls
    const SCAN_INTERVAL: u64 = 100;

    pub fn init(
        pid: linux.pid_t,
        engine: *alert.AlertEngine,
        allocator: std.mem.Allocator,
    ) !Monitor {
        const initial_snap = try proc_scanner.snapshot(pid, allocator);
        return Monitor{
            .target_pid    = pid,
            .alert_engine  = engine,
            .allocator     = allocator,
            .maps_snapshot = initial_snap,
            .syscall_count = 0,
        };
    }

    pub fn deinit(self: *Monitor) void {
        self.maps_snapshot.deinit();
    }

    /// Attaches to the target process through ptrace and starts monitoring.
    pub fn attach(self: *Monitor) !void {
        // PTRACE_ATTACH sends SIGSTOP to the target and suspends it
        const ret = linux.ptrace(linux.PTRACE.ATTACH, self.target_pid, 0, 0);
        if (linux.getErrno(ret) != .SUCCESS) {
            std.log.err("ptrace(ATTACH) failed: {}", .{linux.getErrno(ret)});
            return error.PtraceAttachFailed;
        }

        // Wait for the SIGSTOP generated by ATTACH
        var status: u32 = 0;
        _ = linux.waitpid(self.target_pid, &status, 0);

        // TRACESYSGOOD: adds bit 0x80 to syscall stop signals
        // This distinguishes syscall stops from other SIGTRAP events
        const opts: usize = linux.PTRACE.O_TRACESYSGOOD |
                            linux.PTRACE.O_TRACEFORK |
                            linux.PTRACE.O_TRACECLONE |
                            linux.PTRACE.O_TRACEEXEC;
        _ = linux.ptrace(linux.PTRACE.SETOPTIONS, self.target_pid, 0, opts);

        self.alert_engine.emit(.{
            .level        = .info,
            .technique    = "honeypot_start",
            .syscall      = null,
            .pid          = self.target_pid,
            .details      = "Monitor attached to the decoy through ptrace",
            .timestamp_ns = alert.now(),
        });
    }

    /// Main loop: intercepts syscalls and performs periodic scans.
    pub fn run(self: *Monitor) !void {
        while (true) {
            // Resume the target and stop at the NEXT syscall entry
            _ = linux.ptrace(linux.PTRACE.SYSCALL, self.target_pid, 0, 0);

            var status: u32 = 0;
            const waited = linux.waitpid(self.target_pid, &status, 0);

            if (waited < 0) break;

            // Process exited normally
            if (linux.W.IFEXITED(status)) {
                std.log.info("Decoy exited with code {d}", .{linux.W.EXITSTATUS(status)});
                break;
            }

            if (!linux.W.IFSTOPPED(status)) continue;

            const stop_sig = linux.W.STOPSIG(status);

            // With TRACESYSGOOD, syscall stops arrive as SIGTRAP|0x80 = 0x85
            // Regular signals keep their original number
            if (stop_sig == (linux.SIG.TRAP | 0x80)) {
                try self.handleSyscall();
            } else if (stop_sig == linux.SIG.TRAP) {
                // This may be a ptrace event (fork, clone, exec)
                // For now, just continue
            }
            // Other signals: deliver them back to the process
        }
    }

    fn handleSyscall(self: *Monitor) !void {
        var regs: UserRegsStruct = undefined;
        const ret = linux.ptrace(
            linux.PTRACE.GETREGS,
            self.target_pid,
            0,
            @intFromPtr(&regs),
        );
        if (linux.getErrno(ret) != .SUCCESS) return;

        // orig_rax = syscall number on entry
        // rax on exit = return value (after the second stop)
        // For simplicity, only analyze syscall entry
        const syscall_nr = regs.orig_rax;

        switch (syscall_nr) {
            SYS.mmap => try self.analyzeMmap(&regs),
            SYS.mprotect => try self.analyzeMprotect(&regs),
            SYS.ptrace => try self.analyzePtrace(&regs),
            SYS.process_vm_writev => try self.analyzeProcessVmWritev(&regs),
            SYS.memfd_create => self.analyzeMemfdCreate(&regs),
            else => {},
        }

        self.syscall_count += 1;

        // Periodic /proc/maps scan
        if (self.syscall_count % SCAN_INTERVAL == 0) {
            try self.scanMaps();
        }
    }

    // mmap(addr, length, prot, flags, fd, offset)
    // rdi=addr, rsi=length, rdx=prot, r10=flags, r8=fd, r9=offset
    fn analyzeMmap(self: *Monitor, regs: *const UserRegsStruct) !void {
        const prot  = regs.rdx;
        const flags = regs.r10;

        const is_exec = (prot & PROT_EXEC) != 0;
        const is_anon = (flags & MAP_ANON) != 0;

        if (is_exec and is_anon) {
            self.alert_engine.emit(.{
                .level        = .critical,
                .technique    = "anonymous_exec_mmap",
                .syscall      = "mmap",
                .pid          = self.target_pid,
                .details      = "Anonymous mmap with PROT_EXEC detected — possible shellcode staging",
                .timestamp_ns = alert.now(),
            });
        } else if (is_exec and (prot & PROT_WRITE) != 0) {
            self.alert_engine.emit(.{
                .level        = .critical,
                .technique    = "rwx_mmap",
                .syscall      = "mmap",
                .pid          = self.target_pid,
                .details      = "mmap with PROT_WRITE|PROT_EXEC — classic shellcode staging pattern",
                .timestamp_ns = alert.now(),
            });
        }
    }

    // mprotect(addr, len, prot)
    // rdi=addr, rsi=len, rdx=prot
    fn analyzeMprotect(self: *Monitor, regs: *const UserRegsStruct) !void {
        const prot = regs.rdx;
        if ((prot & PROT_EXEC) != 0) {
            var detail_buf: [128]u8 = undefined;
            const detail = std.fmt.bufPrint(
                &detail_buf,
                "mprotect(addr=0x{x:0>16}, prot=0x{x}) adding PROT_EXEC",
                .{ regs.rdi, prot },
            ) catch "mprotect with PROT_EXEC";

            self.alert_engine.emit(.{
                .level        = .critical,
                .technique    = "mprotect_exec",
                .syscall      = "mprotect",
                .pid          = self.target_pid,
                .details      = detail,
                .timestamp_ns = alert.now(),
            });
        }
    }

    // ptrace(request, pid, addr, data)
    // rdi=request, rsi=pid (alvo)
    fn analyzePtrace(self: *Monitor, regs: *const UserRegsStruct) !void {
        const request   = regs.rdi;
        const target_pid = @as(i32, @intCast(regs.rsi));

        // Is PTRACE_ATTACH = 16 targeting our decoy?
        if (request == 16 and target_pid == self.target_pid) {
            self.alert_engine.emit(.{
                .level        = .critical,
                .technique    = "ptrace_inject_attempt",
                .syscall      = "ptrace",
                .pid          = self.target_pid,
                .details      = "External process attempted PTRACE_ATTACH on the decoy",
                .timestamp_ns = alert.now(),
            });
        }

        // ptrace called from inside the decoy may indicate injected code attempting escalation
        if (request == 16) {
            var detail_buf: [96]u8 = undefined;
            const detail = std.fmt.bufPrint(
                &detail_buf,
                "decoy called ptrace(ATTACH) on pid={d}",
                .{target_pid},
            ) catch "suspicious internal ptrace";

            self.alert_engine.emit(.{
                .level        = .warning,
                .technique    = "internal_ptrace",
                .syscall      = "ptrace",
                .pid          = self.target_pid,
                .details      = detail,
                .timestamp_ns = alert.now(),
            });
        }
    }

    // process_vm_writev(pid, local_iov, liovcnt, remote_iov, riovcnt, flags)
    // rdi = pid alvo
    fn analyzeProcessVmWritev(self: *Monitor, regs: *const UserRegsStruct) !void {
        const target = @as(i32, @intCast(regs.rdi));
        var detail_buf: [96]u8 = undefined;
        const detail = std.fmt.bufPrint(
            &detail_buf,
            "process_vm_writev targeting pid={d}",
            .{target},
        ) catch "suspicious process_vm_writev";

        self.alert_engine.emit(.{
            .level        = .critical,
            .technique    = "process_vm_writev_injection",
            .syscall      = "process_vm_writev",
            .pid          = target,
            .details      = detail,
            .timestamp_ns = alert.now(),
        });
    }

    // memfd_create creates an anonymous file descriptor — a fileless execution vector
    fn analyzeMemfdCreate(self: *Monitor, regs: *const UserRegsStruct) void {
        _ = regs;
        self.alert_engine.emit(.{
            .level        = .warning,
            .technique    = "memfd_create",
            .syscall      = "memfd_create",
            .pid          = self.target_pid,
            .details      = "memfd_create called — possible fileless execution preparation",
            .timestamp_ns = alert.now(),
        });
    }

    fn scanMaps(self: *Monitor) !void {
        var new_snap = proc_scanner.snapshot(self.target_pid, self.allocator) catch return;
        defer new_snap.deinit();

        var new_regions = try proc_scanner.diffSuspicious(
            &self.maps_snapshot,
            &new_snap,
            self.allocator,
        );
        defer new_regions.deinit();

        for (new_regions.items) |region| {
            var detail_buf: [256]u8 = undefined;
            const detail = std.fmt.bufPrint(
                &detail_buf,
                "new suspicious region: 0x{x:0>16}-0x{x:0>16} perms=r{s}x{s} {s}",
                .{
                    region.addr_start,
                    region.addr_end,
                    if (region.perms.write) "w" else "-",
                    if (region.perms.shared) "s" else "p",
                    region.pathname orelse "[anonymous]",
                },
            ) catch "new suspicious region detected";

            self.alert_engine.emit(.{
                .level        = .critical,
                .technique    = "suspicious_memory_region",
                .syscall      = null,
                .pid          = self.target_pid,
                .details      = detail,
                .timestamp_ns = alert.now(),
            });
        }

        // Update snapshot
        self.maps_snapshot.deinit();
        self.maps_snapshot = try proc_scanner.snapshot(self.target_pid, self.allocator);
    }
};
Enter fullscreen mode Exit fullscreen mode

src/decoy.zig

The Decoy attempts to behave like a real service. It keeps fake data in memory and continuously performs work so that it does not look like an idle process. The closer it is to the behavior of a real application, the more useful it becomes in a laboratory or controlled research environment.

const std = @import("std");
const linux = std.os.linux;

/// "Sensitive" data kept in heap memory to simulate
/// a high-value process (authentication server, vault, etc.)
const SensitiveData = struct {
    // In a real scenario, this could be cryptographic keys,
    // session tokens, or database credentials.
    // Here we use recognizable patterns to simplify forensic validation.
    magic: u64 = 0xDEADBEEFCAFEBABE,
    secret_key: [32]u8 = [_]u8{0xAA} ** 32,
    session_token: [64]u8 = [_]u8{0xBB} ** 64,
    db_password: [16]u8 = "honeypot_bait!!!",
};

pub fn run() !void {
    const pid = linux.getpid();

    // Announce the PID to the monitor process through stderr
    // (in production, use a pipe or shared memory)
    std.debug.print("[DECOY] PID: {d}\n", .{pid});
    std.debug.print("[DECOY] Simulating a high-value service...\n", .{});

    // Allocate sensitive data on the heap
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    const allocator = gpa.allocator();
    defer _ = gpa.deinit();

    const data = try allocator.create(SensitiveData);
    defer allocator.destroy(data);
    data.* = SensitiveData{};

    // Prevent the compiler from optimizing away the sensitive data
    std.mem.doNotOptimizeAway(data);

    // "Work" loop — simulates normal CPU activity
    var counter: u64 = 0;
    while (true) : (counter += 1) {
        // Lightweight computation to simulate a real workload
        const hash = std.hash.Wyhash.hash(counter, &data.secret_key);
        std.mem.doNotOptimizeAway(hash);

        // Log normal activity every 10 seconds
        if (counter % 10 == 0) {
            std.debug.print("[DECOY] tick={d}\n", .{counter});
        }

        std.time.sleep(std.time.ns_per_s);
    }
}
Enter fullscreen mode Exit fullscreen mode

src/main.zig

The main.zig file orchestrates the solution. It initializes the alerting mechanism, executes fork(), and defines which process becomes the Monitor and which one runs the Decoy.

const std = @import("std");
const linux = std.os.linux;

const decoy   = @import("decoy.zig");
const monitor = @import("monitor.zig");
const alert   = @import("alert.zig");

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    // Alert engine writing to stdout (redirect to a file in production)
    var engine = alert.AlertEngine.init(std.io.getStdOut());

    engine.emit(.{
        .level        = .info,
        .technique    = "startup",
        .syscall      = null,
        .pid          = linux.getpid(),
        .details      = "proc-honeypot started",
        .timestamp_ns = alert.now(),
    });

    // Fork: the child becomes the Decoy, the parent becomes the Monitor
    const fork_result = try std.posix.fork();

    if (fork_result == 0) {
        // === CHILD PROCESS (DECOY) ===
        // Wait briefly for the parent to become ready to monitor
        std.time.sleep(100 * std.time.ns_per_ms);
        try decoy.run();
        return;
    }

    // === PARENT PROCESS (MONITOR) ===
    const decoy_pid = fork_result;

    // Wait for the child to start
    std.time.sleep(200 * std.time.ns_per_ms);

    var mon = try monitor.Monitor.init(decoy_pid, &engine, allocator);
    defer mon.deinit();

    try mon.attach();
    try mon.run();

    // Wait for the child to exit after the monitor loop ends
    var status: u32 = 0;
    _ = linux.waitpid(decoy_pid, &status, 0);
}
Enter fullscreen mode Exit fullscreen mode

Building and Running the Project

# Zig 0.13.0+
zig build -Doptimize=ReleaseSafe

# Required: CAP_SYS_PTRACE
# During development, run as root or configure capabilities:
sudo setcap cap_sys_ptrace+eip ./zig-out/bin/proc-honeypot

# Basic execution (alerts written to stdout)
sudo ./zig-out/bin/proc-honeypot

# In production, redirect output to a JSONL file
sudo ./zig-out/bin/proc-honeypot > /var/log/honeypot/alerts.jsonl 2>/dev/null
Enter fullscreen mode Exit fullscreen mode

Validating the Honeypot

To validate the behavior without using an actual malicious tool, we can use gdb. When it attaches to an existing process, it uses ptrace(ATTACH), allowing us to reproduce a controlled scenario and observe how the honeypot responds.

Test 1: External ptrace attach Attempt

# Terminal 1 — Start the honeypot
sudo ./zig-out/bin/proc-honeypot > alerts.jsonl 2>&1

# Terminal 2 — Capture the decoy PID from the logs
DECOY_PID=$(grep "DECOY_PID" /dev/stderr | head -1 | awk -F: '{print $2}')

# Attempt to attach with gdb (simulates ptrace injection)
gdb -p $DECOY_PID
Enter fullscreen mode Exit fullscreen mode

After running the test, we expect to receive an alert similar to this:

{"timestamp_ns":1722873600000000000,"level":"CRITICAL","technique":"ptrace_inject_attempt","pid":12345,"syscall":"ptrace","details":"External process attempted PTRACE_ATTACH on the decoy"}
Enter fullscreen mode Exit fullscreen mode

Test 2: Simulated Memory Write

In the second scenario, we use a Python script to write directly into the process memory through /proc/[pid]/mem. The goal is to reproduce part of the behavior commonly associated with injection without running a real malicious payload:

#!/usr/bin/env python3
"""
Educational simulation of injection through /proc/mem
Requires: root or CAP_SYS_PTRACE
"""
import ctypes
import os
import struct

def inject_test(target_pid: int):
    # Test shellcode: only a NOP sled followed by int3 (breakpoint)
    # This is NOT malicious code — it only triggers SIGTRAP for demonstration
    shellcode = b"\x90" * 16 + b"\xcc"  # NOPs + INT3

    # Open /proc/[pid]/mem for writing
    mem_path = f"/proc/{target_pid}/mem"
    try:
        mem_fd = os.open(mem_path, os.O_RDWR)
    except PermissionError:
        print(f"[!] Permission denied while accessing {mem_path}")
        print("[!] The ptrace monitor may have blocked access")
        return

    # Read /proc/[pid]/maps to find a writable region
    with open(f"/proc/{target_pid}/maps") as f:
        for line in f:
            if "rw-p" in line:
                addr = int(line.split("-")[0], 16)
                break

    # Write into the target process memory
    os.lseek(mem_fd, addr, os.SEEK_SET)
    os.write(mem_fd, shellcode)
    os.close(mem_fd)
    print(f"[+] Shellcode written at 0x{addr:x} in process {target_pid}")

if __name__ == "__main__":
    import sys
    inject_test(int(sys.argv[1]))
Enter fullscreen mode Exit fullscreen mode
sudo python3 inject_test.py $DECOY_PID
Enter fullscreen mode Exit fullscreen mode

In this flow, the executable region will be detected during the next /proc/maps comparison, provided that a later permission change occurs, such as an mprotect call adding PROT_EXEC.

Analyzing the Generated Alerts

# Critical alerts in real time
tail -f alerts.jsonl | jq 'select(.level == "CRITICAL")'

# Group alerts by technique
cat alerts.jsonl | jq -r '.technique' | sort | uniq -c | sort -rn

# Incident timeline
cat alerts.jsonl | jq -r '[.timestamp_ns, .level, .technique, .details] | @tsv' | column -t
Enter fullscreen mode Exit fullscreen mode

Limitations to Consider

This first version does not attempt to cover every possible scenario. The goal was to keep the scope controlled and demonstrate the foundation of the detection approach. Because of that, there are a few important limitations:

Limitation 1: Only One Tracer per Process

ptrace allows only one tracer to be attached to a process at a time. Because our Monitor is already attached to the Decoy, an external PTRACE_ATTACH attempt will normally fail with EPERM.

This ends up acting as protection against another attach, but it does not mean that we directly observe the attempt made by the external process. To gain that visibility, we need to monitor the system from a broader layer.

How to improve it: complement the solution with auditd or eBPF to observe ptrace calls across the system:

auditctl -a always,exit -F arch=b64 -S ptrace -F a0=0x10 -k ptrace_attach
Enter fullscreen mode Exit fullscreen mode

Limitation 2: Writes to /proc/[pid]/mem Without Permission Changes

If someone writes to memory through /proc/[pid]/mem but does not make a later mprotect call, the modification may remain unnoticed until the next analysis. The value configured in SCAN_INTERVAL creates a small visibility gap.

How to improve it: reduce SCAN_INTERVAL or add an eBPF layer to monitor access and writes to /proc/*/mem closer to real time.

Limitation 3: x86_64-Focused Implementation

The UserRegsStruct layout and syscall numbers were defined for x86_64. On ARM64, for example, the register set is different, using x0 through x30, as well as sp and pc, and the syscall table also changes.

How to improve it: use Zig's comptime together with builtin.cpu.arch to select the correct structures and syscall numbers for each architecture during compilation.


Possible Next Steps

1. Add an eBPF Layer

By monitoring syscalls directly in the kernel, we stop relying only on ptrace and gain visibility into other processes, not just the Decoy:

// Exemplo de probe em BPF C
SEC("tracepoint/syscalls/sys_enter_process_vm_writev")
int detect_vm_writev(struct trace_event_raw_sys_enter *ctx) { ... }
Enter fullscreen mode Exit fullscreen mode

2. Improve SIEM Integration

In addition to JSONL, we could provide a CEF output mode, making integration with platforms such as Splunk, IBM QRadar, and Microsoft Sentinel easier.

3. Create Different Decoy Profiles

We can use comptime to generate decoy processes with different characteristics, such as a web server, database, or SSH agent. Each profile would have its own in-memory data and a syscall pattern closer to the service it is intended to represent.

4. Cover LD_PRELOAD Scenarios

Another improvement would be to monitor openat() calls for .so libraries that were not present in the initial /proc/maps snapshot. We could also correlate this information with the environment exposed through /proc/[pid]/environ.

5. Implement Active Response

In addition to generating an alert, the Monitor could perform containment actions such as:

  • Terminating the process responsible for the attempt using SIGKILL;
  • Capturing a memory dump of the Decoy for forensic analysis;
  • Isolating network communication using nftables or iptables.

This kind of response must be handled carefully, especially to avoid disruptions caused by false positives.


Conclusion

The purpose of this honeypot is not to replace an EDR, an eBPF-based solution, or any security tool already used in production. The idea is to add a different layer of visibility by closely tracking the syscalls and memory changes of a specific process.

In a laboratory, during research, or even as a complementary detection component, this kind of decoy process can help identify behavior that would normally remain hidden among regular system activity.

For this scenario, Zig proved to be a very interesting choice. Direct access to Linux interfaces through std.os.linux, low runtime overhead, and control over memory make it possible to build a small, predictable monitor that causes very little interference in the environment.

These are exactly the characteristics we want from a defensive tool that needs to observe activity without drawing attention or significantly changing system behavior.

The code presented here can be used as a foundation for further testing, additional detection techniques, and future improvements based on the needs of the environment.


References

Top comments (0)