Skip to content

Home

Apnea: a full-memory sleep obfuscation engine for Linux

Why I built it, how it works, and the four bugs that almost killed it.

Introduction

Memory scanners are the bane of long-running C2 implants. Tools like Hunt-Sleeping-Beacons dump suspended thread memory looking for known C2 fingerprints — Cobalt Strike beacon configs, Sliver implant strings, Mythic agent metadata. The implant is most vulnerable during its sleep windows: 60 seconds, 5 minutes, an hour of doing nothing while its code and data sit decrypted in RAM, easy to fingerprint.

The defensive answer to this is well-known on Windows. Sleep obfuscation engines — Ekko, Cronos, Foliage, DeathSleep — encrypt the implant’s own memory pages during sleep, flip them to PROT_NONE, sleep on a timer, then decrypt and resume. A memory scanner during the sleep window finds ciphertext, not the implant.

On Linux this technique exists in only one open form: a .text-only RC4 implementation with a hardcoded key, useful as an educational artefact but not as an operational engine. Apnea is my Linux implementation done properly: ChaCha20 with per-cycle keys, full-image encryption (text + rodata + data + bss + heap), raw syscalls so the implant has no PLT entries for mprotect/mmap/timer_*, and an isolated wake skeleton in a dedicated linker section.

This post is the story of building it: what Apnea is, how it works, and the four bugs that took the project from “works on my WSL” to “works on a real Ubuntu VM”. The bug section at the end is the most useful part — every one of those bugs is invisible in the Windows literature because the underlying kernel mechanisms differ.


How Apnea was created

The project started as a portfolio exercise: build a Linux equivalent of the Windows sleep-obfuscation lineage from scratch. Two goals shaped the design:

  1. Operational quality, not minimal PoC. The existing Linux references encrypted only .text. Strings in .rodata (C2 URLs, user-agent strings, beacon config) would still be readable during sleep — a scanner that does strings /proc/PID/mem | grep https still finds the C2 endpoint. To raise the bar, I targeted full-image encryption: text + rodata + data + bss + heap + libc-internal anonymous regions.

  2. Zero libc footprint on the implant’s syscall path. A real C2 implant doesn’t want PLT entries pointing to libc’s mmap/mprotect/timer_create — those are exactly the symbols a defender’s static analyzer flags. The entire syscall pipeline (loader, sleep engine, ChaCha20 cipher) was written using raw syscall instructions via inline asm wrappers. The objdump -d of the final binary shows zero call mmap@plt, zero call mprotect@plt, zero call getrandom@plt from the implant’s own code.

The build was structured as a sequence of incremental phases, each verifiable in isolation: shared headers → ELF parser → segment mapper → relocator → stack builder → loader orchestrator → ChaCha20 (verified against RFC 8439 test vectors) → region tracker → sleep engine → stager. Each phase had its own test harness. The discipline of incremental verification was what made the bug hunting in the final section tractable; without it, the issues would have compounded into a single inscrutable crash.

The repository name is a play on the medical term: apnea is the cessation of breathing, exactly what the implant does during sleep — it stops breathing the C2 connection, encrypts its memory, and waits.


How Apnea works

Three components, each replaceable independently:

1. Stager (84 bytes, x86-64 NASM)

A minimal position-independent shellcode loader. Maps a 64 KB anonymous buffer with mmap/RW, reads payload bytes from a file descriptor (stdin or a socket), flips the buffer to RX with mprotect, jumps. Never RWX — that single permission combination is the loudest detection signal an implant can emit on Linux, and Apnea never produces it. Null-free encoding so the stager survives transport through pipes that interpret null bytes.

2. Loader (userland-exec in C)

