Skip to content
meat-daemon-404Public

About

RunSpoof — 校园跑定位与步态模拟 LSPosed 模块(含完整构建链与踩坑文档)

Resources

Stars

6 stars

Watchers

0 watching

Forks

Repository files navigation

RunSpoof · 校园跑定位与步态模拟

一个 LSPosed 模块,面向高校「校园跑 / 阳光晨跑」类 App(以 运动世界校园 com.zjwh.android_wh_physicalfitness 为主要适配目标)提供可调的位置与步态模拟,并附完整的地图选点界面。

项目本身是一份踩坑记录。核心价值不在代码量,而在 docs/ 里那些「为什么不能那么写」——它们每一条都是实际跑崩过、被判定无效过、或者静默失效过之后才定下来的。

合规提示 本仓库以 GPL-3.0 发布,参考了 LocationSpoofer(GPL-3.0, Copyright (C) 2026 SuseOAA)的实现思路,详见 LICENSE 与 NOTICE。 仓库不包含任何第三方 APK、LSPosed 管理器/守护进程二进制,也不包含任何签名密钥。 使用本模块修改运动数据可能违反所在学校的学术诚信规定与目标 App 的服务条款。仓库作者不对由此产生的任何后果负责。请仅在自己拥有或已获授权的设备与账号上、以技术研究与自测为目的使用。


它解决什么

学生的实际需求通常是「不想在操场上绕圈,但希望记录正常」。直接改 GPS 是不够的——校园跑类 App 的判定链远不止坐标:

判定维度 App 实际拿什么 纯改坐标的结果
位置 系统 LocationManager + GNSS(App 里的高德 SDK 只用来画地图,不用来定位) 只改一次坐标不管用,它会持续回调
里程 Location.distanceTo() 逐点累加 两侧取到同一时刻坐标 → 恒为 0
步数 加速度计波形数峰(采样 ≈15 Hz) 1 Hz 推送还原不出 2.75 Hz 步态 → 步频 0
配速 服务端用 SpeedPerTenSec 序列独立重算 速度与轨迹不自洽就判异常
有效性 libswsport.so 的 isValidPoint 四点评分 只伪造坐标,评分维度缺项
环境 GNSS 卫星状态 / WiFi / 基站 / mock 标记 任一维度露馅即风控

所以本模块做的是一个自洽的整套输入:轨迹连续推进、速度与步频互相反推、加速度波形与步数对齐、GNSS/WiFi/基站环境补齐、反作弊标记清理。


快速开始

前置条件

  • Android 13+(targetSdk 34),已 root
  • KernelSU / Magisk + Zygisk Next(或 ReZygisk)
  • LSPosed 2.2.1 及以上

⚠️ LSPosed 2.2.1 不认 legacy Xposed API 82。本模块使用 libxposed 新格式(META-INF/xposed/,API 101+),详见 docs/01-环境准备.md。

安装

完整手把手流程见 docs/06-完整使用教程,这里只是最短路径。

  1. 从 Releases 下载 RunSpoof-v*.apk,安装

  2. LSPosed 管理器 → 模块 → 启用 RunSpoof

  3. 作用域只勾选目标 App(com.zjwh.android_wh_physicalfitness)

  4. ★ 建配置文件(不做这步后面全白费):

    adb shell "touch /data/local/tmp/runspoof_route.txt; chmod 666 /data/local/tmp/runspoof_route.txt"
  5. 强制停止目标 App 后重新打开(autoHotReload=true 通常无需重启手机)

第 4 步为什么必须做:编辑器跑在模块自己 uid 下、注入代码跑在目标 App 的 uid 下,而 Android 10+ 的 per-app data isolation 让注入进程根本看不到模块的数据目录。/data/local/tmp 是唯一两边都可见的地方,但它的权限是 drwxrwx--x 且属主 shell,普通 app uid 建不了文件——只能写一个已经存在的文件。所以必须由你先建好并放开权限。

漏了这步,模块界面会弹框提示「配置没能生效」并给出这条命令。

使用:画自己的路线

打开 RunSpoof 路线(模块自带的地图界面)。这是一个路线编辑器,不是只能填数字的表单:

操作 效果
点地图空白处 追加一个路径点
拖动已有顶点 微调该点位置
长按顶点 删除该点
拖动地图 只移动视野,不动路线中心
定中心 → 点地图 把该点设为路线中心(红色三角)
跑道圈 以当前中心 + 当前半径一键生成 24 点标准跑道形状(两条直道 + 两个半圆)
撤销 回退一步(保留 50 步)
清空 清掉路径
保存配置 才真正写盘生效

