2
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AI と OWASP MASTG で、iOS アプリのセキュリティ自主点検をやってみた

2
Posted at

この記事は 2026 年 10 月時点の情報です。OWASP MASVS v2.1.0 / MASTG v2.0.0、Xcode 26.6、iOS 18.6 シミュレータ、Claude Code で確認しています。登場するコードは、説明用に書き起こした最小のサンプルで、手元で動作を確認したものです。実在するアプリの名前・画面・コードは載せていません。

はじめに

iOS アプリのセキュリティ点検を外部に頼むとそれなりの費用がかかります。その前段として、開発者が自分で、脱獄なしで、ソースコードを使ってどこまで自主点検できるか——を試しました。

やり方を自己流にすると「何を確かめたか」が曖昧になります。そこで、事実上の標準である OWASP MAS(Mobile Application Security) に沿わせました。点検の実行は Claude Code(AI エージェント)に任せ、人は範囲の決定と結果の確認をしました。

この記事では、次の 3 つを書きます。

  • OWASP MAS(MASVS / MASWE / MASTG)の関係と、静的点検で使える範囲
  • AI エージェントに点検させるときの設計(見落としと誤検知をどう抑えるか)
  • 見つかった問題を、シミュレータで「本当に起きるか」まで確かめる方法

結論を先に言うと、自主点検としては十分使えました。ただし「合格=安全」ではなく「この方法では問題を見つけられなかった」でしかない、という線引きが大事でした。

1. OWASP MAS の構成

モバイルアプリのセキュリティには、OWASP の 3 つの文書が対応します。

文書 役割 最新
MASVS アプリが満たすべき基準(何を守るか)。8 分野・24 項目 v2.1.0
MASWE モバイルでよくある弱点のカタログ(Web の CWE にあたる) 随時更新
MASTG どうやって確かめるか(iOS/Android 別の手順) v2.0.0(2026-06 に初の正式版)

点検の単位は MASTG のテスト 1 件にしました。MASTG の各テストは Overview / Steps / Observation / Evaluation の構成で、Evaluation(合否の条件)が明文化されているのが肝です。AI に自分の基準で判断させず、この Evaluation に従わせれば、判定がぶれません。

MASTG v2.0.0 の iOS テストは、自分の集計では現役(deprecated を除く)で 88 件。そのうち、Steps に静的な手順(ソースや設定ファイルを読む)を含むものが 64 件、残りは動作中のアプリや実機が必要な動的テストでした。MASTG は廃止済みテストや細分化の扱いで数がぶれるので、この数字は 2026 年 10 月時点の自分の数え方だと思ってください。

注意: ソースで「代替」してはいけないテストがある

iOS の静的テストの多くは、Steps がビルド済みの .ipa を解析する前提で書かれています。ソースがあるなら同じことをソースで確かめればよい——と思いきや、そうできないテストがあります。

  • バイナリ(Mach-O)を見るテスト: PIE・スタック保護・ARC の有効化
  • 署名済みの成果物を見るテスト: 実効 entitlements

これらは「設定ファイルではこうなっている」までしか言えません。ソースやビルド設定は補助証拠にとどめ、合否は「判定できず(成果物が必要)」にするのが正しい扱いです。ここを「合格」にすると嘘になります。

2. AI エージェントに点検させる設計

.claude/skills の review スキルや、公開されている code-review プラグインを分解して分かったのは、並列レビューは「見落とし」と「誤検知」の両方を構造で抑える必要があるということでした。設計はこうしました。

段階 0: 前提を先に決める

MASTG の多くのテストは「機密データの定義」を前提にしています。これを決めないと「漏えい」の判定ができません(MASTG にも "A definition of sensitive data must be decided before testing begins" とあります)。

  • 機密データの定義(認証情報・個人情報・決済情報・健康情報・識別子など、何を機密とみなすか)
  • セキュリティに関わる文脈(乱数・ハッシュ・エンコードと暗号化の区別)
  • 自社ドメインの一覧(証明書ピンニングの対象を絞るため)

これを先に固めると、誤検知が大きく減ります。

段階 1: 機械的に拾う(AI の判断を入れない)

