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.
-
comptimesupport: 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) │
└─────────────────────────────────────────────────────────────┘
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
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);
}
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));
}
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]
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;
}
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(®s),
);
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(®s),
SYS.mprotect => try self.analyzeMprotect(®s),
SYS.ptrace => try self.analyzePtrace(®s),
SYS.process_vm_writev => try self.analyzeProcessVmWritev(®s),
SYS.memfd_create => self.analyzeMemfdCreate(®s),
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);
}
};
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);
}
}
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);
}
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
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
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"}
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]))
sudo python3 inject_test.py $DECOY_PID
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
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
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) { ... }
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
nftablesoriptables.
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.
Top comments (0)