Fake_IOS_Location

How to change/mock/fake GPS location of your iPhone, that's a question.

前言

本文旨在探索WLOC的底层原理,并将其本地化验证运行。目前已经有开发者开发出网页版,如有需要可调至最后【相关链接】部分

因为之前我的 Tiktok 账号是英区的,刷到的内容和互动的人群也或多或少限制在了西欧。这个限制在平时而言,没有什么问题,但当自己希望看些不同地区的内容热点时,这就有些麻烦了(当然,也可以通过 Tiktok Studio 来解决,却总是有隔靴搔痒的膈应感)。

 

此时,想起了之前看到的通过爱思助手虚拟定位,解决企业微信/钉钉打卡的方案,试了试。发现确实可行,但每次修改都要连接电脑,确实有点麻烦了。

继续探索,发现了这个宝藏项目【Apple WLOC 定位修改

发现可以通过 Apple WLOC的运行方式和 VPN 软件自带的 MitM (中间人攻击) 功能,全程手机操作,无需连接电脑即可完成修改(恢复)GPS定位

 

原理

这个方法可以成功的基础是:iPhone / iPad 的定位不完全依赖 GPS,还会结合:GPS、Wi-Fi 周围热点(BSSID)、蜂窝基站、蓝牙、Apple 的定位数据库等等信息。

设备会把这些内容会通过 Apple 的一个内部定位服务接口:https://gs-loc.apple.com/clls/wloc ,把相关信息(附近 Wi-Fi、基站等)发送给 Apple,Apple 返回估算的位置。然后将设备的 GPS 坐标修改为 wloc 返回的坐标。

梳理整个流程:iPhone 请求 gs-loc.apple.com --> 代理工具拦截 --> 修改返回的经纬度 --> iOS CoreLocation -->  IOS 认为当前位置改变 --> 其他应用请求位置时候提供被修改过的坐标。

项目限制

Apple WLOC 定位修改】本身已经能够运行得不错。但是如果查看项目提供的 shortchut 会发现其利用了全球最大的赛博大善人 Cloudflare 的 worker/pages。我之前也用 worker/pages 做过很多事情,因此很清楚国内不是所有地方都和深圳一样开放的,很多城市(二线、三线)的移动、电信、联通、网通,尤其是移动,很喜欢屏蔽 CF。各位可以先进行尝试,如果直接使用无法访问,可使用我重写的脚本。具体的使用方式,请浏览原项目的 Github 仓库。

真·本地版本

由于我有那么一点【完美主义】和【本地化主义】,本文仅展示完全本地处理,无需利用 Cloudflare 。为了达到这个目的,我通过 CC 将原项目的:

重写成了 Python 脚本,方便后续使用。

在开始前,请准备以下内容以便顺利操作:

  1. a-shell:执行脚本解密、转换地图坐标, 下载【a-shell】;
  2. 小火箭(或其他带有模块 MitM 和 https 解密功能的同类软件):劫持 WLOC 请求并修改返回值;注意,需要事先安装【劫持模块】;
  3. shortcut:IOS系统自带,如果没有(删了),到 App Store 下载,  执行【坐标修改】及【坐标恢复】workflow, 实现定位修改和恢复操作;
  4. wloc.py: 脚本解密/坐标转换的本地版本,下载【wloc.py】;

前期准备

在实际修改定位前,需要做好以下前期准备工作,方可得到正确的结果。

【1】

下载 a-shell 后,需要安装 requests packages, 打开a-shell后运行:

【2】

下载【wloc.py】后,将 wloc.py 复制到 【文件app】-->【On My iPhone】 --> 【a-shell】

【3】

小火箭安装【劫持模块】--> 然后开启 https 解密 (注意添加需解密的域名 gs-loc.apple.com 、gs-loc-cn.apple.com 、bluedot.is.autonavi.com 以及
bluedot.is.autonavi.com.gds.alibabadns.com) 和 MitM 功能。开启相关功能时候,需要安装CA证书,此时需要 (1) 到setting app --> general --> VPN & Device Management 中确认安装;(2) setting app --> general --> about --> Certificate Trust Settings 中信任刚刚安装的CA证书。

【4】

打开链接安装【坐标修改】及【坐标恢复】workflow,将其添加到 shortcut app 中。(随后也可将这两个 workflow 添加到桌面快捷方式,方便后续操作)

 

修改定位操作步骤

Apple 从 iOS 26 开始大幅强化了 locationd 的定位缓存机制,系统会将之前获取的真实定位结果缓存在内存中并长时间复用。这意味着安装模块或切换目标坐标后,即使脚本已成功修改了 WLOC 响应(日志显示"已修改"),系统仍可能继续使用缓存中的旧坐标,导致定位看起来没有变化

