不安全归档解压缩:实验室与Semgrep规则
16 Dec 2024 - 发布者:Michael Pastor
引言
在我最近于Doyensec的实习期间,我有机会研究不同编程语言中的解压缩攻击。由于归档文件格式在软件开发中广泛使用,开发人员理解处理这些文件所涉及的潜在安全风险至关重要。
我的研究目标是识别、分析和检测用于Web和应用程序开发的几种流行编程语言中的易受攻击实现,包括Python、Ruby、Swift、Java、PHP和JavaScript。这些语言具有用于归档解压缩的库,如果使用不当,可能会导致漏洞。
为了演示不安全解压缩的风险,我为每种方法和每种语言创建了具有不同易受攻击实现的概念验证(PoC)代码。我的工作还侧重于为每个易受攻击的实现提供安全替代方案。此外,我创建了一个Web应用程序,用于上传和测试特定实现中使用的代码是否安全。
为了有效搜索大型代码库中的漏洞,我使用了一个流行的SAST(静态应用程序安全测试)工具——Semgrep。具体来说,我编写了一组规则来自动检测那些易受攻击的实现,这将使识别漏洞变得更加容易。
所有编程语言的安全和不安全代码、实验室和Semgrep规则已发布在https://github.com/doyensec/Unsafe-Unpacking。
理解归档路径遍历
提取归档(例如ZIP文件)通常涉及读取其所有内容并将其写入指定的提取路径。归档路径遍历旨在将文件提取到预期提取路径之外的目录。
当归档提取处理不当时,可能会发生这种情况,因为归档可能包含引用父目录的文件名(例如使用../)。如果未正确检查,这些序列可能导致提取发生在预期目录之外。
例如,考虑具有以下结构的ZIP文件:
1
2
3
4
|
/malicious
/foo.txt
/foo.py
/../imbad.txt
|
当将归档解压缩到/home/output时,如果提取方法未验证或清理文件路径,内容可能会写入以下位置:
1
2
3
|
/home/output/foo.txt
/home/output/foo.py
/home/imbad.txt
|
因此,imbad.txt将被写入预期目录之外。如果易受攻击的程序以高权限运行,这可能允许攻击者覆盖敏感文件,例如/etc/passwd——基于Unix的系统存储用户帐户信息的位置。
证明概念:代码示例
为了演示该漏洞,我在各种编程语言中创建了几个概念验证示例。这些代码片段展示了归档提取处理不当的易受攻击实现。
Python
ZipFile库作为读取器和shutil.copyfileobj()作为写入器的组合使程序员负责正确处理提取。
shutil.copyfileobj()的使用很简单:作为第一个参数,我们传递要提取内容的文件的文件描述符,作为第二个参数,我们传递目标文件的文件描述符。由于该方法接收文件描述符而不是路径,它不知道路径是否在输出目录之外,使得以下实现易受攻击。
1
2
3
4
5
6
7
8
9
10
11
|
def unzip(file_name, output):
# bad
with zipfile.ZipFile(file_name, 'r') as zf:
for filename in zf.namelist():
# Output
output_path = os.path.join(output, filename)
with zf.open(filename) as source:
with open(output_path, 'wb') as destination:
shutil.copyfileobj(source, destination)
unzip1(./payloads/payload.zip", "./test_case")
|
如果我们运行前面的代码,我们会意识到ZIP内容(poc.txt)不会被提取到test_case文件夹,而是被提取到父文件夹:
1
2
3
4
5
6
7
|
$ python3 zipfile_shutil.py
$ ls test_case
# No output, empty folder
$ ls
payloads poc.txt test_case zipfile_shutil.py
|
Ruby
1
|
Zip::File.open(file_name).extract(entry, file_path)
|
Ruby的zip库中的extract()方法用于将条目从归档提取到file_path目录。此方法不安全,因为它不会删除冗余点和路径分隔符。调用者有责任确保file_path安全:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
require 'zip'
def unzip1(file_name, file_path)
# bad
Zip::File.open(file_name) do |zip_file|
zip_file.each do |entry|
extraction_path = File.join(file_path, entry.name)
FileUtils.mkdir_p(File.dirname(extraction_path))
zip_file.extract(entry, extraction_path)
end
end
end
unzip1('./payloads/payload.zip', './test_case/')
$ ruby zip_unsafe.rb
$ ls test_case
# No output, empty folder
$ ls
payloads poc.txt test_case zip_unsafe.rb
|
PHP、Swift、JS和Java
所有其他案例都在Doyensec的存储库中记录,以及Semgrep规则和实验室。
不安全解压缩实验室
作为研究的一部分,我开发了一些Web应用程序,允许用户测试特定的归档提取实现是否容易受到解压缩攻击。
- RUN:不上传归档时,应用程序将提取一个预构建的恶意归档。如果用户上传归档,则该归档将被解压缩。
- Clear TXT Files:应用程序将删除先前归档提取的所有文件。
- Fetch Directory Contents:Web应用程序将显示归档目录(文件应该被提取的位置)和当前目录(文件不应该被提取的位置)。
这些Web应用程序实验室适用于除Swift之外的所有语言,Swift提供了一个桌面应用程序。
开发用于漏洞检测的Semgrep规则
检测开源项目中漏洞的最有效方法之一是使用静态应用程序分析工具。Semgrep是一个快速、开源的静态分析工具,用于搜索代码、发现错误并强制执行安全防护和编码标准。
Semgrep通过扫描源代码中的特定语法模式来工作。由于它支持各种编程语言并且编写自定义规则很简单,因此它非常适合我的研究目的。
在以下示例中,我使用GitHub存储库中的Unsafe-Unpacking/Python/PoC/src文件夹,其中包含5个解压缩漏洞。您可以使用以下命令运行Semgrep规则:
1
|
semgrep scan --config=../../rules/zip_shutil_python.yaml
|
…
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
âââââââââââââââââââ
â 5 Code Findings â
âââââââââââââââââââ
zipfile_shutil.py
â¯â¯â± rules.unsafe_unpacking
Unsafe Zip Unpacking
13â shutil.copyfileobj(source, destination)
â®â----------------------------------------
21â shutil.copyfileobj(source, destination)
â®â----------------------------------------
31â shutil.copyfileobj(source, destination)
â®â----------------------------------------
41â shutil.copyfileobj(source, destination)
â®â----------------------------------------
57â shutil.copyfileobj(source_file, target_file)
|
GitHub存储库中可以找到一组15条规则。
缓解措施
由于在大多数易受攻击的实现中,程序员负责清理或验证输出路径,他们可以采取两种方法来缓解问题。
1. 路径清理
为了清理路径,文件名应该被规范化。在Ruby的情况下,可以使用Path.basename方法,该方法删除冗余点并将类似../../../../bad.txt的路径转换为bad.txt。
在以下代码中,当使用File.join计算输出路径时,调用File.basename来清理归档中的条目文件名,从而缓解漏洞:
1
2
3
4
5
6
7
8
9
10
11
|
def safe_unzip(file_name, output)
# good
Zip::File.open(file_name) do |zip_file|
zip_file.each do |entry|
# sanitize the entry path
file_path = File.join(output, File.basename(entry.name))
FileUtils.mkdir_p(File.dirname(file_path))
zip_file.extract(entry, file_path)
end
end
end
|
这种缓解措施的副作用是归档的文件夹结构被扁平化,所有文件都被提取到单个文件夹中。因此,该解决方案可能不适用于许多应用程序。
另一种解决方案是使用Pathname.new().cleanpath,pathname(一个内置的Ruby类)。它可以规范化路径并删除任何../序列:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
require 'pathname'
def safe_unzip(file_name, output)
output += File::SEPARATOR unless output.end_with?(File::SEPARATOR)
Zip::File.open(file_name) do |zip_file|
zip_file.each do |entry|
# Remove any relative path components like "../"
sanitized_name = Pathname.new(entry.name).cleanpath.to_s
sanitized_path = File.join(output, sanitized_name)
FileUtils.mkdir_p(File.dirname(sanitized_path))
zip_file.extract(entry, sanitized_path)
end
end
end
|
但是,如果开发人员希望通过使用任何类型的替换来自己清理路径以删除../,他们应确保重复应用清理,直到没有../序列为止。否则,可能会出现以下情况,导致绕过:
1
2
|
entry = "..././bad.txt"
sanitized_name = entry.gsub(/(\.\.\/)/, '') # ../bad.txt
|
2. 路径验证
在将条目的内容写入目标路径之前,您应确保写入路径在预期的目标目录内。这可以通过使用start_with?来检查写入路径是否以目标路径开头,从而防止目录遍历攻击。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
def safe_unzip(file_name, output)
output += File::SEPARATOR unless output.end_with?(File::SEPARATOR)
# good
Zip::File.open(file_name) do |zip_file|
zip_file.each do |entry|
safe_path = File.expand_path(entry.name, output)
unless safe_path.start_with?(File.expand_path(output))
raise "Attempted Path Traversal Detected: #{entry.name}"
end
FileUtils.mkdir_p(File.dirname(safe_path))
zip_file.extract(entry, safe_path)
end
end
end
|
需要注意的是,应使用File.expand_path而不是File.join。使用File.expand_path()至关重要,因为它将相对文件路径转换为绝对文件路径,确保正确验证并防止路径遍历攻击。
例如,考虑以下使用File.expand_path的安全方法:
1
2
3
4
5
6
7
8
9
|
# output = Ruby/PoC/test_case
# path = Ruby/PoC/secret.txt
path = File.expand_path(entry_var, output)
# Check for path traversal
unless path.start_with?(File.expand_path(output))
raise "Attempted Path Traversal Detected: #{entry_var}"
end
|
在这种情况下,File.expand_path将路径转换为绝对路径,并且使用start_with的检查正确验证提取的文件路径是否在预期的输出目录内。
另一方面,如果您使用File.join构建输出路径,可能会导致漏洞:
1
2
3
4
5
6
7
8
9
|
# output = Ruby/PoC/test_case
# path = Ruby/PoC/test_case/../secret.txt
path = File.join(output, entry_var)
# Incorrect check
unless path.start_with?(File.expand_path(output))
raise "Attempted Path Traversal Detected: #{entry_var}"
end
|
即使路径实际上导致到预期目录之外(test_case/../secret.txt),检查也会错误地返回true,允许攻击者绕过验证并执行路径遍历。要点是始终在验证之前规范化路径。
我忽略的一个细节,我的导师(Savio Sisco)指出,在原始的安全方法中,我没有包含以下行:
1
|
output += File::SEPARATOR unless output.end_with?(File::SEPARATOR)
|
没有这一行,仍然可以绕过start_with检查。尽管在这种情况下路径遍历是不可能的,但它仍然可能导致写入预期目录之外:
1
2
3
4
|
output = "/home/user/output"
entry.name = "../output_bypass/bad.txt"
safe_path = File.expand_path(entry.name, output) # /home/user/output_bypass/bad.txt
safe_path.start_with?(File.expand_path(output))# true
|
结论
这项研究深入探讨了各种编程语言中不安全归档提取的问题。文章展示了给开发人员更多自由的同时也将责任放在他们身上。虽然手动实现很重要,但它们也可能引入严重的安全风险。
此外,作为安全研究人员,理解漏洞的根本原因很重要。通过开发Semgrep规则和实验室,我们希望它能帮助其他人识别、测试和缓解这些漏洞。所有这些资源都在Doyensec存储库中可用。
解压缩攻击是一个广泛的研究领域。虽然本博客涵盖了一些与文件提取相关的案例,但仍然有许多其他攻击,如zip炸弹和符号链接攻击,需要考虑。
关于我实习的一些思考
虽然这篇博客文章不是关于实习的,但我想借此机会讨论我的经历。
两年前,在我准备OSWE期间,我偶然发现了一篇Doyensec博客文章,并将它们用作学习资源。几个月后,我发现他们正在招聘实习生,我认为这是一个不可思议的机会。
我第一次申请时,收到了我的第一个技术挑战——一组易受攻击的代码,如果你喜欢阅读代码,这会很有趣。然而,那年我未能通过挑战。今年,经过与Luca和John的两轮面试,我终于被接受了。面试是360度的,涵盖了各个方面,如如何修复漏洞、计算机如何工作、如何使安全代码片段易受攻击以及如何进行威胁建模。
在最初的几周里,我在其他安全工程师的大量指导下被分配到一些项目。我有机会与他们谈论他们在Doyensec的工作,甚至与一位前实习生谈论他的实习经历。我学到了很多关于公司方法论的知识,不仅在错误搜寻方面,而且在如何更有组织性方面——无论是工作还是生活。就像许多CTF玩家一样,我习惯于工作到深夜,但由于我不是独自在这些项目上工作,这个习惯开始干扰沟通。最初,在太阳还亮着的时候打开Burp感觉很奇怪,但随着时间的推移,我习惯了。直到我完全适应,我才意识到这个简单的改变能如何提高我的生产力。
处理大型代码库或复杂审计的项目真的推动我继续搜索错误,即使看起来像死胡同。有时,在几天没有发现任何有趣的东西后,我会变得非常紧张。然而,Savio在这些时刻提供了很大帮助,建议我保持冷静并坚持清晰的方法论,而不是让我的紧张驱使我无思考地搜寻。最终,我能够在那些项目上找到一些很酷的错误。
尽管我有非常高的期望,但这次经历绝对达到了它们。非常感谢团队,尤其是Luca和Savio,他们在整个过程中非常照顾我。