0
0

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を使って既存iOSアプリからバイナリ解析させたiOSのCoreLocationを偽装する為の技術資料として

0
Posted at

AIを使い既存アプリから未公開仕様を調査した結果内容の公表になります。
もともとやろうとしていたのは、AIはどこまで自分たちがやらなければならなかった事を調べてくれるのか?という観点で始めた内容になります。

Phantom GPS Injection — 仕様書

PhantomGPS 純正ドングル(gpsmock.com 製)に対して、自前 iOS アプリから NMEA を注入し、iOS 全体の CoreLocation を偽装するための技術仕様。
PhantomGPS180825.ipa(2018-08-25 版)のバイナリ解析と実機検証で確定した内容。


1. 対象ハードウェア

項目
製品名 Phantom GPS (Lightning)
メーカー gpsmock.com
modelNumber lightning
firmwareRevision 1.3.0
hardwareRevision 1.3
serialNumber (個体ごとに異なる、例: ZZ1234567)
接続方式 Lightning コネクタ直挿し (MFi 認証チップ内蔵)
公開 protocolStrings com.gpsmock.gps のみ 1 個
動作確認 iOS iOS 26

装置の役割

NMEA をアプリから受け取って、内部的に iOS の Location Services に注入するブリッジ装置。装置自体に GPS 受信機能は無く、純粋にアプリが指定した座標を iOS に流し込むだけ。


2. 接続プロトコル (ExternalAccessory)

2.1 Info.plist 必須設定

<key>UISupportedExternalAccessoryProtocols</key>
<array>
    <string>com.gpsmock.gps</string>
</array>

2.2 セッション確立シーケンス

1. EAAccessoryManager.shared().registerForLocalNotifications()
2. EAAccessoryDidConnect 通知を購読
3. EAAccessoryManager.shared().connectedAccessories から装置を取得
4. EASession(accessory:, forProtocol: "com.gpsmock.gps") を生成
5. session.inputStream:
     - delegate を設定
     - schedule(in: .main, forMode: .common)
     - open()
6. session.outputStream:
     - delegate を設定
     - schedule(in: .main, forMode: .common)
     - open()

2.3 ハンドシェイク等の追加処理

不要。session を開いた直後から outputStream に NMEA を書くだけで動作する。
PhantomGPS バイナリも特別な初期化バイトは送っていない(逆アセで確認済み)。

2.4 inputStream の挙動

装置からは何も流れてこないtotalBytesReceived は常に 0 のまま。
これは故障ではなく仕様。装置は内部ループで NMEA を iOS に渡しているだけで、アプリには応答を返さない。


3. NMEA データフォーマット

PhantomGPS バイナリの逆アセンブル (__cstring 領域) から抽出した正確なフォーマット文字列。

3.1 GPGGA フォーマット

"$GPGGA,%@,%@,%@,1,%d,%f,%d.6,M,%d,M,,*"
位置 プレースホルダ
1 %@ UTC 時刻 012345.00
2 %@ 緯度 + 半球 2303.655200,N
3 %@ 経度 + 半球 12013.812840,E
- 1 (リテラル) 固定値 (Fix Quality = GPS Fix) 1
4 %d 衛星数 8
5 %f HDOP 1.000000
6 %d.6 高度整数部 + リテラル .6 10.6
- M メートル単位 M
7 %d ジオイド高 0
- M メートル単位 M
- ,, 空フィールド (DGPS 用) ,,
- * チェックサム区切り *
8 (後付け) チェックサム 5C
- \r\n 行末

注意: 高度の .6 はリテラルとして埋め込まれており、f 系の小数表記ではない。装置側は <整数>.6 という固定形式を期待している。

3.2 GPRMC フォーマット

"$GPRMC,%@,A,%@,%@,%ld,0,%@,0.0,%@,D*"
位置 プレースホルダ
1 %@ UTC 時刻 012345.00
- A 固定値 (Status = Active) A
2 %@ 緯度 + 半球 2303.655200,N
3 %@ 経度 + 半球 12013.812840,E
4 %ld 速度 (ノット) 0
- 0 進行方位 (固定) 0
5 %@ UTC 日付 290426
- 0.0 磁気偏差 (固定) 0.0
6 %@ 磁気偏差方向 E
- D モード (Differential) D
- * チェックサム区切り *
7 (後付け) チェックサム 78
- \r\n 行末

3.3 緯度・経度の数値表記

PhantomGPS の decimalToDegree:isLatitude: と完全一致させる必要がある。

