本文是作者阅读TLPI(The Linux Programer Interface的总结),为了突出重点,避免一刀砍,我不会过多的去介绍基本的概念和用法,我重点会去介绍原理和细节。因此对于本文的读者,至少要求读过APUE,或者是实际有写过相关代码的程序员,因为知识有点零散,所以我会尽可能以FAQ的形式呈现给读者。
用户和组
每个用户都拥有一个唯一的用户名和一个与之相关联的数值型的用户标识UID,用户可以隶属于一个或多个组,而每个组也都拥有唯一的一个名称和一个组标识符GID,对于进程和内核来说,只认识UID和GID,用户名和组名最终都会转换为UID和GID来标识,这两个ID的主要用途如下:
- 确定各种系统资源的所有权
- 对赋予进程访问上述资源的权限加以控制
如何获取用户和组的信息?
用户和组的信息以及密码相关的信息,主要集中在/etc/shadow
,/etc/passwd
,/etc/group
这三个文件,用户可以通过读取这三个文件来获取信息,但这无疑是增加了难度,需要用户自己去解析这些文件,好在Linux给我们提供了相关的API,帮我们做好了解析。首先是读取/etc/passwd
的信息的API,如下:
#include <sys/types.h>
#include <pwd.h>
struct passwd *getpwnam(const char *name);
struct passwd *getpwuid(uid_t uid);
struct passwd {
char *pw_name; /* username */
char *pw_passwd; /* user password */
uid_t pw_uid; /* user ID */
gid_t pw_gid; /* group ID */
char *pw_gecos; /* user information */
char *pw_dir; /* home directory */
char *pw_shell; /* shell program */
};
其中pw_passwd
在启用shadow密码的情况下是没有用的,不包含有效信息,一般来说都是启用shadow密码的。很不幸的是,上述两个API都是不可重入的,其内部维护了一个静态分配的内存,用于存储返回的passwd结构,因此在多线程场景下推荐使用可重入版本的API,如下:
int getpwnam_r(const char *name, struct passwd *pwd,
char *buf, size_t buflen, struct passwd **result);
int getpwuid_r(uid_t uid, struct passwd *pwd,
char *buf, size_t buflen, struct passwd **result);
注: 上述两个API,在出错和位找到匹配记录的情况下返回的都是NULL,但是可以用过判断errno
来确定是何种类型的错误。
struct passwd *pwd;
errno = 0;
pwd = getpwnam(name);
if (pwd == NULL) {
if(errno == 0)
//not found
else
//error
}
在/etc/group
中寻找组信息就容易的多了,也是提供了两个API,也是不可重入的,额外提供了两个可重入的API。如下:
#include <sys/types.h>
#include <grp.h>
struct group *getgrnam(const char *name);
struct group *getgrgid(gid_t gid);
int getgrnam_r(const char *name, struct group *grp,
char *buf, size_t buflen, struct group **result);
int getgrgid_r(gid_t gid, struct group *grp,
char *buf, size_t buflen, struct group **result);
struct group {
char *gr_name; /* group name */
char *gr_passwd; /* group password */
gid_t gr_gid; /* group ID */
char **gr_mem; /* group members */
};
在查找错误的情况下和未查找到匹配的条目的情况下和getpwnam,mgetpwuid
的行为是一样的。
最后一个就是查找/etc/shadow
相关的信息,这个文件的查找Linux只提供了一个API:
#include <shadow.h>
struct spwd *getspnam(const char *name);
struct spwd {
char *sp_namp; /* Login name */ //登陆的用户名
char *sp_pwdp; /* Encrypted password */ //加密后的密码
long sp_lstchg; /* Date of last change //最后一次更改密码的时间
(measured in days since
1970-01-01 00:00:00 +0000 (UTC)) */
long sp_min; /* Min # of days between changes */ //两次更改密码的最小间隔
long sp_max; /* Max # of days between changes */ //密码的有效期
long sp_warn; /* # of days before password expires //密码在过期之前的警告时间
to warn user to change it */
long sp_inact; /* # of days after password expires
until account is disabled */ //密码过期后的非活动时间,这段时间登陆就会强制修改密码
long sp_expire; /* Date when account expires //账号到期时间,到期后完全不能使用
(measured in days since
1970-01-01 00:00:00 +0000 (UTC)) */
unsigned long sp_flag; /* Reserved */ //保留字段
};
返回的是一个spwd
结构体,这是一个比较复杂的结构体,里面的一些字段还是很有意思的。
最后要介绍的就是如何顺序遍历所有的条目,而不是查找某个条目。贴心的Linux也帮我们提供了全套的服务。
//遍历/etc/shadow的一套API
struct spwd *getspent(void); //用来一条一条的从/etc/shadow中获取条目
void setspent(void); //重新置位指针,从头开始读取/etc/shadow中的条目
void endspent(void); //结束遍历
//遍历/etc/passd的一套API
struct passwd *getpwent(void);
void setpwent(void);
void endpwent(void);
如何进行密码的加密和用户的认证?
出于安全方面的原因,UNIX系统采用单向加密算法对密码进行加密,这意味着由密码的加密形式将无法还原出原始密码,因此验证候选密码的唯一方法是使用同一算法对其加密,并将加密结果与存储在/etc/shadow中的密码进行匹配。加密算法封装与crypt()函数中。
注: 尽管是单向加密,其安全性仍然不够,攻击者可以试图使用相同的加密算法进行加密后比对,但是如果将密码和一些额外添加的字符放在一起进行单向加密,攻击者在不知道额外添加了什么字符的情况下就没办法有效的进行比对了。这些额外添加的字符在这里我们称为salt,盐,这个盐通常是一个具有两个字符的字符串。
#define _XOPEN_SOURCE /* See feature_test_macros(7) */
#include <unistd.h>
char *crypt(const char *key, const char *salt);
#define _GNU_SOURCE /* See feature_test_macros(7) */
#include <crypt.h>
char *crypt_r(const char *key, const char *salt,
struct crypt_data *data);
Link with -lcrypt.
crypt的第一个参数就是要加密的密码,第二个参数是需要添加的盐。返回加密后的字符,其内部是通过静态分配的一段内存来保存加密后的字符的,因此是不可重入的。所以有对应的可重入版本的crypt,使用crypt后需要使用-lcrypt进行动态库的链接。下面通过一段验证用户密码的程序来作为结束。
一个验证用户的程序示例:
#define _XOPEN_SOURCE
#include <unistd.h>
#include <limits.h>
#include <pwd.h>
#include <shadow.h>
#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
#include <string.h>
#include <errno.h>
#include <sys/types.h>
int main(int argc,char *argv)
{
char *username,*password,*encrypted,*p;
struct passwd *pwd;
struct spwd *spwd;
bool authOk;
size_t len;
long lnmax;
//获取username最大长度
lnmax = sysconf(_SC_LOGIN_NAME_MAX);
if(lnmax == -1)
lnmax = 256;
username = malloc(lnmax);
if(username == NULL) {
fprintf(stderr,"malloc failure\n");
exit(EXIT_FAILURE);
}
printf("Username: ");
fflush(stdout);
//用户输入username
if(fgets(username,lnmax,stdin) == NULL) {
exit(EXIT_FAILURE);
}
//设置username
len = strlen(username);
if(username[len - 1] == ‘\n‘)
username[len - 1] = ‘\0‘;
//查询
pwd = getpwnam(username);
if(pwd == NULL) {
perror("couldn‘t get password record");
exit(EXIT_FAILURE);
}
spwd = getspnam(username);
if(spwd == NULL && errno == EACCES) {
perror("no permission to read shadow password file");
exit(EXIT_FAILURE);
}
if(spwd != NULL)
pwd->pw_passwd = spwd->sp_pwdp;
password = getpass("Password: ");
encrypted = crypt(password,pwd->pw_passwd); //pw_passwd就是所谓的盐
//记得立刻清楚密码在内存中的数据
for(p = password; *p != ‘\0‘;)
*p++ = ‘\0‘;
if(encrypted == NULL) {
perror("crypt:");
exit(EXIT_FAILURE);
}
authOk = strcmp(encrypted,pwd->pw_passwd) == 0;
if(!authOk) {
printf("incorrect password\n");
exit(EXIT_FAILURE);
}
printf("Successfully authenticated: UID=%ld\n",(long)pwd->pw_uid);
exit(EXIT_SUCCESS);
}
注: 在调用crypt进行加密的时候,盐该如何获取?,上面的代码是通过获取/etc/shadow
中被加密的密码后传入crypt,crypt获取这段加密的密码中的盐,也就是salt,还有这段加密密码的加密算法。我们可以分析下这个加密密码的组成结构,下面是一段加密的密码示例:
$1$1kpBWi2p$Y4oeVpJgyF.XK.TascXW1.
这段密码被分成了三个部分: $id$salt$encrypted,算法的id,盐,加密后的密文。crypt将加密算法进行编号,通过编号就可以知道使用的是何种加密算法。
id Method
1 md5
2a Blowfish
5 SHA-256
6 SHA-512
上面的代码中,在获取到用户输入的密码后立即进行了加密,加密后将用户输入的密码进行了清楚,这是一个安全要点,也就是所谓的剩余信息保护,如果没有尽快将内存中的明文密码清除这回导致密码被曝光,恶意之徒借程序崩溃之机,读取内核转储文件以获取密码,或者是包含密码的虚拟内存页执行了换出操作,那么特权程序就能从交换文件中读取到密码,此外拥有足够权限的进程通过读取/dev/mem(将计算机物理内存表示为有序字节流)来尝试发现密码。
进程凭证
每个进程都有一套用数字表示的用户ID,和组ID,有时也将这些ID称之为进程凭证,在每个进程对应的task_struct中有一个struct cred
成员专门保存这些进程凭证信息。
task_struct {
......
const struct cred __rcu *real_cred; /* objective and real subjective task
* credentials (COW) */
const struct cred __rcu *cred; /* effective (overridable) subjective task
* credentials (COW) */
}
real_cred 指向的是客体的上下文,在进程采取行动的时候使用。cred指向的是主体的上下文,定义进程如何操作另一个对象的细节,通常情况下两者是相同的。
struct cred {
atomic_t usage;
#ifdef CONFIG_DEBUG_CREDENTIALS
atomic_t subscribers; /* number of processes subscribed */
void *put_addr;
unsigned magic;
#define CRED_MAGIC 0x43736564
#define CRED_MAGIC_DEAD 0x44656144
#endif
kuid_t uid; /* real UID of the task */
kgid_t gid; /* real GID of the task */
kuid_t suid; /* saved UID of the task */
kgid_t sgid; /* saved GID of the task */
kuid_t euid; /* effective UID of the task */
kgid_t egid; /* effective GID of the task */
kuid_t fsuid; /* UID for VFS ops */
kgid_t fsgid; /* GID for VFS ops */
unsigned securebits; /* SUID-less security management */
kernel_cap_t cap_inheritable; /* caps our children can inherit */
kernel_cap_t cap_permitted; /* caps we‘re permitted */
kernel_cap_t cap_effective; /* caps we can actually use */
kernel_cap_t cap_bset; /* capability bounding set */
#ifdef CONFIG_KEYS
unsigned char jit_keyring; /* default keyring to attach requested
* keys to */
struct key __rcu *session_keyring; /* keyring inherited over fork */
struct key *process_keyring; /* keyring private to this process */
struct key *thread_keyring; /* keyring private to this thread */
struct key *request_key_auth; /* assumed request_key authority */
#endif
#ifdef CONFIG_SECURITY
void *security; /* subjective LSM security */
#endif
struct user_struct *user; /* real user ID subscription */
struct user_namespace *user_ns; /* user_ns the caps and keyrings are relative to. */
struct group_info *group_info; /* supplementary groups for euid/fsgid */
struct rcu_head rcu; /* RCU deletion hook */
};
进程凭证主要就是struct cred
结构中的uid,gid,suid,sgid,euid,egid,fsuid,fsgid
等。
什么是实际用户ID和实际组ID?
实际用户ID和实际组ID确定了进程所属于的用户和组,在登陆完成后登陆shell会从/etc/passwd文件中读取第三个和第四个字段作为实际用户ID和实际组ID,此后创建的进程将从父进程中继承这些ID。
什么是有效用户ID和有效组ID?
进程实际运行的时候的用户ID和有效组ID(默认是实际用户ID和实际组ID),当进程尝试执行各种操作时,将结合有效用户ID,有效组ID,连同辅助组ID一起来确定,授予进程的权限,例如:当进程访问诸如文件,system V进程间通信(IPC)对象之类的系统资源时,此类ID会决定系统授予进程的权限,而这些资源的属主则是另由与之相关的用户ID和组ID来决定。通常情况下是等于实际用户ID和实际组ID。但是当进程设置了Set-User-ID或Set-Group-ID权限位的时候,则进程的有效用户ID和有效组ID合进程的属主和属组相同。
什么是保存set-user-ID和保存set-group-ID?
Set-User-ID和Set-Group-ID位的作用就是改变进程的有效用户ID和有效组ID为进程可执行文件的属主和属组,为此内核设计了保存set-User-ID和保存set-Group-ID,配合Set-User-ID和Set-Group-ID位使用,保存set-User-ID和保存set-Group-ID的值是复制有效用户ID和有效组ID的。有不少系统调用允许将设置了set-User-ID程序的有效用户ID在实际用户ID和保存set-User-ID之间切换。换言之通过保存set-user-ID程序可以游走于两种状态之间,具备了获取特权的潜力和以特权进行实际操作。
什么是文件系统用户ID和文件系统组ID?
在linux系统中,要进行诸如打开文件,改变文件的属主,修改文件权限之类的文件系统操作,决定其操作权限的是文件系统用户ID和组ID,而非有效用户ID和组ID,但是通常这两者是相等的,并且当有效用户ID和组ID发生了改变,文件系统用户ID和文件系统组ID也会发生变化,因此说是有效用户ID和组ID来决定其操作权限这也不为过,但是有个例外,Linux下可以通过setfsuid和setfsgid来修改文件系统用户ID和组ID。这会导致和有效用户ID不同步至于linux下为什么会有文件系统用户ID和组ID,这是由于历史原因造成的,linux为了兼容因此得以保留。(关于历史原因详见<<Linux/UNIX系统编程手册 上>>
第九章)
什么是辅助组ID?
辅助组ID用于标识进程所属的若干附加的组,新进程从其父进程处继承这些ID,登陆shell从系统组文件中获取其辅助组的组ID。系统将这些ID与有效用户ID以及文件系统ID想结合,就能决定对文件,System V IPC对象和其他系统资源的访问权限。
如何操作这些进程凭证?
获取实际和有效ID
#include <unistd.h>
#include <sys/types.h>
uid_t getuid(void);
uid_t geteuid(void);
gid_t getgid(void);
gid_t getegid(void);
修改有效ID
#include <sys/types.h>
#include <unistd.h>
int setuid(uid_t uid);
int setgid(gid_t gid);
注: setuid不仅可以修改有效id,还可能会修改实际id,保存id等,这取决于调用setuid的进程是否具有特权:
- 非特权进程调用setuid,仅能修改进程的有效用户id为实际用户id和保存set-User-ID,企图违反此约束将引发EPERM错误,但是通常情况这三个id是相同的,也就是说通常情况下setuid是无用的,仅当程序设置了set-User-ID位的时候才有用(此时三者的ID将不同)。
- 特权进程以非0参数调用setuid将使进程的实际用户ID,有效用户ID和保存set-User-ID均被设置为uid参数所指定的值。一旦设置了那么所有的特权都将丢失。
对于set-User-I程序,如果其有效用户ID是为0,那么就可以使用setuid以不可逆的方式放弃特权,代码如下:
if(setuid(getuid()) == -1)
errExit("setuid");
如果有效用户ID不是0,那么可以利用setuid在实际用户ID,有效用户ID,以及保存set-User-ID之间来回切换。
linux还提供了另外一套API,专门用于修改有效用户ID和有效组ID的,其函数原型如下:
#include <sys/types.h>
#include <unistd.h>
int seteuid(uid_t euid);
int setegid(gid_t egid);
同样上述API,根据是否是特权进程调用也要遵循一定的规则,如下:
- 对于非特权级进程仅能将其有效ID修改为实际ID或者保存设置ID,其效果等同于setuid/setgid。
- 对于特权进程能够将其有效ID修改为任意值,如果修改为非0值,那么将不具有特权了,但是这个操作是可逆的,因为此时实际ID和保存设置ID依然为0,因此可以将有效用户ID重新设置为0。
利用seteuid收放具有set-User-ID位的程序特权,其代码如下:
euid = geteuid; //保存此时的有效用户ID
if(seteuid(getuid()) == -1) //丢弃现有的权限
errExit("seteuid")
if(seteuid(euid) == -1) //重新获取权限
errExit("seteuid");
seteuid/setegid相比于setuid/setgid功能更胜一筹。只修改了有效用户ID,并且是可逆的。
修改实际ID和有效ID
#include <sys/types.h>
#include <unistd.h>
int setreuid(uid_t ruid, uid_t euid);
int setregid(gid_t rgid, gid_t egid);
允许调用进程独立修改器实际和有效用户ID/组ID,若只想修改其中一个可以把另一个设置为-1即可,和上面的系统调用一样,也要遵循一定的规则:
- 非特权进程只能将其实际用户ID设置为当前实际用户ID值或有效用户ID值,且只能将其有效用户ID设置为当前实际用户ID、有效用户ID或保存set-User-ID
- 特权级进程能够设置其实际用户ID和有效用户ID为任意值
- 不管进程是否拥有特权与否,只要如下条件之一成立,就能将保存set-User-ID设置成(新的)的有效用户ID
- ruid不为-1
- 对有效用户ID所设置的值不用于系统调用之前的实际用户ID
获取实际、有效和保存设置ID
#define _GNU_SOURCE /* See feature_test_macros(7) */
#include <unistd.h>
int getresuid(uid_t *ruid, uid_t *euid, uid_t *suid);
int getresgid(gid_t *rgid, gid_t *egid, gid_t *sgid);
允许调用进程独立修改其三个用户ID的值,每个用户ID的新值由系统调用的3个参数给定,这些系统调用的使用,也存在一系列的规则:
- 非特权进程能够将实际用户ID、有效用户ID和保存set-User-ID中的任一ID设置为实际用户ID,有效用户ID或保存set-User-ID中的任一当前值
- 特权级进程能够对其实际用户ID、有效用户ID和保存set-User-ID做任意设置
- 不管系统调用是否对其他ID做了任何改动,总是将文件系统用户ID设置为与有效用户ID相同
获取和修改文件系统ID
#include <unistd.h> /* glibc uses <sys/fsuid.h> */
int setfsuid(uid_t fsuid);
int setfsgid(uid_t fsgid);
设置进程的文件系统用户ID和组ID,同样和上面的其他系统调用一样,也需要遵循一定的规则:
* 非特权进程能够将文件系统用户ID设置为实际用户ID、有效用户ID、文件系统用户ID或保存set-User-ID的当前值。
* 特权级进程能够将文件系统用户ID设置为任意值
总的来说这些进程凭证多且繁杂,很有多的规则需要记忆,因此我的建议就是,做到心中有数,遇到再查,首先要对进程凭证的概念上有基本的认识,在此基础上去使用这些API,将会更加的得心应手。