原文作者:Artem Konev of F5
原文链接:NGINX Unit 中的文件系统隔离 - NGINX
转载来源:NGINX 官方网站
2019 年 11 月(似乎已是半个世纪前),我们宣布将命名空间隔离添加到 NGINX Unit 中。在本文中,我们将探讨该隔离机制最近新添的另一个选项,即对象的rootfs隔离选项。
正如在宣布推出 NGINX Unit 1.18.0 的博文中所提到的,rootfs选项支持您将任意目录指定为应用的系统文件根。因此,您可将应用作为轻量级的按需式容器配置和运行,从而提高其安全性,将其与底层操作系统相隔离,并提高基础架构的细粒度。
但是,这并不意味着可以随意而为之。
注意事项和技术
或许最值得一提的是,rootfs特性仅在支持绑定挂载或nullfs文件系统的 Linux 和 Unix 系统上可用。
此外,如果隔离对象定义了新的挂载命名空间,为提高安全性,NGINX Unit 会使用pivot_root系统调用而非chroot。因此,使用rootfs的另一个技术要求是需要 NGINX Unit 的主进程拥有系统管理员权限,特别是CAP_SYS_ADMIN,以便进行所有必要的系统调用。
实际上,这两个注意事项意味着 主进程需要作为root运行。虽然很有可能您已满足这项要求,但在这里有必要提出:在某些安装中,NGINX Unit 的主进程可作为其他系统用户运行。作为root运行这一要求并不会给所运行的应用带来额外风险,因为主进程不处理客户端连接或运行应用代码, 所有这些都由使用非特权证书运行的单独进程处理。
下面就让我们了解下rootfs可派上用场的一些场景。
保护应用
虽然显而易见,但还是有必要说一下,即整个隔离对象的主要目标便是使应用在物理上无法跨越为其所设置的限制。首次引入时,隔离对象无法限制文件系统访问,但是rootfs选项弥补了这一空白。
假设我们有一个 Python 应用遭到入侵,现在可以注入任意代码(通过静态文件上传或其他方式)。首先,让我们从攻击者的角度看下有哪些可乘之机:
from flask import Flask, request, Response
import os, stat, subprocess
application = Flask(__name__)
@application.route("/find/")
def find():
l = []
path = request.args.get("path")
for root , _, files in os.walk(path):
for f in files:
try:
absp = os.path.join(root, f)
if os.stat(absp).st_mode & stat.S_ISUID:
l.append(absp)
except:
pass
return Response("\n".join(l), mimetype="text/plain")
此代码会扫描系统的setuid可执行文件,这些可执行文件可被进一步利用,尽管这通常是被禁止的。如果可以诱骗任何此类可执行文件提供对敏感数据的访问权限,则系统将受到严重破坏。然而,此类配置错误经常发生,现在就让我们从基本设置的角度看下典型的配置错误是如何发生的:
{
"listeners": {
"*:80": {
"pass": "applications/rootfs_demo"
}
},
"applications": {
"rootfs_demo": {
"type": "python",
"path": "/path/to/rootfs_demo/",
"home": "/path/to/rootfs_demo/venv/",
"module": "wsgi"
}
}
}
这样配置后,受感染的应用将生成以下结果:
$ curl http://localhost/find/?path=/usr/bin
/usr/bin/passwd
/usr/bin/fusermount
/usr/bin/sudo
/usr/bin/chfn
/usr/bin/umount
/usr/bin/pkexec
/usr/bin/sg
/usr/bin/cp
/usr/bin/atrm
...
如您所见,我们的初始扫描收获颇丰:我们通过精心配置的**/usr/bin/cp**可执行文件成功获得了攻击向量。接下来,我们进入下一步,即假设使用先前用过的注入方法,提取一些有价值的数据:
@application.route("/exfiltrate/")
def exfiltrate():
file = request.args.get("file")
subprocess.run(args = ["/usr/bin/cp", "--no-preserve=mode", file, "./out"])
return Response(open("./out").read(), mimetype="text/plain")
通过运行此代码,我们可以窥视一些敏感信息:
$ curl http://localhost/exfiltrate/?file=/etc/shadow
root:$6$QF7EX8XQ4BnLFVo/$f3hqo1vdWqK77kEuY4NOKsvgP1.XBtcO4fOND78IV/jP1i6/PtG/RHWZAqL3PQ3AVvwXwgBUbmAeOVtYDSg2o/:18471:0:99999:7:::
...
现在,让我们将rootfs添加到配置中以避免再次被攻击。下面是我们的新配置:
{
"listeners": {
"*:80": {
"pass": "applications/rootfs_demo"
}
},
"applications": {
"rootfs_demo": {
"type": "python",
"path": "/",
"home": "/venv/",
"module": "wsgi",
"isolation": {
"rootfs": "/path/to/rootfs_demo/"
}
}
}
}
受感染的应用现在如何处理同一攻击的两个阶段?让我们来看看:
$ curl http://localhost/find/?path=/usr/bin
遍历不会生成任何结果,因为系统目录未映射到新的文件系统根目录。
$ curl http://localhost/exfiltrate/?file=/etc/shadow
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">
<title>500 Internal Server Error</title>
<h1>Internal Server Error</h1>
<p>The server encountered an internal error and was unable to complete your request. Either the server is overloaded or there is an error in the application.</p>
“嘿,没那么快!”按照预期,我们现在无法访问敏感文件:rootfs选项禁止该应用访问外界。我们也无法访问备受觊觎的**/etc/shadow**或随意遍历系统目录。该应用已被限制在其沙盒目录之内。
但是,如果攻击者知道该应用会被沙盒化并有应对之策,该怎么办?长话短说,攻击者可通过不少臭名昭著的方法逃脱chroot环境,并且经过精心筹备后可将此环境用于攻击。但是,NGINX Unit 通过使用pivot_root系统调用而非chroot的方法解决了这一问题。您只需启用mount命名空间以及rootfs选项:
"isolation": {
"rootfs": "/path/to/rootfs_demo/",
"namespaces": {
"mount": true
}
}
最后,rootfs的另一项简洁特性是您可以将语言特定的依赖项自动映射到新文件系统。因此,启用rootfs后,我们无需执行任何操作便可使import指令生效。import命令保持不变如下:
from flask import Flask, request, Response
import os, stat, subprocess
请注意,如果rootfs仅更改了文件系统根目录,那么第二个 import 指令将变为无效,标准模块将无处可寻。另外还请注意,我们无需采取任何措施即可使 Flask 虚拟环境成功运行,一切都是自动实现的。不幸的是,这种映射目前仅可用于 NGINX Unit 支持的某些语言,即 Java、Python 和 Ruby。
谈到映射,让我们简要探讨下 可通过rootfs解决的另一个问题。
处理全局依赖项
如上所述,NGINX Unit 启用了完善的内部机制,以确保在rootfs将特定语言的依赖项映射到新文件系统根目录之后,这些依赖项仍可供您的应用使用。但是其他依赖项呢?
通常,当应用依赖于可置于系统上任何位置的自定义库或模块时,working_directory选项和environment对象以及诸如root之类的语言特定选项将支持您在自定义依赖项之间按需切换。
而且,某些语言本身就提供了一个工具集,可通过虚拟环境或其他类型的版本控制来操纵这种依赖性。NGINX Unit 实际上就利用了这点,在本地支持Python 虚拟环境。但是,如果您的应用具有固定的全系统依赖项,并且这些依赖项必须位于绝对的预定义路径中,则情况可能会变得一团糟:并排使用不同版本的依赖项可能会变得很复杂,即便不是完全没有可能。在这种情况下,您可以使用rootfs来实施基本的运行时切换机制。
设想以下 PHP 应用(尽管您也可以使用其他语言执行这一操作):
<?php
require '/var/custom/module.php'; // Our hardcoded dependency
module_do_stuff("How do you like this?\n");
?>
现在,让我们简单了解下依赖项 module.php。假设我们有两个版本(并无花哨之处,只是表明两者之间的不同而已):
<?php
// Version A, stored as /www/data/a/var/custom/module.php
function module_do_stuff($stuff) {
echo "Implementation A, legacy: ".$stuff;
}
?>
<?php
// Version B, stored as /www/data/b/var/custom/module.php
function module_do_stuff($stuff) {
echo "Implementation B, brand new: ".$stuff;
}
?>
现在,我们将应用的两个相同副本放置在**/www/data/a/和/www/data/b/**中,并应用以下配置:
{
"listeners": {
"*:80": {
"pass": "applications/ab_app"
}
},
"applications": {
"ab_app": {
"type": "php",
"user": "www-data",
"script": "index.php",
"root": "/",
"isolation": {
"rootfs": "/www/data/a/"
}
}
}
}
请注意,应用的root选项与rootfs值相关。实际上,当使用rootfs时,这适用于所有基于应用路径的选项。
利用此配置,curl命令将生成以下结果:
$ curl http://localhost
Implementation A, legacy: How do you like this?
接下来,我们要将module.php切换到版本 B。这时我们无需费力重新安装或(更有可能在现实中发生)启动另一个容器,而是只需运行以下命令:
$ curl -X PUT -d '"/www/data/b/"' --unix-socket /var/run/control.unit.sock http://localhost/config/applications/ab_app/isolation/rootfs/
这将更新rootfs设置(注意 URL 中的 config API 路径),而 NGINX Unit 配置的所有其他部分均保持不变。现在,curl查询得到了不同的响应:
$ curl http://localhost
这个示例虽然简单,但清晰地表明了 NGINX Unit 可如何帮助您消除创建和维护多个虚拟机或容器的繁琐工作,从而高效应对全系统依赖项的不同组合。您可以复制和回滚您的应用或语言运行时在预定义路径中的标准库和自定义模块变体,只需更改 NGINX Unit 配置中的单个设置即可。遗憾的是,当存在分层、隐藏和间接依赖项时,此功能可能会表现得有些失常,目前我们正在积极解决这一问题。
结语
在本文中,我们探讨了可从新rootfs特性中获益的几种使用场景。我们相信,这已充分展示了 NGINX Unit 正从简单但强大的 Web 服务器转变成强劲的轻量级容器化引擎。我们不希望给人以言过其实之感。实际上,我们还有许多功能要实现,例如文中提到的语言特定的依赖项映射。
我们还深知,要想提供一套真正通用的隔离功能,我们还需要添加另一项特性,即目录绑定。 为了全面兑现 NGINX Unit 容器化承诺,我们需要支持将任意目录挂载到根及可信文件系统中的任何位置。 即将发布的版本将提供这一支持,敬请期待!
同往常一样,诚邀您查看我们的roadmap,(您可以在这里了解到您最喜欢的特性近期是否会实施),并对我们的内部计划进行评分和评论。欢迎您随时在GitHub仓库中提问,并分享您的改进建议。
NGINX Plus 订户可免费获得 NGINX Unit 支持。立即下载 NGINX Plus30 天免费试用版或与我们联系以讨论您的使用场景。
更多资源
想要更及时全面地获取NGINX相关的技术干货、互动问答、系列课程、活动资源?请前往NGINX开源社区官方网站 。