为此,请严格遵循下面的操作步骤,确保定位修改成功。大部分必要的操作我已经通过 workflow 处理完毕。但一些无法自动化的操作,比如开关系统定位,请在运行到对应步骤后的等待时间里(3秒),手动关闭或开启。

  1. 打开代理服务;
  2. 长按高德地图、苹果地图、百度地图、Google Map中想要【伪装】的地点,【复制分享链接】;
  3. 运行【本地修改WLOC定位】workflow;
  4. workflow打开定位服务设置页面,手动关闭定位;
  5. workflow设置虚拟定位;
  6. workflow在此打开定位服务设置页面,手动重新打开定位;
  7. 打开地图验证;
  8. 使用完成,运行【坐标恢复】workflow,恢复真实定位;

 

后记、附件、相关链接

enjoy

尽情使用吧。

WLOC工作流附件

修改WLOC工作流详情
修改WLOC工作流详情

 

WLOC.PY

#!/usr/bin/env python3
"""CLI coordinate parser: extracts lat/lon from Apple Maps, amap, or raw text."""

import sys
import re
import math
import urllib.request
import urllib.error
import urllib.parse
import requests

USER_AGENT = "Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/27.0 Mobile/24A5370h Safari/604.1"

GCJ_A = 6378245.0
GCJ_EE = 0.00669342162296594323


def safe_decode(s):
    """URL-decode, replace + with space, return '' on error."""
    if not s:
        return ""
    try:
        return re.sub(r'\+', ' ', urllib.parse.unquote(str(s)))
    except Exception:
        return str(s)


def extract_from_string(s):
    """Extract lat/lon/name/src from a string. Returns dict or None."""
    if not s:
        return None
    string = str(s)

    # Apple Maps: coordinate=, ll=, sll=  (lat, lon order)
    m = re.search(r'(?:coordinate|ll|sll)=(-?\d{1,3}\.\d+)(?:,|%2C)(-?\d{1,3}\.\d+)', string, re.I)
    if m:
        nm = re.search(r'[?&]name=([^&]+)', string, re.I)
        name = safe_decode(nm.group(1)) if nm else ""
        return {"lat": float(m.group(1)), "lon": float(m.group(2)), "name": name, "src": "apple"}

    # Amap ?p= pattern: lon,lat order in URL!
    m = re.search(
        r'[?&]p=[^,&%]*(?:,|%2C)(-?\d{1,3}\.\d+)(?:,|%2C)(-?\d{1,3}\.\d+)(?:(?:,|%2C)((?:(?!,|%2C|&).)+))?',
        string, re.I)
    if m:
        name = safe_decode(m.group(3)) if m.group(3) else ""
        return {"lat": float(m.group(2)), "lon": float(m.group(1)), "name": name, "src": "amap"}

    # Amap ?q= pattern: lon,lat order
    m = re.search(
        r'[?&]q=(-?\d{1,3}\.\d+)(?:,|%2C)(-?\d{1,3}\.\d+)(?:(?:,|%2C)((?:(?!,|%2C|&).)+))?',
        string, re.I)
    if m:
        name = safe_decode(m.group(3)) if m.group(3) else ""
        return {"lat": float(m.group(2)), "lon": float(m.group(1)), "name": name, "src": "amap"}

    # Raw lat,lon text: lat first
    m = re.search(r'(-?\d{1,3}\.\d{4,})\s*(?:,|%2C)\s*(-?\d{1,3}\.\d{4,})', string)
    if m:
        return {"lat": float(m.group(1)), "lon": float(m.group(2)), "name": "", "src": "text"}

    return None


def gcj_out_of_china(lng, lat):
    """Return True if coordinates are outside China."""
    return lng < 72.004 or lng > 137.8347 or lat < 0.8293 or lat > 55.8271


def gcj_delta_lat(x, y):
    r = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * math.sqrt(abs(x))
    r += ((20.0 * math.sin(6.0 * x * math.pi) + 20.0 * math.sin(2.0 * x * math.pi)) * 2.0) / 3.0
    r += ((20.0 * math.sin(y * math.pi) + 40.0 * math.sin((y / 3.0) * math.pi)) * 2.0) / 3.0
    r += ((160.0 * math.sin((y / 12.0) * math.pi) + 320 * math.sin((y * math.pi) / 30.0)) * 2.0) / 3.0
    return r


