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
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:
With small changes to our main function, we can draw the shells being used as well:
Analyzing /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.
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!