某校园跑软件逆向分析记录
一、前期准备
- 设备:Nexus 5X(root)
- 工具:Frida、jadx-gui
二、背景知识:什么是伪造签名
伪造签名攻击:攻击者修改了别人的 APK 后,让 App 内部(或某个验证方)的签名校验逻辑读到”原作者的签名”,从而相信”这个包没有被篡改过”——尽管它实际上已经被改过、而且是用攻击者自己的密钥重新签的名。
攻击是怎么成立的:先理解正常世界——安卓系统安装 APK 时会严格验证签名。你改了包里的任何一个字节,原签名立刻作废,系统会拒绝覆盖安装。攻击者唯一的出路是用自己的密钥重签——这完全合法合规,系统也认,因为系统只管”这个包从头到尾没被第三方改过”,不管签它的人是谁。
三、分析过程
1. 动态抓包,定位协议
先用 Frida 写一段抓包代码,观察 App 的网络行为。
Python 驱动:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| import frida, time
PKG = "com.tanma.x" JS = open(r"C:\Users\27313\Desktop\x\hook_okhttp.js", encoding="utf-8").read()
def on_message(message, data): if message["type"] == "send": print(message["payload"], flush=True)
device = frida.get_usb_device(timeout=10) pid = device.spawn([PKG]) session = device.attach(pid) script = session.create_script(JS) script.on("message", on_message) script.load() device.resume(pid) time.sleep(45)
|
Hook 脚本(hook_okhttp.js):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| Java.perform(function () { var Chain = Java.use('okhttp3.internal.http.RealInterceptorChain');
Chain.proceed.overload('okhttp3.Request').implementation = function (request) {
log(request.method() + ' ' + request.url().toString()); var hs = request.headers(); for (var i = 0; i < hs.size(); i++) log(' ' + hs.name(i) + ': ' + hs.value(i)); var body = request.body(); if (body !== null) { var buf = Java.use('okio.Buffer').$new(); body.writeTo(buf); log(' [body] ' + buf.readUtf8()); }
var response = this.proceed(request);
log('----- [RESPONSE] code=' + response.code() + ' -----'); log(' [body] ' + response.peekBody(1024 * 1024).string()); return response; }; });
|
主要逻辑:spawn(让 App 停在入口处出生)→ 把 JS 代码注入进去 → resume 放行 → App 每调用被 hook 的函数,JS 里的 send() 就把数据发回电脑。
抓取到的数据(登录之后):

2. 静态分析,定位签名逻辑
通过 appKey 等关键词在 jadx 中搜索,找到关键类:

分析其主要逻辑(关键代码):

还原后的逻辑(Kotlin 风格伪码):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56
| class MyInterceptor : Interceptor {
companion object { private const val APPKEY = "389885588s0648fa" private const val APPSECRET = "56E39A1658455588885690425C0FD16055A21676" private val UTF8 = Charset.forName("UTF-8") private val SPECIAL = listOf(" ", "~", "!", "(", ")", "'") }
override fun intercept(chain: Interceptor.Chain): Response { val request = chain.request() val signStr = StringBuilder()
val names = TreeSet<String>().apply { addAll(request.url().queryParameterNames) } for (name in names) { val value = request.url().queryParameterValues(name).firstOrNull() ?: continue if (value.isNotEmpty()) signStr.append(name).append(value) } signStr.append(APPKEY).append(APPSECRET)
request.body()?.let { body -> val buffer = Buffer() body.writeTo(buffer) signStr.append(buffer.readString(body.contentType()?.charset(UTF8) ?: UTF8)) }
var dealStr = signStr.toString() var spoiled = false if (dealStr.isNotEmpty()) { for (c in SPECIAL) if (c in dealStr) { dealStr = dealStr.replace(c, ""); spoiled = true } if (spoiled) dealStr = URLEncoder.encode(dealStr, "utf-8") } val mdSign = if (spoiled) MD5Digest.encodeByMD5(dealStr)!!.uppercase() + "encodeutf8" else MD5Digest.encodeByMD5(signStr.toString())!!.uppercase()
val signed = try { val user = PreUtil(Constants.SP_NAME_USER).getValue(Constants.SP_USER, User::class.java, null) if (user != null) request.newBuilder() .addHeader("token", user.oauthToken?.token.orEmpty()) .addHeader("appKey", APPKEY) .addHeader("sign", mdSign).build() else request.newBuilder() .addHeader("appKey", APPKEY) .addHeader("sign", mdSign).build() } catch (ex: Exception) { ex.printStackTrace(); request } return chain.proceed(signed) } }
|
3. 请求体构造逻辑(官方原版)
提交跑步记录 commitRunRecord():
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
| private void commitRunRecord() { StudentRunRecordRequestBody body = new StudentRunRecordRequestBody(); body.setRecordDate(mDateFormat.format(new Date(runSharedPre.getLong(Constants.SP_RUN_START_TIME, 0L)))); body.setAgainRunStatus(runSharedPre.getString(Constants.SP_RUN_AGAIN_RUNSTATUS, "0")); body.setAgainRunTime(Integer.valueOf(runSharedPre.getInt(Constants.SP_RUN_AGAIN_RUNTIME, 0))); body.setAppVersions(APKVersionUtils.INSTANCE.getVersionName(this)); body.setBrand(APKVersionUtils.INSTANCE.getDeviceBrand()); body.setMobileType(APKVersionUtils.INSTANCE.getSystemModel()); body.setSysVersions(APKVersionUtils.INSTANCE.getSystemVersion()); body.setTrackPoints(new TrackUtils(this).getTrackToString(1)); body.setRealityTrackPoints(new TrackUtils(this).getTrackToString(0)); body.setDistanceTimeStatus(runSharedPre.getString(Constants.SP_RUN_DISTANCE_TIME_STATUS, "1")); body.setInnerSchool(new TrackUtils(this).pointsIsInArea(1)); body.setRunDistance(Integer.valueOf(new TrackUtils(this).getDistance())); body.setRunTime(Integer.valueOf(runningTime / 60)); User user = this.mUser; if (user != null && user.getUserId() > 0) { body.setUserId(Integer.valueOf(user.getUserId())); } else { try { UserUtils userUtils = new UserUtils(); Boolean isLogin = Boolean.valueOf(userUtils.getLoginState()); User user2 = userUtils.getUserInfo(); if (isLogin.booleanValue() && user2 != null) { body.setUserId(Integer.valueOf(user2.getUserId())); } } catch (Exception e) { e.printStackTrace(); body.setUserId(0); } } body.setVocalStatus(runSharedPre.getString(Constants.SP_RUN_VOCALVERIFY_STATUS, "0")); body.setYearSemester(this.mRunStandard.getSemesterYear()); ApiClient.INSTANCE.getInstance().schoolApi.saveRunRecordV2(body) .compose(ResponseTransformer.INSTANCE.handleResult()) .compose(SchedulerProvider.INSTANCE.getInstance().applySchedulers()) .blockingSubscribe( runResult -> { EventBus.getDefault().post(new RunningEvent(6, runningTime, runResult)); xApplication.INSTANCE.instance().getDataBase().getTrackDao().deleteAll(); SharedPreferences sp = xApplication.INSTANCE.instance() .getSharedPreferences(Constants.SP_NAME_RUNDATA, 0); sp.edit().clear().apply(); }, throwable -> { } ); }
|
轨迹序列化 getTrackToString():
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| public final String getTrackToString(int effective) { List<Track> list; if (effective == 1) { list = ContextExtKt.daoSession(this.context).getTrackDao().queryBuilder() .where(TrackDao.Properties.Effective.eq(effective)).list(); } else { list = ContextExtKt.daoSession(this.context).getTrackDao().loadAll(); } StringBuilder sb = new StringBuilder("["); for (Track track : list) { sb.append("\""); sb.append(track.getLongitude()); sb.append('-'); sb.append(track.getLatitude()); sb.append('-'); sb.append(track.getTime()); sb.append('-'); sb.append(track.getAccuracy()); sb.append(list.get(list.size()-1) == track ? "\"" : "\","); } sb.append("]"); return String.valueOf(sb); }
|
里程计算 getDistance():
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| public final int getDistance() { List<Track> list = daoSession(...).queryBuilder() .where(TrackDao.Properties.Effective.eq(1)).list(); Track track = null; float totalDistance = 0.0f; for (Track value : list) { if (track != null) { LatLng latLng = new LatLng(track.getLatitude(), track.getLongitude()); totalDistance += AMapUtils.calculateLineDistance(latLng, new LatLng(value.getLatitude(), value.getLongitude())) * ((float) 1.3d); } track = value; } return (int) totalDistance; }
|
轨迹点实体 Track:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| public class Track { private Long id; private double latitude; private double longitude; private int locationType; private float accuracy; private float speed; private float bearing; private long time; private int original; private int effective;
public Track(Long id, double latitude, double longitude, int locationType, float accuracy, float speed, float bearing, long time, int original, int effective) { } }
|
4. 伪造工具的对照实现(逻辑还原)
① 签名复刻 —— SignUtils.get()(开门的钥匙)
把 MyInterceptor 的算法逐行搬过来,连同从 APK 里抠出的密钥:
1 2 3 4 5 6 7 8 9 10 11 12 13
| private static final String APPKEY = "389885588s0648fa"; private static final String APPSECRET = "56E39A1658455588885690425C0FD16055A21676";
public static String get(Map<String,String> query, String body) { StringBuilder sb = new StringBuilder(); for (String str : new TreeSet(query.keySet())) { sb.append(str).append(query.get(str)); } sb.append(APPKEY).append(APPSECRET); if (body != null) sb.append(body); }
|
有个手抄痕迹很有意思:它的请求头用的是小写 appkey(官方是 appKey),HTTP 头不区分大小写所以能过——典型的手工移植特征。
② 协议登录 —— Request.login()
1 2 3 4 5 6 7
| String strStringToMD5 = MD5Utils.stringToMD5(password);
map2.put("sign", SignUtils.get(null, strObj2String)); map2.put("token", this.token); map2.put("appkey", "389885588s0648fa"); http.doPostJson2Byte("https://run-lb.tanmasports.com/v1/auth/login/password", ...);
|
③ 拉规则 —— getRunStandard() / getSchoolBound()
注意两个细节:**schoolId=3680 是硬编码的**;getSchoolBound 拿到的围栏数据在后面只干一件事——塞进 realityTrackPoints 字段充数:
1
| newRecordBody.setRealityTrackPoints(schoolBound[0].getSiteBound() + "--");
|
④ 轨迹凭空生成 —— TrackUtils.gen()
模板图随机游走 + 共线插值 + 整数速度时间戳 + 均匀分布精度 + 起点固定 now-30分钟。这是整个伪造中唯一真正”造数据”的部分,也是全部八个统计指纹的来源。
⑤ 提交 —— Request.recordNew()
1 2 3 4 5
| map.put("sign", SignUtils.get(null, strObj2String)); map.put("token", this.token); map.put("appkey", "389885588s0648fa"); return http.doPostJson2Byte("https://run-lb.tanmasports.com/v1/x/save/run/record/new", ...);
|
五、防御建议(如何防止此类伪造现象)
1. 服务端数据校验(最重要)
伪造工具能完美复刻协议,却复刻不了真实 GPS 数据的物理统计特征,所以防线必须建在服务端:
| 校验项 |
原理 |
成本 |
| 瞬时速度连续性 |
伪造轨迹相邻点速度恒为整数(1/2/3/4 m/s),真实速度是连续值 |
极低 |
| 轨迹时长 = runTime 字段 |
伪造工具的时间戳按随机速度累加,与手填时长脱钩 |
极低 |
| 三点共线性检测 |
伪造插值点严格落在直线上,真实 GPS 有横向抖动 |
低 |
| accuracy 分布检验 |
伪造精度服从均匀分布 U[0,10),真实 GPS 右偏分布 |
低 |
| 速度自相关 |
人类速度序列平滑(lag-1 自相关≈0.9),伪造序列≈0 |
中 |
| 里程-轨迹强绑定 |
runDistance 必须等于轨迹逐段累计(含系统补偿系数容差) |
低 |
| 跨账号轨迹聚类 |
伪造模板复用导致不同账号轨迹精确命中相同坐标 |
中 |
2. 字段完整性强制
伪造工具提交的 vocalStatus、innerSchool、distanceTimeStatus 恒为 null,realityTrackPoints 是塞的垃圾串。服务端应校验:
- 必填字段非空且取值合法
realityTrackPoints 必须可解析为合法轨迹数组
realityTrackPoints 与 trackPoints 的点数比值合理(原始点 ≥ 有效点)
3. 协议层加固
- 签名升级:MD5 无时间戳、无 nonce,可离线复刻。应改为 HMAC + 时间戳 + 随机数防重放
- 密钥保护:appKey/appSecret 硬编码在 Java 层等于公开。至少下沉到 native 层并做动态派生,理想方案是服务端下发会话密钥
- 一次性提交令牌:
startRun 时服务端下发提交令牌,saveRunRecord 时校验令牌与开始时间、会话的一致性,防止”登录即提交”的极简会话
4. 行为与抽查机制
- 请求序列分析:真实客户端有持续轮询、地图加载等并发行为;协议复刻工具的会话只有”登录→查询→提交”极简序列,特征明显
- 声纹抽查强制化:跑步中随机声纹验证,未通过抽查的记录标记待复核
- 人工抽查轨迹回放:定期在地图上回放轨迹形态,肉眼即可识别”完美直线段”这类异常
- 设备指纹一致性:提交时的设备信息与该账号历史设备比对,突变即标记
5. 架构原则
客户端控制不构成安全边界。无论签名、加壳还是混淆,客户端提交的一切自证字段都可被复刻;真正的安全只能来自服务端对数据物理合理性的检验。这是本案例分析得出的核心结论:攻击者跨越了协议层的所有门槛,最终暴露在数据层的统计学规律之下。