21年挖的对象存储漏洞到现在结束了吗?
在翻阅参考云对象存储的文档时候,了解到response-*参数可以修改对象返回header头的特性,于是尝试增加参数response-content-type修改返回类型为text/html,发现一个影响几乎主流云厂商对象存储的漏洞(可利用对象存储造成xss)。
一、前言
2021年的时候收到过很多云对象存储自身以及用户使用配置不当的漏洞报告,于是对云对象存储风险进行全面梳理,在翻阅参考云对象存储的文档时候,了解到response-*参数可以修改对象返回header头的特性,于是尝试增加参数response-content-type修改返回类型为text/html,发现一个影响几乎主流云厂商对象存储的漏洞(可利用对象存储造成xss)。
![]()
不过此漏洞并未在2021年结束,一直持续到2025年,中间有绕过有修复,这里总结一下这个漏洞的历程。
二、演变过程
2.1、匿名修改response-*
最开始的时候是匿名访问的(可在url中直接增加response-*参数),不过时间久远没有找到截图,我们现在可以从腾讯云的文档可以发现这个不需要签名,既可通过response-*参数可以修改对象返回的header例子。
这里腾讯云文档给出的案例是修改response-content-type为application/octet-stream。
[对象存储 GET Object-API 文档-文档中心-腾讯云](https://cloud.tencent.com/document/product/436/7753)
GET /exampleobject?response-content-type=application%2Foctet-stream&response-cache-control=max-age%3D86400&response-content-disposition=attachment%3B%20filename%3Dexample.jpg HTTP/1.1
Host: examplebucket-1250000000.cos.ap-beijing.myqcloud.com
Date: Fri, 10 Apr 2020 09:35:17 GMT
Authorization: q-sign-algorithm=sha1&q-ak=************************************&q-sign-time=1586511317;1586518517&q-key-time=1586511317;1586518517&q-header-list=date;host&q-url-param-list=response-cache-control;response-content-disposition;response-content-type&q-signature=****************************************
Connection: close![]()
修复措施也很简单,禁止匿名的方式(未签名)GET请求对象,如果是未签名增加response-*头就报错400。![]()
是不是这样就彻底修复了这个漏洞呢?
2.2、通过临时密钥签名response-*
上面说到的修复措施是禁止匿名的访问请求,那如果不是匿名的方式呢?是不是继续可以利用了?给用户一个密钥的场景?
还真有这个场景,比如业务上有一个头像上传的功能,需要上传头像到oss桶,但是业务让用户在客户端直接上传图片到oss桶上,而非上传到业务的服务端,再通过服务端上传到oss中(避免浪费业务的流量)。
云对象存储提出一个临时密钥的解决方案,服务端生成临时密钥(临时密钥限制了上传的路径以及content-type等限制),再交给客户端上传。
假如我们通过临时秘钥进行前端上传,上传了一个txt文件
![]()
这里txt的文件后缀是txt,文件的的Content-Type是text/plain
![]()
这样看上去是没问题的,但是在读取文件的时候会有问题,我们在读取云对象的时候设置Content-Type为html,然后通过绑定的CDN域名访问,最终是变成了html解析且造成xss窃取cookie。
![]()
生成特定的带有?response-content-type=text/html签名的url(url中有签名)。
![]()
也是可以修改:response-content-disposition参数
![]()
如何进行修复呢?答案是使用签名,只能根据policy进行上传,而非临时密钥可以对url进行签名读取。代码如下:
templates/test.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>上传文件到OSS</title>
</head>
<body>
<div class="container">
<form>
<div class="mb-3">
<label for="file" class="form-label">选择文件</label>
<input type="file" class="form-control" id="file" name="file" required>
</div>
<button type="submit" class="btn btn-primary">上传</button>
</form>
</div>
<script type="text/javascript">
const form = document.querySelector('form');
const fileInput = document.querySelector('#file');
form.addEventListener('submit', (event) => {
event.preventDefault();
let file = fileInput.files[0];
let filename = fileInput.files[0].name;
fetch('http://127.0.0.1:5000/token', { method: 'GET' })
.then(response => response.json())
.then(data => {
const formData = new FormData();
formData.append('name',filename);
formData.append('policy', data.policy);
formData.append('OSSAccessKeyId', data.ossAccessKeyId);
formData.append('success_action_status', '200');
formData.append('signature', data.signature);
formData.append('key', data.dir + filename);
// file必须为最后一个表单域,除file以外的其他表单域无顺序要求。
formData.append('file', file);
fetch(data.host, { method: 'POST', body: formData},).then((res) => {
// console.log(res);
alert('文件已上传');
});
})
.catch(error => {
console.log('Error occurred while getting OSS upload parameters:', error);
});
});
</script>
</body>
</html>
main.py
import json
import base64
import hmac
import datetime
import time
from hashlib import sha1
from flask import request, Flask, render_template
import requests
from requests_toolbelt import MultipartEncoder
# 配置环境变量
access_key_id = ""
access_key_secret = ""
bucket_name = ''
endpoint = 'oss-cn-shanghai.aliyuncs.com' # 替换为你的OSS Endpoint
upload_dir = 'test'
expire_time = 3600 # 有效期,单位为秒
def generate_expiration(seconds):
now = int(time.time())
expiration_time = now + seconds
gmt = datetime.datetime.utcfromtimestamp(expiration_time).isoformat()
gmt += 'Z'
return gmt
def generate_signature(access_key_secret, expiration, conditions):
policy_dict = {
'expiration': expiration,
'conditions': conditions
}
policy = json.dumps(policy_dict).strip()
policy_encode = base64.b64encode(policy.encode())
h = hmac.new(access_key_secret.encode(), policy_encode, sha1)
signature = base64.b64encode(h.digest()).strip()
return signature.decode()
def generate_upload_params():
policy = {
# 有效期。
"expiration": generate_expiration(expire_time),
# 约束条件。
"conditions": [
# 未指定success_action_redirect时,上传成功后的返回状态码,默认为 204。
["eq", "$success_action_status", "200"],
# 表单域的值必须以指定前缀开始。例如指定key的值以user/user1开始,则可以写为["starts-with", "$key", "user/user1"]。
["starts-with", "$key", upload_dir],
# 限制上传Object的最小和最大允许大小,单位为字节。
["content-length-range", 1, 1000000],
# 限制上传的文件为指定的图片类型
["in", "$content-type", ["image/jpg", "image/png", "image/jpeg"]]
]
}
signature = generate_signature(access_key_secret, policy['expiration'], policy['conditions'])
response = {
'policy': base64.b64encode(json.dumps(policy).encode('utf-8')).decode(),
'ossAccessKeyId': access_key_id,
'signature': signature,
'host': f'https://{bucket_name}.{endpoint}',
'url': f'https://{bucket_name}.{endpoint}',
'dir': upload_dir
}
return response
def upload_file_to_oss(upload_params):
url = f"https://{bucket_name}.{endpoint}"
headers = {
"Content-Type": "image/png"
}
name = "1.png"
m = MultipartEncoder(
fields={
"name": name,
"policy": upload_params["policy"],
"OSSAccessKeyId": upload_params["ossAccessKeyId"],
"success_action_status": "200",
"signature": upload_params["signature"],
"key": upload_params["dir"] + "/" + name,
'file': (name, "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", 'image/png'),
}
)
headers['Content-Type'] = m.content_type
# proxies = {"https": "http://127.0.0.1:4321"}
proxies = {}
response = requests.post(url, headers=headers, data=m.to_string(), proxies=proxies)
if response.status_code == 200:
print("File uploaded successfully!")
else:
print("Failed to upload file:", response.text)
app = Flask(__name__)
@app.route('/')
def index():
name = "test"
return render_template('test.html', name=name)
@app.route('/token', methods=['GET'])
def echo_data():
rsp = generate_upload_params()
return json.dumps(rsp)
if __name__ == '__main__':
# app.run(debug=True)
# # 生成上传参数
upload_params = generate_upload_params()
# print(upload_params)
upload_file_to_oss(upload_params)
[如何进行服务端签名直传\_对象存储(OSS)-阿里云帮助中心](https://help.aliyun.com/zh/oss/use-cases/obtain-signature-information-from-the-server-and-upload-data-to-oss?spm=a2c4g.11186623.help-menu-31815.d_6_1_0_0.4b254cf5ZEJN7y#ae933a0194svk)
这样解决了客户端的需求了,也保证了业务的安全,是不是再出问题了?
2.3、第三方服务CDN签名response-*
有时候oss这边没问题了,但是与其他产品使用,还是会出现问题。
比如在cdn上支持回源oss,并且支持私有对象的读取,这样又可以通过临时密钥给url签名了。
![]()
![]()
这里的修复方案是:禁止GET请求
![]()
但是并没有禁用response-*,而是仅仅禁用response-content-type,下面两个参数都可以被拿来恶意利用。
?response-content-type=text/html
?response-content-disposition=attachment; filename=test.exe
这里演示将一个pdf文件转成exe文件下载(response-content-type目前修复了,我没有截图),通过cdn特性,不需要我们自己签名,即可完成对response-content-type参数修改。
![]()
咨询阿里云过策略:response-content-type参数GET请求禁止,并未对所有的bucket禁用,之前使用过这个参数的bucket,还支持这个参数,对于之前没有使用过这个参数的目前不再支持使用这个参数GET请求使用。
所以还受这个问题影响的,可以咨询阿里云,让他们人工去掉白名单即可。
三、修复建议
这里给出一些云对象存储的注意:
1、上传的时候通过policy限制content-type,避免攻击者上传html、svg等文件造成xss。(注意:即使上传的时候指定了content-type也可以通过response-content-type修改返回类型)
2、控制好桶的读写权限以及列目录权限。
3、通过非业务域(非敏感域)使用cdn服务,通过不同域限制危害程度。
4、最后要参考云厂商给出的文档,避免编码时候造成参数拼接、挂载云对象存储使用sys_admin等等问题。
四、总结
文章中总结了云对象存储response-*参数攻与防的三次拉扯,是不是最终到这里结束了呢?
目前有一些业务有在使用response-*特性,云厂商也未完全干掉response-*特性,后面说不定云对象存储又出了新的特性或有其他产品特性搭配会有重复上演此类问题。
response-*漏洞:留着青山在,不怕没柴烧,云厂商!你给我等着