Skip to content

Chapter 2 Set-UID 特权程序

The Need for Privileged Programs

普通用户不能直接修改 /etc/shadow,因为该文件保存了系统账户的密码哈希,通常只有 root 能够读取或写入。

seed@VM:~$ ls -l /etc/shadow
-rw-r----- 1 root shadow ... /etc/shadow
seed@VM:~$ echo test >> /etc/shadow
-bash: /etc/shadow: Permission denied

但是普通用户又必须能够修改自己的密码。

seed@VM:~$ passwd
Changing password for seed.
(current) UNIX password:
Enter new UNIX password:
Retype new UNIX password:
passwd: password updated successfully

passwd 程序需要以更高的权限写入 /etc/shadow,但不能因此允许普通用户直接获得一个 root shell。

解决这类问题需要 privileged program(特权程序),程序本身拥有完成特定操作所需的权限,用户只能通过程序提供的功能完成操作。passwd 本身通常就是 root-owned 的 Set-UID 程序:

$ ls -l /usr/bin/passwd
-rwsr-xr-x 1 root root ... /usr/bin/passwd

用户执行 passwd 时,程序暂时以 root 的有效身份更新密码文件,程序结束后,用户不会因此获得一个持续存在的 root shell。

1 Set-UID 机制

Real User ID and Effective User ID
  • Real User ID(ruid):真正启动该进程的用户,表示谁运行了程序。
  • Effective User ID(euid):进程执行访问控制检查时使用的用户身份,表示程序以谁的权限运行。

普通进程通常满足 ruid = euid = 当前用户,执行 root 拥有的 Set-UID 程序时可能出现 ruid = seed、euid = root。

文件访问权限主要依据 euid 判断,而不是只依据 ruid。因此,程序可以知道实际调用者是普通用户,同时暂时以文件拥有者的身份访问受保护资源。

Set-UID 位位于 owner 的执行权限位置,八进制表示的最高位为 4。

4 755
│ │
│ └── owner、group、other 的普通权限
└──── Set-UID 位

使用 chmod 设置 Set-UID

chmod 4755 file
chmod u+s file

设置成功后,ls -l 的 owner 执行位会显示为 s:

-rwsr-xr-x 1 root root ... file

文件被执行时,如果 Set-UID 位有效,进程的 euid 会变为文件拥有者的 UID。

观察 euid 的变化
文件无 Set-UID 位,进程仍以 seed 身份运行
$ cp /bin/id myid
$ sudo chown root myid
$ ./myid
uid=1000(seed) gid=1000(seed) groups=1000(seed), ...
uid 仍是 seed,euid 变成了文件拥有者 root
$ sudo chmod 4755 myid
$ ./myid
uid=1000(seed) gid=1000(seed) euid=0(root) groups=1000(seed), ...

修改拥有者会清除 Set-UID

chown 改变文件拥有者时会自动清除 Set-UID 位。否则,攻击者可能先准备一个带 Set-UID 位的可执行文件,再把它改成 root 所有,从而把任意程序变成 root 特权程序。

示例:Set-UID cat

如果把 /bin/cat 复制出来,并将副本设置为 root 拥有的 Set-UID 程序,普通用户就可以通过这个副本读取原本无权读取的文件:

seed@VM:~$ cp /bin/cat ./mycat
seed@VM:~$ sudo chown root mycat
seed@VM:~$ sudo chmod 4755 mycat

seed@VM:~$ ls -l mycat
-rwsr-xr-x 1 root seed ... mycat

seed@VM:~$ cat /etc/shadow
cat: /etc/shadow: Permission denied

seed@VM:~$ ./mycat /etc/shadow
root:$6$...:...
daemon:*:...
bin:*:...

root 拥有的 Set-UID 程序中的任何漏洞,都可能直接变成 root 权限漏洞。

示例:Set-UID Shell

如果 Bob 可以在自己的账户中创建文件,他可以创建一个属于 Bob 的 Set-UID shell。

# 在 Bob 的账户中执行
$ cp /bin/bash /tmp/mybash
$ chmod 4755 /tmp/mybash

其他用户执行 /tmp/mybash 时,进程会以 Bob 的 euid 运行,因此可能获得 Bob 能访问的文件和资源。

Set-UID 的权限来自文件拥有者,而不是来自执行文件的用户。

2 Set-UID 程序的攻击面