def gcj_delta_lon(x, y):
    r = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * math.sqrt(abs(x))
    r += ((20.0 * math.sin(6.0 * x * math.pi) + 20.0 * math.sin(2.0 * x * math.pi)) * 2.0) / 3.0
    r += ((20.0 * math.sin(x * math.pi) + 40.0 * math.sin((x / 3.0) * math.pi)) * 2.0) / 3.0
    r += ((150.0 * math.sin((x / 12.0) * math.pi) + 300.0 * math.sin((x / 30.0) * math.pi)) * 2.0) / 3.0
    return r


def wgs84_to_gcj02(lat, lon):
    """WGS84 -> GCJ-02 forward transform."""
    if gcj_out_of_china(lon, lat):
        return {"lat": lat, "lon": lon}
    d_lat = gcj_delta_lat(lon - 105.0, lat - 35.0)
    d_lon = gcj_delta_lon(lon - 105.0, lat - 35.0)
    rad_lat = (lat / 180.0) * math.pi
    magic = math.sin(rad_lat)
    magic = 1 - GCJ_EE * magic * magic
    sqrt_magic = math.sqrt(magic)
    d_lat = (d_lat * 180.0) / (((GCJ_A * (1 - GCJ_EE)) / (magic * sqrt_magic)) * math.pi)
    d_lon = (d_lon * 180.0) / ((GCJ_A / sqrt_magic) * math.cos(rad_lat) * math.pi)
    return {"lat": lat + d_lat, "lon": lon + d_lon}


def gcj02_to_wgs84(lat, lon):
    """GCJ-02 -> WGS84 iterative inverse (6 iterations)."""
    if gcj_out_of_china(lon, lat):
        return {"lat": lat, "lon": lon}
    wgs_lat = lat
    wgs_lon = lon
    for _ in range(6):
        g = wgs84_to_gcj02(wgs_lat, wgs_lon)
        err_lat = g["lat"] - lat
        err_lon = g["lon"] - lon
        if abs(err_lat) < 1e-9 and abs(err_lon) < 1e-9:
            break
        wgs_lat -= err_lat
        wgs_lon -= err_lon
    return {"lat": wgs_lat, "lon": wgs_lon}


def round6(n):
    """Round to 6 decimal places."""
    return round(float(n), 6)


def fetch_url(url, headers=None):
    """Fetch URL with manual redirect handling. Returns (response_url, body, location_header)."""
    req = urllib.request.Request(url, headers=headers or {
        "User-Agent": USER_AGENT,
        "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
        "Accept-Language": "zh-CN,zh-Hans;q=0.9",
    })
    try:
        resp = urllib.request.urlopen(req, timeout=10)
        return resp.url, resp.read().decode("utf-8", errors="replace"), resp.headers.get("Location")
    except urllib.error.HTTPError as e:
        if e.code in (301, 302, 303, 307, 308) and e.headers.get("Location"):
            return e.url, "", e.headers.get("Location")
        raise


def parse_coords(raw):
    """Parse raw text, extract URL, fetch and follow redirects, extract coordinates."""
    text = str(raw or "").strip()
    if not text:
        raise ValueError("Empty input")

    url_match = re.search(r'https?://[^\s\'"<>]+', text, re.I)
    target = url_match.group(0) if url_match else text

    # Try extraction on target first
    hit = extract_from_string(target)
    if hit:
        return hit

    if url_match:
        cur = target
        for _ in range(5):
            resp_url, body, location = fetch_url(cur)
            # Try location header
            if location:
                cur = urllib.parse.urljoin(cur, location)
                hit = extract_from_string(cur)
                if hit:
                    return hit
                continue
            # Try response URL
            hit = extract_from_string(resp_url)
            if hit:
                return hit
            # Try body
            if body:
                hit = extract_from_string(body)
                if hit:
                    return hit
            break

    raise ValueError("Failed to extract coordinates from URL")


def main():
    if len(sys.argv) < 2:
        print("Usage: python wloc.py <text with URL>", file=sys.stderr)
        sys.exit(1)

    raw = sys.argv[1]
    try:
        result = parse_coords(raw)
        lat = result["lat"]
        lon = result["lon"]
        src = result.get("src", "")

        # Convert GCJ-02 to WGS84 for amap and apple sources
        if src in ("amap", "apple"):
            wgs = gcj02_to_wgs84(lat, lon)
            lat = wgs["lat"]
            lon = wgs["lon"]

        lat = round6(lat)
        lon = round6(lon)
        print(f"lat={lat} lon={lon}")
        with open("./result.txt", "w") as file:
        	file.write(f"{lat},{lon}")
        
        return lat, lon
    except Exception as e:
        print(f"Error: {e}", file=sys.stderr)
        sys.exit(1)


if __name__ == "__main__":
    main()

相关链接

https://github.com/Yu9191/wloc

https://github.com/OpenHRTT/wloc

https://github.com/Yu9191/wloc/issues/46