Share Notes

chundev

View the Project on GitHub latteouka/share-notes

iOS GPS 偽造的真實原理 — pymobiledevice3 通訊堆疊與 Pikmin Bloom 反偵測實戰

日期:2026-04-22 技術棧:pymobiledevice3 / CoreLocation / CMPedometer / Niantic Real-World Engine


TL;DR

iOS GPS 偽造不是「假造衛星訊號」,而是利用 Apple 留給 Xcode 的開發者通道直接 override CoreLocation 的輸出。但對 Pikmin Bloom 這種 Niantic 遊戲來說,光改 GPS 還不夠 — 遊戲會交叉比對 CMPedometer 步數、GPS 速度合理性、horizontalAccuracy 值、軌跡連續性。這篇筆記拆解:(1)pymobiledevice3 是怎麼把 (lat, lon) 送進 iOS 深處的;(2)Niantic 遊戲拿什麼資料來判斷你是不是在真的走路;(3)為什麼一個「像真人」的隨機漫步器要用 correlated random walk + trail repulsion + home tether 這種物理模型,以及每個參數背後的 trade-off。

前置閱讀:pymobiledevice3 Wi-Fi GPS Spoofing 完整攻略(iOS 16 / 17+ 三條通道的設定流程、pair record 匯出、AP isolation 踩坑)。這篇不重複,直接從原理開始。


第一部分:CoreLocation 被 override 的那一瞬間

iOS 的位置來源鏈

當一個 App 呼叫 CLLocationManager.startUpdatingLocation(),系統內部走的是:

App (你的程式)
 ↓ XPC
CoreLocation.framework
 ↓ XPC
locationd  (/usr/libexec/locationd)
 ↓ 融合多個 source
┌────────┬────────┬──────────┬────────────┐
│ GPS HW │ Wi-Fi  │ Cellular │ Bluetooth  │
│ 衛星   │ BSSID  │ tower    │ beacon     │
│ 晶片   │ lookup │ lookup   │            │
└────────┴────────┴──────────┴────────────┘

關鍵:locationd 是全系統唯一跟位置硬體對話的 process,所有 App 拿到的 CLLocation 都是它融合後分發的。

Simulate-location 做的事,是在 locationd 的 source 融合層塞一個 override input。一旦生效:

這不是 hook、不是 swizzle、更不是 jailbreak — 是 Apple 合法留在 locationd 裡的開發者通道。啟動它需要:

  1. 裝置 pair 過一台 Mac(存有 pair record)
  2. Developer Mode 啟用(iOS 16+ 需要手動打開)
  3. DeveloperDiskImage 掛載(iOS ≤16)或 RemoteXPC tunnel 建立(iOS 17+)

為什麼 horizontalAccuracy = 65 是最大漏洞

真實 GPS 的 accuracy 是連續變動的:

環境 真實 accuracy
空曠戶外、7+ 顆衛星 3-8 m
都市峽谷 15-30 m
室內、窗邊 30-100 m(靠 Wi-Fi positioning)
地下室 100-500 m(純 BSSID lookup)

Simulate-location 永遠回報 65.0(一個 float 常數,不管你在哪)。這是 anti-cheat 最便宜的鉤子:連續看到 accuracy == 65.0accuracy 永遠不變 就能標記整個 session。有些商業 spoofer 會在中間層加「假的 accuracy 抖動」再丟給 App,但 pymobiledevice3 沒做這層 — 直接走開發者通道,吃 locationd 原生的 65.0。


第二部分:pymobiledevice3 到底在幹嘛

pymobiledevice3 是 libimobiledevice(Apple 私有的 MobileDevice.framework 跨平台重寫)的純 Python 版本。它實作了從 Mac 對 iPhone 發開發者指令的整套堆疊。Location 只是它支援的上百個 service 之一。

完整通訊堆疊有四層

Layer 1 — usbmuxd(Mac 端 daemon)