攻击面

Attack surface(攻击面) 是攻击者可以向程序输入数据、影响程序行为,或从程序中提取数据的所有入口的集合。在 Set-UID 程序中,用户可以影响的输入至少包括:

  1. Explicit user inputs:命令行参数、标准输入、配置文件、文件名等
  2. System inputs controlled by users:用户能够控制的文件、目录、进程、信号和设备等
  3. Environment variables:PATH、IFS、LD_PRELOAD 等环境变量
  4. Non-privileged processes:特权程序创建或调用的、仍由用户控制的普通进程

特权程序的攻击面比普通程序更危险,因为攻击者控制的输入会在高权限上下文中被解释或执行。

2. 1 来自环境变量的攻击

环境变量是由父进程传递给子进程的字符串,例如:

SHELL=/bin/bash
PATH=/usr/local/bin:/usr/bin:/bin
LD_PRELOAD=./libmylib.so

C 程序可以通过 envp 参数或全局变量 environ 访问环境变量:

int main(int argc, char *argv[], char *envp[]) {
    ...
}

进程启动时,环境变量字符串和 argv 字符串通常位于用户栈上,envp 和 argv 是指向这些字符串的指针数组。

用户可以在启动程序前设置环境变量,因此 Set-UID 程序不应默认信任继承来的环境。环境变量可能影响 shell 如何查找和解释命令、应用程序如何选择文件或外部程序、动态链接器加载哪些共享库以及程序调用的库函数和外部程序的行为。

system() 机制

system() 会把字符串交给 shell 执行,可以近似理解为:

system("cmd")  ≈  /bin/sh -c "cmd"

因此,system() 不只是调用一个程序,还会引入 shell 的命令搜索、变量展开、通配符、管道、重定向和命令分隔符等行为。

示例:PATH 环境变量攻击
#include <stdlib.h>

int main()
{
    system("cal");
}

上面的程序以相对命令名调用 cal,当它是 root-owned Set-UID 程序时,攻击者可以在自己可写的目录中放置一个恶意的 cal,再修改 PATH,使 shell 优先找到恶意文件:

$ gcc -o vul vul.c
$ sudo chown root vul
$ sudo chmod 4755 vul

# 准备一个名为 cal 的恶意程序,使其启动 shell
$ printf '#!/bin/sh\nexec /bin/bash -p\n' > cal
$ chmod +x cal

$ export PATH=.:$PATH
$ ./vul
#

此时程序原本想运行系统的 cal,但 system() 通过 PATH 找到了当前目录中的恶意 cal,导致特权程序间接启动 root shell。

为了避免 PATH 攻击,应当不在特权程序中使用没有路径的外部命令,如果必须调用外部程序,则使用可信的绝对路径。更好的方式是直接使用 execve(),避免启动 shell,也不要把当前目录 . 放在特权程序依赖的 PATH 中。

IFS Attacks

IFS(Internal Field Separator)是 shell 用来进行字段分割的环境变量。攻击者可以通过修改 IFS 影响某些 shell 命令的分词过程,特别是当特权程序把环境变量或用户输入再次交给 shell 展开时。

IFS="/"
system("/bin/cal")

正常情况下,system 会交给 shell 执行,shell 把 /bin/cal 当成一个完整的程序路径,直接运行 /bin/cal。但 IFS 把 / 当成分隔符,字符串被切开,shell 不再把它识别成完整路径,执行逻辑变成找命令 bin,参数 cal。

IFS 对字面量命令的影响依赖具体 shell 和解析形式,它不是对所有 system("/bin/cal") 调用都成立的通用攻击方式,但只要程序依赖 shell 解析,就不应把继承的环境变量视为可信输入。

动态链接程序在运行时由 loader 和 dynamic linker 加载共享库,静态链接程序则把所需库代码直接放入可执行文件。通常动态链接文件更小,但运行时依赖外部共享库。

$ gcc -o hello_dynamic hello.c
$ gcc -static -o hello_static hello.c
$ ls -l
-rwxrwxr-x ... hello_dynamic
-rwxrwxr-x ... hello_static

使用 ldd 查看共享库依赖

$ ldd a.out
        linux-gate.so.1 =>  (...)
        libc.so.6 => /lib/i386-linux-gnu/libc.so.6 (...)
        /lib/ld-linux.so.2 (...)