画布上会实时显示:实心折线(你的路径)、虚线(自动闭合回起点)、编号顶点(起点为绿色)、红色三角(路线中心),以及顶部「路径 N 点 | 绕一圈 X 米」。

只有按「保存配置」才会写盘。 编辑过程中的所有改动都只在内存里,误触不会改掉已保存的配置。

坐标系说明(重要):编辑器底图是高德瓦片,所以你点出来的经纬度是 GCJ-02(火星坐标);而 Android 的 Location 和 App 的判区用 WGS-84。模块在读配置时会自动做 GCJ-02 → WGS-84 转换(Geo 类),高德那一侧的 hook 再转回 GCJ-02。你不需要手动换算,直接在地图上点就行。 实测这一步差约 370 米,不做的话轨迹会整体偏出跑道。详见 docs/00 的 B0。

四个参数可随时改,改完立刻显示换算结果:

参数 可调范围 说明
半径 5 – 200 m 用「跑道圈」时的半圆半径;直道长度取 2×半径
速度 1.9 – 5.4 m/s 实时显示对应配速,须落在你校的配速区间
步频 45 – 290 SPM 实时显示对应步幅
海拔 −500 – 9000 m 建议填。留空会用兜底值,但「所有人上报同一个海拔」本身就是可被聚合的特征

为什么海拔要自己填:App 记录的每公里爬升(elevationGain)与海拔 (endAltAbs)都取自 Location.getAltitude()。如果这个值是个常量, 那么每一圈的爬升都恰好是 0 m、海拔一个数都不变 —— 真人跑步不会这样, 而且这类「恒定值」不需要任何外部参照就能被单字段判出来。 填成自己所在地的大致海拔即可(手机自带指南针/地图 App 都能看到)。 详见 docs/07。

超出范围的值在保存时会被自动钳制到区间内 —— 手填一个出界值跑完也是白跑,静默修正比让成绩作废好。

内置默认值

参数 默认 说明
中心点 0.0, 0.0 没有任何内置坐标。0,0 = 「还没设过」,打开编辑器画一遍才会变成真实位置
半径 39 m 生成一圈约 398 米,接近标准 400 米跑道
速度 2.4 m/s ≈ 6'57"/km,慢跑姿态,不贴任何区间上下限
步频 172 SPM 与 2.4 m/s 自洽的慢跑步频

这些值怎么换算、怎么按自己学校的规则调整,见 docs/03-判定规则怎么摸.md。

为什么不留内置坐标:每个学校的有效跑区都不一样,内置任何一处真实坐标都只对那一所学校有意义,对别人反而是错的。而且 0,0 一眼就能认出「配置没读到」,比留一个看起来像真坐标的占位值安全。


验证到哪一步了

关于本文档的数据口径:本仓库不记录任何一次真实跑步的实测数据(场次 ID、里程、配速、步频、时间戳、日志数值等),也不记录设备标识、账号信息或坐标。下面只描述功能是否走通与机制是否成立,具体数值一律不给。这样做既是为了脱敏,也是为了避免把一次偶然的观测当成结论——本仓库此前吃过几次「拿单场数据下普遍结论」的亏,详见 docs/00。

以下环节均已在 LSPosed + 目标 App 的真实运行环境下走通:

环节 判定依据(定性)
模块注入 logcat 出现完整的 hook 安装日志,各 hook 无异常
配置读取 文件通道读到配置,路径点数与编辑器一致
坐标系转换 转换前后轨迹在跑道上的位置从整体偏移变为贴合;GCJ-02 → WGS-84 的偏移在数百米量级
热重载 改配置文件后无需重启 App 即可生效
进入计分跑 点 GO 能进入计分跑步 Activity
轨迹位置 界面地图上轨迹贴合操场跑道轮廓,「不在有效区域内」提示消失,打卡点全部落在跑道上
打卡点通过 进度能完整推进到「已打卡全部点位」
配速/步频被采信 App 界面实时显示由模块生成的配速与步频
里程累计 里程随运行时间正常累加

也就是说:位置、步态、打卡、上传四件事都通了。

坐标系这一条是最后修掉的:早先版本配置读对了、日志也对,但轨迹整体偏移、App 判「不在有效区域内」。原因是编辑器取的是高德瓦片(GCJ-02),而 Location 要 WGS-84,代码里没有转换。修完后同一份配置立刻就对齐了,见 docs/00 的 B0。

案例:分段不达标(时间戳污染)—— 机制已复现并修复

曾有一场计分跑:打卡全过、里程与配速步频全部达标,结果页只报一条「分段不达标」。

从 App 自己记录的 segment_json 定位到根因:分段边界在两条相差数百秒的 时间线之间来回跳,算出远超人类速度的「鬼魂分段」, 并被打了 state:256 异常标记。污染源是模拟器假 GPS 塞进来的 Location 自带 getTime(),而模块按 getTime() 反查坐标。 完整推导见 docs/00 B6。

