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 |
半球指定:
- 緯度:
>= 0→N,< 0→S - 経度:
>= 0→E,< 0→W
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 必須でないが推奨
-
UIBackgroundModesにexternal-accessoryを追加 → 画面ロック中も注入継続 -
UIBackgroundModesにaudioを追加(無音再生)→ サスペンド回避(PhantomGPS が採用)
6. CoreLocation との統合
6.1 動作モデル
- アプリが
outputStreamに NMEA を書く - 装置内部のロジックが、その NMEA を iOS の Location Services に渡す(中身は不可視のブリッジ動作)
- iOS はそれを「位置情報の最新値」として扱う
-
任意のアプリ(地図、ゲーム、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 から遠隔指定する仕組み
- iOS アプリ側で UDP 9999 をリッスン (
NWListener) - Mac から
<lat>,<lon>の文字列を UDP で送信 - 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] 化 → 動作確認 |