リバースエンジニアリングについて質問があるのですが、アプリを解析した場合、どこまでわかるのですか? 例えば、関数の内容や変数の中身まで?
いい質問ですね!リバースエンジニアリングで「どこまで分かるか」は、対象のアプリの種類(ネイティブアプリか、スクリプト系か)、コンパイル方法、難読化の有無などによって大きく変わります。
ざっくり整理すると:
1. ネイティブアプリ(例:C/C++で書かれてコンパイルされたバイナリ)
- 関数名や変数名
→ 基本的に失われます(最適化やシンボル削除により)。sub_401000のような無意味なラベルになることが多いです。
ただし、デバッグ情報が残っている場合(例えば開発用ビルドを解析した場合)は、関数名や変数名も見えます。 - 関数の内容
→ 機械語を逆アセンブルすることで命令列(アセンブリ)として見えます。
IDA Pro や Ghidra のような逆コンパイラを使うと「C風の疑似コード」として表示可能。
つまり「何をしているか」は追えるが、ソースコードそのものには戻らない。 - 変数の中身
→ 逆アセンブルだけでは「名前」までは分からないけど、メモリのどの位置に何が入るかは追える。
動的解析(デバッガで実行してレジスタやメモリを見る)と組み合わせれば、「実行時の値」まで把握可能。
2. バイトコード系(Java, .NET, Androidアプリなど)
- 関数名や変数名
→ そのまま残っている場合が多い(特にローカル変数は削除されやすいが、メソッド名やクラス名は残る)。
ProGuard などの難読化をしていなければ、ほぼソースそのまま。 - 関数の内容
→.classや.dllはデコンパイルすれば、オリジナルに近いソースコードが得られる。
例:Javaならjadやcfr、.NETならILSpy。 - 変数の中身
→ 変数名は最適化で消えることもあるけど、実行時にどういう値が入るかは動的解析で把握可能。
3. スクリプト系(JavaScript, Python, PHP など)
- 関数名や変数名
→ 基本的にそのまま見える(ただし圧縮や難読化されている場合は読みにくくなる)。 - 関数の内容
→ ソースそのまま、または軽い変換を経た状態で取得可能。 - 変数の中身
→ 実行時の値はデバッガで確認可能。
まとめ
- ネイティブアプリ:ソースコードレベルではなく、アセンブリや疑似コードで解析。変数名は消えるが処理の流れは分かる。
- バイトコード系アプリ:ほぼソースに近い形で再現可能(難読化してなければほぼ丸見え)。
- スクリプト系:ソースがそのまま手に入ることも多い。
「どこまで外から覗けるのか(セキュリティ観点)」を知りたい です。 iOSアプリで自作のREST APIを使用する際の認証トークンを得る、初めの通信において、iOSアプリ側から送る固定パスをどうするか考えていました、どうすれば秘匿できるのかな?と。
いいですね — セキュリティ観点での「初回の固定パス(いわゆるクライアントシークレット)」の扱いについて、結論から言うと:
「クライアント側(iOSアプリ)に完全に秘密を置くことはできない」
端末を攻撃者が持てばバイナリを解析・改変できるため、埋め込んだ固定値(パスワード/シークレット)は最終的に露出する可能性があります。だから目的は「絶対に漏れない」にするのではなく「漏れても被害を限定/検出できる設計」をすることです。
以下、実践的な推奨と具体案を優先度高い順にまとめます。
高信頼で推奨する方法(サーバで秘匿・端末検証を組み合わせる)
- クライアントに対する“永久的な共有シークレット”を持たせない
→ 代わりに「端末ごと/インストールごとの資格情報(公開鍵方式)」を使う。 - App Attest(Apple)+チャレンジ/レスポンス方式(最も推奨)
- 端末内の秘密は Secure Enclave / Keychain に格納
- 短寿命のアクセストークン+リフレッシュはサーバ経由で
実務的な補助対策(これだけで安全とは言えないが有効)
- TLS(HTTPS)は必須。証明書ピンニングを検討
中間者対策としてピンニングを行うと安全性が上がるが、運用コスト・更新運用に注意(失効時のロックアウトリスク)。 - 難読化(バイナリ難読化)
固定文字列やAPIパスを見えにくくする。効果は限定的だが検出コストを上げられる。反リバースエンジニア向けの一手。 - Jailbreak/root 検知(だが頼り過ぎない)
Jailbreak を検知して警告・機能制限するのは有効。ただし回避方法も多数あるので「追加の防御層」として扱う。 - サーバ側で異常検知・レート制限・ワークフロー
- ローテーションと失効
固定値が漏れた場合のために、サーバ側の鍵・認証方式をローテーションできるしくみを持つ。
実装パターン(簡潔なサンプル設計)
A)ユーザー認証あり(普通のケース)
- ユーザーはID/PWでログイン(またはOAuth)。
- サーバは短期アクセストークン(例:15分)とリフレッシュトークンを発行。
- リフレッシュは端末固有の証明(App Attest or device key)を要求して発行。
→ クライアント側に「固定パス」を置かない。
B)ユーザー無しでアプリ単体の認証(APIキー的に使いたい場合)
- 初回起動でクライアントが端末鍵を生成(Secure Enclave)。
- サーバに公開鍵登録要求を送る。サーバはチャレンジを返す。
- クライアントがチャレンジに署名して返す。サーバで署名検証&Apple App Attest 検証を行う。
- サーバは短期アクセストークンを返す(以降はこのトークンでAPI呼び出し)。
→ 固定文字列は不要。端末ごとの鍵+attestationで代替。
注意点(現実的な限界)
- 脱獄/物理アクセスされている端末では秘密は抜かれる可能性が高い。Secure Enclaveがあっても、OS改変やメモリ抽出で突破されるリスクはゼロではない。
- ネットワーク越しの攻撃を想定した設計(TLS/ピンニング) と サーバ側での監視・制限 が必須。
- 「完璧に隠す」ことは出来ないので、被害最小化(短寿命トークン、監査、ローテーション)が鍵。
まとめ(短く)
- iOSアプリに固定パスを埋め込むのは避ける。
- 代わりに 端末固有キー+App Attest(またはSecure Enclaveでの鍵生成)+チャレンジ署名 の組合せで初回認証を行い、短寿命トークン を使う。
- さらに TLS + 証明書ピンニング、難読化、サーバ側の異常検知/レート制限 を組み合わせる。
- それでも端末が完全に乗っ取られたら安全は保証できないので、サーバ側で被害を限定・検知できる設計にする。
Secure Enclave と チャレンジを返す。 のところを詳しく教えてください。
Secure Enclave とチャレンジ/レスポンスの実装を 実践できるレベル で掘り下げます。
以下は「iOS クライアント(Swift)」 ⇄ 「サーバ(例:Node.js)」 のフロー+コード例+運用上の注意点です。長めですがそのままコピペして使えるレベルにしてあります。
概要フロー(短く)
- クライアント(初回)で鍵ペア(EC P-256)を Secure Enclave に生成・保存。
- クライアントは公開鍵(SPKI DER を Base64)をサーバへ送る要求を出す(
POST /register)。 - サーバは 一回限りのランダムチャレンジ(nonce) を生成(例:32バイト)、保存してクライアントに返す。
- クライアントはチャレンジを Secure Enclave の秘密鍵で署名し、署名(DER)を返す(
POST /register/complete)。 - サーバは公開鍵で署名を検証。成功ならその公開鍵を端末IDに紐付けて登録し、短寿命のアクセストークンを発行する。
iOS (Swift) — Secure Enclave に鍵をつくる・公開鍵を送る・チャレンジに署名する
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 |
import Foundation import Security import CryptoKit // --- 1) 鍵生成(Secure Enclave) --- func ensureKeyPair(label: String = "com.example.devicekey") throws -> SecKey { let tag = label.data(using: .utf8)! // 構成 let attributes: [String: Any] = [ kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom, kSecAttrKeySizeInBits as String: 256, kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave, // Secure Enclave kSecPrivateKeyAttrs as String: [ kSecAttrIsPermanent as String: true, kSecAttrApplicationTag as String: tag, // kSecAttrAccessControl でより強い制約をつけられる(例: userPresence) ] ] var error: Unmanaged<CFError>? if let privateKey = SecKeyCreateRandomKey(attributes as CFDictionary, &error) { return privateKey } else { throw error!.takeRetainedValue() as Error } } // --- 2) 公開鍵(SPKI DER)を取得して Base64 にする --- func exportPublicKeySPKIBase64(privateKey: SecKey) throws -> String { guard let pubKey = SecKeyCopyPublicKey(privateKey) else { throw NSError(domain: "KeyError", code: -1, userInfo: [NSLocalizedDescriptionKey: "no public key"]) } var error: Unmanaged<CFError>? guard let rawPub = SecKeyCopyExternalRepresentation(pubKey, &error) as Data? else { throw error!.takeRetainedValue() as Error } // rawPub for EC P-256 is 65 bytes: 0x04 || X(32) || Y(32) // SPKI DER prefix for EC P-256 (ASN.1 SubjectPublicKeyInfo prefix) let spkiPrefixHex = "3059301306072A8648CE3D020106082A8648CE3D030107034200" let prefixData = Data(hex: spkiPrefixHex) // helper below let spki = prefixData + rawPub return spki.base64EncodedString() } // helper: hex -> Data extension Data { init(hex: String) { self.init() var s = hex while s.count > 0 { let c = String(s.prefix(2)) s = String(s.dropFirst(2)) let val = UInt8(c, radix: 16)! self.append(val) } } } // --- 3) チャレンジを受け取り署名する --- func signChallenge(privateKey: SecKey, challengeBase64: String) throws -> String { let challenge = Data(base64Encoded: challengeBase64)! var error: Unmanaged<CFError>? let algorithm = SecKeyAlgorithm.ecdsaSignatureMessageX962SHA256 // message→ハッシュ→ECDSA(DER) guard SecKeyIsAlgorithmSupported(privateKey, .sign, algorithm) else { throw NSError(domain: "KeyError", code: -1, userInfo: [NSLocalizedDescriptionKey: "algorithm unsupported"]) } guard let sig = SecKeyCreateSignature(privateKey, algorithm, challenge as CFData, &error) as Data? else { throw error!.takeRetainedValue() as Error } return sig.base64EncodedString() // DER シグネチャ(ASN.1, r/s) } |
ポイント:
kSecAttrTokenIDSecureEnclaveを付けると秘密鍵は Secure Enclave 内に生成・保持され、外に出てきません(安全性向上)。- 公開鍵は
SecKeyCopyExternalRepresentationで取得 → 上に示した SPKI プレフィックス を付けて DER(SPKI) にします(そのままだと raw 公開点なのでサーバが使いにくいため)。 - 署名アルゴリズムは
ecdsaSignatureMessageX962SHA256(メッセージを SHA-256 でハッシュして ECDSA、出力は ASN.1 DER 形式)を例にしています。チャレンジは生のバイト列をそのまま渡して構いません。
サーバ(Node.js) — チャレンジ発行と署名検証の例
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
// Node.js (built-in crypto) const crypto = require('crypto'); // --- 1) /register で公開鍵受け取り & チャレンジ返す --- app.post('/register', async (req, res) => { const { bundleId, publicKeyBase64 } = req.body; // publicKeyBase64 = SPKI DER (base64) const publicKeyDer = Buffer.from(publicKeyBase64, 'base64'); // 作業: 公開鍵の fingerprint を作る(deviceId として) const fingerprint = crypto.createHash('sha256').update(publicKeyDer).digest('hex'); // 作成するチャレンジ(32バイトランダム) const challenge = crypto.randomBytes(32); // DBに challenge と fingerprint を一時保存(有効期限例: 60秒) await savePendingChallenge(fingerprint, challenge, /*expiresAt*/ Date.now() + 60_000); res.json({ fingerprint, challenge: challenge.toString('base64') }); }); // --- 2) /register/complete で署名を受け検証 --- app.post('/register/complete', async (req, res) => { const { fingerprint, signatureBase64 } = req.body; // 1) 先に保存したチャレンジを取り出す const pending = await getPendingChallenge(fingerprint); if (!pending) return res.status(400).json({error: "no pending"}); const challenge = Buffer.from(pending.challenge, 'base64'); // 2) 公開鍵を DB から取り出す(登録時に保存しておく) const publicKeyDer = await getPublicKeyDerForFingerprint(fingerprint); // Buffer if (!publicKeyDer) return res.status(400).json({error: "public key missing"}); // 3) 検証 const publicKey = crypto.createPublicKey({ key: publicKeyDer, format: 'der', type: 'spki' }); const verifier = crypto.createVerify('sha256'); verifier.update(challenge); verifier.end(); const signature = Buffer.from(signatureBase64, 'base64'); const ok = verifier.verify(publicKey, signature); if (!ok) return res.status(401).json({ error: "signature mismatch" }); // 成功: fingerprint を device-id として紐付け、アクセストークン発行など const token = issueShortLivedToken({ device: fingerprint }); await finalizeRegistration(fingerprint); res.json({ token }); }); |
ポイント:
- サーバに渡す公開鍵は SPKI DER(上で作った prefix + raw 公開点) を base64 にしたもの。Node の
createPublicKey({format:'der', type:'spki'})でそのまま扱えます。 - 署名は iOS が返す ASN.1 DER(ECDSA の r/s)なので、そのまま
verify(publicKey, signature)で検証できます。 - チャレンジは短時間・一回限りで運用(例:60秒・使い切り)。リプレイ防止の要です。
実装/運用上の重要な注意点(実務で絶対見てほしい)
- チャレンジは短命・一回限り(例 32 バイトランダム、TTL 30〜120 秒)。必ず DB に pending として保存し、検証後削除する。
- チャレンジは「その公開鍵に紐づく」 ようにする(上の例では fingerprint)。そうしないと別公開鍵で悪用される可能性がある。
- SSL/TLS は必須(HTTPS)。可能なら 証明書ピンニング を検討(運用の負担あり)。
- 公開鍵の fingerprint を作って device-id とする(SHA-256(publicKeyDer) など)。これで後続の API 呼び出しでデバイスを識別できる。
- 鍵ローテーション・失効手段を用意する。端末紛失や鍵漏洩時にサーバから取り消せる設計にする。
- リジェクト基準:同一公開鍵の大量登録や短時間大量リクエストはブロック/アラート。
- Secure Enclave がない端末のフォールバックをどう扱うか決める(Keychain にソフト鍵を置くならリスクを理解したうえで)。
- App Attest を組み合わせるとさらに強くなる(App Attest は「このアサーションは正規のAppから来ている」ことを Apple が保証してくれる)。可能なら Attest をチャレンジに含めて検証する運用を強く推奨。
なぜこれが安全か(&欠点)
- 秘密鍵は 端末から取り出せない(Secure Enclave)。つまりバイナリから固定シークレットを引っ張られるリスクを避けられる。
- サーバ側は 公開鍵で署名を検証 するだけ。固定パスワードをバイナリに埋めるより遥かに安全。
- ただし:脱獄端末や OS を改変された環境ではキー操作を傍受されるリスクが残る(Secure Enclave自体は強いが、周辺の攻撃ベクトルは存在する)。だから App Attest やヒューリスティック検知、ログ監査と組み合わせる。
追加で実装すると良いもの(優先度高い順)
ポイント:
- サーバに渡す公開鍵は SPKI DER(上で作った prefix + raw 公開点) を base64 にしたもの。Node の
createPublicKey({format:'der', type:'spki'})でそのまま扱えます。 - 署名は iOS が返す ASN.1 DER(ECDSA の r/s)なので、そのまま
verify(publicKey, signature)で検証できます。 - チャレンジは短時間・一回限りで運用(例:60秒・使い切り)。リプレイ防止の要です。
実装/運用上の重要な注意点(実務で絶対見てほしい)
- チャレンジは短命・一回限り(例 32 バイトランダム、TTL 30〜120 秒)。必ず DB に pending として保存し、検証後削除する。
- チャレンジは「その公開鍵に紐づく」 ようにする(上の例では fingerprint)。そうしないと別公開鍵で悪用される可能性がある。
- SSL/TLS は必須(HTTPS)。可能なら 証明書ピンニング を検討(運用の負担あり)。
- 公開鍵の fingerprint を作って device-id とする(SHA-256(publicKeyDer) など)。これで後続の API 呼び出しでデバイスを識別できる。
- 鍵ローテーション・失効手段を用意する。端末紛失や鍵漏洩時にサーバから取り消せる設計にする。
- リジェクト基準:同一公開鍵の大量登録や短時間大量リクエストはブロック/アラート。
- Secure Enclave がない端末のフォールバックをどう扱うか決める(Keychain にソフト鍵を置くならリスクを理解したうえで)。
- App Attest を組み合わせるとさらに強くなる(App Attest は「このアサーションは正規のAppから来ている」ことを Apple が保証してくれる)。可能なら Attest をチャレンジに含めて検証する運用を強く推奨。
なぜこれが安全か(&欠点)
- 秘密鍵は 端末から取り出せない(Secure Enclave)。つまりバイナリから固定シークレットを引っ張られるリスクを避けられる。
- サーバ側は 公開鍵で署名を検証 するだけ。固定パスワードをバイナリに埋めるより遥かに安全。
- ただし:脱獄端末や OS を改変された環境ではキー操作を傍受されるリスクが残る(Secure Enclave自体は強いが、周辺の攻撃ベクトルは存在する)。だから App Attest やヒューリスティック検知、ログ監査と組み合わせる。
追加で実装すると良いもの(優先度高い順)
- App Attest の導入(Apple が発行するアサーションを使って「正規アプリか」を検証)。
- 短期トークン(15分等) + リフレッシュには device-bound 証明を要求。
- 登録時にユーザーアクションを入れる(CAPTCHA、メール確認、SMSなど) — 特に大規模に公開する場合。
- サーバの異常検知(同 IP からの高頻度登録、地理的矛盾など)。
結論:個人データやパスを扱うアプリの作成は極力避ける。lol
コメント