LD_PRELOAD 指定需要在其他共享库之前加载的库。预加载的库可以定义与系统库相同的函数,从而覆盖原来的实现。

示例
int main()
{
    printf("Hello World\n");
    sleep(2);
}
不设置 LD_PRELOAD
$ unset LD_PRELOAD
$ ./a.out
Hello World
设置自定义库后,动态链接器会优先加载该库
$ export LD_PRELOAD=./libmylib.so.7
$ ldd a.out
        linux-gate.so.1 =>  (...)
        ./libmylib.so.7 => (...)
        libc.so.6 => (...)

$ ./a.out
Hello World
I am not sleeping!

如果动态链接器允许普通用户通过 LD_PRELOAD 替换 root-owned Set-UID 程序使用的库,用户就可以在库中执行任意 root 代码。因此,动态链接器对特权程序会进入 secure-execution 模式,通常忽略或清除 LD_PRELOAD、LD_LIBRARY_PATH 等危险变量。

$ export LD_PRELOAD=./libmylib.so.1.0.1
$ export LD_LIBRARY_PATH=.
$ export LD_MYOWN="my own value"

$ env | grep LD_
LD_PRELOAD=./libmylib.so.1.0.1
LD_LIBRARY_PATH=.
LD_MYOWN=my own value

$ myenv | grep LD_
LD_MYOWN=my own value

LD_PRELOAD 和 LD_LIBRARY_PATH 被特权程序的动态链接器过滤,而普通的、没有特殊含义的 LD_MYOWN 仍然可以传入程序。安全程序不能因为动态链接器过滤了部分变量,就认为整个环境是可信的。

2. 2 来自用户输入的攻击

int main(int argc, char *argv[])
{
    char *cat = "/bin/cat";

    if (argc < 2) {
        printf("Please type a file name.\n");
        return 1;
    }

    char *command = malloc(strlen(cat) + strlen(argv[1]) + 2);
    sprintf(command, "%s %s", cat, argv[1]);
    system(command);
    return 0;
}

上面的程序把用户提供的文件名拼接进 shell 命令,当用户输入 aa 时,程序构造的命令是 /bin/cat aa。

但是,用户输入的内容会被 shell 作为命令代码重新解析,aa;/bin/sh 最终构造出的命令是 /bin/cat aa;/bin/sh,分号会结束第一个命令并启动第二个 shell,这就是命令注入(Command Injection)。

攻击实验示例
将程序编译为 root-owned Set-UID 程序
$ gcc -o catall catall.c
$ sudo chown root catall
$ sudo chmod 4755 catall
$ ./catall "aa;/bin/sh"
/bin/cat: aa: No such file or directory
#
$ id
uid=1000(seed) gid=1000(seed) euid=0(root) groups=0(root)

虽然真实用户仍然是 seed,但被注入的 shell 继承了 root 的有效权限。

先把 /bin/sh 临时链接到 zsh,以避免某些 shell 在特权场景中主动降权
$ ls -l /bin/sh
/bin/sh -> /bin/dash

$ sudo ln -sf /bin/zsh /bin/sh
$ ls -l /bin/sh
/bin/sh -> /bin/zsh
实验完成后必须恢复原来的链接
$ sudo ln -sf /bin/dash /bin/sh

辨析

$ catall aa;/bin/sh
$ catall "aa;/bin/sh"
  • 第一条命令中,当前 shell 会先执行 catall aa,再执行 /bin/sh,分号没有作为参数传给 catall。
  • 第二条命令把整个字符串作为一个参数传递给程序,程序随后把它拼进 system() 的命令字符串中,分号才会在第二次 shell 解析时产生命令注入。

漏洞的根本原因是把用户输入同时放进了用户想要读取的文件名(Data)和 shell 要执行的命令字符串(Code)中。当程序把数据拼接进 shell 命令时,用户可以通过 ;、|、&&、重定向等 shell 元字符改变程序代码。安全的方式是直接调用目标程序,把用户输入作为独立的参数传递给它。

示例
char *v[3];

v[0] = "/bin/cat";
v[1] = argv[1];
v[2] = 0;

execve(v[0], v, 0);

此时不启动 shell,argv[1] 始终是一个普通参数:

$ safecatall "aa;/bin/sh"
/bin/cat: 'aa;/bin/sh': No such file or directory

;/bin/sh 会被当作文件名的一部分,而不会被解释为新的命令。

