Analyzing Linux Authentication Files

Python for InfoSec - Analyzing Linux Authentication Files
When auditing Unix-based systems, particularly some Linux flavors, some requirements arise regarding the authentication files.

These systems have two main files: /etc/passwd containing user information like username, main group, home directory and shell being used, and /etc/shadow containing the user’s hashed password, settings and related data.

We will be using these files from the box containing the OWASP Broken Web Applications Project.

Analyzing /etc/passwd file

Python for InfoSec - /etc/passwd file

This file contains 7 fields:

  • User ID: is used when the user logs in is compared to the /etc/shadow file. It has a maximum length of 32 characters.
  • Password: indicates with an “x” that the real password is stored in another file, /etc/shadow in this case.
  • UID: the unique identifier assigned to a user, 0 is used for root, other predefined accounts use the range 1-99, and for system accounts the commonly used range is 100-999.
  • GID: the primary user group. More details can be found in the /etc/group file.
  • User Info: is usually used for the user’s full name and other relevant data.
  • Home Dir: is the absolute path of the user’s home directory.
  • Shell: is the absolute path of the user’s default shell.

This is the content we are going to analyze:

root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/bin/sh
bin:x:2:2:bin:/bin:/bin/sh
sys:x:3:3:sys:/dev:/bin/sh
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/bin/sh
man:x:6:12:man:/var/cache/man:/bin/sh
lp:x:7:7:lp:/var/spool/lpd:/bin/sh
mail:x:8:8:mail:/var/mail:/bin/sh
news:x:9:9:news:/var/spool/news:/bin/sh
uucp:x:10:10:uucp:/var/spool/uucp:/bin/sh
proxy:x:13:13:proxy:/bin:/bin/sh
www-data:x:33:33:www-data:/var/www:/bin/sh
backup:x:34:34:backup:/var/backups:/bin/sh
list:x:38:38:Mailing List Manager:/var/list:/bin/sh
irc:x:39:39:ircd:/var/run/ircd:/bin/sh
gnats:x:41:41:Gnats Bug-Reporting System (admin):/var/lib/gnats:/bin/sh
nobody:x:65534:65534:nobody:/nonexistent:/bin/sh
libuuid:x:100:101::/var/lib/libuuid:/bin/sh
syslog:x:101:102::/home/syslog:/bin/false
klog:x:102:103::/home/klog:/bin/false
mysql:x:103:105:MySQL Server,,,:/var/lib/mysql:/bin/false
landscape:x:104:122::/var/lib/landscape:/bin/false
sshd:x:105:65534::/var/run/sshd:/usr/sbin/nologin
postgres:x:106:109:PostgreSQL administrator,,,:/var/lib/postgresql:/bin/bash
messagebus:x:107:114::/var/run/dbus:/bin/false
tomcat6:x:108:115::/usr/share/tomcat6:/bin/false
user:x:1000:1000:user,,,:/home/user:/bin/bash
polkituser:x:109:118:PolicyKit,,,:/var/run/PolicyKit:/bin/false
haldaemon:x:110:119:Hardware abstraction layer,,,:/var/run/hald:/bin/false
pulse:x:111:120:PulseAudio daemon,,,:/var/run/pulse:/bin/false
postfix:x:112:123::/var/spool/postfix:/bin/false

We are interested in extracting group and shell information from this file. If you need additional information about user groups please use the groups <username> command or for a group perspective you can look in the /etc/group file. Let’s start working on it. 

We will be first importing some python libraries we need and define the base structure:

from datetime import datetime, timedelta

passwd_data = {
    "groups": {},
    "shells": {}
}

The following function will iterate the content of our /etc/passwd file and return the information to be analyzed. We will be counting the number of users with a particular group membership and the shells they are using. We will also be ignoring those lines that are commented.

def passwd_parse(filename):
    
    with open(filename) as passwd:
        for line in passwd:
            if not line.startswith(('#', '\n')):
                user_info = line.split(':')
                
                username = user_info[0]
                group = user_info[3]
                shell = user_info[6].replace("\n","")

                if str(group) not in passwd_data["groups"]:
                    passwd_data["groups"][str(group)] = {}
                    passwd_data["groups"][str(group)]["count"] = 1
                    passwd_data["groups"][str(group)]["users"] = [username]
                else:
                    passwd_data["groups"][str(group)]["count"] += 1
                    passwd_data["groups"][str(group)]["users"].append(username)

                if shell not in passwd_data["shells"]:
                    passwd_data["shells"][shell] = {}
                    passwd_data["shells"][shell]["count"] = 1
                    passwd_data["shells"][shell]["users"] = [username]
                else:
                    passwd_data["shells"][shell]["count"] += 1
                    passwd_data["shells"][shell]["users"].append(username)
            
        return passwd_data