grep と依存ライブラリの脆弱性照会で、客観的に拾えるものを先に出します。AI は使いません。

# 例: ATS の例外設定を Info.plist から抜き出す
/usr/libexec/PlistBuddy -c 'Print :NSAppTransportSecurity' Info.plist

# 例: 平文 http の URL(本番コードに残っていないか)
git grep -n 'http://' -- Sources/

# 例: 証明書検証を無効化するコード
git grep -nE 'serverTrust|URLAuthenticationChallenge|DisabledTrustEvaluator'

ここで確認項目に入れておきたいのが、Dependabot などの依存ライブラリの脆弱性通知が有効になっているかです。無効のままだと、依存ライブラリに脆弱性が出ても誰にも通知が来ません。通知に頼れない場合は GitHub Advisory Database を直接照会できますが、Swift のライブラリは登録が少なく「該当なし=登録されていない」であって「安全」ではない、と注記する必要がありました。

段階 2: 分野ごとに並列で点検

MASVS の分野ごとにサブエージェントを分けました(保存・暗号・通信・WebView・認証・プライバシー)。各担当には「ファイル:行 と、Evaluation のどの条件に当たるかが書けない指摘は出すな」「合格と判定した項目も、確かめた方法を一緒に返せ」と指示しました。後者が重要で、MASVS の評価の章も「ツールを走らせて失敗だけ報告するのは不十分」としています。

段階 3: 1 件ずつ確かめる

ここが Anthropic の公開リポジトリにある code-review プラグインから学んだ点です。プラグインは 2025 年末に「信頼度を点数で付けて足切り」から「指摘が本当にコード上で成立するか、1 件ずつ検証する」方式に変わっていました。これに倣い、不合格の指摘は全件、別のエージェントでコードを開いて検証しました。さらに見落とし対策として、高リスク分野の「合格」も再確認させました(不合格だけ再確認しても、誤検知は減るが見落としは減らないため)。

LLM は深刻度の見積もりが苦手です(GitHub Security Lab も同じことを書いています)。そこで重大度は AI に付けさせず、人が「影響度 × 悪用のしやすさ」で付けました。

3. 見つかった問題を「本当に起きるか」まで確かめる

静的点検は「コードを読むとこうなる」で終わります。説得力を持たせるには、シミュレータで再現するのが効きます。自分のアプリを、自分の端末で、自分で作ったダミー値で確かめるだけなので、準備は要りません。

例: 外部から渡した値でログイン状態が変わる、を確かめる

カスタム URL スキームのハンドラが、受け取った値を検証せずに認証情報として保存してしまう——という典型的な不備を考えます。サンプルで書くとこうです。

// 悪い例: URL のクエリをそのまま認証情報として保存している
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
    guard let url = URLContexts.first?.url,
          let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else { return }
    if let json = components.queryItems?.first(where: { $0.name == "session" })?.value,
       let data = json.data(using: .utf8),
       let auth = try? JSONDecoder().decode(AuthResponse.self, from: data) {
        AuthStore.shared.save(auth)   // 送り元も真正性も確かめていない
    }
}

シミュレータでの確かめ方は、ダミー値を URL で投げるだけです。

# 自分で作ったダミー値。本物のトークンは使わない
URL=$(python3 -c "import urllib.parse,json; \
  j=json.dumps({'uid':'DUMMY','auth_token':'DUMMY','client_id':'DUMMY'}); \
  print('myapp://auth?session='+urllib.parse.quote(j))")

xcrun simctl openurl <UDID> "$URL"

実際にやって分かったのは、今回の経路(simctl openurl のように、アプリの外からスキームを開く)では iOS が確認ダイアログを出すことでした。処理が走るのはユーザーが「開く」を押した後です。つまり「無言で成立する」のではなく「リンクを開いて 1 タップ」が要る。ダイアログが出るかは開く経路(Safari から、別アプリから、など)で変わりうるので、自分のアプリで想定する経路ごとに確かめるのが確実です。この手の緩和要因は、コードを読むだけでは分からず、動かして初めて確定します。深刻度を公平に書くために、この事実は重要でした。