2. 3 能力泄露

能力泄漏(Capability leaking) 指进程虽然已经放弃高权限,但仍然保留了在高权限状态下获得的资源访问能力。

示例:文件描述符
fd = open("/etc/zzz", O_RDWR | O_APPEND);

setuid(getuid());       // Dropping the privilege

char *v[2];
v[0] = "/bin/sh";
v[1] = 0;
execve(v[0], v, 0);

open() 在降权之前执行,因此 fd 已经代表一个以 root 身份打开的文件。降权不会自动关闭或重新检查该文件描述符。

$ cat /etc/zzz
bbbbbbbbbbbbbbbb

$ echo aaaaaaaaaaaa > /etc/zzz
bash: /etc/zzz: Permission denied

$ cap_leak
fd is 3

$ echo cccccccccccc >&3
$ cat /etc/zzz
bbbbbbbbbbbbbbbb
cccccccccccc

普通用户不能直接写 /etc/zzz,但可以通过继承的文件描述符 3 写入该文件。

真实案例:DYLD_PRINT_TO_FILE

DYLD_PRINT_TO_FILE 原本用于让动态链接器把错误信息写入指定文件,但攻击者可以把它指向一个本应只能由 root 创建或写入的文件。

$ DYLD_PRINT_TO_FILE=/this_system_is_vulnerable su <some_username>
Password:

$ ls -la /this_system_is_vulnerable
-rw-r--r-- 1 root wheel ... /this_system_is_vulnerable

$ echo "Test 1" >&3
$ echo "Test 2" >&3
$ cat /this_system_is_vulnerable
Test 1
Test 2

动态链接器以高权限打开文件并留下文件描述符 3,后续的低权限 shell 通过 >&3 使用该描述符写入文件。

2. 4 分析

Set-UID 方式和服务端方式都可以让普通用户请求特权操作,但攻击面不同。

  • Set-UID 方式:普通用户直接启动特权程序,特权程序继承用户的环境变量、文件描述符和其他进程上下文,代码在用户可影响的环境中执行特权操作。
  • 服务端方式:一个长期运行的特权服务拥有自己的环境,普通用户进程通过 IPC、socket 或其他接口发送请求,服务端只处理定义好的请求,不直接继承请求者的整个进程环境。

服务端方式并不会自动安全,服务仍然需要认证、输入验证和访问控制。但从环境变量角度看,服务端的特权执行环境不直接由每个请求者控制,攻击面通常更小。

最小权限原则

Every program and every privileged user of the system should operate using the least amount of privilege necessary to complete the job.

Set-UID root 程序通常一次获得完整的 root 权限,但程序真正需要的可能只是读取某类文件、创建 raw socket 或修改少量属性。因此,Set-UID root 容易违反最小权限原则。

3 POSIX Capabilities

POSIX capabilities 将 root 的整体权限拆分成多个更小的权限单元。程序只获得完成任务所需的 capability,而不必获得完整的 root 身份。

查看 Linux 内核所有能力

$ man capabilities
Capability 作用
CAP_CHOWN 任意修改文件的 UID 和 GID
CAP_DAC_OVERRIDE 绕过文件读、写、执行权限检查
CAP_DAC_READ_SEARCH 绕过文件读取和目录搜索相关的权限检查
CAP_NET_RAW 使用 raw socket 和 packet socket

进程和文件都可以关联 capability。常见的 capability 集合包括:

  • permitted(p):进程被允许使用的 capability
  • effective(e):当前实际启用的 capability
  • inheritable(i):允许在执行新程序时继续传递的 capability
示例:设置文件能力
复制 shell,默认情况下不能读取 /etc/shadow
$ cp /bin/bash ./mybash
$ ./mybash

$ cat < /etc/shadow
mybash: /etc/shadow: Permission denied
为 mybash 设置 CAP_DAC_READ_SEARCH
$ sudo setcap CAP_DAC_READ_SEARCH=ep mybash
$ getcap mybash
mybash = cap_dac_read_search+ep
使用 getpcaps 查看当前进程的能力
$ getpcaps $$
65331: = cap_dac_read_search+ep
再次进入 mybash
$ cat < /etc/shadow
root:!:...
daemon:*:...
bin:*:...

$ cat > /zzzz
mybash: /zzzz: Permission denied

