I am testing PAM scenarios on RedHat 7.5

The pam module pam_succeed_if.so looks like the most basic level of conditional testing that PAM has to offer, and it is not meeting my needs. You can only create tests on the user, uid, gid, shell, home, ruser, rhost, tty, and service fields.

In my situation, I do want to test based off the 'rhost' field, however after putting the module into debug, I saw that the rhost field was not set.

My goal is to only run a PAM module in /etc/pam.d/sudo if the user is logged in locally to the machine. If we can detect that the user is logged in through SSH, then I want to skip the module. I had actually come up with 3 different ideas that I thought would work, but all ended up failing.

I'll share a couple of the solutions that ended up failing

Conditional entry using pam_exec.so

I wanted to add the following pam entry on top of the pam module that I want to conditionally skip:

auth    [success=ok default=1]    pam_exec.so /etc/security/deny-ssh-user.sh

The contents of /etc/security/deny-ssh-user.sh

# Returns 1 if the user is logged in through SSH
# Returns 0 if the user is not logged in through SSH
if [ -n "${SSH_CLIENT}" ] || [ -n "${SSH_TTY}" ] || [ -n "${SSH_CONNECTION}" ]; then
    case $(ps -o comm= -p $PPID) in
        sshd|*/sshd) SSH_SESSION=true;;

if "${SSH_SESSION}"; then
    exit 1
    exit 0

I've gone through the source code for pam_exec.so at https://github.com/linux-pam/linux-pam/blob/master/modules/pam_exec/pam_exec.c and astonishingly, it will ALWAYS return PAM_SUCCESS, regardless of the exit code of the script. And I can't get the script to cause the pam_exec module to return PAM_SERVICE_ERR, PAM_SYSTEM_ERR, or PAM_IGNORE.

Conditional entry using pam_access.so

Again, I add the following pam entry on top of the pam module that I want to conditionally skip

auth    [success=ok perm_denied=1]    pam_access.so    accessfile=/etc/security/ssh-sudo-access.conf noaudit

The contents of /etc/security/ssh-sudo-access.conf


Wow, super clean right? It will return success if you are logged in locally, and deny everything else. Well no. It turns out when the pam_access.so module is put into debug, it has no knowledge of remote hosts, only the terminal that is being used. So the pam_access can't actually block access by remote hosts.

It has been an infuriating day of literally reading source code just to find out what black magic I have to cast just so I can skip a PAM module.

  • 1
    My reading of pam_exec.c is that it will return PAM_SYSTEM_ERR when the script returns non-zero. Maybe you can double-check that method again. – Tim Smith Jun 29 '18 at 1:45
  • That is exactly the interpretation I had too. Unfortunately, that isn't the case. I even tried to return all exit codes that pam_exec claims to return: 4 (PAM_SYSTEM_ERR), 3 (PAM_SERVICE_ERR), and 25 (PAM_IGNORE) in my script. In the pam module, I tried catching all 30 different PAM status codes, and the only status code that impacted the behavior was PAM_SUCCESS. The pam_exec.so module should really be written to explain that it fires off a script, and will always return success regardless of whatever exit code the script returns – Luke Pafford Jun 29 '18 at 4:55
  • It might help to use auth [success=1] pam_debug.so auth=auth_err, instead of ... pam_exec.so .... And verify that it's failing as it should, so you know the stack is configured right. – Tim Smith Jun 29 '18 at 5:58

Here is a solution that works for me. My /etc/pam.d/sudo:

auth            [success=1]     pam_exec.so    /tmp/test-pam
auth            required        pam_deny.so
auth            include         system-auth
account         include         system-auth
session         include         system-auth

And /tmp/test-pam:

#! /bin/bash
/bin/last -i -p now ${PAM_TTY#/dev/} | \
    /bin/awk 'NR==1 { if ($3 != "") exit 9; exit 0; }'

I get this behavior:

$ sudo date
[sudo] password for jdoe:
Thu Jun 28 23:51:58 MDT 2018
$ ssh localhost
Last login: Thu Jun 28 23:40:23 2018 from ::1
valli$ sudo date
/tmp/test-pam failed: exit code 9
[sudo] password for jdoe:
sudo: PAM authentication error: System error

The first line added to the default pam.d/sudo calls pam_exec and, if it succeeds, skips the next entry. The second line just denies access unconditionally.

In /tmp/test-pam I call last to get the IP address associated with the TTY pam was invoked from. ${PAM_TTY#/dev/} removes /dev/ from the front of the value, because last doesn't recognize the full device path. The -i flag makes last show either the IP address or the placeholder if there is no IP address; by default it shows an info string which is much harder to check. This is also why I used last instead of who or w; those don't have a similar option. The -p now option isn't strictly necessary, as we'll see awk is only checking the first line of output, but it restricts last to show only users who are presently logged in.

The awk command just checks the first line, and if the third field isn't it exits with an error. Since this is the last command in /tmp/test-pam, awk's exit code becomes the exit code for the script.

On my system, none of the tests you were trying in your deny-ssh-user.sh would work. If you put env > /tmp/test-pam.log at the top of your script, you'll see that the environment has been stripped, so none of your SSH_FOO variables will be set. And $PPID could point to any number of processes. For example, run perl -e 'system("sudo cat /etc/passwd")' and see that $PPID refers to the perl process.

This is Arch Linux, kernel 4.16.11-1-ARCH, in case it matters. I don't think it should, though.


Well it turns out I'm actually an idiot, the pam_exec.so module is perfectly fine for creating PAM conditionals.

Tim Smith was correct in assessing that both tests in my /etc/security/deny-ssh-user.sh script were NEVER setting the variable SSH_SESSION to true. I didn't take that into consideration because the script works in a normal shell, but the environment context is stripped when executed by pam_exec.so.

I ended up rewriting the script to use the last utility just like his example, however I had to change some of it because the switches for last differ from Arch Linux to RedHat.

Here is the revised script at /etc/security/deny-ssh-user.sh:

# Returns 1 if the user is logged in through SSH
# Returns 0 if the user is not logged in through SSH

function isSshSession {
    local terminal="${1}"
    if $(/usr/bin/last -i | 
        /usr/bin/grep "${terminal}" |
        /usr/bin/grep 'still logged in' |
        /usr/bin/awk '{print $3}' |
        /usr/bin/grep -q --invert-match '0\.0\.0\.0'); then
        echo true
        echo false

function stripTerminal {
    local terminal="${1}"

    # PAM_TTY is in the form /dev/pts/X
    # Last utility displays TTY in the form pts/x
    # Returns the first five characters stripped from TTY
    echo "${terminal:5}"

lastTerminal=$( stripTerminal "${PAM_TTY}")
SSH_SESSION=$(isSshSession "${lastTerminal}")

if "${SSH_SESSION}"; then
    exit 1
    exit 0

Contents of /etc/pam.d/sudo

auth    [success=ok default=1]    pam_exec.so /etc/security/deny-ssh-user.sh
auth    sufficient    pam_module_to_skip.so

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.