软件安全实验 SEEDlabs 环境变量与Set-UID 实验
Task 1:配置环境变量
本任务中,我们学习设置和删除环境变量的指令。我们用Bash来完成。用户使用的默认的shell设置 在文件/etc/passwd中(每一项的最后一个字段)。你可以用 chsh命令来修改shell程序(本实验无需修 改)
使用 printenv或者 env指令来打印环境变量。

如果你对某个特定的环境变量感兴趣,比如 PWD, 你可以用指令“printenv PWD”或者“env | grep PWD”。

使用 export和 unset来设置或者取消环境变量。注意:这两个指令不是单独的程序;它们是两个 Bash 的内部指令(即,你不能在Bash外调用它们噢)
- 使用
export设置环境变量,使用unset取消环境变量:

Task 2:从父进程向子进程传递环境变量
本任务中,我们研究子进程是如何继承父进程的环境变量的。Unix操作系统中,fork()系统调用会 复制发起调用的进程,创建一个新进程。新进程称作子进程,被复制的进程称作父进程。然而,有些东西 是没有被子进程继承的(在命令行中输入指令 man fork,可以查看 fork()的指南)。在本任务中,我们 想要知道子进程是否继承了父进程的环境变量。
第1步 编译并运行以下程序,描述你的观察。在文件夹 LabSetup中,执行gcc myprintenv.c命令编 译,这将生成一个名为a.out的二进制文件。然后使用a.out > file命令,运行并将输出保存到一个文件中。

第2步现在注释掉子进程中的printenv()语句,并取消注释父进程中的printenv()语句 。再次编译gcc myprintenv.c -o b.out并运行代码,描述你的观察结果。将输出保存在另一个文件中b.out > o2.txt。

第3步使用diff命令比较这两个文件的差异。

结论:
父进程和子进程继承了完全相同的环境变量,除了记录程序名的 _。fork出来的子进程会继承父进程的环境变量。程序名的环境变量 _ 反映了执行的二进制文件名。
Task 3:环境变量和execve()
本任务中,我们研究通过execve()运行一个新程序,环境变量是如何受影响的。函数execve()调用系统调用来加载新命令并执行它;这个函数永远不会返回。没有创建新进程;相反,调用进程的代码段、数据段、bss段和栈被加载的程序覆盖。本质上,execve()在调用进程中运行了新程序。我们对环境 变量发生了什么感兴趣;它们会被新程序自动继承吗?
第1步编译并运行以下程序,描述你的观察。这个程序简单地执行了一个名为“/usr/bin/env”的程序, 它的作用是打印出当前进程的环境变量。

发现输出为空。
第2步将第行中execve()的调用更改为以下内容,即修改execve("/usr/bin/env", argv, NULL);为execve("/usr/bin/env",argv,environ);;描述你的观察。


发现打印出了当前进程的环境变量。
第3步请就新程序如何获取其环境变量得出你的结论:
execve()函数的原型是:
1 | |
pathname: 要执行的程序的路径。argv: 参数数组,以NULL结尾,包含传递给程序的命令行参数。envp: 环境变量数组,也以NULL结尾。
新程序通过execve()函数的第三个参数传递的environ变量来获取环境变量。
使用 execve() 启动新程序时,新程序的环境变量是否存在,完全由第三个参数 envp 决定。如果传 NULL,则没有环境变量;如果传 environ,则继承父进程的所有环境变量。
Task 4:环境变量和system()
本任务中,我们研究通过system()运行一个新程序,环境变量是如何受影响的。system()也是用来 执行一个命令的,但是和execve()直接执行一个命令不同,system()实际上执行“/bin/sh-ccommand”, 即它先执行/bin/sh,然后让shell执行这个command。
如果你查阅system()函数的实现,你会发现它使用execl()来执行/bin/sh;execl()调用execve(), 并将环境变量数组传递给它。因此,使用system()时,调用进程的环境变量会传递给新程序/bin/sh。 请编译并运行以下程序来验证这一点。
1 | |


我们使用man system查看函数的手册:

