ChatGPT APIキーを盗む方法
ChatGPTのAPIキーをクライアントアプリに組み込むと、攻撃者に簡単に抜き取られてしまう。記事はiOSアプリのキー保管から説き起こし、フロントエンドでキーを隠せない理由と、呼び出しをどこに置くべきかを明らかにする。
日本語
コピー

フロントエンドセキュリティの基本

2025年1月27日

AI のゴールドラッシュが真っ最中だ。
そしてゴールドラッシュでは、悪党がシャベルを盗む。
よくある致命的なミスが一つある。ChatGPT の API key を、きらびやかな新作 AI アプリにそのまま埋め込んでしまうことだ。そうなればキーを盗まれ、クレジットを使い果たされ、痛いほど高い請求書が積み上がるのはわけない。
今日は基本のセキュリティ原則を一つ話す。
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 バンドルとしてユーザーに届く。実行可能なコードはすべてそこに入っている。
以前書いたが、App Store のどんなアプリでも .ipa ファイルを手に入れるのは簡単だ。このファイルに .app バンドルが入っている。悪党がキーを盗む最初の寄り道だ。

軽い読み物が欲しければ、App Bundle を調べて面接で度肝を抜くをどうぞ!
この調子で進めば、.app バンドルに入るのは造作もない。ただのフォルダで、だいたいこんな見た目だ。

本命は 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 に難読化とデコードの関数を一対書かせることになる。そのクレジットが盗まれる前に使い切ってしまおう。
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)を使えば、あるアプリやサイトのネットワーク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 キーを守るのは、ほぼセキュリティの見せかけにすぎない。公開リリースするアプリにキーをバンドルした時点で、それは本当の意味では安全たりえない。
やってはいけないことが分かったところで、何ができるのか?
クライアントは本質的に安全ではないが、バックエンドを悪意ある行為者から守るほうはずっと直接的だ。サードパーティ API キーを守る「教科書どおり」のやり方は、(ローテーションする)secrets manager にキーを置き、ネットワークリクエストを自分のバックエンド経由でプロキシすることだ。
そうすれば、自分のシステムでネットワークリクエストを認証でき、潜在的な攻撃面を狭められる。
まだ自前のインフラを構築していないなら、Firebase Cloud Functions を使えばこのミドルウェア層をもっと簡単に実装できる。
ちなみに、iOS の AppAttest、Android の Play Integrity、そして RASP(実行時アプリケーション自己保護)ライブラリの導入で、デバイスにさらに何層かの防御を追加できる。これらは脱獄デバイスでのアプリ実行を防ぎ、リバースエンジニアリングツールにも対抗できる。
一つ注意:Firebase を使っているなら、僕は個人的に AppAttest で大きく躓いた。もう直っているのかもしれないが、英国人として文句を言う機会は絶対に逃さない。
結論
API キーをクライアントに保存してはいけない。
いや、絶対に だ。
キーをクライアントに置いても安全だと説く人間は、愚かか悪意があるかのどちらかだ。
アプリにバンドルされたものは何であれ掘り起こせる。平文でハードコードされた API キーや Info.plist ファイルは、探すのに何の苦労もいらない。難読化のような小細工はもう少し手が込んでいるが、本気の攻撃者や脱獄デバイスの前では同じように無力だ。
ネットワークプロキシは諸刃の剣だ。SSL pinning のような手段はプロキシ対策になるが、自分が何をしているか分かっていなければ、得られる利益より面倒のほうがはるかに大きくなりかねない。
アプリの完全性チェックやリバースエンジニアリング検知ライブラリといったクライアント側の防御機構は、アプリやサイトにさらに何層かの保護を加えられる。
API キーを(比較的)安全にしたければ、すべてをバックエンドに置くか、Firebase Cloud Functions のような製品を使うことだ。そうすれば安心して眠れる。たぶんね。