Assuming we saved this information in a data/passwd.txt file, we may want to capture the file content in the following way:

def main():
    passwd_info = passwd_parse("data/passwd.txt")
    
if __name__ == "__main__":
    main()

And to complement this, let’s draw some graphics. For that we will be using the following function:

def build_pie_chart(title, labels, sizes):

    fig1, ax1 = plt.subplots()

    patches, texts, autotexts = ax1.pie(sizes, 
                                        labels=None, 
                                        autopct='%1.0f%%',
                                        shadow=True, 
                                        startangle=70)

    ax1.axis('equal')
    plt.title(title, y=1.05, fontsize=25)
    plt.legend( loc="best", labels=labels) 

    for autotext in autotexts:
        autotext.set_color('white')

    plt.setp(autotexts, size=12, weight="bold")

    plt.rcParams["figure.figsize"] = (20,12)
    plt.legend(loc=3, labels=labels, prop={'size': 18})
    plt.show()

So, our main function will now be this:

def main():
    passwd_info = passwd_parse("data/passwd.txt")
    
    group_labels = []
    group_sizes = []

    # Groups
    for group_id, group_data in passwd_data["groups"].items():
        group_labels.append(group_id)
        group_sizes.append(group_data["count"])
        
    build_pie_chart("Linux Groups", group_labels, group_sizes)

And this is the resulting picture:

Linux Groups - Python for InfoSec - Analyzing Linux Authentication Files

With small changes to our main function, we can draw the shells being used as well:

Linux Shells - Python for InfoSec - Analyzing Linux Authentication Files

Analyzing /etc/shadow file

Python for InfoSec - /etc/shadow file

These are the fields of the /etc/shadow file:

  • User ID: is the same identifier used in the /etc/passwd file.
  • Encrypted Password: is the encrypted password and its format is usually $id$salt$hashed.
  • Last Chg Days: is an integer value indicating the number of days since Jan 1, 1970, when the password was last changed.
  • Min Days: this is the minimum nunber of days a user is authorized to change the password.
  • Max Days: this is the maximum number of days a password is valid. Exceeded this period, the user will be forced to change the password.
  • Warn Days: is a notification of the number of days before the user password expires.
  • Inactive Days: is the number of days after password expiration that the account will be disabled.
  • Disabled Days: is an integer value indicating the number of days since Jan 1, 1970, when the account will no longer be used.
  • Not Used: this field is not currently being used.

And this is how the content looks:

root:$6$GUZ2FiFh$Gte3X3tiK1jEGB83oZnD7TyiogYS0lind43lpVJxN5dL/W0CsnHJfW9X7XBzMUUndXo8WCGYBPaYP79dwo..n.:14528:0:99999:7:::
daemon:*:14471:0:99999:7:::
bin:*:14471:0:99999:7:::
sys:*:14471:0:99999:7:::
sync:*:14471:0:99999:7:::
games:*:14471:0:99999:7:::
man:*:14471:0:99999:7:::
lp:*:14471:0:99999:7:::
mail:*:14471:0:99999:7:::
news:*:14471:0:99999:7:::
uucp:*:14471:0:99999:7:::
proxy:*:14471:0:99999:7:::
www-data:*:14471:0:99999:7:::
backup:*:14471:0:99999:7:::
list:*:14471:0:99999:7:::
irc:*:14471:0:99999:7:::
gnats:*:14471:0:99999:7:::
nobody:*:14471:0:99999:7:::
libuuid:!:14471:0:99999:7:::
syslog:*:14471:0:99999:7:::
klog:*:14471:0:99999:7:::
mysql:!:14471:0:99999:7:::
landscape:*:14471:0:99999:7:::
sshd:*:14471:0:99999:7:::
postgres:*:14471:0:99999:7:::
messagebus:*:14471:0:99999:7:::
tomcat6:*:14471:0:99999:7:::
user:$6$vGtj5JSH$mT18gcTVlO6HV.NYH0mx3/ItiiwKK.HyjV..RLxG6e.Gz9W894nBkn9wjQoaLUcL4W65pHicFVBUAOh8MMTJf1:14528:0:99999:7:::
polkituser:*:14480:0:99999:7:::
haldaemon:*:14480:0:99999:7:::
pulse:*:14480:0:99999:7:::
postfix:*:14544:0:99999:7:::