可以看到system()函数是通过创建一个子进程,执行execl("/bin/sh", "sh", "-c", command, (char *) NULL);,调用进程的环境变量会传递给新程序/bin/sh。
注意,
execl里的(char *) NULL只是参数列表的结束,并不会影响环境变量的传递。环境变量默认总是会自动传递给新程序,除非你用execve并且显式把 envp 设为 NULL。如果你要控制新程序的环境变量,应该用
execve或execle。
Task 5:环境变量和Set-UID程序
Set-UID 是Unix 系统中重要的安全机制。当 Set-UID程序执行时,它将获得程序拥有者的权限。如 果程序所有者是root,所有执行该程序的用户都将以root权限执行该程序。Set-UID使得我们可以做许多 有趣的事情,但是在执行 Set-UID程序时,会提高执行者的权限,这是有风险的。尽管 Set-UID的行为 是由他们的程序逻辑所决定的,而不是用户决定的,用户却可以通过环境变量来修改 Set-UID的行为。 为了理解 Set-UID程序是如何被影响的,我们首先弄清楚 Set-UID程序的环境变量是否由用户程序继承 而来
第1步 编写以下程序,打印当前进程的所有环境变量。
1 | |
第2步 编译上述程序得到foo,将其所有者更改为root,并使其成为一个 Set-UID程序。
1 | |
查看一下foo的权限,发现所有者更改为了root。

第3步 在你的shell(你需要确保你在普通用户帐户中,而不是root帐户中)中,使用export命令设置 以下环境变量(它们可能已经存在):
- PATH
- LD_LIBRARY_PATH
- ANY_NAME(这个环境变量是你自己定义的,随便取一个名字即可)

这些环境变量是在普通用户的shell进程中设置的。现在,在你的shell中运行第2步中的Set-UID程 序。在shell中键入程序名后,shell会fork一个子进程,并使用子进程来运行该程序。请检查你在shell进 程(父进程)中设置的所有环境变量是否都进入了Set-UID子进程。描述你的观察。如果你有惊奇的发 现,请描述它们。
然后运行foo并查看这些环境变量的值

发现只有在父进程中设置的PATH和MY_NAME的环境变量进入子进程,而LD_LIBRARY_PATH这个环境变量没有进入子进程。
原因:LD_LIBRARY_PATH这个环境变量设置的是动态链接器的地址,由于动态链接器的保护机制,虽然在一个root权限的程序下创建子进程并继承父进程的环境变量,但由于我们是在普通用户下修改的LD_LIBRARY_PATH这个环境变量,所以是无法在子进程中生效的,而PATH和MY_NAME则没有这种保护机制,因此可以被成功设置。
Task 6:PATH环境变量和Set-UID程序
由于调用了shell程序,在Set-UID程序中调用system()是非常危险的。这是因为shell程序的实际 行为会受到环境变量的影响,例如PATH环境变量;这些环境变量由用户提供,可能是恶意的。通过更改这些变量,恶意用户可以控制Set-UID程序的行为。在Bash中,你可以通过以下方式更改PATH环境变 量(本示例将目录 /home/seed添加到PATH环境变量的开头):
1 | |

Note: system(cmd) 函数首先执行 /bin/sh 程序,然后要求这个shell 程序运行cmd命令。在Ubuntu 20.04(以及之前的几个版本)中,/bin/sh实际上是指向/bin/dash的符号链接。这个shell程序有一个 对策,可以防止自己在Set-UID进程中被执行。基本上,如果dash检测到它是在Set-UID进程中执行的, 它会立即将有效用户ID更改为进程的真实用户ID,从而基本上放弃特权。由于我们的受害程序是一个 Set-UID 程序,因此/bin/dash中的对策可以阻止我们的攻击。
为了了解我们的攻击如何在没有这种对策的情况下进行,我们将/bin/sh链接到另一个没有这种对 策的shell。我们在Ubuntu20.04VM中安装了一个名为zsh的shell程序。我们使用以下命令将/bin/sh 链接到/bin/zsh:
1 | |
下面的Set-UID程序应该执行/bin/ls命令;但是,程序员只使用ls命令的相对路径,而不是绝对 路径:
1 | |
请编译上述程序,将其所有者更改为root,并使其成为 Set-UID程序。你能让这个Set-UID程序运行你 自己的恶意代码,而不是/bin/ls吗?如果可以,你的恶意代码是否以root权限运行?描述并解释你的 观察。

可以看出,编译出来的LS文件确实执行了system("ls")的操作,更改后的文件所有者确实变成了root
我们在/home/seed下编写我们的恶意代码。
1 | |
然后编译并命名成ls:
1 | |
然后再执行我们的myls文件:

