一个拥有 1000 万以上安装量的政府应用内,一个 2,835 字节的文件中存放着沙特国家银行的实时客户端证书。让任何人注意到它,需要一条病毒式传播的推文。
TL;DR
官方 Nusuk 应用(com.moh.nusukapp,朝觐与副朝部,1000 万+ 安装量,带有 Google Play 的"政府"徽章)附带了一个 PKCS#12 文件,其中包含一个私有 RSA 密钥和由沙特国家银行颁发的客户端证书。该文件的密码被硬编码在应用自身代码中,仅相隔几行。密码只有一个字符:2。
在其旁边,以明文形式存放着该银行 Banking-as-a-Service API 的 OAuth2 客户端 ID 和客户端密钥,请求的作用域包括 identity accounts cards verification kyc cardpay transfers。
任何从 Google Play 下载该应用的人都拥有所有这些信息。
我试图举报。但被告知漏洞门户仅对沙特阿拉伯境内的用户开放。于是我发了推文。该推文获得了 150 万次浏览,突然间,同一个门户网站要求提供详细信息。一天后,这些凭证从应用中消失了。
https://x.com/iam_zachi/status/2094445016194207745
本文仅涵盖银行相关的发现。以下所有内容已在发布的应用中得到修复,供应商方面表示凭证已被轮换。
Nusuk 是什么
Nusuk 是沙特政府官方的朝觐与副朝平台。它处理朝觐许可、电子签证、预订以及 Nusuk 卡。它由朝觐与副朝部运营,在 Google Play 上被标记为经过验证的政府应用,拥有超过一千万次安装。它还包含一个钱包功能,即 Nusuk Wallet,与沙特国家银行共同构建,并获得了沙特中央银行 SAMA 的批准。本文讨论的就是钱包部分。
发现过程
我直接从 Google Play 拉取了 APK 包(版本 17.4.9,版本代码 131215)并解包。没什么特别的:标准的 Kotlin/Compose,没有加壳,没有有意义的混淆。
在应用的资源文件 res/raw/nusuk.pfx 中,存放着一个 2,835 字节的 PKCS#12 容器。.pfx 文件是证书及其私钥的标准捆绑格式,并使用密码加密。
密码就在应用的代码中,距离加载该文件的代码仅几行:
1const-string v3, "2"
一个字符,作为字面量存在于反编译的字节码中。执行一次 openssl 命令后:
1RSA 私钥,2048 位,2 个质因数2主题: C=SA, ST=Jeddah, L=Jeddah, O=Nusuk.sa, CN=eshbeata@staq.io3颁发者: C=SA, ST=Riyadh, L=Riyadh, O=The Saudi National Bank, OU=Finto,4 CN=Application Issuer5序列号: 0x24 (36)6有效期: 2026-04-27 -> 2027-04-277扩展密钥用法(关键): TLS Web 客户端身份验证
一个由银行颁发的实时客户端证书,有效期还有一年,用于客户端向服务器进行 TLS 身份验证。
它并非孤立存在。相同的代码路径属于钱包组件,打包为 com.walletstaq,并连接到 Staq Technologies 的 Trustless SDK(该公司运营 SNB 的 Finto BaaS 平台)。还有三个值以明文形式存在:
- CLIENT_ID = 379cc899…
- CLIENT_SECRET = ELrfNe9w…(64 个原始字节,base64 编码)
- SERVER_URL = https://api.baas.alahli.com/api/
以及请求的 OAuth 作用域:
1identity accounts cards verification kyc cardpay transfers
这两个凭证还被连接成一个字符串,连同基础 URL 一起传递给了调试日志记录器。
(我不会发布密钥材料或完整的密钥值。重要的是问题的形态。)
为什么这很糟糕(通俗解释)
把银行的 API 想象成一扇有两把锁的门。
第一把锁是双向 TLS。通常,服务器会用证书向你证明其身份。使用 mTLS 时,你也需要用证书向服务器证明你的身份。这就是那个 .pfx 文件:证书和证明你拥有它的私钥。它本应是只有合法客户端系统才拥有的东西。
第二把锁是 OAuth 客户端密钥,即应用程序用来向银行 API 请求访问令牌的密码。
这两把锁都随 Google Play 上的一个免费应用一起发布,而存放第一把锁的盒子的钥匙就是数字 2。
我确认目标主机确实强制执行 mTLS:与 api.baas.alahli.com 的 TLS 握手请求客户端证书(它发送了 Acceptable client certificate CA names),并且服务器证书显示 CN=*.baas.alahli.com, O=The Saudi National Bank。所以这并非用于测试环境的装饰性证书。它是生产环境银行 API 前门的凭证,其作用域涵盖身份、账户、卡片、KYC、卡支付和转账。
除了泄露问题之外,其底层还存在一个设计问题。一个以完全相同方式分发到一千万台设备的客户端证书,无法区分不同的安装实例。每个副本都呈现相同的凭证,因此该证书只能告诉银行是哪个应用在调用,而无法说明是谁在调用。此类 API 的凭证应放在你自己的后端:应用与你的服务器通信,你的服务器再与银行通信。
我没有做什么
我只对令牌端点(POST /api/tppa/token)执行了一次检查,以确认凭证是否有效。它返回了 nginx 的 HTTP 403 错误。不提供任何证书的请求也返回了同样的错误,裸根 URL 也是如此。这是 API 前端的网络级封锁,很可能是基于地理位置的,并且这并不能说明凭证是否有效。
从沙特阿拉伯境外,我无法确定这些凭证是否处于活动状态。我到此为止。任何进一步的操作都将是试图绕过银行的访问控制,而且这个发现并不依赖于这一点:一个私钥和一个具有转账权限的银行 OAuth 密钥存放在一个可公开下载的工件中,这本身就是发现,无论我个人是否能访问该端点。
试图举报
这是让那条推文病毒式传播的部分,也是更有趣的一半。
我寻找了一种负责任地举报的方式。现有的渠道如下:
渠道
结果
nusuk.sa, haj.gov.sa, hajj.nusuk.sa 上的 security.txt
不存在
沙特 CERT (cert.gov.sa) 漏洞报告页面
重定向到 NCA;自身报告页面已关闭
NCA 漏洞表单 (haseen.gov.sa)
从德国无法访问:超时,被地理封锁
bugbounty.sa
已关闭项目,从外部返回 HTTP 403
HackerOne / Bugcrowd
没有针对 Nusuk、该部委或 Elm 的项目
应用商店列表联系方式
支持地址,无安全授权
这些都没有给我留下一个我能实际联系到的、具有安全授权的渠道。我还是发了邮件。来自 Haseen 支持的回复:
"访问 Haseen 门户仅限于沙特阿拉伯王国境内的用户。如有任何进一步疑问,您可以通过官方 Haseen 门户上的'我们关心'服务联系我们。"
而 Haseen 门户正是我无法访问的东西。我回信解释说我并非沙特人,这是一个暴露在政府应用中的私人银行证书,我只想把它交出去。得到的答复再次是,该表格仅对沙特公民有效。
因此,我通过 CERT/CC 的 VINCE 平台以协调中介的身份提交了一份报告(VRF#26-08-DXMKL),范围仅限于此发现。这是当受影响方没有可联系的渠道时采取的方法。
然后我发了推文,主要是出于沮丧。
该推文获得了 150 万次浏览。几小时内,Haseen 支持主动给我发了邮件,就在那个曾两次告诉我门户不对外国人开放的邮件线程上:
"根据我们相关团队的意见,请向我们提供有关该安全漏洞的更多详细信息。"
我发送了全部细节。我宁愿问题得到修复,也不愿在流程上争个对错。
修复
下一个应用更新同时在 Android 和 iOS 上发布。我直接从 Play 商店拉取了新的 Android 构建版本(17.5.0,版本代码 156635),并将其与我分析过的版本进行了对比。三项检查:
- 新的 APK 包中没有任何证书容器:没有 .pfx、.p12、.pkcs12、.jks、.bks、.pem 或 .key 扩展名的文件。我还对旧的 nusuk.pfx 进行了哈希处理,并将其与新构建中所有相同大小的文件进行了逐字节比较,以防它只是被重命名了。没有匹配项。
- 没有任何已知的凭证。我在所有 DEX 文件、原生库、assets、XML、JSON 和原始资源中搜索了基础 URL、作用域、客户端 ID 和客户端密钥的精确旧值。全部四个均为零命中。
- 没有代码路径。版本 17.4.9 在 com.walletstaq 和 com.trustless 包下包含 4,571 个文件;17.5.0 包含零个。标记 baas.alahli、tppa/token、nusuk.pfx、证书主题和颁发者名称在解码后的构建中均返回零命中。
整个钱包和 BaaS 集成被移除了。步骤 1 中的重命名检查排除了这些值只是被移动到包中其他位置的可能性。
外界无法验证的部分
从应用中移除密钥并不会使密钥失效。旧的 APK 副本会永久存在,并且该证书有效期至 2027 年 4 月。因此,悬而未决的问题是它是否已被吊销,以及 OAuth 密钥是否已被轮换。
我试图寻找一种独立验证的方法。但没有,而原因本身就是一个发现。
该证书没有携带 crlDistributionPoints 扩展,因此根本没有引用任何吊销列表。其唯一的吊销端点是:
1OCSP - URI: http://finto-ocsp-responder.prod.svc.cluster.local:8080/api/v1/ocsp
.cluster.local 是 Kubernetes 集群的内部 DNS 后缀。它在公共互联网上按定义是不可路由的,并且从该集群外部的任何地方解析都会返回 NXDOMAIN,而且是通过纯 HTTP 在 8080 端口上。
因此,无法从银行基础设施外部检查此证书的吊销状态,因为外部没有任何可查询的东西。对于该集群之外的任何依赖方来说,来自此颁发者的证书实际上是无法撤销的,这本身就值得在某个人的架构审查中单独写一段。
同一个字段还发布了银行 BaaS 平台生产 PKI 的集群名称、命名空间、服务名称和端口,这些信息都包含在一个分发给一千万人的应用中。
只有 SNB、Finto 或 Staq 可以确认轮换。供应商方面表示凭证已被轮换。我无法独立验证这一点。
时间线
日期 (2026)
事件
8 月 29 日
分析 APK,本地确认发现
8 月 29 日
通过电子邮件报告;Haseen 回复称门户仅限于沙特境内用户
8 月 31 日
再次尝试,得到相同答复。我发推文;约 150 万次浏览
9 月 1 日
Haseen 主动重新打开邮件线程,要求提供详细信息。详细信息已发送
9 月 1 日
同时通过 CERT/CC VINCE (VRF#26-08-DXMKL) 作为中介提交报告
9 月 1 日
版本 17.5.0 在 Google Play 上发布
9 月 3 日
对未修改的 17.5.0 APK 包进行重新测试,确认已完全移除
9 月 8 日
撰写本文
我无法证明这次更新是由我的报告引起的。17.5.0 可能已经在开发流程中了。我能证明的是,这些材料存在于 17.4.9 中,而不存在于 17.5.0 中。
我的总结
- 将东西隐藏在应用中并非安全边界。无论是放在资源文件中、原生 .so 库里、混淆代码后面,还是放在存储在同一个二进制文件中的密码后面,都不是。如果应用能读取它,那么所有安装该应用的人也都能读取。每年都有几个团队在公开场合学到这一点。
- 共享的客户端证书不是身份验证。如果一千万台设备出示相同的证书,它只能说明是哪个应用在调用,而无法说明是谁在调用,而且这个应用是任何人都可以下载的文件。第三方 API 的凭证,尤其是银行的凭证,应该放在你控制的服务器上。
- 对漏洞披露渠道进行地理围栏本身就是一种漏洞。攻击者不会填写表单。如果向一个全球发布、拥有千万用户的全球应用报告漏洞的唯一方式是身处某个国家境内,那么那些无法访问表单的人恰恰是你最想听到声音的人。一条病毒式传播的推文打开了一个渠道,而一个 security.txt 文件本可以免费打开它。
我在访客模式下分析了公开可用的 Play 商店 APK 包,没有账户,也没有真实的个人数据。我没有尝试绕过银行 API 前的网络级封锁。本文中的每个值要么是结构性的(路径、类名、证书元数据),要么已被编辑;没有发布任何私钥材料或完整密钥。