We will add another dictionary to save our data:

shadow_data = {
    "lastchg_days": {},
    "min_days": {},
    "max_days": {},
    "warn_days": {},
    "inactive_days": {},
    "disabled_days": {}
}

And we will be using a similar function to complete this information:

def shadow_parse(filename):
    
    with open(filename) as shadow:
        for line in shadow:
            if not line.startswith(('#', '\n')):
                shadow_info = line.split(':')
               
                username = shadow_info[0]
                raw_lastchg_days = shadow_info[2]
                min_days = shadow_info[3]
                max_days = shadow_info[4]
                warn_days = shadow_info[5]
                inactive_days = shadow_info[6]
                disabled_days = shadow_info[7]
                                
                lastchg_days = (datetime(1970,1,1) + timedelta(days=int(raw_lastchg_days))).date().strftime("%Y-%m-%d")
                    
                if lastchg_days not in shadow_data["lastchg_days"]:
                    shadow_data["lastchg_days"][lastchg_days] = {}
                    shadow_data["lastchg_days"][lastchg_days]["count"] = 1
                    shadow_data["lastchg_days"][lastchg_days]["users"] = [username]
                else:
                    shadow_data["lastchg_days"][lastchg_days]["count"] += 1
                    shadow_data["lastchg_days"][lastchg_days]["users"].append(username)
                    
                if str(min_days) not in shadow_data["min_days"]:
                    shadow_data["min_days"][str(min_days)] = {}
                    shadow_data["min_days"][str(min_days)]["count"] = 1
                    shadow_data["min_days"][str(min_days)]["users"] = [username]
                else:
                    shadow_data["min_days"][str(min_days)]["count"] += 1
                    shadow_data["min_days"][str(min_days)]["users"].append(username)
                    
                if str(max_days) not in shadow_data["max_days"]:
                    shadow_data["max_days"][str(max_days)] = {}
                    shadow_data["max_days"][str(max_days)]["count"] = 1
                    shadow_data["max_days"][str(max_days)]["users"] = [username]
                else:
                    shadow_data["max_days"][str(max_days)]["count"] += 1
                    shadow_data["max_days"][str(max_days)]["users"].append(username)
                    
                if str(warn_days) not in shadow_data["warn_days"]:
                    shadow_data["warn_days"][str(warn_days)] = {}
                    shadow_data["warn_days"][str(warn_days)]["count"] = 1
                    shadow_data["warn_days"][str(warn_days)]["users"] = [username]
                else:
                    shadow_data["warn_days"][str(warn_days)]["count"] += 1
                    shadow_data["warn_days"][str(warn_days)]["users"].append(username)
                    
                if str(inactive_days) not in shadow_data["inactive_days"]:
                    shadow_data["inactive_days"][str(inactive_days)] = {}
                    shadow_data["inactive_days"][str(inactive_days)]["count"] = 1
                    shadow_data["inactive_days"][str(inactive_days)]["users"] = [username]
                else:
                    shadow_data["inactive_days"][str(inactive_days)]["count"] += 1
                    shadow_data["inactive_days"][str(inactive_days)]["users"].append(username)
                    
                if str(disabled_days) not in shadow_data["disabled_days"]:
                    shadow_data["disabled_days"][str(disabled_days)] = {}
                    shadow_data["disabled_days"][str(disabled_days)]["count"] = 1
                    shadow_data["disabled_days"][str(disabled_days)]["users"] = [username]
                else:
                    shadow_data["disabled_days"][str(disabled_days)]["count"] += 1
                    shadow_data["disabled_days"][str(disabled_days)]["users"].append(username)
            
        return shadow_data

Finally, we will be adding this function to our main function using a data/shadow.txt file and with minimal changes we should be able to see when the users changed their password.

Last Password Change - Python for InfoSec - Analyzing Linux Authentication Files

You can see a full version of the code here.

Conclusion

With this, you may be able to answer questions like:

  • Do accounts that don’t need interactive logon have a false shell in place?
  • Are the users assigned to the correct group considering the least privilege principle? You may need to perform additional work to have a complete answer to this question, as previously mentioned
  • Have the users recently changed their password?
  • Are the expiration settings properly configured?
  • Is the user being asked to change his password with proper anticipation?

Thanks for reading!