发现可以使用Set-UID程序运行我们的恶意代码,而且恶意代码是以root权限运行的。
Task 7:LD_PRELOAD环境变量和Set-UID程序
在这个任务中,我们研究Set-UID程序如何处理某些环境变量,包括LD_PRELOAD、LD_LIBRARY_PATH 和其他LD_*如何影响动态加载器/链接器的行为。动态加载器/链接器是操作系统(OS)的一部分,它加载 (从持久性存储到RAM)并链接可执行文件在运行时所需的共享库。
在Linux 中,ld.so或ld-linux.so是动态加载器/链接器(用于不同类型的二进制文件)。在影响其 行为的环境变量中,本实验关注LD_LIBRARY_PATH和LD_PRELOAD。在Linux中,LD_LIBRARY_PATH是一 组以冒号分隔的目录,应首先在其中搜索库,然后是标准目录集。LD_PRELOAD指定了要在所有其他库之 前加载的附加的用户指定的共享库的列表。在这个任务中,我们将只研究LD_PRELOAD。
第1步 首先,我们将看到这些环境变量在运行普通程序时如何影响动态加载器/链接器的行为。请按照 以下步骤操作:
- 让我们构建一个动态链接库。创建以下程序,并将其命名为mylib.c。它基本上覆盖了libc中的 sleep() 函数:
1 | |
- 编译以上程序用下述指令(注意-lc中的是l)
1 | |
- 设置
LD_PRELOAD环境变量的值
1 | |
- 最后编译下面的程序myprog,和上面的动态链接库libmylib.so.1.0.1在同一目录下:
1 | |
第2步 完成上述操作后,请在以下条件下运行myprog,观察会发生什么。
- 使 myprog 为一个普通程序,以普通用户身份执行它。

发现执行的是我们编写的sleep函数。
- 使 myprog 为一个 Set-UID 特权程序,以普通用户身份执行它。

发现等待了一秒后,没有输出,说明执行的是libc中的sleep()函数。
- 使 myprog 为一个 Set-UID 特权程序,在 root 下重新设置 LD_PRELOAD 环境变量,并执行它。

发现执行的是我们编写的sleep函数。
- 使myprog为一个 Set-UIDuser1程序(user1是程序的所有者,即另一个用户账户),在不同的用户 账户下重新加LD_PRELOAD环境变量,并执行它。


发现等待了一秒后,没有输出,说明执行的是libc中的sleep()函数。
第3步 虽然你运行的是同一个程序,你也应该能够在上述场景中观察到不同的行为。你需要找出导致差 异的原因。是环境变量在这里发挥了作用。请设计一个实验来找出主要原因,并解释为什么第二步中的行 为不同。(提示:子进程可能不会继承LD_*环境变量)
设计一个实验来找出导致这些差异的原因,并解释为什么第二步的行为不同。
设计实验:
本实验旨在探究 Linux 系统中 Set-UID 程序执行时环境变量(尤其是 LD_PRELOAD)是否继承父进程,及其与进程 uid/euid 的关系。
改进myprog.c为myprog2.c(打印这个程序运行时的进程的uid、euid以及LD_PRELOAD环境变量的值)
1 | |
自动化运行上面四个场景,这里直接写一个shell脚本myprog.sh
1 | |
运行结果如下:

运行结果分析总结
场景1:普通程序,seed用户
- uid/euid:都是 seed
- LD_PRELOAD:被子进程完整继承并生效
- sleep函数:被你的自定义库劫持(输出 “I am not sleeping!”)
场景2:Set-UID root程序,seed用户
- uid:seed
- euid:root(权限提升)
- LD_PRELOAD:在程序运行时被清除(显示为
(null)) - sleep函数:没有被劫持,执行的是 libc 的原版 sleep
场景3:Set-UID root程序,root用户
- uid/euid:都是 root
- LD_PRELOAD:继承并生效
- sleep函数:被你的自定义库劫持(输出 “I am not sleeping!”)
场景4:Set-UID user1程序,seed用户
- uid:seed
- euid:user1(权限提升)
- LD_PRELOAD:被清除(显示为
(null)) - sleep函数:没有被劫持,执行的是 libc 的原版 sleep
| 程序类型 | 执行用户 | uid | euid | LD_PRELOAD环境变量 | 执行的sleep函数 |
|---|---|---|---|---|---|
| 普通程序 | seed | seed | seed | 继承父进程 | 我们编写的 |
| Set-UID程序 | seed | seed | root | 没有继承父进程 | libc的 |
| Set-UID程序 | root | root | root | 继承父进程 | 我们编写的 |
| Set-UID user1程序 | seed | seed | user1 | 没有继承父进程 | libc的 |
结论:
通过以上脚本自动化测试,充分验证了 Set-UID 程序下环境变量 LD_PRELOAD 的继承情况。
当程序的 uid 与 euid 不一致时(即发生权限提升),操作系统会自动清除 LD_PRELOAD 等危险环境变量,防止通过动态库劫持进行提权攻击。只有当 uid 和 euid 一致时,环境变量才会被完整继承,LD_PRELOAD 才有效,sleep 函数才能被劫持。
也就是:
只有当 uid 和 euid 一致时(无权限提升),环境变量 LD_PRELOAD 才会被继承和生效。
当程序以 Set-UID 方式运行并发生权限提升(uid ≠ euid),系统会自动清除 LD_PRELOAD(和其它危险变量),防止动态库劫持造成提权漏洞。
root 用户运行 Set-UID 程序时,因没有权限提升,LD_PRELOAD 依然有效,sleep 可被劫持。
Task 8:使用 system() 与 execve() 调用外部程序的对比
尽管 system()和 execve()都可以被用于执行新的程序,但是 system()在高特权态下更加危险, 比如 Set-UID程序。在前面的任务里,我们看到了 PATH环境变量是如何影响 system()的行为的,因为 该变量会影响shell的工作。execve()则没有这个问题,因为它不调用shell。除了环境变量,调用shell 还有另外危险的结果。
Bob 为一家审计机构工作,他需要调查一家公司是否有涉嫌欺诈。为了调查的目的,Bob需要能够读 取该公司Unix系统中的所有文件;另一方面,为了保护系统的完整性,Bob不能修改任何文件。为了实现 这一目标,系统超级用户Vince写了一个特殊的设置Set-UIDroot程序(见下文),然后授权了Bob该程序 的执行权限。该程序需要Bob在命令行中键入文件名,然后运行/bin/cat显示了指定的文件。由于程序在root权限下运行,它可以显示Bob指定的任何文件。然而,由于程序没有写操作,所以Vince非常确定 Bob不能使用这个特殊的程序来修改任何文件。
1 | |
第一步编译上述程序,使其成为root所有的Set-UID程序。该程序将使用system()来调用该命令。如果你是Bob,你能损害系统的完整性吗?例如,你可以删除对你没有写权限的文件吗?
使其成为root所有的 Set-UID 程序,然后创建一个seed没有写权限的文件;发现catcall有命令注入漏洞,可以调用system()执行其他系统命令; 使用命令catcall "test.txt;rm test.txt"成功将没有写权限的test.txt删除。

第二步注释掉system(command)语句,取消注释execve()语句;程序将使用execve()来调用命令。 编译程序,并使其成为root拥有的Set-UID程序。你在第一步中的攻击仍然有效吗?请描述并解释你的 观察结果。

创建一个seed没有写权限的文件,再使用命令catcall "test.txt;rm test.txt"

发现无法删除test.txt,攻击失效。
system() 的行为与风险
system(command)实际上会启动一个新的 shell(如/bin/sh或/bin/bash),让 shell 来解释和执行你的命令字符串。shell 会对命令字符串做解析,比如支持分号、管道、重定向等。- 当你执行:
1 | |
system(command) 最终执行的内容是:
/bin/cat test.txt;rm test.txtexecve("/bin/cat", {"/bin/cat", "test.txt;rm test.txt", NULL}, NULL);1
2
3
4
5
6
7
8
9
10
shell 会把分号当作**命令分隔符**,先执行 `/bin/cat test.txt`,再执行 `rm test.txt`。
- **因为这个程序是 Set-UID root(以 root 权限运行),所有这些命令都拥有 root 权限**。即使你没有删除权限,`rm` 也能删除只读文件。这就是所谓的**命令注入漏洞**:攻击者可以通过构造特殊的参数,执行原本不允许的命令,危害系统安全。
**execve() 的行为与安全性**
- `execve()` 是直接调用操作系统内核,启动一个新的程序(如 `/bin/cat`),并把参数数组传给它。没有 shell 参与——**不会做任何解析或解释**,只会把参数原样传递。
- 例如:
1 | |
文件描述符(File Descriptor,简称 fd)是操作系统中用于管理和操作文件或其他输入/输出资源(如网络连接、管道等)的一个重要概念。当打开一个文件时,操作系统会返回一个文件描述符,后续的读写操作都通过这个描述符进行。
我们在/etc下创建文件zzz,并运行cap_leak
此时输出了zzz文件的文件描述符fd(File Descriptor),并且执行了setuid(getuid())操作,将进程的uid改为了当前用户的,也就是将uid设为seed,然后调用execve()函数执行了bin/sh开启了一个shell。
虽然这个进程的有效用户ID是 seed ,但是该进程仍然拥有特权,我们可以以普通用户的身份将恶意代码写入/etc/zzz文件中,这个过程需要利用文件描述符fd。

可以发现成功写入了文件。
原理:
虽然程序用 setuid(getuid()) 把自己的权限降到了普通用户(seed),但在降权之前已经用 root 权限打开了 /etc/zzz 这个文件,并拿到了一个“文件钥匙”(文件描述符)。
后面程序通过 execve() 启动了一个新的 shell,这个“文件钥匙”也被带进了新的 shell。
结果就是,虽然新 shell 已经不是 root 权限,但它还能用这把“文件钥匙”往原本只有 root 能写的 /etc/zzz 文件里写东西,相当于钻了系统权限的空子,实现了“权限泄露”。