結果の確かめ方も、画面だけでなく痕跡で裏を取りました。注入した値が API リクエストに乗ったかは、ログ・モックサーバー・プロキシ・HTTP キャッシュなど、どこかに残る痕跡で確認します。今回はアプリの HTTP キャッシュにリクエストヘッダーが残っていたので、それを使いました(キャッシュの設定によっては残らないので、残っていなければ他の手段を使います)。

CONT=$(xcrun simctl get_app_container <UDID> <bundle-id> data)
# ダミー値がリクエストヘッダーとして残っているか
grep -ra 'DUMMY' "$CONT/Library/Caches" | head

例: WebView の localStorage を確かめる

もう一つ典型的なのが、ネイティブのログイン状態を WebView に引き継ぐために、トークンを localStorage へ注入するパターンです。

// 危険: 読み込むページが何であれ注入してしまう(origin を絞っていない)
let js = "localStorage.setItem('auth', '\(tokenJSON)');"
let script = WKUserScript(source: js, injectionTime: .atDocumentEnd, forMainFrameOnly: true)
webView.configuration.userContentController.addUserScript(script)

WKUserScript は、その WebView のメインフレームで読み込む各ページで実行されます(forMainFrameOnly: true なので iframe は除きますが、ページの種類は選べません)。つまり、同じ WebView で外部サイト(ヘルプページ、広告の遷移先など)を開くと、そのサイトの localStorage にも本物のトークンが書き込まれます。localStorage は同一 origin の JavaScript から読めるので、そのページに任意スクリプトを差し込める余地(第三者の XSS、計測タグ、テーマのカスタム JS)があれば、トークンが読めてしまいます。

シミュレータでは、WKWebView の localStorage が origin ごとに sqlite で保存されるので、中身を直接見られます。

find "$CONT/Library/WebKit" -name 'localstorage.sqlite3' | while read db; do
  echo "=== $db"
  sqlite3 "$db" "SELECT key FROM ItemTable;"
done

外部ページを開いた後に、その origin のテーブルに auth が現れれば、注入が効いてしまっている証拠です。

直し方の方向は 2 つです。

  • 注入する相手を自社 origin に限定する(didCommit でホストを完全一致で確認してから evaluateJavaScript)
  • 外部ページは、トークンを注入しない別の WebView(websiteDataStore = .nonPersistent())か SFSafariViewController で開く

なお WKContentWorld による隔離は効きません。隔離されるのは JavaScript の変数空間で、localStorage は origin 単位で共有されるためです。

4. 修正を提案するなら「既存動作への影響」までが仕事

点検で終わらせず、「この修正で既存の画面が壊れないか」を逆向きに追うところまでをセットにしました。たとえば WebView の注入を自社 origin に絞る場合、

  • 自社ページを開く全導線で、注入が維持されるか
  • 外部ページを開く導線で、その外部ページが auth を前提にしていないか
  • 注入のタイミング(.atDocumentEnd → didCommit)がずれて、Web 側の読み取りに間に合うか

を一つずつ確認します。セキュリティ修正は「塞いだら別の機能が動かなくなった」が起きやすいので、ここまでやって初めて提案になります。

まとめ

  • OWASP MASTG に沿わせると、「何を・どの基準で確かめたか」が明確になる。Evaluation の節を判定基準にするのが肝
  • AI に点検させるなら、機械的抽出 → 並列点検 → 1 件ずつ検証、の段階を分ける。合格にも証拠を要求し、重大度は人が付ける
  • 静的点検はシミュレータでの再現とセットにすると、深刻度(緩和要因を含む)が確定する。自分のアプリ・自分の端末・ダミー値で完結する
  • 「合格」は「安全」ではなく「この方法では見つからなかった」。成果物が必要なテストや動的テスト、サーバー側は別途必要
  • 依存ライブラリの脆弱性通知(Dependabot など)が有効かも、点検の最初に確認項目へ入れる。無通知の状態は、それ自体が宿題

自主点検は、外部診断の代わりにはなりません。ただ「どこに問題がありそうか」「外部診断で何を重点的に見てほしいか」を、根拠つきで言える状態にはなります。その下調べとして、十分に実用的でした。

参考

2
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?