Reads an ELF binary from a memory buffer (no open/read of the payload file once it’s in memory) and executes it:

  • Parse: validates magic bytes, EI_CLASS=ELFCLASS64, EI_DATA=ELFDATA2LSB, supported e_machine and e_type. Rejects PT_INTERP — Apnea only loads statically-linked binaries, since implementing a userland dynamic linker would be a separate ~3-5k LoC project.
  • Map: each PT_LOAD segment becomes an anonymous mapping with two-phase permissions: mmap RW, memcpy data from the ELF buffer, mprotect to final permissions. Two-phase mapping means the implant never has a RWX page mapped, even momentarily.
  • Relocate: applies R_X86_64_RELATIVE for static-PIE payloads (the only relocation type that doesn’t require external symbol resolution).
  • Build stack: constructs an 8 MB anonymous stack with the System V x86-64 process initialization layout: argc, argv[], envp[], and auxiliary vector entries (AT_PHDR, AT_PHNUM, AT_ENTRY, AT_PAGESZ, AT_RANDOM, …). This is the layout glibc’s __libc_start_main reads — getting it byte-perfect is what makes the loaded binary believe it was launched by execve.
  • Jump: three lines of x86-64 assembly switch RSP to the new stack, zero RDX (signals no rtld_fini cleanup needed), and jump to the payload’s entry point. From the payload’s perspective, it has been launched normally by the kernel.

Notably, the loader does not call execve or execveat — those syscalls are heavily monitored by virtually every Linux EDR and audit subsystem. The entire userland-exec sequence avoids them.

3. Sleep engine

The core of the project. Lives in two parts:

Setup half (apn_sleep_cycle_self, regular .text): enumerates anonymous regions by parsing /proc/self/maps with a hand-rolled parser (no fopen/fgets/sscanf — those pull libc state I want to avoid), filters out the .decrypt_stub.text range and the user stack region, derives a fresh ChaCha20 256-bit key and 96-bit nonce from getrandom(2), queries FS_BASE via arch_prctl, and computes the rseq registration address.

Execution half (apn_full_cycle_stub_inner, isolated .decrypt_stub.text section): runs on a swapped stack (a fresh 4 KB stub_stack so the user stack can be encrypted safely), unregisters rseq, blocks all signals, then loops:

for each region:
    sys_mprotect(addr, size, PROT_READ | PROT_WRITE)
    chacha20_xor_stub(key, nonce, 0, addr, size)
    sys_mprotect(addr, size, PROT_NONE)

sys_nanosleep(seconds)  ← THIS is the encrypted sleep window

for each region (same order, same key/nonce):
    sys_mprotect(addr, size, PROT_READ | PROT_WRITE)
    chacha20_xor_stub(key, nonce, 0, addr, size)  ← XOR twice = identity = decrypt
    sys_mprotect(addr, size, original_prot)

After the loop, signals are restored and rseq re-registered, the stub returns through the stack-swap wrapper back to the user stack, and execution resumes in sleep_cycle_self exactly where it left off.

ChaCha20 (not AES) because: constant-time by design, no lookup tables (immune to cache-timing side channels), ~100 lines of C, and RFC 8439 provides publicly verifiable test vectors so the implementation is auditable.

The .decrypt_stub.text section is page-aligned via a dedicated linker script and bracketed by __apn_decrypt_stub_start / __apn_decrypt_stub_end symbols. The region enumerator carves this range out of the encryption set — the wake skeleton must stay executable while everything else is PROT_NONE.


Proof of concept

A small capture script runs the sleeper payload through the loader on an isolated VM, snapshots /proc/PID/maps and gdb byte dumps at three time windows (before sleep, during the encrypted window, after wake), and prints a screenshot-ready evidence summary.

Terminal output showing the PROT_NONE region count, seven memory regions changing to no access during sleep, and raw bytes at the sleeper's init symbol changing to ciphertext before being restored
PoC capture output: PROT_NONE region count, permission cycle per region, and raw memory bytes at the sleeper's _init symbol read through gdb/ptrace. Ubuntu 22.04, glibc 2.35, kernel 6.8 HWE.

Reading the figure:

Section [1] counts PROT_NONE regions per phase. The baseline 1 is a libc.so.6 padding page that’s always PROT_NONE, filtered out of the per-region table. The 7 added during sleep are the implant’s actual encrypted footprint.

Section [2] shows the permission diff per region. Three sleeper PT_LOADs (text, rodata, data — mapped anonymously by the Apnea loader, hence visible in this view), the heap, and three anonymous regions for glibc’s internal allocations. Every row follows the same pattern: original_perm → ---p → original_perm. The 7ffff7ec8000-7ffff7fac000 row in particular is a 912 KB region — that’s libc’s malloc arena and various caches, all encrypted.