/var/run/usbmuxd 是 macOS 隨 iTunes / Mobile Device Framework 裝上去的常駐 daemon。它做兩件事:

  1. 偵測接上的 iOS 裝置(USB + Wi-Fi Bonjour _apple-mobdev2._tcp.
  2. 把「Mac 上的 TCP 連線」proxy 到「iOS 裝置內部的 TCP port」

pymobiledevice3 的 create_using_usbmux() 背後就是往這個 socket 丟:

{"MessageType": "Connect", "DeviceID": ..., "PortNumber": 62078}

然後拿回一條已經 proxy 到裝置內部的 socket — 寫進去的 bytes 會直達 iPhone 上的 lockdownd。Wi-Fi 連線則是繞過 usbmuxd,用 create_using_tcp(hostname, port=62078) 直接連 iPhone 的 IP。

Layer 2 — lockdownd(iOS 端 daemon)

iPhone 內部監聽 TCP 62078 的 daemon。它是 iOS 對外的開發者總閘門,處理三種請求:

MessageType 用途
QueryType 取得裝置資訊(ProductVersion、UDID、SerialNumber)
Pair / ValidatePair 首次配對與信任驗證
StartService 啟動一個 service,回傳新 port 讓你直接連

Pairing 的本質:Mac 生成一對公私鑰,把公鑰存到 iPhone 的 keychain。之後每次連 lockdownd,Mac 用私鑰過 TLS handshake 證明自己被信任過。Pair record(那個 .plist 檔)就是 Mac 這邊的「私鑰 + iPhone 回存的 device certificate」。

Layer 3 — Services over lockdown

StartService 能啟動的 service 超過 50 種,每個有自己獨立的二進位協議。跟位置相關的兩個:

Layer 4a — DTX(DVT 通道,iOS 17+)

DTX(Distributed eXchange Protocol)是 Xcode Instruments 跟裝置溝通用的協議,格式類似精簡版的 ObjC remote invocation:

DTXMessage {
  channel_id: 42,
  method: "setSimulatedLocationWithLatitude:longitude:",
  args: [37.7749, -122.4194],
  expects_reply: true
}

iOS 17 之後,LocationSimulation 變成 DTServiceHub 上的一條 DTX channel。pymobiledevice3.services.dvt.instruments.location_simulation.LocationSimulation 就是這條 channel 的 Python wrapper。

Layer 4b — RemoteXPC + tunneld(iOS 17+ 的包裝層)

iOS 17 把 DTX 藏在 RemoteXPC 後面 — RemoteXPC = Apple 用 HTTP/2 frames 包裝 XPC messages 的混合協議。要開 RemoteXPC 連線需要:

  1. 先建一條 IP-level tunnel(QUIC on iOS 17-18.1、TCP on iOS 18.2+)
  2. 在 tunnel 裡跑 RemoteXPC handshake
  3. 在 RemoteXPC 裡開 DTX channel

sudo pymobiledevice3 remote tunneld 是跑在 Mac 上的 daemon,做三件事:

一張圖看完

App 要位置
   ↓
CoreLocation.framework
   ↓ (XPC)
locationd ←────────────────────┐
   ↑                            │ override
   │  ≤ iOS 16                  │
   │   com.apple.dt.simulatelocation
   │   (需要 DDI 掛載)
   │
   │  iOS 17+
   │   DTServiceHub ←─ DTX channel: LocationSimulation
   │                    ↑
   │                    RemoteXPC
   │                    ↑
   │                    QUIC/TCP tunnel (tunneld)
   │                    ↑
   └── lockdownd (TCP 62078) ←──USB/WiFi──  usbmuxd (Mac)
                                                ↑
                                          pymobiledevice3

pymobiledevice3 的價值,就是把這整條堆疊從 C 框架 port 到 Python,而且還逆向補完了 Apple 沒公開的 RemoteXPC 細節


第三部分:Pikmin Bloom 實際拿什麼資料?

Pikmin Bloom 是 Niantic 在 Pokémon GO 之後的另一款步行遊戲,共用底層的 Real-World Engine。它收兩條獨立的資料流

A. CLLocation 流(GPS)

CLLocation {
  coordinate: (lat, lon)       // 位置
  altitude: Double              // 高度
  horizontalAccuracy: Double    // ★ 水平精度
  speed: Double                 // locationd 推算的速度
  course: Double                // locationd 推算的航向
  timestamp: Date
}

用途:判斷玩家在哪、走多遠、速度合不合理。

B. CMPedometer 流(步數)

CMPedometer {
  numberOfSteps: Int
  distance: Double
  currentPace: Double
  currentCadence: Double
}

資料來源是加速度計 + 陀螺儀,由 iPhone 的 M 系列 motion coprocessor 即時處理。不經過 locationd,不受 simulate-location 影響。 這點常常被 spoofing 教學忽略。

結論:光偽造 GPS 只能完成一半

偽造 GPS CMPedometer Pikmin Bloom 表現
✓ 已偽造 ✓ 手機在口袋真的走 步數 ✓ / 小花 ✓ / 新地區 ✓(完美)
✓ 已偽造 ✗ 手機放桌上 小花 ✓(GPS 距離)/ 步數 = 0
✗ 未偽造 ✓ 手機真搖晃 步數 ✓ / 小花卡在原地(沒移動)

所以「真・偽造」= 偽造 GPS + 搖晃手機(夾在擺錘上、腳踏車把手、電扇震動台)。光跑 pymobiledevice3 不帶著手機走,步數那條永遠是死的。這也是為什麼 iAnyGo 類工具會建議使用者把手機放進口袋走一小段再開 spoofer。


第四部分:為什麼不能等速直線走?

Niantic 的反偵測由三個元素組成:

1. 速度閾值(Speed Cap)

根據 Pokémon GO 社群多年實測(Pikmin Bloom 未公開但表現類似):

速度 行為
< 10.5 km/h 所有資料正常計入
10.5 – 20 km/h 部分功能關閉
20 – 35 km/h 開車判定,距離不累加
> 35 km/h 完全凍結,可能觸發 cooldown

Pikmin Bloom 社群共識:< 15 km/h 種花穩定、< 10 km/h 最保險。

2. horizontalAccuracy filter

上面第一部分講過的 65.0 問題。遊戲可以簡單過一個 filter:只收 accuracy 有波動且 < 某閾值的 sample,spoofed 訊號連續 65.0 就被整批丟掉。

3. 軌跡形狀

等速直線走路是最明顯的紅旗。真人走路有:


第五部分:隨機漫步的物理模型

對應上面三條偵測點,一個「像真人」的漫步器需要:

Correlated Random Walk(有記憶的亂走)

每 tick 的新 heading 是「上一個 heading + 高斯雜訊」,不是白雜訊:

heading_new = heading_prev + random.gauss(0, sigma=22°)

σ=22° 是經驗值:太小軌跡太直、太大會抖到像癲癇。產出的軌跡是自然的 S 形曲線,跟 Brownian motion 的鋸齒狀很不一樣。

Home Tether(繩子拉回中心)

無邊界的 random walk 會一路 diffusion 飄走,這對 Pikmin Bloom 不合理(連續半小時不會跑到三條街外)。解法是超出 max_radius_m 時把 heading 往中心拉:

dist_from_home = haversine(position, home)
if dist_from_home > max_radius:
    pull_bearing = bearing_toward(home, from=position)
    pull_strength = min(1.0, (dist_from_home - max_radius) / max_radius)
    heading = lerp(heading, pull_bearing, pull_strength)

Trail Repulsion(短期軌跡斥力)

避免一分鐘內踩同一塊地。把最近 N 個位置當成斥力源,加上 inverse-square 力場:

for recent in trail[-300:]:
    dx, dy = position - recent
    dist_sq = dx*dx + dy*dy + epsilon
    force += (dx, dy) / (dist_sq ** 1.5)  # inverse-square
heading = add_force(heading, force, weight=0.1)

選 inverse-square 而不是 inverse-distance 的原因:遠的點影響趨近 0,只有剛踩過的點會把你推開。Pikmin Bloom 的 fog-of-war 會記錄走過的格子,重踩不產小花 — 用斥力場強制探索新區域。

Position Jitter(加抖動但不回寫)

每 tick 在乾淨座標上加小抖動當 output,但不把抖動寫回 state

current_clean = advance_by_step(current_clean, heading, step_m)
output = current_clean + gauss_noise(sigma_m=1.0)   # yield 給 iOS
# current_clean 保持乾淨

如果把 jittered 座標回寫,N tick 後累積誤差會 O(√N) 放大,random walk 整個漂到銀河系去。這是隨機漫步模擬的一個關鍵 invariant,寫錯不容易看出來但結果會越跑越歪。

為什麼 nominal = 19 km/h 而不是 15?

原本想用 15 km/h 當 nominal,實測發現 position jitter σ=1mstep=5.3m 下每步的實際距離期望值會膨脹:

E[|step_real|] = E[|step_nom + noise|]
              ≈ step_nom × (1 + σ²/(2·step_nom²))
              = 5.3 × 1.018
              ≈ 5.4 m

加上速度 jitter σ=12%,effective speed ≈ 19.8 km/h。與其在 UI 寫 15 卻跑出 19,不如把 profile 直接標 19 讓數字跟實際一致。如果 Pikmin Bloom 後續把 filter 改嚴(只認 < 15 km/h),就把 nominal 降到 13-14 看表現。


學到的事


參考資料