入力: decimal degrees (例: 23.060920)
処理:
  1. degrees    = floor(absolute(input))         // 23
  2. minutes    = (absolute(input) - degrees) * 60   // 3.6552
  3. combined   = degrees * 100 + minutes        // 2303.6552
  4. int_part   = floor(combined)                // 2303
  5. frac_part  = (combined - int_part) * 1e6    // 655200 (truncate, fcvtzs 相当)
出力 (緯度):  String(format: "%04ld.%06ld", int_part, frac_part)
出力 (経度):  String(format: "%05ld.%06ld", int_part, frac_part)

重要: 一般的な NMEA の %02d%07.4f (5 桁小数) ではなく、整数 long×2 + 6 桁固定小数
標準 NMEA パーサは互換に解釈するが、Phantom GPS ドングルはこの厳密な書式で運用されており、念のため一致させておく。

入力 (decimal) 出力 (緯度) 出力 (経度)
23.060920 2303.655200,N
120.230214 12013.812840,E

半球指定:

  • 緯度: >= 0N, < 0S
  • 経度: >= 0E, < 0W

3.4 NMEA チェックサム

$ の直後から * の直前までのバイトを XOR して、小文字 16 進 2 桁で出力。

"%02lx"   // ← PhantomGPS のフォーマット文字列 (lowercase hex)

実装:

private static func nmeaChecksum(_ body: String) -> String {
    let cs = body.utf8.reduce(UInt8(0)) { $0 ^ $1 }
    return String(format: "%02x", cs)
}

4. 送信時の重大な制約

4.1 装置バッファ上限 = 128 バイト

PhantomGPS の EASessionController._writeData メソッド内に以下の防御コードがあり、装置側のバッファ上限が 128 バイト以下であることが強く示唆される。

if ([self.writeData length] >= 0x81 /* 129 */) {
    self.writeData = nil;   // ← 破棄
    return;
}
[outputStream write:bytes maxLength:length];

4.2 GPGGA + GPRMC は連結禁止

  • GPGGA: 約 75 バイト
  • GPRMC: 約 70 バイト
  • 連結すると 約 145 バイト → 128B を超えて装置に拒絶される

このため、それぞれを別の outputStream.write 呼び出しで送信する必要がある

4.3 送信レート

1 Hz (1 秒に 1 回 GPGGA + 1 回 GPRMC) で動作確認済み。
PhantomGPS も同程度のレート。


5. アプリ実装要件

5.1 書き込みキューの設計

連結バッファではなく、チャンク配列として保持する。各チャンクが 1 回の outputStream.write 呼び出しに 1:1 対応する。

private var writeQueue: [Data] = []  // ← Data ではなく [Data]

func send(_ chunk: Data) {
    if !writeQueue.isEmpty || !output.hasSpaceAvailable {
        writeQueue.append(chunk)
        return
    }
    writeOneChunk(chunk, to: output)
}

private func writeOneChunk(_ chunk: Data, to output: OutputStream) {
    let n = output.write(chunk_bytes, maxLength: chunk.count)
    if n > 0 {
        if n < chunk.count {
            writeQueue.insert(chunk.dropFirst(n), at: 0)  // 部分書き込みは戻す
        }
    } else {
        writeQueue.insert(chunk, at: 0)  // エラー時も戻す
    }
}

private func flushWriteQueue() {
    while !writeQueue.isEmpty && output.hasSpaceAvailable {
        let chunk = writeQueue.removeFirst()
        writeOneChunk(chunk, to: output)
    }
}

5.2 hasSpaceAvailable イベント処理

StreamDelegate の .hasSpaceAvailable イベントで flushWriteQueue() を呼び、満杯時に積んだチャンクを順次フラッシュする。

case .hasSpaceAvailable:
    flushWriteQueue()

5.3 注入ロジック

1 秒ごとのタイマーで、現在座標から GPGGA と GPRMC を生成し、個別に send() する。

private func sendInjectionNMEA() {
    // 「停止検知」回避のため数 m の jitter を付与
    let jitterLat = injectionLat + Double.random(in: -0.000005...0.000005)
    let jitterLon = injectionLon + Double.random(in: -0.000005...0.000005)
    let now = Date()

    // GPGGA と GPRMC は連結せず別チャンクで送る (装置の 128B 制限)
    if let gpgga = makeGPGGA(lat: jitterLat, lon: jitterLon, alt: injectionAlt, time: now)
        .data(using: .ascii) {
        send(gpgga)
    }
    if let gprmc = makeGPRMC(lat: jitterLat, lon: jitterLon, time: now)
        .data(using: .ascii) {
        send(gprmc)
    }
}

5.4 必須でないが推奨

  • UIBackgroundModesexternal-accessory を追加 → 画面ロック中も注入継続
  • UIBackgroundModesaudio を追加(無音再生)→ サスペンド回避(PhantomGPS が採用)