Section [3] is the strongest evidence: raw memory at the sleeper’s _init symbol (0x401000) read via gdb attached over ptrace. Ptrace bypasses PROT_NONE, so during the sleep window gdb still reads the page — and what it reads is ciphertext. The bytes before sleep are f3 0f 1e fa 48 83 ec 08 ...: an endbr64 instruction (Intel CET branch-target marker) followed by sub rsp, 0x8, a valid Intel CET-protected function prologue. During sleep the bytes are cb 5b 75 0f df 0e 45 84 ...: ChaCha20 ciphertext. After wake the bytes are identical to before, byte-for-byte. The two [VERIFIED] lines at the bottom are automatic cmp checks — they assert PRE == POST (encryption is symmetric) and PRE != MID (encryption actually changes the bytes, not just access permissions).

The total memory encrypted in this run is approximately 2.1 MB. The encrypt loop takes ~3 ms on a modern x86-64 CPU; the decrypt loop the same. The bulk of the cycle time is the nanosleep itself.


The four bugs I hit during testing

This is the section I wish existed when I started. Each of these bugs is invisible until you actually run on a real Ubuntu VM, and each took hours to diagnose and minutes to fix.

Bug 1 — MAP_ANONYMOUS is a GNU extension that strict-C11 hides

What it is. MAP_ANONYMOUS is defined in <sys/mman.h> only when _GNU_SOURCE or _DEFAULT_SOURCE is in effect. With -std=c11 -Wpedantic, GCC enables __STRICT_ANSI__, which tells glibc’s feature-test-macro system to hide all non-POSIX-strict symbols. MAP_ANONYMOUS, MAP_FIXED_NOREPLACE, several struct sigevent fields, all disappear from the visible API.

How it manifested during testing. WSL compiled cleanly. Confidence was high. Then I transferred the code to a fresh Ubuntu 22.04 VM and ran make all:

../../sleep/sleep_self.c:80:52: error: 'MAP_ANONYMOUS' undeclared (first use in this function)
   80 |                                     MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
      |                                                  ^~~~~~~~~~~~~

The build had passed on WSL because WSL ships glibc with slightly more permissive feature-test-macro defaults (Microsoft’s patches relax some __STRICT_ANSI__ checks). On Ubuntu native with vanilla glibc 2.35, the macro is invisible and the build dies.

How I fixed it. Standard idiom — one line in the root Makefile:

-CFLAGS_BASE := -Wall -Wextra -Werror -Wpedantic -std=c11
+CFLAGS_BASE := -Wall -Wextra -Werror -Wpedantic -std=c11 -D_GNU_SOURCE

The interesting takeaway: WSL is not a reliable Linux dev environment for distributing security tools. Develop on WSL if you must, but every commit must be smoke-tested on a real Ubuntu / Debian / Fedora VM before you trust it.


Bug 2 — Stack canaries live in TLS, and your wake-skeleton functions can’t have one

What it is. GCC’s -fstack-protector-strong (default on Ubuntu since 22.04) inserts canary checks in every function with a “vulnerable” local buffer. The canary value is read from __stack_chk_guard, which on x86-64 glibc lives in TLS at offset FS:0x28. Every protected function emits:

; entry
mov  rax, fs:0x28           ; load canary from TLS
mov  QWORD PTR [rbp-0x8], rax

; body...

; exit
mov  rcx, QWORD PTR [rbp-0x8]
xor  rcx, fs:0x28            ; reload from TLS, compare
jne  __stack_chk_fail

The function reads __stack_chk_guard from TLS at entry, and reads it again from TLS at exit to verify nothing on the stack got smashed.

How it manifested during testing. The sleeper would print [sleeper] entering encrypted sleep, hang briefly, then Segmentation fault (core dumped). The core dump told the truth:

$ gdb -batch ./test/test_loader /tmp/core.test_loader.10082 \
    -ex 'info registers rip' -ex 'bt'

Program terminated with signal SIGSEGV.
rip            0x44b4f0            __stack_chk_fail
#0  0x000000000044b4f0 in __stack_chk_fail ()
#1  0xb2887ac0104cf4d8 in ?? ()
[backtrace garbage — stack chain destroyed]

nm confirmed 0x44b4f0 was __stack_chk_fail in the statically-linked libc. What happens: the stub function reads the canary at entry, runs its body (which encrypts the TCB page where __stack_chk_guard lives), and at exit tries to read the canary again from FS:0x28. The page is now ciphertext. The XOR doesn’t match. Jump to __stack_chk_fail, which is itself in an encrypted region of libc. Instruction fetch fault. The program dies inside the die-handler.

