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

前端安全基础

2025年1月27日

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 的第一站。

想找点轻松读物,可以看看通过检查 App Bundle 在面试中惊艳全场!
按这个思路走,进入 .app bundle 轻而易举。它就是个文件夹,大概长这样:

真正的干货在 Bev 里——我那靠谱又爱喝酒的副业项目的 UNIX 可执行文件。我应用里所有编译链接后的代码都在里面。
用一条内置的 UNIX 命令 strings,就能查看编译进这个可执行文件的每一条原始字符串。
strings Bev
输出自然相当啰嗦:

除了原始字符串,你还会看到一大堆编译后函数的符号名。
但在这份列表的底部附近,我们看到了战利品:
12345678-90ab-cdef-1234-567890abcdef
API key 的原始字符串,就这么明晃晃地摆着——任何一个会用正则的反派都能轻松搜到。
Info.plist
你可能会想,好吧,我不会把原始字符串放进应用里等着被反编译。
那我就把它们放进 Info.plist,那才是它们本该待的地方。

这样一来,取这个值的 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 和太多空闲时间的人都能打开:

完蛋。

不管它们是怎么进到 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。

程序加载后几秒钟,我们就在一个网络请求里发现了带着 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 之类的产品。这样你就能安心睡觉了。大概吧。