6. CoreLocation との統合

6.1 動作モデル

  1. アプリが outputStream に NMEA を書く
  2. 装置内部のロジックが、その NMEA を iOS の Location Services に渡す(中身は不可視のブリッジ動作)
  3. iOS はそれを「位置情報の最新値」として扱う
  4. 任意のアプリ(地図、ゲーム、SNS 等)が CLLocationManager.startUpdatingLocation() を呼ぶと、注入された座標が返る

6.2 CLLocation.sourceInformation.isProducedByAccessory について

この値は NO のままで正常

Apple は MFi GPS ホワイトリスト(Bad Elf, Dual XGPS, Garmin GLO 等)に登録された装置に対してのみ isProducedByAccessory = YES を返す。com.gpsmock.gps はホワイトリスト外なので、装置由来であっても NO 表示になる。

ただし位置情報自体は正しく置き換えられるので、実用上は問題なし。

6.3 isSimulatedBySoftware について

NO で返ることが多い。isProducedByAccessory 同様、装置経路では Apple がフラグを立てない。


7. アーキテクチャ概要

7.1 自前アプリの構成

test/                         iOS アプリ
├── testApp.swift              アプリエントリ
├── ContentView.swift          TabView (Location / Accessory)
├── LocationTestView.swift     CLLocationManager で受信状況を確認
└── AccessoryDebugView.swift   ★ NMEA 注入ロジック本体
                                - AccessoryDebugViewModel
                                - AccessorySession (StreamDelegate)
                                - InjectionControlView (UI)
                                - ManualCommandView (デバッグ用 ASCII/HEX 送信)

mac_app/                      macOS リモコンアプリ (Mac → iPhone を UDP)
├── MacSpoofer.swift           MapKit でピン刺し → UDP 9999 へ送信
└── build.sh                   swiftc 直接ビルド

Info.plist                    UISupportedExternalAccessoryProtocols 等

7.2 Mac から遠隔指定する仕組み

  1. iOS アプリ側で UDP 9999 をリッスン (NWListener)
  2. Mac から <lat>,<lon> の文字列を UDP で送信
  3. iOS が受信したら injectionLat, injectionLon を更新し、未開始なら自動で注入開始

両方のアプリは同じ Wi-Fi 上にあり、ファイアウォール越しでなければそのまま動く。


8. 検証済み事項 / 既知の制限

8.1 検証済み

  • iOS 26 (iPhone 13 mini) で動作
  • 任意の他アプリの CLLocationManager に注入座標が反映される
  • 1 Hz の注入レートで「停止検知」(jitter 付与済み)を回避できる

8.2 制限・既知の挙動

項目 状態
シミュレータ ❌ ExternalAccessory が動かないため不可
macOS / visionOS ❌ 同上
バックグラウンド継続 UIBackgroundModes 設定次第
isProducedByAccessory フラグ ❌ 常に NO (Apple 側仕様)
中国本土での座標 ⚠ GCJ-02 ↔ WGS-84 変換が必要(未実装)
速度・進行方位 ⚠ 0 固定。移動シミュレートには別実装必要
装置電源 OFF / 抜去時の復帰 ⚠ EAAccessoryDidDisconnect ハンドリング済みだが要実機検証

9. 参考: 元 PhantomGPS バイナリの主要シンボル

逆アセンブル時のアンカー。

アドレス (arm64) シンボル
0x100020b1c -[EASessionController _writeData] (実書き込み、128B 制限あり)
0x100020ce4 -[EASessionController _readData]
0x100020f78 -[EASessionController setupControllerForAccessory:withProtocolString:]
0x100021018 -[EASessionController openSession] (ハンドシェイク無し)
0x100021264 -[EASessionController closeSession]
0x100021468 -[EASessionController writeData:] (公開ラッパ)
0x100021508 -[EASessionController readData:]
0x1000215bc -[EASessionController stream:handleEvent:]
0x100021a34 -[GPSManager NMEAChecksum:]
0x100021aa8 -[GPSManager decimalToDegree:isLatitude:]
0x100021c9c -[GPSManager sendNMEAData:longitude:altitude:speed:insindeChina:]
0x1000224b8 -[GPSManager commonGCG2WGS:wgsPoint:insindeChina:] (GCJ-02 変換)

主要なフォーマット文字列は __cstring 領域 0x00c9a000 付近に集中している。


10. 変更履歴

日付 変更
2026-04-29 初版作成
2026-04-29 緯度経度を %04ld.%06ld / %05ld.%06ld に変更 (PhantomGPS と完全一致)
2026-04-30 128B 制限の発見、GPGGA/GPRMC を別チャンクに分割、書き込みキューを [Data] 化 → 動作確認
0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?