TLPI读书笔记第8章-用户和组1
用户和组 ID 的主要用途有二:
其一,确定各种系统资源的所有权;
其二,对赋予进程访问上述资源的权限加以控制。
比方说,每个文件都属于某个特定的用户和组,而每个进程也拥有相应的用户 ID 和组 ID 属性,这就决定了进程的所有者,以及进程访问文件时所拥有的权限。 本章首先会关注用于定义用户和组的系统文件,随后将描述用来从这些系统文件中获取信息的库函数。最后,将讨论用来加密和认证登录密码的 crypt()函数。
8.1 密码文件: /etc/passwd
针对系统的每个用户账号,系统密码文件/etc/passwd 会专列一行进行描述。每行都包含 7个字段,之间用冒号分隔,如下所示:
mysql:x:1000:1000::/home/mysql:/bin/bash
接下来,将按顺序介绍这 7 个字段。
1.登录名:
登录系统时,用户所必须输入的唯一名称。通常,也将其称为用户名。此外,也可将登录名视为人类可读的(符号)标识符,与数字用户标识符(稍后介绍)相对应。当使用诸如 ls(1)这样的程序去显示文件的所有权时(比如,执行 ls –l 时),会显示出登录名,而非与文件关联的数值型用户 ID。
2.经过加密的密码:
该字段包含的是经过加密处理的密码,长度为 13 个字符, 8.5 节会对此做深入讨论。如果密码字段中包含了任何其他字符串,特别是,当字符串长度超过 13 个字符时,将禁止此账户登录,原因是此类字符串不能代表一个经过加密的有效密码。不过,请注意,要是启用了 shadow 密码(这是常规做法),系统将会不解析该字段。这时, /etc/passwd 中的密码字段通常会包含字母“x”(当然,也可以是任何非空字串),而经过加密处理的密码实际上却存储到 shadow 密码文件中(参见 8.2 节)。
若/etc/passwd 中密码字段为空,则该账户登录时无需密码(即便启用了 shadow 密码, 也是如此)。
3.用户 ID(UID):
用户的数值型 ID。如果该字段的值为 0,那么相应账户即具有特权级权限。这种账号一般只有一个,其登录名为 root。在 Linux 2.2 或更早的版本中,用户 ID 为 16 位值,其范围为 0~65535。而 Linux 2.4 及其以后的版本则以 32 位值来存储用户 ID,因此能够支持更多的用户数。
4.组 ID(GID):
用户属组中首选属组的数值型 ID。关于用户与属组之间从属关系的进一步信息,会在系统组文件中加以定义。
5.注释:
该字段存放关于用户的描述性文字。诸如 finger(1)之类的各种程序会显示此信息。
6.主目录:
用户登录后所处的初始路径。会以该字段内容来设置 HOME 环境变量。
7.登录 shell:
一旦用户登录,便交由该程序控制。通常,该程序为 shell 的一种(比如,bash),但也可以是其他任何程序。如果该字段为空,那么登录 shell 默认为/bin/sh(Bourne shell)。会以该字段值来设置 SHELL 环境变量。
在单机系统中,所有密码信息都存储在/etc/passwd 文件中1。然而,如果使用了 NIS(网络信息系统)或LDAP(轻型目录访问协议)在网络环境中分发密码,那么部分密码信息可能会由远端系统保存。只要访问密码信息的程序采用的是本章稍后描述的函数(getpwnam()、getpwuid()等),那么无论是使用 NIS 还是 LDAP,对应用程序来说都是透明的。类似论断同样适用于本章随后几节所讨论的 shadow 密码文件和组文件
8.2 shadow 密码文件: /etc/shadow
很久以来, UNIX 一直在/etc/passwd 中维护所有的用户信息,这其中包括经过加密处理的密码。但这一举措也带来了安全问题。由于许多非特权级别系统工具需要读取密码文件中的其他信息,密码文件因而不得不对所有用户开放可读权限。这就为密码破解工具提供了可乘之机,它们会尝试对可能成为密码的大量词汇(比如,字典中的标准单词或人名)进行加密,然后再将结果与经过加密处理的用户密码进行比对。
作为防范此类攻击的手段之一, shadow密码文件/etc/shadow 应运而生。其理念是用户的所有非敏感信息存放于“人人可读”的密码文件中,而经过加密处理的密码则由 shadow 密码文件单独维护,仅供具有特权的程序读取。 shadow 密码文件包含有登录名(用来匹配密码文件中的相应记录)、经过加密的密码,以及其他若干与安全性相关的字段。 shadow(5)手册页对这些字段作了详细描述。本章将着重关注经过加密的密码字段,将在 8.5 节介绍 crypt()库函数时做深入讨论。
SUSv3 并未对 shadow 密码作出规范,也并非所有的 UNIX 实现都提供这一特性,即使是都支持这一特性的各种实现,在关于 API 和文件位置上的细节也不尽相同
8.3 组文件: /etc/group
出于各种管理方面的考虑,尤其是要控制对文件和其他系统资源的访问,对用户进行编组极具实用价值。 对用户所属各组信息的定义由两部分组成:
一,密码文件中相应用户记录的组 ID 字段; 二,组文件列出的用户所属各组。
这种将信息分置于两个文件中的奇怪现状,自有其历史渊源。在早期 UNIX 实现中,一个用户同时只能从属于一个组。登录时,用户最初的属组关系由密码文件的组 ID 字段决定,在此之后,可使用 newgrp(1)命令去改变用户属组,但需要用户提供组密码(若该组处于密码的保护之下) 。
4.2BSD 引入了并发多属组的概念, POSIX.1-1990 随后对其进行了标准化。采用这种方案,组文件会列出每个用户所属的其他属组。(groups(1)命令会显示当前 shell 进程所属各组的信息,如果将一个或多个用户名作为其命令行参数,那么该命令将显示相应用户所属各组的信息。)
系统中的每个组在组文件/etc/group 中都对应着一条记录。每条记录包含 4 个字段,之间以冒号分隔,如下所示:
1.组名:
组的名称。与密码文件中的登录名相似,可以将其视为与数值型组标识符相对应的人类可读(符号)标识符。
2.经过加密处理的密码:
组密码属于非强制特性,对应于该字段。随着多属组的出现,当今的 UNIX 系统已经很少使用组密码。不过,依然可以为组设置密码(特权用户可使用 gpasswd 命令来设置组密码)。如果用户并非某组的成员,那么在使用newgrp(1)启动新 shell 之前(新 shell 的属组包括该组),就需要用户提供此密码。如果启用了shadow 密码,那么系统将不解析该字段(这时,该字段通常只包含字母 x,但也允许其内容为包括空字符串在内的任何字符串),而经过加密的密码实际上则存放于shadow 组文件/etc/gshadow 中,仅供具有特权的用户和程序访问。组密码的加密方式类似于用户密码。
3.组 ID(GID):
该组的数值型 ID。正常情况下,对应于组 ID 号 0,只定义一个名为 root的组(与/etc/passwd 中用户 ID 为 0 的记录相近)。在 Linux 2.2 或更早的版本中,组ID 为 16 位值,其范围为 0~65535;而自 Linux 2.4 以后的版本则以 32 位值来存储组 ID。
4.用户列表:
属于该组的用户名列表,之间以逗号分隔。
8.4 获取用户和组的信息
本节所要介绍的库函数,其功能包括从密码文件、 shadow 密码文件和组文件中获取单条记录,以及扫描上述各个文件的所有记录。
从密码文件获取记录
函数 getpwnam()和 getpwuid()的作用是从密码文件中获取记录。 为 name 提供一个登录名, getpwnam()函数就会返回一个指针,指向如下类型的结构,其 中包含了与密码记录相对应的信息:
struct passwd{
char *pw_name;
char *pw_passwd;
uid_t pw_uid;
gid_t pw_gid;
char *pw_gecos;
char *pw_dir;
char *pw_shell;
}/*正好是/etc/passwd中的7列数据*/
passwd 结构的 pw_gecos 和 pw_passwd 字段虽未在 SUSv3 中定义,但获得了所有 UNIX实现的支持。仅当未启用 shadow 密码的情况下, pw_passwd 字段才会包含有效信息。要确定是否启用了 shadow 密码,最简单的编程方法是在成功调用 getpwnam()之后,紧接着调用getspnam()(稍后介绍),并观察后者是否能为同一用户名返回一条 shadow 密码记录。某些其他实现还会在该结构中定义额外的非标准字段。
函数 getpwuid()的返回结果与 getpwnam()完全一致,但会使用提供给 uid 参数的数值型用户 ID 作为查询条件。 getpwnam()和 getpwuid()均会返回一个指针,指向一个静态分配的结构。对此二者(或是下文描述的 getpwent()函数)的任何一次调用都会改写该数据结构。
SUSv3 规定,如果在 passwd 文件中未发现匹配记录,那么 getpwnam()和 getpwuid()将返回 NULL,且不会改变 errno。这意味着,可以使用如下代码,对出错和“未发现匹配记录”这两种情况加以区分:
struct passwd *pwd;
errno=0;
pwd=getpwname(name);
if(pwd==NULL){
if(errno==0){
/*not found*/
}else{
/*error*/
}
}
然而,不少 UNIX 实现在这一点上并未遵守 SUSv3 规范。如果未能在 passwd 文件中发现一条匹配记录,那么两个函数均会返回 NULL,并将 errno 设置为非零值,比如, ENOENT 或ESRCH。 针对这种情况, 2.7 版本之前的 glibc 会产生 ENOENT 错误, 而从 2.7 版本开始, glibc开始遵守 SUSv3 规范。实现之间之所以存在上述差异,部分原因是由于 POSIX.1-1990 不但不要求两个函数在出错时设置 errno,而且还允许它们针对“未发现匹配记录”的情况去设置errno。总而言之,在使用这两个函数时,若要区分上述这两种情况(出错和“未发现匹配记 录”),实际上将无法保证代码的可移植性。
从组文件获取记录
函数 getgrnam()和 getgrgid()的作用是从组文件中获取记录。
函数 getgrnam()和 getgrgid()分别通过组名和组 ID 来查找属组信息。两个函数都会返回一个指针,指向如下类型结构:
struct group{
char *gr_name;
char *gr_passwd;
gid_t gid;
char **grmem;
}
与前述密码相关函数一样,对这两个函数的任何一次调用都会改写该结构的内容1。 如果未能在 group 文件中发现匹配记录,那么这两个函数的行为变化与前述 getpwnam()和 getpwuid()函数相同
扫描密码文件和组文件中的所有记录
函数 setpwent()、 getpwent()和 endpwent()的作用是按顺序扫描密码文件中的记录。
函数 getpwent()能够从密码文件中逐条返回记录,当不再有记录(或出错)时,该函数 返回 NULL。 getpwent()一经调用,会自动打开密码文件。当密码文件处理完毕后,可调用endpwent()将其关闭。 可使用以下代码遍历整个密码文件,并打印出登录名和用户 ID。
struct passwd *pwd;
while((pwd=getpwent())!=NULL)
printf("%-8s %-5ld\n",pwd->pwd_name,pwd->pwd_uid);
endpwent();
如果需要让后续的 getpwent()调用(也许是在程序的其他代码中,也许是在所调用的其他库函数中,该函数再次出现)再次打开密码文件并重启扫描过程,此处的 endpwent()调用就必不可少。此外,如果对该文件处理到中途时,还可以调用 setpwent()函数重返文件起始处。 函数 getgrent()、 setgrent()和 endgrent()针对组文件执行类似的任务。由于这 3 个函数与前述的密码文件函数功能相似,故而其函数原型也就不再列出,详细信息请参考手册页。
从 shadow 密码文件中获取记录
下列函数的作用包括从 shadow 密码文件中获取个别记录,以及扫描该文件中的所有记录。
由于上述函数在操作上类似于相应的密码文件函数,故而此处对它们的介绍也就点到为止。(上述函数既未在 SUSv3 中明确定义,也未获得所有 UNIX 实现的支持。) 函数 getspnam()和 getspent()会返回指向 spwd 类型结构的指针。该结构的形式如下:
struct spwd{
char *sp_namp;
char *sp_pwdp;
long sp_lstchg;
long sp_min;
long sp_max;
long sp_warn;
long sp_inact;
long sp_expire;
unsigned long sp_flag;
}
在程序清单 8-2 中,将会演示对 getspnam()的使用
8.5 密码加密和用户认证
某些应用程序会要求用户对自身进行认证,通常会采取用户名(登录名) /密码的认证方式。出于这一目的,应用程序可能会维护其自有的用户名和密码数据库。然而,或许是由于势所必然,或许是为了方便起见,有时需要让用户输入标准的用户名/密码(定义于/etc/passwd和/etc/shadow 之中)。(本节的剩余部分将假定系统启用了 shadow 密码,经过加密处理的密码也因此存储于/etc/shadow 中。)需要登录到远程系统的网络应用程序,诸如 ssh 和 ftp,就是此类程序的典范,必须按标准的 login 程序那样,对用户名和密码加以验证。 由于安全方面的原因, UNIX 系统采用单向加密算法对密码进行加密,这意味着由密码的加密形式将无法还原出原始密码。因此,验证候选密码的唯一方法是使用同一算法对其进行加密,并将加密结果与存储于/etc/shadow 中的密码进行匹配。加密算法封装于 crypt()函数之中。
crypt()算法会接受一个最长可达 8 字符的密钥(即密码),并施之以数据加密算法(DES)的一种变体。 salt 参数指向一个两字符的字符串,用来扰动(改变) DES 算法,设计该技术,意在使得经过加密的密码更加难以破解。该函数会返回一个指针,指向长度为 13 个字符的字符串,该字符串为静态分配而成,内容即为经过加密处理的密码
salt 参数和经过加密的密码,其组成成员均取自同一字符集合,范围在[a-zA-Z0-9/.]之间,共计 64 个字符。因此,两个字符的 salt 参数可使加密算法产生 4096(64×64)种不同变化。 这意味着,预先对整部字典进行加密,再以其中的每个单词与经过加密处理的密码进行比对的做法并不可行,破解程序需要对照字典的 4096 种加密版本来检查密码。 由 crypt()所返回的经过加密的密码中,头两个字符是对原始 salt 值的拷贝。也就是说,加密候选密码时,能够从已加密密码(存储于/etc/shadow 内)中获取 salt 值。(加密新密码时,passwd(1)这样的程序会生成一个随机 salt 值。)事实上,在 salt 字符串中,只有前两个字符对 crypt()函数有意义。因此,可以直接将已加密密码指定为 salt 参数。 要想在 Linux 中使用 crypt(),在编译程序时需开启– lcrypt 选项,以便程序链接 crypt 库
程序示例
程序清单 8-2 演示了如何使用 crypt()来验证用户。该程序首先读取用户名,然后会获取相应的密码记录以及(如开启了 shadow 密码功能) shadow 密码记录。若未能发现密码记录,或程序没有权限读取 shadow 密码文件(需要超级用户权限,或具有 shadow 组成员资格),该程序会打印一条错误消息并退出。接下来,该程序会使用 getpass()函数,读取用户密码。
getpass()函数首先会屏蔽回显功能,并停止对终端特殊字符的处理(诸如中断字符,一般为 Control-C)。然后,该函数会打印出 prompt 所指向的字符串,读取一行输入,返回以 NULL 结尾的输入字符串(剥离尾部的换行符)作为函数结果。(该字符串由静态分配而成,故而后续对 getpass()的调用会覆盖其原有内容。)返回结果之前, getpass()会将终端设置还原。 使用 getpass()读取密码之后,程序清单 8-2 所示程序会对密码进行验证—使用 crypt()加密密码,并将结果与 shadow 密码文件中经过加密的密码记录进行比对。若两者匹配,则显示用户 ID,如下所示: