某校园跑软件逆向分析记录

一、前期准备

  • 设备: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): # JS 里 send() 的数据都到这
if message["type"] == "send":
print(message["payload"], flush=True)

device = frida.get_usb_device(timeout=10) # 经 adb USB 找到手机上的 frida-server
pid = device.spawn([PKG]) # 冷启动 App,进程暂停在入口
session = device.attach(pid) # 附上注入的 agent
script = session.create_script(JS) # 编译我们的 JS
script.on("message", on_message) # 注册回传通道
script.load() # 注入并执行 → hook 生效
device.resume(pid) # 放行,App 继续跑
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 () {
// 从 ART 运行时里拿到这个 Java 类的反射句柄
var Chain = Java.use('okhttp3.internal.http.RealInterceptorChain');

// 把 proceed 方法"偷梁换柱":原方法体被替换成这个函数
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(); // 借 OkHttp 自家的 Buffer 读 body
body.writeTo(buf);
log(' [body] ' + buf.readUtf8());
}

var response = this.proceed(request); // 调用"原版" proceed,请求真正发出

// ---- 出:响应刚回来,还是明文 ----
log('----- [RESPONSE] code=' + response.code() + ' -----');
log(' [body] ' + response.peekBody(1024 * 1024).string()); // peek=偷偷看但不消费
return response;
};
});

主要逻辑:spawn(让 App 停在入口处出生)→ 把 JS 代码注入进去 → resume 放行 → App 每调用被 hook 的函数,JS 里的 send() 就把数据发回电脑。

抓取到的数据(登录之后):

抓包数据

2. 静态分析,定位签名逻辑

通过 appKey 等关键词在 jadx 中搜索,找到关键类:

jadx 搜索 appKey

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

关键代码

还原后的逻辑(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()

// 1. 查询参数: TreeSet 收集 = 字典序 + 去重,只取每个参数的第一个值,空值跳过
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)

// 2. body 原文追加(字符集听 Content-Type 的,默认 UTF-8)
request.body()?.let { body ->
val buffer = Buffer()
body.writeTo(buffer)
signStr.append(buffer.readString(body.contentType()?.charset(UTF8) ?: UTF8))
}

// 3. 特殊字符分支: 剔除后整体 URL 编码,MD5 大写 + "encodeutf8" 后缀
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()

// 4. 加头放行;读不到用户信息就裸请求放行(防御性容错)
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(); // ★ 清空跑步SP
},
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 {
// effective=0 → 全量原始点(这就是 realityTrackPoints 的来源)
list = ContextExtKt.daoSession(this.context).getTrackDao().loadAll();
}
StringBuilder sb = new StringBuilder("[");
for (Track track : list) { // jadx 展开的 forEachIndexed,等价于此
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); // ★ GPS里程补偿系数 1.3
}
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; // GPS精度
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";      // ← 从 X 提取
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())) { // TreeSet排序,同官方
sb.append(str).append(query.get(str));
}
sb.append(APPKEY).append(APPSECRET);
if (body != null) sb.append(body);
// ...下面六连 contains/replaceAll(空格 ~ ! ( ) ')+ URLEncode
// + "encodeutf8" 后缀——和 MyInterceptor 一字不差
}

有个手抄痕迹很有意思:它的请求头用的是小写 appkey(官方是 appKey),HTTP 头不区分大小写所以能过——典型的手工移植特征。

② 协议登录 —— Request.login()

1
2
3
4
5
6
7
String strStringToMD5 = MD5Utils.stringToMD5(password);   // 密码先MD5——模仿官方客户端行为
// body: appVersion/brand/deviceToken/deviceType/mobileType/password/sysVersion/userPhone
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", ...);
// code==10000 → 拿到 oauthToken

③ 拉规则 —— 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));   // 对body整体签名
map.put("token", this.token);
map.put("appkey", "389885588s0648fa");
return http.doPostJson2Byte("https://run-lb.tanmasports.com/v1/x/save/run/record/new", ...);
// ↑ 打的也是 V2 端点,和官方现行客户端一致

五、防御建议(如何防止此类伪造现象)

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. 架构原则

客户端控制不构成安全边界。无论签名、加壳还是混淆,客户端提交的一切自证字段都可被复刻;真正的安全只能来自服务端对数据物理合理性的检验。这是本案例分析得出的核心结论:攻击者跨越了协议层的所有门槛,最终暴露在数据层的统计学规律之下。