banner

Before we start

This is lesson zero of a course on building a Linux C2 agent from the operating system up. It is not a manual for my project. I will use my own agent as an example here and there, but the point is for you to understand Linux well enough to build your own, on whatever design you like.

Some history

The first Linux distro I used was Ubuntu 8.04. I have been writing Linux malware since 2015.

In my first year at Topsec, I decided to build something people would know me for. That became emp3r0r, created in 2019.

I built a dedicated Linux C2 because almost nobody had serious Linux support. Most people were taught to throw a reverse-shell one-liner at a target and import pty to "upgrade" it into a usable shell. I wanted to know why we could not have decent tools. So emp3r0r shipped with proper PTY support and a built-in SSH server: seamless SFTP with whatever file manager you already use, and a remote PTY that works with any CLI tools you need to run.

Later I expanded emp3r0r to Windows. I hand-rolled a conhost-based interactive shell and wrote it up in Fully Interactive Remote Shell for Windows. For PTY-like remote consoles on Windows hosts that predate ConPTY, I later switched to go-console, a cross-platform PTY wrapper. On Windows it is backed by winpty, Ryan Prichard's C++ PTY emulation, through the go-winpty binding. Eventually I moved the whole thing to proper ConPTY support. As far as I know, I was the only one supporting remote interactive shells on those older Windows hosts.

Years went by, and I learned more about malware and Linux.

emp3r0r was designed as a Go C2 largely because Go cross-compiles beautifully: one binary runs on any Linux distro out of the box, with none of the glibc nightmare. Not long after, I ran into Go's issues. It is bloated: a useful Go agent (implant, or whatever you prefer) is 10MB or more. It is also inflexible, because you cannot tear the runtime down, which makes a real sleep mask impossible. I guess people write C2s in C for a reason.

Today I am writing a new C2 agent from scratch, and emp3r0r now has an experimental pure C agent. It is not a statically linked, self-contained blob like most Linux malware. It is surgical: it leans on code that is already on the system, the way mature Windows malware does. Well, Linux is not Windows. There is far less code you can just call out of the box, and that is the central challenge of Linux malware.

In fact, a statically linked executable in certain locations is almost equivalent to an admission of Linux malware. Most system binaries are dynamically linked, so a static blob sitting in /tmp or /dev/shm stands out immediately. Commodity malware authors do not care: they static-link their crypto miners so one binary runs on every Linux machine without recompiling for each environment.

For too long, Linux malware has lagged behind Windows. Cheap tricks such as LD_PRELOAD and memfd_create are treated as sophisticated APT tradecraft simply because nobody has seen decent Linux malware. In this age when AI coding is the new normal, there is no excuse for still using outdated bullshit.

Who this course is for

You should be comfortable writing C, compiling it, and using a Linux shell. You do not need to know anything about ELF files, syscalls, or the dynamic linker. I will explain each term the first time I use it, with a small thing to try, so you can see it for yourself instead of taking my word for it.

By the end of this lesson you should be able to answer these questions:

  • If the kernel is the only thing that touches hardware, how does your code ever run?
  • Why can't you just drop code into memory and jump to it?
  • What actually happens when you call a C function like read()?
  • What is libc, and why might you avoid it?
  • What is TLS doing, and why not ship your own?
  • What is inside an ELF file, and how does code end up running from memory?

Part I — How a program runs

1. A program is a guest in the kernel's house

Want to read a file? There is no CPU instruction for that. Want to open a socket? There is no CPU instruction for that either. The CPU knows how to move numbers around, do arithmetic, and jump. Everything else, disks, network cards, timers, is owned by the kernel, and the kernel is pre-existing native code that runs in a privileged CPU mode.

Think of the kernel as the only person with keys to the building. Your program is a visitor. It can walk around its own room (its memory), but it cannot go into the server room, and it cannot change the locks. When it wants something done, it has to ask.

That ask is a syscall (we will cover those in lesson 3). That request is the whole relationship between a program and the operating system, and almost everything interesting in this course happens at that boundary.

A running program is a process: an isolated box with its own private view of memory, plus one or more threads of execution. That memory is virtual. The CPU translates every access through page tables the kernel maintains, so a process believes it owns the whole address range while the kernel quietly shares and recycles physical memory underneath. That is why two processes can both use address 0x400000 without colliding, and why a pointer from one process means nothing in another.

See the real layout of a running program for yourself:

cat /proc/self/maps

Each line is one region of memory: its address range, its permissions, and where it comes from. The first entries are usually your program's code and data, then the heap, the stack, and a set of files from disk. Keep this output around; it is the ground truth for most of this course.

2. Memory, pages, and how code runs

The kernel does not hand out memory one byte at a time. It works in fixed-size chunks called pages, usually 4KB. Every page has permissions the CPU enforces: readable, writable, executable, or not. On x86-64, "not executable" is a page-table bit called NX (no-execute).

A page can be marked writable and executable at the same time, and that combination is dangerous: anything that can write to the page can then run what it wrote. So there is a rule of thumb called W^X, short for "write XOR execute": a page should be writable or executable, never both. The "XOR" comes from logic: one or the other, not both. Some hardened systems enforce W^X strictly; on a normal Linux box it is more of a discipline that good software follows. As a malware developer, you want your program to behave like widely-used legitimate software, so defenders have no clear baseline that makes you stand out.

Why do we even care what permissions our memory pages have? This comes down to how code is executed on Linux (and most operating systems). Let's go down to the bottom, because this is where a lot of people stop understanding and just repeat words.

The CPU only speaks machine code

A CPU is a chip that executes a stream of binary instructions. It can load a number, add two numbers, compare them, jump somewhere else, read or write memory. That's the vocabulary. Those instructions are called machine code, and it is the CPU's native language. Machine code is binary, not text. When you disassemble it, you get assembly, the human-readable form of the same instructions, something like xor rax, rax.

Machine code is specific to a CPU family. An Intel or AMD chip and an ARM phone speak different instruction sets, so the same machine code does not run on both. When you compile a C file, that is exactly what the compiler is doing: translating your x + 1 into whatever instructions your target CPU understands.

So how do Python and bash run, then?

If the CPU only runs machine code, how can you run a text file full of Python? You don't. You run the interpreter, /usr/bin/python, which is a native program somebody already compiled for your CPU. The interpreter opens your script, reads it, and does what each line says. Your script is not code the CPU executes; it is data the interpreter reads and acts on. The interpreter is the code.

Same for bash. /bin/bash is native machine code; your .sh file is input to it.

Java, bytecode, and virtual machines

Java is the middle case, and it is where two words trip everyone up: bytecode and virtual machine.

Java wants two things at once: ship one file that runs everywhere, and don't hand out source code. If it compiled straight to Intel machine code, that file would not run on ARM. If it shipped source, every machine would need a compiler and would have to compile it first.

So Java compiles your source to bytecode: a list of instructions for a CPU that does not physically exist. If you have seen assembly, bytecode looks like assembly for an imaginary machine. The instructions are small, usually one byte each, which is where the name comes from. One instruction might mean "load the value in local slot 3", another might mean "add the top two values", another "call this method".

Your real CPU cannot run those instructions, because it has never heard of them. You need something that has. That something is the virtual machine (for Java, the JVM). A VM is a native program that pretends to be a CPU: it reads bytecode instructions one at a time and carries them out. So your real CPU runs the JVM, and the JVM runs your bytecode. The bytecode is data; the JVM is code.

Why bother with all this? Portability. Bytecode is the same on every platform. The only thing that has to be ported is the VM, and once a platform has a VM, every Java program runs on it.

A VM gives you more than portability. It is a managed environment. It keeps its own call stack for method calls, its own heap that it cleans up for you (garbage collection), and its own rules. That is why a Java error gives you a Java stack trace instead of a segmentation fault.

And a modern VM does not just interpret forever, because that would be slow. It watches which bytecode runs the most, compiles just those parts into real machine code while the program is running, and then executes the native version. That is a JIT, a just-in-time compiler. Which means a running Java process really does have native machine code in memory that was generated on the spot, and that code sits in anonymous executable pages, the exact thing from the previous section. So "it's just a Java process" is not a great excuse when a scanner finds executable memory in it.

One warning, because "virtual machine" is an overloaded term. The JVM is a process virtual machine: it emulates a CPU for one program, inside a normal process. When people say "a VM" meaning a whole computer running an operating system, like QEMU or VirtualBox, that is a system virtual machine: it emulates an entire computer, devices and all, and runs an OS inside. Both are called VMs. They are not the same thing.

There is one more wrinkle. A system VM runs a guest operating system, and a kernel executes privileged instructions that the CPU normally allows only for the real kernel. Modern system VMs handle that with hardware virtualization (Intel VT-x, AMD-V): the guest kernel's privileged instructions run directly on the CPU, under the hypervisor's control. Before that hardware existed, the hypervisor had to trap each privileged instruction and emulate it in software, which was much slower. So "it's a VM" can mean two very different things under the hood.

So everything that runs is native code

To be clear: whatever you write, the thing the CPU executes is native machine code. There are three common routes to get there:

  • compiled ahead of time to native code (C, C++, Go, Rust),
  • interpreted from source by a native interpreter (Python, bash),
  • compiled to bytecode and run by a VM, often with a JIT (Java, C#, JavaScript).

Native code normally lives in two kinds of file: executables and shared libraries (DLLs on Windows).

A process is the container

Now the part that matters for us. Linux is not designed to run loose native code on the fly. A process is the container that native code runs in, and a process is created from an executable file in the file system. Note that I didn't say "on the disk". Linux exposes most of its stuff through virtual file systems, /proc and /sys and all. Some people think running an executable from /dev/shm or memfd makes it invisible, but in practice it is often much louder than just running from the "disk".

Okay, now, you might already have guessed the answer. Windows C2s inject their code into existing or sacrificial processes as threads (a thread is a running code flow that shares the process's memory). In Linux, we must do something similar, but the environment is different.

Windows is a binary distribution. The OS is designed to load proprietary code all the time, so process injection, remote thread creation, and RPC calls are common and unremarkable. Linux is not built that way. If you try the same tricks there, you have very few options: ptrace (a debugging API), loading a shared library, or writing straight into another process's memory. All of them are loud and easy to detect. And loading a shared library is not really a separate option: either you set LD_PRELOAD before the process starts, or you need ptrace (or process_vm_writev) to make a live process load it.

By the way, why run code inside a process at all, instead of just launching an ELF from the file system? Because then your payload has to be written to the file system, where it is trivial to fingerprint before it ever runs. execve is easy to inspect, and a scanner can simply read the file and match a signature. You cannot hide it, because the file has to be a valid ELF the OS can load. Packers exist to conceal as much as possible, but they only raise the bar.

There is also a rule of thumb here: don't rewrite your whole implant every time it gets detected. Keep it modular, expose only what has to be exposed, and when a piece gets burned, rewrite just that piece. Code that is already running is far more expensive to detect, because catching it means scanning process memory.

So if you avoid launching an ELF from the file system, your code ends up running as a thread that belongs to neither the executable nor any shared library the process has loaded. It shows up in the process mappings as anonymous pages. This is often seen in JIT processes (such as Java, but even GNOME has GJS, so it's actually more common than you think).

The W^X dance

W^X is why you cannot simply drop new machine code into a buffer and jump to it. The buffer is writable, not executable. To run code that did not come from a file, you have to change permissions as you go:

#include <sys/mman.h>
#include <string.h>

int main(void) {
    /* x86-64 machine code for: mov eax, 42; ret */
    unsigned char code[] = {0xb8, 42, 0, 0, 0, 0xc3};

    void *page = mmap(NULL, 4096, PROT_READ | PROT_WRITE,
                      MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    memcpy(page, code, sizeof(code));            /* writable, not runnable */
    mprotect(page, 4096, PROT_READ | PROT_EXEC); /* runnable, not writable */

    int (*fn)(void) = (int (*)(void))page;
    return fn();
}

mmap asks the kernel for memory. MAP_ANONYMOUS means "not backed by a file on disk". mprotect changes a mapping's permissions. Only after the second call can the CPU run those bytes. Compile it, run it, and check the exit code:

gcc wxtest.c -o wxtest && ./wxtest; echo $?

You should see 42. If you comment out the mprotect line, the program crashes with a segmentation fault, because the CPU refuses to execute a page that is only writable. That crash is W^X doing its job.

JIT runtimes take the same approach. They write the generated machine code into a writable page, then flip it to executable. A well-behaved JIT does not allocate RWX memory either, for the same reason: it would stand out.

The result of this dance is anonymous executable memory: memory the CPU can run that is backed by no file. Normal programs rarely have any. That makes it one of the strongest signals a process can emit, and it is the central tension of running code in memory: to do it at all, you must create something legitimate software almost never creates. We will spend a lot of this course managing that tension.


Part II — The system and its libraries

3. Syscalls: asking the kernel for a favor

A syscall (system call) is a kernel function that a user process can call to ask for something it cannot do itself. The CPU needs a defined way to make that request, and that convention is an ABI, an application binary interface: the low-level rules for how functions or syscalls are called. On x86-64 it goes like this: put the syscall number in rax, up to six arguments in rdi, rsi, rdx, r10, r8, r9, then execute the syscall instruction. The CPU switches into the kernel, the kernel does the work, and the result lands back in rax. A negative result is an error code, which is where the familiar errno comes from. Here is a complete one:

; equivalent to write(1, "hi\n", 3)
mov rax, 1          ; syscall number for write
mov rdi, 1          ; first argument: fd 1, stdout
lea rsi, [msg]      ; second argument: pointer to the bytes
mov rdx, 3          ; third argument: length
syscall

Every syscall number is specific to a CPU architecture. read is 0 and mmap is 9 on x86-64, but the same names have different numbers on ARM. If you ever port low-level code, that is the kind of thing that bites you.

When you write C, you never touch this. read(), open(), and write() are ordinary functions in the C library that eventually execute the same instruction. You can watch it happen:

strace -e trace=openat,read,write /bin/ls

Every line is a syscall your program made, with its arguments and result. So the next time someone says a program "just runs", remember: under the hood it is mostly openat, read, mmap, and write.

A payload that wants to stay small can skip the C library entirely and make these calls itself. That is freestanding code, and it is exactly what a shellcode stager is. The catch is that you now own every detail, and one of them is easy to miss: the kernel can see where your syscall instruction ran from.

In a normal program, that address is inside libc, a mapped region backed by a file on disk. In shellcode, it is inside anonymous memory, and that mismatch stands out. The kernel maps a small shared library into every process, called the vDSO (virtual dynamic shared object). It shows up in /proc/self/maps as an ordinary mapped region and contains a syscall; ret sequence. Call into the vDSO instead of issuing the instruction yourself, and the syscall appears to come from a normal, kernel-provided address. This is what people mean by indirect syscalls. Be honest about the limits, though: the syscall still happens and is still recorded. You changed one address a detector can inspect, not the event itself.

One last freestanding problem. If your code is mapped read-and-execute, it cannot hold a mutable variable, because writing would crash the process. So where do you keep a cached function pointer or a counter? On x86-64 you can point the GS segment register at a separate writable page and reach it through %gs:0. It is a common trick for code that has no writable data of its own.

4. libc and the dynamic linker

Here is a thing that confuses almost everyone: libc is not the operating system. glibc and musl are just two implementations of the C standard library, and they are ordinary user-space programs. The kernel does not know or care which one you have. libc gives you printf, malloc, string handling, and thin wrappers around the syscalls we just looked at.

Most programs link libc dynamically. The executable does not contain libc; it contains a list of shared libraries it needs and the name of the dynamic linker (ld.so), which loads them when your code starts. That linker maps each required .so file into memory, resolves every name the program references (a symbol, like printf), and jumps to your entry point. So a running program is really a mix of its own code and a bunch of shared libraries. You can see the list:

ldd /bin/ls        # which shared libraries ls needs

And you can see where the kernel put them:

cat /proc/self/maps | grep -i libc

Two things here matter to us.

First, a dynamic binary tells on itself. Its strings name the libraries and functions it uses, which is a fingerprint. That is why commodity Linux malware links statically: one self-contained binary runs everywhere. The trade-off is that a static binary is weird on a normal desktop or server, where almost everything is dynamic, so it sticks out just by existing.

Second, you do not need to link a library to use it. You can find it at runtime and call into it. The usual tools are dlopen (load a shared library) and dlsym (look up a symbol in it). There is a wrinkle: those used to live in a separate libdl.so, and glibc 2.34 folded them into libc itself, while musl never had a separate libdl. Doing this by hand means coping with that.

ABI compatibility rears its head again here. glibc is backward compatible but not forward compatible: build against a newer glibc, reference a symbol version an older one does not have, and your binary simply will not start. That is why you build against an old target and resolve optional libraries at runtime. And do not static-link glibc; it breaks things like name resolution and dlopen, which need matching runtime pieces.

The takeaway: reuse what the target already has. It keeps your payload small and boring, which is exactly what you want. The cost is that you have to find and bind those libraries at runtime. Oh, and LD_PRELOAD is the environment variable that makes the dynamic linker load a library of your choice first, which is why it shows up in both malware and debugging.

5. TLS: encryption you should not reinvent

Almost every C2 talks to its operator over TLS, the encryption behind HTTPS. TLS does two things: it lets the client confirm it is talking to the right server, and it encrypts everything after that so a network observer sees nothing useful.

The setup is called the handshake. The client sends a hello with the protocol versions and ciphers it supports and the server name it wants (the SNI). The server proves who it is with a certificate, both sides agree on a shared secret, and then the connection switches to encrypted records. The SNI goes out in cleartext, so anyone on the path can still see which site you are visiting. There is a newer extension, Encrypted Client Hello (ECH), that hides that name behind a public cover name, but it needs server-side cooperation and a new enough TLS library.

The beginner mistake is thinking you have to ship a TLS stack inside your payload. You almost never do. The target already has one. Load the system's HTTP client (libcurl) or TLS library (OpenSSL/libssl) at runtime, exactly like we just talked about, and let it do the hard part. Smaller payload, fewer fingerprints.

You do need a little crypto of your own, but far less than people assume:

  • AES-GCM, a symmetric cipher with authentication, to encrypt each message.
  • SHA-256, a hash, for integrity and key derivation.
  • HKDF, which turns one secret into several keys, to derive session keys from a shared secret.
  • ECDH, a key exchange, so two parties can agree on a secret over a public channel.
  • ECDSA, a signature scheme, so the agent can prove it holds its private key.

That is the whole toolkit. Put it behind a small interface and you can swap implementations: call the system's OpenSSL, or vendor a small self-contained library for hosts that lack a usable one. Either way, the crypto is somebody else's code.


Part III — Files and payloads

6. ELF: the container underneath everything

Every executable, shared library, and relocatable object on Linux is an ELF file. Three kinds matter here:

  • ET_EXEC: a normal executable. It assumes a fixed address and usually wants its own process.
  • ET_DYN: a position-independent executable or shared library. It can be mapped wherever there is room.
  • ET_REL: a relocatable object, the .o a compiler produces before the linker. No addresses are resolved yet.

You can check any file:

file /bin/ls
readelf -h /bin/ls     # the header: type, entry point, and so on
readelf -l /bin/ls     # program headers: the segments the loader maps

An ELF is split into segments (what the loader maps into memory) and sections (mostly for tools and debugging). At runtime only the segments matter. That is why a loader that maps a file by hand cares about segments and dynamic tables, and can ignore the section headers.

An ET_REL object has no addresses yet, so it is full of relocations: notes that say "put the address of this symbol here". A normal linker resolves them at build time. A loader can do it at runtime, which is exactly how a Windows-style BOF runs on Linux: map the object, patch the relocations by hand, call the entry point.

Position-independent code, built with -fPIC, is what makes that possible: it uses relative addressing, so the same bytes run at any address. Shellcode is the extreme version. It is raw machine code, pulled out of an ELF and flattened into a blob with no headers and no assumptions about where it lives. The price is that you lose everything a loader gives you: no writable globals, no function pointers sitting in data, no fixed layout. That is why shellcode ends up doing tricks like the GS register one from earlier.

7. From pieces to a stager

Now we can put it together into one real thing: a stager. A stager is a small payload whose only job is to fetch and run the real payload, because you cannot always deliver the whole agent in your first foothold. The flow:

  1. A small stub runs. It may be raw shellcode, so it maps a writable page, puts the next stage there, flips the page to executable, and jumps.
  2. A downloader fetches the real payload over the network. It is kept separate so that, once the download is done, its networking code and the server address can be wiped from memory.
  3. The payload arrives encrypted or compressed, so it never looks like an executable on the wire. The stub decrypts and unpacks it into memory and transfers control. The agent then runs entirely in memory.

The obfuscation is deliberately simple, RC4 or a little compression, because it only has to beat network inspection and static strings, not a determined analyst. The final payload is often a real ELF internally, repackaged by a packer into a flat blob the stub can map without a loader.

The split is on purpose. Memory is precious in a long-running implant, so every stage is built to be discarded when it is done: the downloader dies after the download, and only the agent remains. Real frameworks go further with a supervisor that restarts the agent while keeping the identity the server pinned, but that is a later lesson.

Notice none of this was exotic. Processes, pages, syscalls, shared libraries, TLS, and ELF. That is the whole vocabulary.


Comments

comments powered by Disqus