我如何窃取你的ChatGPT API密钥

把 ChatGPT 的 API key 打包进客户端应用,攻击者能轻易把它抠出来;文章从 iOS 应用的密钥存储说起,讲清了为什么前端藏不住密钥,以及该把调用放到哪里。

中文
复制
How I Stole Your ChatGPT API Keys 的题图

前端安全基础

Jacob Bartlett 的头像

2025年1月27日

image

AI 淘金热正酣。

而在淘金热中,坏人会偷铲子。

一个致命错误屡见不鲜:把 ChatGPT API key 直接打包进你光鲜的新 AI 应用里。这样一来,偷走你的 key、烧光你的额度、攒出一堆肉疼的账单都变得轻而易举。

今天,我要讲一条基本安全法则:

不要把 API key 存在客户端。 就是永远不要。

今天我们先看坏人嗅出你 API key 的两种主要手段,然后聊聊怎么避免这个下场。


反编译你的应用

如果你把 API key 存在设备上,可能会用上各种防护手段,复杂程度参差不齐。我会详细过一遍最流行的几种做法,并演示绕过它们有多容易。

代码里的裸字符串

这通常是工程师在被打上安全补丁之前的第一步。从 API 请求数据时,你会这么写:

func callChatGPTAPI(prompt: String) async throws -> Data {
 let url = URL(string: "https://api.chatgpt.com")!
 var request = URLRequest(url: url)
 let apiKey = "12345678-90ab-cdef-1234-567890abcdef"
 request.addValue(apiKey, forHTTPHeaderField: "Authorization")
 return try await URLSession.shared.data(for: request).0
}

这段代码会被编译,以 .app bundle 的形式交付给用户,其中包含全部可执行代码。

我之前写过,拿到 App Store 上任意应用的 .ipa 文件有多容易,这个文件里就装着 .app bundle。这是坏人偷 key 的第一站。

image

想找点轻松读物,可以看看通过检查 App Bundle 在面试中惊艳全场!

按这个思路走,进入 .app bundle 轻而易举。它就是个文件夹,大概长这样:

image

真正的干货在 Bev 里——我那靠谱又爱喝酒的副业项目的 UNIX 可执行文件。我应用里所有编译链接后的代码都在里面。

用一条内置的 UNIX 命令 strings,就能查看编译进这个可执行文件的每一条原始字符串。

strings Bev

输出自然相当啰嗦:

image

除了原始字符串,你还会看到一大堆编译后函数的符号名。

但在这份列表的底部附近,我们看到了战利品:

12345678-90ab-cdef-1234-567890abcdef

API key 的原始字符串,就这么明晃晃地摆着——任何一个会用正则的反派都能轻松搜到。

Info.plist

你可能会想,好吧,我不会把原始字符串放进应用里等着被反编译。

那我就把它们放进 Info.plist,那才是它们本该待的地方。

image

这样一来,取这个值的 API 调用点会稍微复杂一点。

func callChatGPTAPI(prompt: String) async throws -> Data {
 let url = URL(string: "https://api.chatgpt.com")!
 var request = URLRequest(url: url)
 let apiKey = getAPIKeyPlist()
 request.addValue(apiKey, forHTTPHeaderField: "Authorization")
 return try await URLSession.shared.data(for: request).0
}

func getAPIKeyPlist() -> String? {
 Bundle.main.object(forInfoDictionaryKey: "API_KEY") as? String
}

这里还有几个更讲究的层次。

  • 与其直接硬编码 key,你可以准备多套 key,分别用于开发和生产环境。为每个环境创建 .xcconfig 文件,构建时解析成 Info.plist 里的值。
  • 如果你是真正的安全原教旨主义者,就会知道绝不能把密钥提交进版本控制。也许你把 API key 安全地存在 CI/CD 流水线上,在构建脚本里注入 .xcconfig 或 Info.plist。

但这一切都是白费。

再看一眼那个 .app 文件夹吧,任何有一台 MacBook、一部 iPhone 和太多空闲时间的人都能打开:

image

完蛋。

image

不管它们是怎么进到 Info.plist 里的,这些值都是公开可取的。

混淆字符串

如果你不加分辨地相信网上读到的一切,可能会觉得自己混淆一下字符串就很聪明。2019 年我第一次决定当常驻安全专家时,就是这么想的。

你可以在混淆算法和加盐上玩出各种花样,但说真的:你最后还是会叫 ChatGPT 写一对函数来混淆和解码你的 key。趁那些额度还没被人偷走,赶紧用掉吧。

private func obfuscate(key: String) -> String? {
 let salt = "00000000-0000-0000-0000-000000000000"
 guard let keyData = key.data(using: .utf8),
 let saltData = salt.data(using: .utf8) else {
 return nil
 }
 let obfuscatedData = zip(keyData, saltData).map { $0 ^ $1 }
 let obfuscatedBase64 = Data(obfuscatedData).base64EncodedString()
 return obfuscatedBase64
}

private func decode(obfuscated: String) -> String? {
 let salt = "00000000-0000-0000-0000-000000000000"
 guard let obfuscatedData = Data(base64Encoded: obfuscated),
 let saltData = salt.data(using: .utf8) else {
 return nil
 }
 let apiKeyData = zip(obfuscatedData, saltData).map { $0 ^ $1 }
 return String(bytes: apiKeyData, encoding: .utf8)
}