⚠️ 一处更正:这一节早先写过「客户端直接拒绝上传(upload_success=0)」。 复查发现那只是上传过程中的中间态。判定为「分段不达标」的依据是结果页那条 原因 + segment_json 里的 state:256 标记,不是上传失败。这个仓库此前 栽过一次「把不同场次的结果页混成一场」(docs/00 G3),所以这里把话收窄。

机制已用真代码复现(A/B 对照)

用仓库里真实的 RouteEngine + GaitEngine + 同一份 19 点配置(经真实 Config.parse() 解析),同一段时间、同一组被污染的时间戳,只改「位置按什么 时间反查」:

异常段 最大速度
旧版(位置取被污染的 getTime()) 出现 远超人类速度,约 20+ m/s 量级
修复后(位置取干净递进时间) 0 落在设定速度附近,正常

修复后同一窗口的总距离与「设定速度 × 窗口时长」在路线曲率的误差内吻合。

已验证的部分

项 状态
Clock 不变式 8 条断言全过:纯函数性(同一入参任何时刻同一输出)、单调、1s 间隔精确、60s 无漂移、历史 fix 不被钳、垃圾值可重入
旧实现缺陷 已实证:同一 fix 两次读得到相差极大的时间,会破坏 distanceTo()
派发节拍(真机) TIMELINE 自检连续多周期判定为 CLEAN:无负间隔、无大间隔、minGap/maxGap 贴近派发周期
无递归崩溃 StackOverflow 0、FATAL 0(getTime() 已接管,timeOf() 内不再调它)
设备墙钟 与宿主机同源,绝对时间正确,不会触发 G1

本轮额外修掉的两处

  1. Clock 兜底不纯:旧写法遇到「未来值」会钳到当前时刻,导致同一 Location 在不同时刻读到不同时间 → 坐标漂移 → distanceTo() 算错。 改为返回固定 anchorWall,仍是纯函数。
  2. 一次 tick 造多条 fix:位置派发里 buildLocation() 曾在 listener 循环内, 同一秒 N 个 listener 各拿一条时间戳不同的 fix(本该是同一次定位)。 已提到循环外,一次 tick 只造一条。

顺带澄清一个自己造的假警报:曾在 timeOf() 里统计「相邻两次时间戳是否 递增」,打出大量 SUSPECT。那是指标错——timeOf() 每秒被 调用极多次(App 反复读属性),跨对象比较顺序天然会乱。改成在派发点 统计后即为 CLEAN。负面结论前先确认探测手段本身可用(同 docs/00 §10.5 纪律)。

✅ 实跑验证通过

修复后连续多场真实计分跑,全部 upload_success=1,分段数据无异常。

判据全部满足:分段边界严格递增(startTime < 上次 end 恒成立)、 无超出人类速度的鬼魂段、无 state:256、打卡点 isPass 全 true。

修复前存在问题的场次同样保留在记录中作为对照,因此可以说: 「分段不达标」已消失。

注:期间有一场曾被误操作中断(用 monkey -c ...LAUNCHER 测触摸注入, 那条命令会以 LAUNCHER intent 重启 Activity,把进行中的跑步打回首页)。 中断后 App 仍落盘了该记录,尾段带一条畸形补写。这条不是模块缺陷, 是中断会话的产物。 教训:不要用 monkey 带 -c LAUNCHER 做交互探测。

仍然存在的缺口

  • Location(Parcel) 构造路径未接管:nemu 的 Location 经 binder 进 App 时, 其字段(native distanceTo 直读)仍是 nemu 的坐标。 目前靠 getter 接管兜住;实测未出现异常,暂不构成问题。

构建