How I fixed it. no_stack_protector attribute on every function placed in .decrypt_stub.text. I rolled it into the section macro:

#define DSTUB __attribute__((section(".decrypt_stub.text"), no_stack_protector))

This requires GCC 11+ (I target 11.4 on Ubuntu 22.04). The attribute disables canary insertion for tagged functions while leaving the rest of the binary protected normally. It’s safe: stub functions are called only by my own asm wrapper with controlled arguments, no untrusted-input surface.

After this fix the crash moved to a different RIP — meaning the canary check no longer fired, but something else was still broken.


Bug 3 — glibc’s __rseq_size disagrees with what the kernel registered

What it is. rseq (Restartable Sequences) is a Linux feature where the kernel writes per-thread state to a small struct in user-mode TLS on every context switch. Glibc 2.32+ registers it automatically at thread startup. The metadata is exposed via three weak symbols: __rseq_offset, __rseq_size, __rseq_flags.

If the implant encrypts the TLS page containing the rseq struct, the kernel page-faults on the next context switch. The fix is to unregister rseq before encryption, re-register on wake. In theory straightforward. In practice: on Ubuntu 22.04 with glibc 2.35, glibc registered rseq with size = 32 bytes, but exposes __rseq_size = 28. Known glibc-side accounting bug fixed in 2.36+ (which exposes 32 correctly). I used __rseq_size from the weak symbol. The kernel rejected my unregister call.

How it manifested during testing. Stack canary fix in place. Standalone sleeper still segfaulted. dmesg and strace together:

dmesg:
[ 3194.867991] sleeper[9465]: segfault at 7ffff7e15040 ip 000000000049916b
              sp 00007ffff7ffa940 error 14 likely on CPU 1

strace:
rseq(0x4c1340, 0x20, 0, 0x53053053)        = 0           ← glibc REGISTERED, len=32
getrandom(...)                              = 32          ← my key
getrandom(...)                              = 12          ← my nonce
rseq(0x4c1340, 0x1c, 0x1, 0x53053053)      = -1 EINVAL    ← my UNREGISTER, len=28
rt_sigprocmask(SIG_SETMASK, ~[], [], 8)    = 0
mprotect(0x4ab000, 90112, RW)              = 0
mprotect(0x4ab000, 90112, PROT_NONE)       = 0
mprotect(0x4c1000, 139264, RW)             = 0            ← encrypts the TCB!
mprotect(0x4c1000, 139264, PROT_NONE)      = 0
--- SIGSEGV {si_signo=SIGSEGV, si_code=SI_KERNEL, si_addr=NULL} ---

The smoking gun: rseq(0x4c1340, 0x1c, 0x1, ...) = -1 EINVAL. I passed len=28 (0x1c) but the kernel was registered with len=32 (0x20). Kernel checks current->rseq_len != rseq_len during unregister and returns EINVAL. I had ignored the return value as best-effort. rseq stayed active. I then encrypted the TLS page. Kernel preempted, tried to update rseq, page fault, SIGSEGV si_code=SI_KERNEL (kernel-originated, not user-mode).

How I fixed it. Multi-size fallback — try kernel-canonical sizes (32 for original, 64 for the extended struct introduced in Linux 6.3+) before falling back to __rseq_size:

long rc = sys_rseq(addr, 32, UNREGISTER, sig);
if (rc != 0) rc = sys_rseq(addr, 64, UNREGISTER, sig);
if (rc != 0 && __rseq_size != 32 && __rseq_size != 64)
    rc = sys_rseq(addr, __rseq_size, UNREGISTER, sig);

Same logic for the re-register path. Cost: 1-2 extra syscalls in the hot path. Robust across glibc 2.35 (broken __rseq_size), 2.36+ (correct), future kernel rseq extensions.

After this fix the standalone sleeper finally worked end-to-end. Then I tested via the loader.


Bug 4 — Two static-linked binaries on the same thread can’t both register rseq

What it is. Each statically-linked binary has its own embedded glibc. When such a binary starts, __libc_start_main registers rseq on the current thread. The kernel allows only one rseq registration per thread; a second registration with different parameters returns EINVAL.

