初探特权程序
Linux中的用户空间存在着各种各样的资源(比如内核映射的虚文件、etc目录下的配置文件等等),由于这些资源是非常重要的,不能让它们被随便访问。
用户作为程序的实际控制方存在,也是资源的实际访问提出方,用户的身份影响着程序的访问权限,那么应该如何给用户分配权,控制资源被安全的访问呢?
特权用户和特权资源的产生
当某一资源只希望被特定用户的访问,那么此时用户间就在权限层面产生了差异,我们可以将能访问同一资源的用户放入特权组内统一进行管理。
只有当资源的身份属于用户的可控制范围内时,资源才可以被操作。
在Linux存在着一个名为cred的结构体,它是记录着身份信息信息(ID为0时,一般都是对应着root用户)。
1 2 3 4 5 6 7 8 9 10 11 12 | struct cred { ...... 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 */ ......} |
在cred结构体中存在着带有xxxid字眼的成员,它们是进行身份匹配的关键,这些ID意思会在后面进行解释。
通过ls查看文件属性时,可以发现第一列是文件所有者拥有权限,第二列是与所有者同组用户拥有的权限,第三列是其他用户拥有的权限。
1 2 3 4 | ls -lh /etc/ld.so.conf-rw-r--r-- 1 root root 34 Apr 10 2024 /etc/ld.so.conf| directory | owner | group | other || - | rw- | r-- | r-- | |
UID和GID的产生
通过上面我们可以知道每个用户或用户组和可以被访问的资源都具有特定的权限,这一权限通过数字序号ID进行标识。
资源访问控制的细化
当用户拥有资源的访问权限时,还要面临一个问题,就是用户能怎么样控制资源,就好比一个人有了买到菜刀的权限,但他应该只用它来做菜,而不是四处砍人。
出于这一个访问控制细化的需求,资源的访问控制权限被分成了读、写、执行三类。
1 2 3 4 5 6 7 8 9 | ls -lh /etc/ld.so.conf-rw-r--r-- 1 root root 34 Apr 10 2024 /etc/ld.so.conf| | owner | group | other || d | rwx | rwx | rwx |d:是否为目录r:读权限w:写权限x:可执行权限 / s:Set-UID程序 |
通过strace工具追踪ls命令可以发现,文件属性通过statx、lgetxattr、getxattr三个接口进行获取。
statx用户获取文件的基本属性,可以通过struct statx结构体查看具体的定义。
lgetxattr和getxattr用于获取文件的扩展属性(xattr Extended attributes),lgetxattr基于getxattr的扩展,用于检查软链接文件的属性。
1 2 3 4 5 6 7 8 9 10 | statx(AT_FDCWD, "./Templates", ...)lgetxattr("./Templates", "security.selinux", 0x55e9c62ab530, 255) = -1 ENODATA (No data available)getxattr("./Templates", "system.posix_acl_access", NULL, 0) = -1 ENODATA (No data available)getxattr("./Templates", "system.posix_acl_default", NULL, 0) = -1 ENODATA (No data available)对应的系统调用号#define __NR_statx 332#define __NR_getxattr 191#define __NR_lgetxattr 192#define __NR_fgetxattr 193 |
Linux内核对资源访问细化控制的实现
在Linux系统当中,所有的文件系统都是通过file_system_type结构体进行描述的,内核通过file_system_type中定义信息对文件系统进行管理。
所有文件系统在使用前都需要通过register_filesystem接口注册,完成文件系统的注册只是第一步,抽象出来的文件系统管理模型还需要具体的管理对象。
当具体的文件系统挂载到指定的文件系统类型时,内核会创建fs_context结构体处理文件系统的上下文信息,file_system_type结构体中实现init_fs_context接口负责具体的初始化工作。
1 2 3 4 5 | do_new_mount -> fs_context_for_mount -> alloc_fs_context -> fs_context->file_system_type->init_fs_context -> fs_context->fs_context_operations = xxx |
观察fs_context结构体不难发现,结构体中存在这一个名为cred的成员(由cred结构体描述),它记录着挂载者的用户身份信息。
1 2 3 4 5 6 7 8 9 10 | fs_context -> creddo_new_mount -> fs_context_for_mount -> alloc_fs_context -> fs_context->cred = get_current_cred -> current->credcurrent是内核记录当前进程的变量 |
初始化完fs_context之后,会进行下一个重要的操作,这个操作就是创建spuer_block,在fs_context初始化的过程中,会初始化ops成员,该成员中的get_tree成员会指定初始化spuer_block的操作,get_tree成员一般一般由对应的文件系统类型提供实现。
值得一提的是,fs_context结构体只在挂载过程中有效,完成挂载操作后就会通过put_fs_context接口释放掉。
1 2 3 4 5 6 7 8 9 10 | fs_context -> const struct fs_context_operations *ops;fs_context_operations -> get_treedo_new_mount -> vfs_get_tree -> fs_context->ops->get_tree -> get_tree_nodev(fs_context, xxx) -> xxx |
超级块中存储着挂载文件系统的所需各种信息,描述了文件系统的特性和状态,内核通过超级块对已挂载的文件系统进行维护。
fs_context结构体通过根节点root创建超级块,除了公有的超级块外,Linux内核还允许文件系统建立一个私有的超级块s_fs_info。
1 2 3 4 5 6 7 8 9 | do_new_mount -> vfs_get_tree -> super_struct sb = fs_context->root->d_sb;私有超级块:fs_context -> s_fs_infosuper_block -> s_fs_info |
super_block超级块作为文件系统元数据信息的抽象,负责管理着整个文件系统,在它当中s_flags和s_iflags两个成员分别用于标识文件系统特性和文件系统内部特性。
这两个标志位是影响文件系统权限的关键成员。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | struct super_block { ...... unsigned long s_flags; unsigned long s_iflags; ......}/* sb->s_flags */#define SB_RDONLY BIT(0) /* Mount read-only */#define SB_NOSUID BIT(1) /* Ignore suid and sgid bits */......#define SB_NOUSER BIT(31)/* sb->s_iflags */#define SB_I_CGROUPWB 0x00000001 /* cgroup-aware writeback enabled */#define SB_I_NOEXEC 0x00000002 /* Ignore executables on this fs */......#define SB_I_RETIRED 0x00000800 /* superblock shouldn't be reused */ |
比如下方,sb_permission函数会通过sb_rdonly(sb)接口检查文件系统是否为只读状态。
1 2 3 4 5 6 7 8 9 10 11 12 13 | static inline bool sb_rdonly(const struct super_block *sb) { return sb->s_flags & SB_RDONLY; }static int sb_permission(struct super_block *sb, struct inode *inode, int mask){ if (unlikely(mask & MAY_WRITE)) { umode_t mode = inode->i_mode; /* Nobody gets write access to a read-only fs. */ if (sb_rdonly(sb) && (S_ISREG(mode) || S_ISDIR(mode) || S_ISLNK(mode))) return -EROFS; } return 0;} |
当超级块建立好后,就可以操作文件系统内的各种文件了,文件系统的大小可说不准,想要找到其中的某某文件,就必须有个带路党来带路。
文件系统中通过inode节点记录文件的位置和属性信息,当系统要操作文件时,会通过inode节点找到文件在存储介质上的位置。
1 2 3 | super_block -> struct dentry* s_root; -> struct inode *d_inode; |
从上面可以看到,inode节点和super_block超级块之间出现一层dentry结构,它的存在并不是莫名奇妙的。
struct dentry中的dentry的全称是direcrory entry,struct dentry结构体中的struct qstr d_name成员记录了当前节点名(比如完整路径是/tmp/aaa,而d_name只记录aaa),父节点通过struct dentry *d_parent成员进行查找,子节点通过struct list_head d_subdirs成员进行查找,struct inode* d_node成员负责记录节点名对应的节点属性(struct inode结构体中也可以通过i_dentry成员找到自身对应dentry。)。
文件系统中的不同inode节点间可能是具有联系的,但struct inode结构体本身并没有对这种联系进行记录,于是记录节点间树状关系的任务就落到了struct dentry结构体的身上,从上面对struct dentry结构体的概要描述中也可以发现它是可以将节点串联在一起的。
当文件被操纵时,内核会使用struct file描述被打开文件的状态信息,task_struct结构体中的files成员存储着被打开的文件。
文件被打开后,内核会通过alloc_fd函数找到一个未使用的文件描述符(大于等于0的整数),程序可以通过文件描述符对文件进行操作。
1 2 3 | struct task_struct -> struct files_struct files -> struct file __rcu * fd_array[NR_OPEN_DEFAULT]; |
在struct inode结构体中,可以看到其中i_uid和i_gid记录着节点所属的用户和组ID,i_mode记录了节点的属性(文件类型和访问权限),i_ino记录着节点索引编号。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | 文件类型:套接字 #define S_IFSOCK 0140000软链接文件 #define S_IFLNK 0120000普通文件 #define S_IFREG 0100000块设备 #define S_IFBLK 0060000目录 #define S_IFDIR 0040000字符设备 #define S_IFCHR 0020000管道设备 #define S_IFIFO 0010000特权用户程序 #define S_ISUID 0004000访问权限:#define S_IRWXU 00700#define S_IRUSR 00400......#define S_IWOTH 00002#define S_IXOTH 00001struct inode { umode_t i_mode; unsigned short i_opflags; kuid_t i_uid; kgid_t i_gid; ...... i_ino ......} |
超级块和节点的补充
块和节点是真实存在在存储介质中的,struct super_block的作用是从存储介质中提取出块信息进行管理,struct inode的作用是从存储介质中提取出节点信息进行管理。
存储介质中存储的文件,其文件中的原始数据位于块内,文件的属性信息则位于节点中,通过节点可以索引到块的位置。
文件系统ID的产生
好吧,其实fsuid其实在这里并没有被用到,在后面我们可以看到fsuid一般是与euid保持一致的。
特权程序的产生
在某些情况下不能将资源看作一个整体,资源内的数据各用户隐私数据的集合(比如密码文件),用户访问密码文件后也可以看到其他用户的密码显然是不合适的。
也可以考虑在访问资源的层面进行进一步的控制,用户访问资源时只能看到与自身相关的数据,但这显然会大大增加操作系统的复杂度。
因此目前的操作系统选择了一种更加简单有效的访问控制规则,即特权程序。
特权程序可以看作是一个中立方,它拥有着用户不具备的额外权限,用户可以借助特权程序访问自身受限的资源。
这一点比较像用户到银行取钱,用户到了银行之后,虽然所有用户的钱是银行统一管理的,但是钱只能从自己的账户内取,但取钱是自己去银行仓库里面拿吗?肯定不是,需要借助ATM、前台客服这样的特权人员才可以将钱取到。
特权程序的分类
在Linux当中,特权程序分成守护进程和SetUID进程两大类,守护进程指的是特权用户运行的程序,SetUID进程指的是通过特殊比特位标记的程序,当内核运行程序时会检查该比特位,如果发现是SetUID程序就对它进行特别照顾。
与之类似的还有Set-GID程序,原理与Set-UID程序相同,只是对象从单个用户变成了整组。
有效ID和保留ID的产生
前面介绍过普通ID对用户和资源的所有者进行了区分,但用户A操作特权程序时会在程序运行期间获得特权程序所有者的权限,那么这个时候普通ID就不够用了。
内核添加了euid有效用户ID和egid有效用户组ID两种有效ID,有效ID专门用于运行期,帮助普通用户在运行期拿到特殊的权限。
对于Set-UID程序来讲,它的用户ID和有效ID是不一样的,在GLibC提供了一种名为setuid的函数,程序可以调用此接口将进程运行期的身份凭证euid改变,为了让euid可以恢复,所以设置了suid保留ID作为euid的副本。
特权程序的运行原理
从下面可以看到,sudo作为特权程序拥有着一个特殊的符号s,这与一般可执行文件所标记的x符号有所不同。
1 2 3 4 5 | ls -lh /bin/sudo-rwsr-xr-x 1 root root 276K Jun 27 2023 /bin/sudols -lh /bin/ping-rwxr-xr-x 1 root root 89K Nov 27 2022 /bin/ping |
这个特殊的符号让Set-UID程序拥有了特权,帮助普通用户在运行期完身份的切换。每个用户都具有一个真实的用户ID,而在运行期则会使用有效用户ID,当用户运行普通程序时,其有效ID仍是真实ID,但当用户运行特权程序时,其有效ID则会变成程序所属者的真实ID,有效ID拥有什么权限,程序在运行期也就拥有什么权限。
cred结构体在描述进程的task_struct中会有一份记录。
1 2 3 4 5 6 7 | struct stask_struct { ...... const struct cred __rcu *ptracer_cred; const struct cred __rcu *real_cred; const struct cred __rcu *cred; ......} |
在内核正式通过exec_binprm开始加载程序之前,bprm_execve函数会先通过prepare_bprm_creds准备待运行程序的权限。
bprm代表二进制程序,struct linux_binprm结构体存储着二进制文件加载时所使用的参数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | sysycall execve -> do_execve -> do_execveat_common -> bprm_execve |-> prepare_bprm_creds |-> exec_binprm -> search_binary_handler -> load_binaryprepare_exec_creds -> prepare_exec_creds -> prepare_creds |-> struct task_struct *task = current; |-> old = task->cred; |-> memcpy(new, old, sizeof(struct cred)); |
prepare_creds函数会根据当前进程先复刻出来一份身份凭证cred。
进入exec_binprm后,会调用一个有趣的函数search_binary_handler,它会在已注册的可执行文件类型链表formats中进行匹配,找到对应的可执行文件类型struct linux_binfmt。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | list_for_each_entry(fmt, &formats, lh) { if (!try_module_get(fmt->module)) continue; read_unlock(&binfmt_lock); retval = fmt->load_binary(bprm); read_lock(&binfmt_lock); put_binfmt(fmt); if (bprm->point_of_no_return || (retval != -ENOEXEC)) { read_unlock(&binfmt_lock); return retval; }} |
然后再通过load_binary调用load_elf_binary(这里以elf格式为例),除此之外,还有binfmt_script脚本文件、binfmt_flat等等。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 | static struct linux_binfmt elf_format = { .module = THIS_MODULE, .load_binary = load_elf_binary, .load_shlib = load_elf_library,#ifdef CONFIG_COREDUMP .core_dump = elf_core_dump, .min_coredump = ELF_EXEC_PAGESIZE,#endif};load_elf_binary -> begin_new_exec -> bprm_creds_from_file |-> bprm_fill_uid |-> security_bprm_creds_from_filebprm_fill_uid (...) { ...... uid = i_uid_into_mnt(mnt_userns, inode); gid = i_gid_into_mnt(mnt_userns, inode); ...... if (mode & S_ISUID) { bprm->per_clear |= PER_CLEAR_ON_SETID; bprm->cred->euid = uid; } if ((mode & (S_ISGID | S_IXGRP)) == (S_ISGID | S_IXGRP)) { bprm->per_clear |= PER_CLEAR_ON_SETID; bprm->cred->egid = gid; } ......} |
当程序准备开始运行前,bprm_fill_uid函数会根据当前文件的信息更新身份凭证cred,当发现文件Set-UID位置被设置时,就会更新当前身份凭证中的euid为文件的uid。
当你观察binfmt_script脚本文件中的load_binary接口实现时,会发现它并没有去设置比特位,这是因为Set-UID机制只对二进制文件生效。
通过bprm_fill_uid完成身份凭证cred的设置后,内核会继续通过security_bprm_creds_from_file函数,将suid和fsuid更新成euid。
1 2 3 4 | security_bprm_creds_from_file -> cap_bprm_creds_from_file |-> new->suid = new->fsuid = new->euid; |-> new->sgid = new->fsgid = new->egid; |
在进程运行前的最后时刻,会通过commit_creds函数将刚刚生成的cred更新给当前进程。
1 2 | begin_new_exec -> commit_creds |
总结
在Linux的世界中资源被抽象成文件,由此衍生出了各类文件系统类型,Linux内核自然针对各种文件系统类型进行管理,保证文件系统挂载完成后可以提供块和节点信息让内核操纵。

节点代表着文件属性,节点间本身是没有联系的,为了维持文件间的树状关系,Linux内核创造出了struct dentry结构体进行管理。

不同资源间是存在差异的,为了应对这种权限上的差别,Linux允许资源间设立自己的“山大王”,“山大王”有权拒绝外来者访问资源。并且Linux还给资源添加了控制权限(可读可写可执行),限定操作资源的方式在指定途径下进行。
但考虑外来者有时访问并不是出于恶意且它的需求是有必要的,所以Linux内核允许“山大王”派出自己拥有的特别信使,外来者可以向这个特别信使提出需求,然后由特别信使替自己执行操作,作为“山大王”认证的信使,自然也对“山头”内资源具有操作权限。
一个信使在执行任务期间可以对“山头”内资源进行任意的操作也不算合理,但想要进一步细化对信使的控制就需要付出更多的成本。
这个进一步细化访问控制的实现对于Linux内核而言应该怎么实现呢,实现难度又是多大呢,对于目前的Linux内核而言,这种机制又是否已经实现好了呢。
被滥用的特权程序
外部环境
特权程序本身的操作是值得信任的,但是特权程序的操作往往需要依赖外部信息,这些信息分成用户传递的信息和系统产生的信息两大类。
特权程序首先需要保证用户输入产生的信息对自己没有影响。
用户的输入可以看作是一个“挑战书”,既然程序已经知道有人要过来挑战自己,当然也会做足准备进行防御,但是明枪易躲,暗箭难防啊!系统产生的信息往往就是那个暗箭。
系统产生的信息又可以分成两大类,一是程序内部显式操作的各种系统资源(比如读写文件),此时程序会从系统资源中获取各种数据,二是每个程序都默认附带的环境变量。
权限泄露
权限泄露发生在程序通过setuid接口变更euid的前后,假如程序在euid变更前持有高特权,且在带有高特权身份运行期间打开了某某资源,如果在setuid切换到低特权身份后,原先打开的资源没有释放,就会导致资源被低特权用户使用。
前面说过Linux下的资源权限通过inode中的i_uid标明,打开文件时会验证进程的euid的权限是否在i_uid之上,如果可以打开文件那么内核就会返回一个文件描述符,此时任意权限的用户都可以通过文件描述符操作文件。
示例讲解
源代码在下方给出。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 | #include <stdio.h>#include <stdlib.h>#include <unistd.h>#include <fcntl.h>#include <errno.h>void vuln_by_user_input(const char* input){ char cmd[0x100]; snprintf(cmd, 0x100, "echo %s", input); system(cmd);}void vuln_by_permission_leak(void){ int my_fd; printf("will get password\n"); my_fd = open("./private_data.bin", O_RDWR | O_APPEND); if (my_fd > 0) { printf("get fd num %d\n", my_fd); } else { printf("open file failed [errno %d], will exit.\n", errno); return; } system("/bin/sh");}int main(int argc, char** argv){ switch (argc) { case 1: vuln_by_permission_leak(); break; case 2: vuln_by_user_input(argv[1]); break; default: break; } return 0;} |
对产生的二进制文件和程序所需文件进行如下的操作。
1 2 3 4 5 | chown root:root ./private_data.binchmod 600 ./private_data.binchown root:root ./set_uid_examplechmod 4755 ./set_uid_example |
经过上面的设置后再次观察文件,我们可以发现数据文件变成了只有root用户可读可写的状态,set_uid_example也变成了root用户授权的Set-UID程序。
1 2 | -rw------- 1 root root 8 Nov 8 08:13 private_data.bin-rwsr-xr-x 1 root root 18K Nov 8 08:49 set_uid_example |
构造意外的外部输入获得Shell
通过vuln_by_user_input函数我们可以发现,函数会将argv[1]作为echo命令的输出字符串。
system函数是一个非常容易受到外部环境影响的接口,这是因为它通过/bin/sh运行命令(即先运行shell再运行命令),此时命令会从环境变量中的PATH查找,因此system函数非常容易收到环境变量的影响,除此之外,通过system函数调用命令也保留了/bin/sh的强大解析功能。
如果我们构造出xx ; yy或xx && yy,system函数命令时就会先执行xx再执行yy,导致执行方式与预期不同进而产生漏洞。
1 | execl("/bin/sh", "sh", "-c", command, (char *) NULL); |
在这个示例当中,我们就可以通过;或&&作为命令分隔符,使得system函数执行/bin/sh。
1 2 3 4 5 6 7 | ./set_uid_example "233333 ; /bin/sh"233333$ exit./set_uid_example "233333 && /bin/sh"233333$ exit |
权限的泄露
通过vuln_by_permission_leak函数,Set-UID程序会先将private_data.bin文件打开,前面展示过这是一个只有root用户才可读可写的文件。
虽然在进入Shell后,Shell无法继承原程序euid获得root权限(前面说过Shell脚本不能作为Set-UID程序)。
1 2 | cat private_data.bincat: private_data.bin: Permission denied |
但是原Set-UID程序打开的特权文件并没有关闭,使得普通用户也可以通过文件描述符操作文件。
1 2 3 4 5 | ./set_uid_example will get passwordget fd num 3$ echo "CCCCC" >& 3$ exit |
下面展示了echo篡改后的文件内容。
1 2 | sudo cat private_data.bin12345678CCCCC |