CAP_DAC_READ_SEARCH 只允许绕过读取和搜索检查,并不授予写权限。这比直接把整个 shell 设置为 root-owned Set-UID 更符合最小权限原则。

Case Study 1: Wireshark

Wireshark 的图形界面、协议解析和展示部分不需要 root 权限,真正需要访问网络接口、执行抓包操作的是 dumpcap。

$ getcap /usr/bin/dumpcap
/usr/bin/dumpcap = cap_net_admin,cap_net_raw+eip

这种设计把特权操作限制在较小的辅助程序中,减少了复杂图形和解析代码运行在高权限环境中的风险。

Case Study 2: ping

ping 需要创建 raw socket,但不需要完整的 root 权限。

$ getcap /usr/bin/ping
/usr/bin/ping = cap_net_raw+ep

CAP_NET_RAW 只提供创建 raw socket 等网络能力,权限范围小于完整的 root 身份。

Case Study 3: Android Permissions

Android 采用声明式、细粒度的权限模型:

  1. 应用在安装包中声明需要的 permissions
  2. 安装时由系统和用户决定是否授予这些 permissions
  3. 应用运行时只能使用已经获得的权限

应用不能因为自身代码需要某项特权,就自动获得整个系统的 root 权限。Android 的应用权限、应用沙箱和用户授权机制体现了把大权限拆分成小权限的思想。

4 Review Questions

The chown command automatically disables the Set-UID bit, when it changes the owner of a Set-UID program. Please explain why it does that.

chown 改变文件拥有者时会清除 Set-UID 位,因为文件拥有者决定执行该文件时的 euid。如果 Set-UID 位在换 owner 后仍然保留,攻击者就可能先准备任意恶意程序,再把它转移为 root 所有而保留 Set-UID,最终获得 root 权限。

清除 Set-UID 位可以避免权限随文件拥有者变化而被意外或恶意转移。

When a program takes an input from users, we can redirect the input device, so the program can take the input from a file. For example, we can use prog < myfile to provide the data from myfile as input to the prog program. Now, if prog is a root-owned Set-UID program, can we use $ prog < /etc/shadow to to get this privileged program to read from the /etc/shadow file?

不能直接通过 $ prog < /etc/shadow 让普通用户读取 /etc/shadow,因为 shell 会在启动 prog 之前,先以当前普通用户的权限打开 /etc/shadow 并完成标准输入重定向。普通用户没有打开该文件的权限,因此重定向阶段就会失败,Set-UID 程序根本还没有开始执行。如果特权程序自己打开文件,或者继承了特权状态下已经打开的文件描述符,则属于另一种情况,可能产生能力泄漏。

When a parent Set-UID process (effective user ID is root, and the real user ID is bob) creates a child process using fork(), the standard input, output, and error devices of the parent will be inherited by the child. If the child process drops its root privilege, it still retains the access right to these devices. This seems to be a capability leaking, similar to the capability-leaking case covered in this chapter. Can this pose any danger?

可以。父进程的标准输入、标准输出和标准错误本质上也是文件描述符。fork() 创建的子进程会继承这些描述符;即使子进程随后放弃 root 权限,已经打开的设备或文件描述符仍然可能保持原来的访问能力。

因此,降权后启动子进程前应当:

  • 关闭不需要的描述符
  • 检查标准输入、输出和错误是否指向可信对象
  • 使用 FD_CLOEXEC 防止描述符跨 execve() 泄漏
  • 重新以低权限打开需要使用的资源
Assume that you have a file that you would allow other users to read, only if a user’s ID is smaller than 1000. Please describe how you can actually achieve this.

普通文件权限和 ACL 不适合直接表达“UID 小于 1000”这样的数值范围。可以编写一个最小化的 root-owned Set-UID helper:

uid_t uid = getuid();       /* real UID, not euid */

if (uid >= 1000)
    exit(1);

/* 以特权身份打开并读取目标文件 */

该 helper 应当:

  • 使用 getuid() 检查真实调用者的 UID
  • 只允许 UID 小于 1000 的调用者继续
  • 只实现读取功能,不调用 shell、不执行用户提供的命令
  • 对输入路径进行限制,避免任意文件读取
  • 读取完成后尽快降权或退出

如果允许的用户集合是固定的,也可以创建专用用户组并使用组权限;但对于动态的 UID 数值范围,受控的权限检查程序更直接。