Apnea’s loader is itself a statically-linked binary. Its glibc registers rseq at its own TLS during startup. Then the loader maps the sleeper payload anonymously and jumps to the sleeper’s _start. The sleeper has its own statically-linked glibc, which also tries to register rseq during its __libc_start_main. Kernel says no. EINVAL.

The sleeper’s glibc fails to register silently — there’s no way to surface the failure to user code. The sleeper continues. Its __rseq_offset / __rseq_size symbols (linked at compile time) point to where the sleeper’s TLS would have rseq. But the kernel’s active registration is still at the loader’s TLS, not the sleeper’s.

When the sleeper later calls apn_sleep_cycle_self, the unregister logic uses the sleeper’s __rseq_offset — wrong address. Unregister fails silently. The encryption step then encrypts both the loader’s TLS region and the sleeper’s TLS region (both are anonymous in the process). Kernel context-switches, tries to update rseq at the loader’s TLS address (which is the active one), page fault, SIGSEGV.

How it manifested during testing. Standalone sleeper worked. Same sleeper via loader crashed instantly:

$ ./test/test_loader ./test/payloads/sleeper
[loader] calling loader_exec()
[sleeper] before self-encrypt sleep — code/data alive
Segmentation fault

The strace showed the sleeper’s glibc trying to register rseq at 0x55555557bd60 — an address in the loader’s address space (the 0x5555... range is the PIE base of test_loader, not the sleeper at 0x400000+). That observation was the key: there are two glibcs in one process, both trying to do __libc_start_main work, and the second one is targeting the wrong TCB.

How I fixed it. The loader unregisters its own rseq just before jumping to the payload, leaving the thread “rseq-clean” so the sleeper’s glibc can register fresh:

/* In the loader, just before the jump to the payload */
extern const ptrdiff_t   __rseq_offset __attribute__((weak));
extern const unsigned int __rseq_size  __attribute__((weak));

if (&__rseq_size != NULL && __rseq_size != 0) {
    unsigned long fs_base = 0;
    if (sys_arch_prctl(ARCH_GET_FS, &fs_base) == 0) {
        void *addr = (void *)(fs_base + __rseq_offset);
        long rc = sys_rseq(addr, 32, UNREGISTER, APN_RSEQ_SIG);
        if (rc != 0) rc = sys_rseq(addr, 64, UNREGISTER, APN_RSEQ_SIG);
        if (rc != 0 && __rseq_size != 32 && __rseq_size != 64)
            rc = sys_rseq(addr, __rseq_size, UNREGISTER, APN_RSEQ_SIG);
    }
}

apn_jump_to_entry(stack.rsp, payload_entry);

For raw shellcode payloads (no glibc), __rseq_offset / __rseq_size are undefined and the entire if block is a no-op. So the fix is harmless for non-glibc payloads.

After this fix, the full chain loader → sleeper runs cleanly end-to-end. Three minutes of debugging output saved by writing a one-paragraph design note ahead of time would have been an entire afternoon recovered.


What’s next

Honest list of what Apnea doesn’t do yet:

  • aarch64. The raw-syscall layer is scoped for it but the implementation is still #error’d. Probably a week of work — different syscall calling convention, different opcodes for the CET equivalents.
  • Dynamic linking. The parser rejects PT_INTERP. Implementing a userspace ld-linux.so is a separate 3-5k LoC project. Most modern Linux C2 implants are statically-linked anyway (Sliver, Mythic Poseidon, Havoc agents).
  • C2 integration. Apnea is C2-agnostic — a dispatch interface is reserved but not yet written. The next concrete test will be wrapping a Sliver implant and running the full chain on a fresh VM.
  • CI matrix. Bugs 3 and 4 only surface on certain glibc/kernel combinations. A GitHub Actions matrix running the PoC capture script on Ubuntu 22.04 / 24.04 / Debian 12 / Fedora 40 on every push would catch regressions in future glibc releases.

See the full project

This post walked through the sleep-obfuscation engine specifically. The full Apnea project — stager, userland-exec loader, sleep engine, payload examples, the capture script that produced the PoC screenshot above, and per-component design notes — is open source on GitHub:

github.com/norahc-x/apnea

If you reproduce on a different distro and one of the bug fixes doesn’t apply, open an issue with the strace + dmesg output — that’s the data I need to extend the fallbacks. Issues and PRs welcome.


This work is for authorized security testing, security research, and educational use only. All experiments were performed exclusively in isolated VMs that I own.