.\build.ps1 `
    -Sdk 'D:\toolchain\android-sdk' `
    -Jdk 'D:\toolchain\jdk17' `
    -ApiJar 'D:\path\to\libxposed-api102-classes.jar'

脚本会走完 javac → d8 → aapt2 link → jar uf → zipalign → apksigner 并自校验 APK 条目。

依赖(需自行准备,仓库不含):

  • JDK 17
  • Android SDK,build-tools;34.0.0 + platforms;android-34
  • libxposed API 102 的 classes.jar(从 LSPosed 发布包内提取,或 io.github.libxposed:api)

构建细节与坑见 docs/02-构建与打包.md。其中最要命的一条:META-INF/xposed/ 必须用 jar uf 塞进 APK 根目录,用 aapt2 -A assets 会因 Windows 反斜杠路径导致 LSPosed 完全不识别。


仓库结构

.
├── AndroidManifest.xml          # 模块 manifest(含 xposed meta-data)
├── assets/META-INF/xposed/      # ★ LSPosed 模块声明(必须进 APK 根目录)
│   ├── module.prop              #   API 版本声明
│   ├── scope.list               #   默认作用域
│   └── java_init.list           #   模块入口类
├── src/com/qf/runspoof/
│   ├── RunSpoofModule.java      # 主入口:全部 hook
│   ├── RouteEngine.java         # 轨迹引擎:时间 → 连续坐标
│   ├── GaitEngine.java          # 步态引擎:加速度波形合成
│   ├── Factory.java             # 反射构造 Location/SensorEvent/GnssStatus/...
│   ├── ConfigStore.java         # 配置读写(/data/local/tmp 文件通道)
│   └── MapPickerActivity.java   # 地图选点 UI
├── build.ps1                    # 一键构建
├── docs/                        # ★ 踩坑与流程文档(本项目最有价值的部分)
├── LICENSE                      # GPL-3.0
└── NOTICE                       # 上游归属声明

文档

文档 内容
00-踩坑总览 先读这个。全部已踩过的坑与结论索引
01-环境准备 KernelSU + LSPosed + Zygisk 配置,LSPosed 起不来的排查
02-构建与打包 手搓构建链、APK 打包、签名
03-判定规则怎么摸 怎么摸清本校规则,以及规则怎么映射成参数
04-模块实现原理 每一类 hook 为什么这么挂
05-模拟器与虚拟化 MuMu / Hyper-V / HVCI 踩坑,以及为什么建议真机
06-完整使用教程 ★ 从零到跑起来,按步骤走。第一次用先看这篇
07-第三轮-恒定值与跨通道一致性 为什么「恒定值」和「跨通道不一致」比数值偏差更危险;海拔/精度/计步双通道的改法;以及一个必须避免的统计陷阱

已知限制

  1. 设备时间必须是准确的北京时间,否则成绩传不上去 ⚠️ 最容易忽略的一条 曾观察到:整场计分跑跑完、打卡全过、里程与步频都正常,界面底部显示「正在上传跑步数据…」,但 App 弹出:

    为了您跑步记录的有效性,请修改为准确的北京时间后再上传记录

    模拟器默认时钟和真实时间差了几个小时,App 拿它跟服务端下发的跑区时间段比对,对不上就拒绝上传。把设备时钟校准到北京时间后重跑,成绩正常上传成功。

    adb shell "settings put global auto_time 0"
    adb shell "date MMMDDhhmmYYYY.ss"   # 例:date 101909012026.21 = 10/19 09:01
    adb shell date                      # 确认

    顺带纠正一个曾经的错误结论:早期版本这里写过「打卡点要求按 1→2→3→4→5 顺序通过,本仓库做不到」。那是把一次 0.00 公里的旧记录(配置还没通,根本没进跑区)的结果页,当成了后来那场完整跑步的结论,属于张冠李戴。闭环路线实际能正常打卡、正常上传,不需要按编号重排路径。

  2. 只做端侧输入伪造,不保证服务端采信 静态分析(见目标 App 的 native 层)表明服务端会用 SpeedPerTenSec 序列独立重算成绩。本模块把轨迹平滑度、配速合理性、步频-速度自洽度都按生理参数做到了自洽,但没有实机验证过判定阈值。

  3. 反射字段名按 Android 14/15 AOSP 编写 CellIdentityLte.mCi、CellSignalStrengthLte.mRssi、ScanResult.SSID 等字段若在某版本改名,对应 hook 会静默失效(代码里全部 try-catch 包住,不会崩)。logcat 搜 RunSpoof: 可看到每条 hook 的成功/失败。

  4. 模拟器体验差 MuMu 等模拟器在宿主机开了 Hyper-V/HVCI 后会退化到 VirtualBox 的 Snail 纯软件模拟模式,LSPosed native 桥接会崩。详见 docs/05。强烈建议真机。

  5. 反作弊不止这些 目标 App 还有极验设备校验、网易易盾、天穹风控、人脸核身、姿态识别等。本模块只处理了「输入数据」这一层。


免责声明

本项目仅供技术研究与学习,用于理解 Android 定位链路、传感器数据流、以及 LSPosed 模块机制。

  • 不要用于代替真实体育锻炼
  • 不要在未获授权的设备上使用
  • 使用可能导致目标 App 账号被封禁、体育成绩被判无效、甚至触发学校纪律处分
  • 作者不提供任何形式的担保,不对使用后果负责

如果你真的只是想要体育学分——去跑步,比调这个快。


致谢

About

RunSpoof — 校园跑定位与步态模拟 LSPosed 模块(含完整构建链与踩坑文档)

Resources

Stars

6 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages