Linux users password migration

Ola Thoresen
6 min readJan 29, 2021

--

If you have maintained a server for a while, you probably have some users aboard with passwords encrypted with an old and unsecre algorithm, or you might want to migrate from one authentication backend to another (e.g. from /etc/shadow to ldap or vice versa) and want to migrate the passwords without interacting with all your users.

Now, it might be reasonable to require your users to change their passwords from time to time, but that is another discussion. In this case, what you want, is to allow your users to keep their password, while still upgrading the hashing algorithm. But since you do not know their actual password, this means that you can’t just convert your old hashes to a new format, and probably not move them to a new backend very easily.

Fortunlately, PAM allows you to run arbitrary commands on login, and it even has a flag to send the provided password to this command. This means that on a high level, what we do is we make PAM execute a script. This script gets the username and password from PAM, and creates a new hash with the wanted algorithm, and saves the new hash to a file. You can then replace your existing hashes with the new ones, or you can use the new hash in your new auth backend when you migrate.

So let’s go through this step by step.

I made a simple bash-script, but if you want to do more advanced stuff, you can of course do this in whatever language you want. Especially if you want to create a full migration script, you might want to read out the old values for the home directories, the groups, maybe any existing password expiry values etc.

/root/bin/conv_passwd.sh

#!/bin/bash# Get password from PAM
read password
# A few files we use to save and validate the results
SHADFILE=/root/newshadow
LOGFILE=/root/convpass.log
# Let's see if the user has been converted already
# The username is provided as an environment variable.
CHECK=$(grep ^$PAM_USER $SHADFILE)
if [ "x$CHECK" == "x" ]; then
# The user has not been migrated already
#
# First, we need to validate that the provided password
# is the correct one.
# Since this script is run for ALL password-attempts, and
# before the user is actually logged in, any brute force attack,
# or wrong password entered by the user will also be sent to the
# script. So we can't just blindly accept whatever password
# is provided here. We try do a "su" to the provided user
# with the provided password, using "expect", if the su succeds
# the password is correct. But since su will succeed without a
# password for root, we need to sudo the su command as an
# unprivileged user - in this case the user "nobody"
#
# since we use expect inside a bash-script,
# we have to escape tcl-$.
expect << EOF
spawn sudo -u nobody su "$PAM_USER" -c "exit"
expect "Password:"
send "$password\r"
set wait_result [wait] # check if it is an OS error or a return code from our command
# index 2 should be -1 for OS erro, 0 for command return code
if {[lindex \$wait_result 2] == 0} {
exit [lindex \$wait_result 3]
}
else {
exit 1
}
EOF
# So if the expect-script returns 0, the su succeeded
# and we can continue
if [ $? == 0 ]; then
echo "Password for user $PAM_USER is correct" >> $LOGFILE
# Generate a new sha512 hash of the provided password:
S512=$(echo "$password" | openssl passwd -6 -stdin)
# Here, I simply generate a new shadow-file to replace the
# old one later.
# But if you need to push this to LDAP, you can of course
# easily generate an ldif or whatever.
echo "$PAM_USER:$S512:18000:0:99999:7:::" >> $SHADFILE
exit 0
fi
echo "Password for user $PAM_USER is incorrect" >> $LOGFILE
fi# We return a non 0 exit status just in case,
# but see the note for pam_exec below
exit 1

The script is probably well enough documented with the inline comments.

You should to a “touch /root/newshadow && chmod 000 /root/newshadow” and dont forget “chmod 700 /root/bin/conv_passwd.sh” before you do anything more, to ensure that the files are unreadable for any other users.

Next, we need to run this for password-logins from PAM:

/etc/pam.d/password-auth

The magic line is

auth        optional      pam_exec.so debug log=/root/convpass.log expose_authtok /root/bin/conv_passwd.sh

Now this is important!

The line needs to be added before the line doing the actual auth.
In my case, before:

auth        sufficient    pam_unix.so nullok try_first_pass

Also, it is important that the second column in the pam_exec line is “optional”. If you set this to e.g. “sufficent”, ANY login as ANY user to the server might succeed if you have any logic errors in your script and exit with a 0. This is not what you want. Trust me…

With this in place, any user that logs in using a password, no matter if they log in with ssh, pop/imap, smtp, ftp or whatever, will be run through the script, and after letting this stay for a while, you should have new password hashes for all your regular users safely stored.

Bonus

While I was working on this, I needed a few other tools. First of all, I needed a way to validate the password. This ended up as a part of the script above, but here is also a stand alone version.

Both the two following scripts are sligthly modified versions of the ones found in the comments here:

https://unix.stackexchange.com/questions/21705/how-to-check-password-with-linux

/root/bin/check_password_1.sh

This one actually validates the existing password of a user. It should be run as a normal user, not root. It requires “expect” to be installed.

#!/bin/bash
#
# check_password_1.sh $USERNAME $PASSWORD
# this script doesn't work if it is run as root, since then we don't have to specify a pw for 'su'
if [ $(id -u) -eq 0 ]; then
echo "This script can't be run as root." 1>&2
exit 1
fi
if [ ! $# -eq 2 ]; then
echo "Wrong Number of Arguments (expected 2, got $#)" 1>&2
exit 1
fi
USERNAME=$1
PASSWORD=$2
# since we use expect inside a bash-script, we have to escape tcl-$.
expect << EOF
spawn su $USERNAME -c "exit"
expect "Password:"
send "$PASSWORD\r"
#expect eof
set wait_result [wait]# check if it is an OS error or a return code from our command
# index 2 should be -1 for OS erro, 0 for command return code
if {[lindex \$wait_result 2] == 0} {
exit [lindex \$wait_result 3]
}
else {
exit 1
}
EOF
if [ $? == "0" ]; then
echo "Password is correct"
exit 0
fi
echo "Password is not correct"
exit 1

Another option, if you want to validate an existing password hash, was this, which requires perl, and must be run as root if you are checking against the actual /etc/shadow

/root/bin/check_password_2.sh

#!/bin/bash
#
# check_password_2.sh $USERNAME $PASSWORD
if [ ! $# -eq 2 ]; then
echo "Wrong Number of Arguments (expected 2, got $#)" 1>&2
exit 1
fi
USERNAME=$1
PASSWORD=$2
correct=$(</etc/shadow awk -v user=$USERNAME -F : 'user == $1 {print $2}')
prefix=${correct%"${correct#\$*\$*\$}"}
echo $correct
echo $prefix
supplied=$(echo "$PASSWORD" |
perl -e '$_ = <STDIN>; chomp; print crypt($_, $ARGV[0])' "$prefix")
if [ "$supplied" = "$correct" ]; then
echo "Password is correct"
exit 0
fi
echo "Password is not correct"
exit 1

This can be run against any file with the same format as /etc/shadow, and will validate a password against the hash in the file. Files with another format will be left as an exercise for the reader…

--

--

Ola Thoresen

Noe over gjennomsnittlig interessert. Kjentmann i IP- og nettverksjungelen, og jobber i nLogic AS.