在运行时获取密钥,无非就是把混淆过的原始字符串解码而已。

func callChatGPTAPI(prompt: String) async throws -> Data { 
 let url = URL(string: "https://api.chatgpt.com")!
 var request = URLRequest(url: url)
 let apiKey = getAPIKeyObfuscated()
 request.addValue(apiKey, forHTTPHeaderField: "Authorization")
 return try await URLSession.shared.data(for: request).0
}
 
func getAPIKeyObfuscated() -> String? {
 decode(obfuscated: "AQIDBAUGBwgACQBRUgBTVFVWAAECAwQABQYHCAkAUVJTVFVW")
}

虽然它比原始字符串稍微难读一点,但对一个有足够动机的黑客来说,偷走你的密钥依然轻而易举。

如果你用的是 base-64 编码或 XOR 这类不安全的做法,逆向工程简直不费吹灰之力。就算算法和 salt 都还不错,攻击者也能用 Frida 这类工具,在运行时从设备内存里把 API 密钥抠出来。

混淆带来的是基于隐匿的安全性,但这不是有效的做法。无论你的混淆做得多好,那个 API 密钥最终都会被解码,并通过网络发出去。

这就引出了第二种、也可能致命得多的窃取 API 密钥的方法。


监控网络流量

这是你手上最强大的技术之一。

Proxyman 这类免费工具(如果你是老炮儿,就用 Charles Proxy)可以让你查看某个 App 或网站的全部网络 I/O。

image

程序加载后几秒钟,我们就在一个网络请求里发现了带着 API 密钥的 auth 头。

12345678-90ab-cdef-1234-567890abcdef

因为我这人比较怪,这也是我在即将入职一家新公司时喜欢做的事:看看他们的网络流量,判断这是一家 REST 派还是 GraphQL 派。

缓解代理抓包

可以用 SSL pinning 之类的技术来缓解这个攻击面。SSL pinning 会检查后端服务器出示的 TLS 证书,验证它们是否与设备上打包的证书一致。这能防住中间人攻击,也能挡住 Proxyman 这类流量监控工具的代理根证书。

要在 iOS 上实现 SSL pinning,你可以用 URLSessionDelegate 写一个网络请求装饰器,实现 urlSession(didReceive: URLAuthenticationChallenge)。然后就可以用 Security Framework 的 SecTrustCopyCertificateChain 把 SSL 证书和你固定的副本做比对。

但是,你猜到了,SSL pinning 未必有传说中那么管用。逆向工程师可以用工具绕过 pinning,在越狱设备上更是轻而易举。

而且,在你并不完全清楚自己在做什么的时候,SSL pinning 是有风险的。

那时我还是个资历尚浅、急于证明自己的工程师,天真地针对 AWS 持有的 Cognito 根证书做了 SSL pinning。 结果 AWS 就在同一周轮换了那张证书。 长话短说,英国的新冠检测中心停摆了整整 59 分钟,那是我人生中最漫长的 59 分钟。


我要怎么保护自己的应用?

希望读到这里,你已经记住了我们的核心结论:在客户端保护 API key,基本上是一场安全表演。只要把 key 打包进公开发布的应用里,它就不可能真正安全。

既然知道了不该做什么,那能做什么?

客户端天然不安全,但保护后端免受恶意行为者攻击要直接得多。保护第三方 API key 的“教科书式”做法,是把它放进一个(会轮换的)secrets manager,并让网络请求经由你的后端代理。

这样一来,你就可以用自己的系统对网络请求做认证,把潜在的攻击面收窄。

如果你还没开始自建基础设施,Firebase Cloud Functions 提供了一种更简单的方式来实现这层中间件。

顺带一提,你还可以通过 iOS 上的 AppAttest、Android 上的 Play Integrity,以及引入 RASP(运行时应用自我保护)库,为设备再加几层防护。这些手段能阻止应用在越狱设备上运行,也能抵御逆向工程工具。

一个小提醒:如果你在用 Firebase,我个人在 AppAttest 上踩过大坑。也许他们已经修好了,但作为一个英国人,我绝不会放过任何一个抱怨的机会。


结论

不要把 API key 存在客户端。

我是说,永远不要。

任何告诉你 key 放在客户端很安全的人,不是蠢就是坏。

打包进 app 的任何东西都能被翻出来。明文写死的 API key 和 Info.plist 文件,找起来毫无难度;混淆之类的花招稍微高级一点,但面对有心的攻击者或越狱设备照样不堪一击。

网络代理是把双刃剑。SSL pinning 这类手段能帮你防住代理,但如果你不清楚自己在做什么,它带来的麻烦可能远大于收益。

客户端防护机制,比如 app 完整性校验或反逆向检测库,可以给你的 app 或网站多加几层保护。

想让 API key(相对)安全,就得把所有东西放在后端,或者用 Firebase Cloud Functions 之类的产品。这样你就能安心睡觉了。大概吧。

image

来源: Jacob's Tech Tavern← 返回首页