这个是工作中遇见的一个app,需要分析一下app对于应用列表检测感知这个问题

对应的样本的网址:https://github.com/TMLP-Team/TMLP-Detectors-and-Bypassers/blob/main/Detectors/%E5%87%8C%E5%8D%BF%E6%A3%80%E6%B5%8B_v1.6_fix.apk

直接拖进jeb看一下

alt text

调用lua,看一下lingqing.bin

alt text

Lua 介绍

C 语言和 Lua 的交互是通过 lua_State来实现的,所以首先需要创建一个 lua_State

Lua 脚本加载进 Lua 虚拟机, Lua 提供了三个常用的函数

LUALIB_API int (luaL_loadfilex) (lua_State *L, const char *filename,
                                               const char *mode);
#define luaL_loadfile(L,f)	luaL_loadfilex(L,f,NULL)

LUALIB_API int (luaL_loadbufferx) (lua_State *L, const char *buff, size_t sz,
                                   const char *name, const char *mode);
LUALIB_API int (luaL_loadstring) (lua_State *L, const char *s);

这三个函数最后走的都是 lua_load 函数,把经过编译后的代码放到了栈顶,执行加载到栈顶的 Lua 代码,上面的函数只是将程序加载到了栈顶,执行了之后才能变成虚拟机中的函数或者变量,因为在栈顶也没有需要传入的参数,所以只需要调用 lua_pcall 即可

LUA_API int lua_pcallk (lua_State *L, int nargs, int nresults, int errfunc,
                        lua_KContext ctx, lua_KFunction k)

上面的常用的函数有对应的执行合并参数

alt text

lua 加载执行流程

luaL_loadbufferx来直接看

LUALIB_API int luaL_loadbufferx (lua_State *L, const char *buff, size_t size,
                                 const char *name, const char *mode) {
  LoadS ls;
  ls.s = buff;
  ls.size = size;
  return lua_load(L, getS, &ls, name, mode);
}

调用lua_load

LUA_API int lua_load (lua_State *L, lua_Reader reader, void *data,
                      const char *chunkname, const char *mode) {
  ZIO z;
  int status;
  lua_lock(L);
  if (!chunkname) chunkname = "?";
  luaZ_init(L, &z, reader, data);
  status = luaD_protectedparser(L, &z, chunkname, mode);
  if (status == LUA_OK) {  /* no errors? */
    LClosure *f = clLvalue(L->top - 1);  /* get newly created function */
    if (f->nupvalues >= 1) {  /* does it have an upvalue? */
      /* get global table from registry */
      Table *reg = hvalue(&G(L)->l_registry);
      const TValue *gt = luaH_getint(reg, LUA_RIDX_GLOBALS);
      /* set global table as 1st upvalue of 'f' (may be LUA_ENV) */
      setobj(L, f->upvals[0]->v, gt);
      luaC_upvalbarrier(L, f->upvals[0]);
    }
  }
  lua_unlock(L);
  return status;
}

luaD_protectedparser

int luaD_protectedparser (lua_State *L, ZIO *z, const char *name,
                                        const char *mode) {
  struct SParser p;
  int status;
  L->nny++;  /* cannot yield during parsing */
  p.z = z; p.name = name; p.mode = mode;
  p.dyd.actvar.arr = NULL; p.dyd.actvar.size = 0;
  p.dyd.gt.arr = NULL; p.dyd.gt.size = 0;
  p.dyd.label.arr = NULL; p.dyd.label.size = 0;
  luaZ_initbuffer(L, &p.buff);
  status = luaD_pcall(L, f_parser, &p, savestack(L, L->top), L->errfunc);
  luaZ_freebuffer(L, &p.buff);
  luaM_freearray(L, p.dyd.actvar.arr, p.dyd.actvar.size);
  luaM_freearray(L, p.dyd.gt.arr, p.dyd.gt.size);
  luaM_freearray(L, p.dyd.label.arr, p.dyd.label.size);
  L->nny--;
  return status;
}

f_parser

static void f_parser (lua_State *L, void *ud) {
  LClosure *cl;
  struct SParser *p = cast(struct SParser *, ud);
  int c = zgetc(p->z);  /* read first character */
  if (c == LUA_SIGNATURE[0]) {
    checkmode(L, p->mode, "binary");
    cl = luaU_undump(L, p->z, p->name);
  }
  else {
    checkmode(L, p->mode, "text");
    cl = luaY_parser(L, p->z, &p->buff, &p->dyd, p->name, c);
  }
  lua_assert(cl->nupvalues == cl->p->sizeupvalues);
  luaF_initupvals(L, cl);
}

luaU_undump 或者luaY_parser,一个是luac的二进制文件,一个是luac的脚本文件

思路和流程

因为有很多魔改对于文件的结构上的那些调整,可以在load完成之后,执行之前的时候,因为这个时候程序在栈顶的时候,调用标准的lua_dump函数,就可以获得luac文件了

alt text

typedef int (*lua_Writer) (lua_State *L, const void *p, size_t sz, void *ud);

当前的文件是lua 5.3.3版本:

alt text

直接ida看一下luaL_loadbufferx,这个的luaL_loadbufferx和源码中的是不一样的所以如果frida hook的话,是要在leave的时候

alt text

看这个反编译中带有lua_dump的函数,所以可以直接调用,脚本:

var lq_lua = Process.findModuleByName("liblqd_lua.so");
var count = 0;

var fopen  = Process.findModuleByName("libc.so").findExportByName("fopen");
var fwrite = Process.findModuleByName("libc.so").findExportByName("fwrite");
var fopen_func = new NativeFunction(fopen, 'pointer', ['pointer', 'pointer']);
var fwrite_func = new NativeFunction(fwrite, 'size_t', ['pointer', 'size_t', 'size_t', 'pointer']);
var targetAddr = lq_lua.base.add(0x1660C);
var lua_dump_ptr = lq_lua.base.add(0x13E14);
var lua_dump = new NativeFunction(lua_dump_ptr, 'int', ['pointer', 'pointer', 'pointer', 'int']);

var currentFile = null;
var writer_cb = new NativeCallback(function (L, p, sz, ud) {
    try {
        const n = Number(sz);
        if (n === 0) return 0;
        const wrote = fwrite_func(p, n, 1, ud);
        return (Number(wrote) === 1) ? 0 : 1;
    } catch (e) {
        //expected a pointer
        console.log("writer_cb exception: " + e);
        return 1;
    }
}, 'int', ['pointer', 'pointer', 'size_t', 'pointer']);

console.log("lq_luaV_gettable @ " + targetAddr +
            " insn=" + Instruction.parse(targetAddr).toString());
console.log("lua_dump @ " + lua_dump_ptr);

Interceptor.attach(targetAddr, {
    onEnter: (args) => {
        count += 1;
        this.fname = "/data/data/com.lingqing.detector/files/dump/dump.luac" + count;
        this.L = args[0];
    },
    onLeave: (retval) => {
        console.log("lq_luaV_gettable called " + count + " times");
        try {
            currentFile = new File(this.fname, "wb");
        } catch (e) {
            console.log("Failed to open file " + this.fname + ": " + e);
            currentFile = null;
        }
        var cName = Memory.allocUtf8String(this.fname);
        var cMode = Memory.allocUtf8String("wb");
        var file = fopen_func(cName, cMode);
        if (currentFile) {
            try {
                //fwrite_func
                var status = lua_dump(this.L, writer_cb, file, 0);
                console.log("lua_dump status=" + status);
            } catch (e2) {
                console.log("lua_dump exception: " + e2);
            }
            try { currentFile.flush(); currentFile.close(); } catch (e3) {}
        }
        currentFile = null;
    }
});

可以抓到这6个luac,但是这么抓的还是会有加密的,说明需要调用原版本的lualua_dump,自己编译一个调用lua_dump

var lq_lua = Process.findModuleByName("liblqd_lua.so");
var liblua = Module.load("/data/data/com.lingqing.detector/files/liblua_share.so");
var pdump = liblua.findExportByName("lua_dump");
var fun_dump = new NativeFunction(pdump, "int", ["pointer", "pointer", "pointer", "int"]);
var count = 0;

var fopen  = Process.findModuleByName("libc.so").findExportByName("fopen");
var fwrite = Process.findModuleByName("libc.so").findExportByName("fwrite");
var fopen_func = new NativeFunction(fopen, 'pointer', ['pointer', 'pointer']);
var fwrite_func = new NativeFunction(fwrite, 'size_t', ['pointer', 'size_t', 'size_t', 'pointer']);
var targetAddr = lq_lua.base.add(0x1660C);
var lua_dump_ptr = lq_lua.base.add(0x13E14);
var lua_dump = new NativeFunction(lua_dump_ptr, 'int', ['pointer', 'pointer', 'pointer', 'int']);

var currentFile = null;
var writer_cb = new NativeCallback(function (L, p, sz, ud) {
    try {
        const n = Number(sz);
        if (n === 0) return 0;
        const wrote = fwrite_func(p, n, 1, ud);
        return (Number(wrote) === 1) ? 0 : 1;
    } catch (e) {
        //expected a pointer
        console.log("writer_cb exception: " + e);
        return 1;
    }
}, 'int', ['pointer', 'pointer', 'size_t', 'pointer']);

console.log("lq_luaV_gettable @ " + targetAddr +
            " insn=" + Instruction.parse(targetAddr).toString());
console.log("lua_dump @ " + lua_dump_ptr);

Interceptor.attach(targetAddr, {
    onEnter: (args) => {
        count += 1;
        this.fname = "/data/data/com.lingqing.detector/files/dump/dump.luac" + count;
        this.L = args[0];
    },
    onLeave: (retval) => {
        console.log("lq_luaV_gettable called " + count + " times");
        try {
            currentFile = new File(this.fname, "wb");
        } catch (e) {
            console.log("Failed to open file " + this.fname + ": " + e);
            currentFile = null;
        }
        var cName = Memory.allocUtf8String(this.fname);
        var cMode = Memory.allocUtf8String("wb");
        var file = fopen_func(cName, cMode);
        if (currentFile) {
            try {
                //fwrite_func
                var status = fun_dump(this.L, writer_cb, ptr(file), 0);
                console.log("lua_dump status=" + status);
            } catch (e2) {
                console.log("lua_dump exception: " + e2);
            }
            try { currentFile.flush(); currentFile.close(); } catch (e3) {}
        }
        currentFile = null;
    }
});

这次就可以进行反编译了

alt text

但是a1和a6并不能反编译出来,这里可能有点问题,回看这个样本自带的关于luaso,在lua_load里进行逆向可以发现

因为按照之前看源码的流程,首先定位到luaD_protectedparser

alt text

进来后定位到f_parser

alt text

再定位到luaU_undump,但是这个里面还有一个luaU_undump0x1a,0x1c两个case的是魔改的undumpsub_32D38的位置上

alt text

进入sub_32d38中找到LoadFunction,可以看到loadstring是魔改的

alt text

直接打开unluac源码的LStringType,添加解密代码逻辑

package unluac.parse;

import java.io.IOException;
import java.io.OutputStream;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;

import unluac.Version;

public abstract class LStringType extends BObjectType<LString> {

  public static LStringType get(Version.StringType type) {
    switch (type) {
      case LUA50:
        return new LStringType50();
      case LUA53:
        return new LStringType53();
      case LUA54:
        return new LStringType54();
      default:
        throw new IllegalStateException();
    }
  }

  protected ThreadLocal<StringBuilder> b = new ThreadLocal<StringBuilder>() {

    @Override
    protected StringBuilder initialValue() {
      return new StringBuilder();
    }

  };

}

class LStringType50 extends LStringType {

  @Override
  public LString parse(final ByteBuffer buffer, BHeader header) {
    BInteger sizeT = header.sizeT.parse(buffer, header);
    final StringBuilder b = this.b.get();
    b.setLength(0);
    sizeT.iterate(new Runnable() {

      @Override
      public void run() {
        b.append((char) (0xFF & buffer.get()));
      }

    });
    if (b.length() == 0) {
      return LString.NULL;
    } else {
      char last = b.charAt(b.length() - 1);
      b.delete(b.length() - 1, b.length());
      String s = b.toString();
      if (header.debug) {
        System.out.println("-- parsed <string> \"" + s + "\"");
      }
      return new LString(s, last);
    }
  }

  @Override
  public void write(OutputStream out, BHeader header, LString string) throws IOException {
    int len = string.value.length();
    if (string == LString.NULL) {
      header.sizeT.write(out, header, header.sizeT.create(0));
    } else {
      header.sizeT.write(out, header, header.sizeT.create(len + 1));
      for (int i = 0; i < len; i++) {
        out.write(string.value.charAt(i));
      }
      out.write(0);
    }
  }
}

class LStringType53 extends LStringType {

  @Override
  public LString parse(final ByteBuffer buffer, BHeader header) {
    // 读取长度(含终止 0)
    BInteger sizeT;
    int sizeByte = 0xFF & buffer.get();
    if (sizeByte == 0) {
      return LString.NULL;
    } else if (sizeByte == 0xFF) {
      sizeT = header.sizeT.parse(buffer, header); // 真实长度(含终止 0)
    } else {
      sizeT = new BInteger(sizeByte);
    }
    int fullLen = sizeT.asInt(); // 包括终止 0
    int dataLen = fullLen - 1;   // 实际字符串长度
    if (dataLen < 0) {
      // 异常情况,返回空
      return LString.NULL;
    }

    // 读取加壳后的原始数据(魔改版:全部被异或编码)
    byte[] encoded = new byte[dataLen];
    for (int i = 0; i < dataLen; i++) {
      encoded[i] = buffer.get();
    }

    // 解码:与 IDA 反编译出的 LoadString_xor 算法一致
    byte[] decoded = decodeXor(encoded);
    String s = new String(decoded, StandardCharsets.ISO_8859_1);
    if (header.debug) {
      System.out.println("-- parsed <string-xor> \"" + s + "\"");
    }
    return new LString(s);
  }

  @Override
  public void write(OutputStream out, BHeader header, LString string) throws IOException {
    if (string == LString.NULL) {
      out.write(0);
    } else {
      // 写出时保持魔改前的格式(明文写出,未重新加壳)。
      // 如果需要生成“加壳” dump,可在这里对 string.value 先 encodeXor 后再写。
      int plainLenWithNull = string.value.length() + 1;
      if (plainLenWithNull < 0xFF) {
        out.write((byte) plainLenWithNull);
      } else {
        out.write(0xFF);
        header.sizeT.write(out, header, header.sizeT.create(plainLenWithNull));
      }
      for (int i = 0; i < string.value.length(); i++) {
        out.write(string.value.charAt(i));
      }
    }
  }

  // 魔改 XOR 解码逻辑:输入为被编码的实际数据(不含终止 0)
  private static byte[] decodeXor(byte[] enc) {
    int n = enc.length;
    if (n == 0) {
      return enc; // 空串
    }
    byte[] out = new byte[n];
    int mod = n % 255;
    int e0 = enc[0] & 0xFF;
    out[0] = (byte) (e0 ^ mod);
    int v8 = mod ^ e0; // (n % 255) ^ e0
    long t = 2L * n + v8; // 可能超过 32 位,使用 long
    long step = n + v8;
    for (int i = 1; i < n; i++) {
      out[i] = (byte) (enc[i] ^ (int) (t % 255));
      t += step;
    }
    return out;
  }

  // 如果未来需要重新加壳,可提供 encodeXor
  @SuppressWarnings("unused")
  private static byte[] encodeXor(byte[] plain) {
    int n = plain.length;
    if (n == 0) {
      return plain;
    }
    byte[] enc = new byte[n];
    int mod = n % 255;
    int p0 = plain[0] & 0xFF;
    // 逆推:e0 = out[0] ^ (n % 255)
    int e0 = p0 ^ mod;
    enc[0] = (byte) e0;
    int v8 = mod ^ e0;
    long t = 2L * n + v8;
    long step = n + v8;
    for (int i = 1; i < n; i++) {
      int mask = (int) (t % 255);
      enc[i] = (byte) ((plain[i] & 0xFF) ^ mask);
      t += step;
    }
    return enc;
  }
}

class LStringType54 extends LStringType {

  @Override
  public LString parse(final ByteBuffer buffer, BHeader header) {
    BInteger sizeT = header.sizeT.parse(buffer, header);
    if (sizeT.asInt() == 0) {
      return LString.NULL;
    }
    final StringBuilder b = this.b.get();
    b.setLength(0);
    sizeT.iterate(new Runnable() {

      boolean first = true;

      @Override
      public void run() {
        if (!first) {
          b.append((char) (0xFF & buffer.get()));
        } else {
          first = false;
        }
      }

    });
    String s = b.toString();
    if (header.debug) {
      System.out.println("-- parsed <string> \"" + s + "\"");
    }
    return new LString(s);
  }

  @Override
  public void write(OutputStream out, BHeader header, LString string) throws IOException {
    if (string == LString.NULL) {
      header.sizeT.write(out, header, header.sizeT.create(0));
    } else {
      header.sizeT.write(out, header, header.sizeT.create(string.value.length() + 1));
      for (int i = 0; i < string.value.length(); i++) {
        out.write(string.value.charAt(i));
      }
    }
  }
}

自己编译一下unluac然后运行

java -jar /Users/l0x1c/decom/unluac.jar ./dump.luac6 --disassemble --output out.asm

编译回bin

java -jar /Users/l0x1c/decom/unluac.jar ./out.asm -assemble  --output out.bin

如果要用这个unluac的这个逻辑需要在lua_load的时候hook,因为如果调用原版的是已经解密完的了

var picStore = Process.findModuleByName("liblqd_lua.so");
var count = 0;

console.log(Instruction.parse(picStore.base.add(0x13D24)).toString());
Interceptor.attach(picStore.base.add(0x13D24), { // luaL_loadfilex func_addr
    onEnter: (args) => {
        var reader = ptr(args[2])
        count += 1;
        this.fname = "/data/data/com.lingqing.detector/files/dump/dump.luac" + count.toString()
        var file_handle = new File(this.fname + "hook", "wb");
        console.log(reader.readByteArray(16))
        var len = ptr(reader.add(8)).readInt()
        file_handle.write(reader.readPointer().readByteArray(len));
        file_handle.flush();
        file_handle.close();
        this.L = args[0];

    },
    onLeave: (retval) => {
    }
})

这个时候dump出来的1文件可以看到头可以和那个case相对应上了

alt text

这个时候调用编译好的unluac,看一下

alt text

通过分析来看的话,6.lua的文件应该是字符串加密的解密器,直接hook一下lua_pushstring以及strncmp看一下大概的流程

var mInterval = setInterval(function () {
    var mod = Process.findModuleByName("liblqd_lua.so")
    var strncmp = mod.findExportByName("strncmp");
    if (mod == null) {
        console.log("无")
        return
    }
    clearInterval(mInterval);
    
    var addr = mod.base.add(0x12204)
    console.log("hook addr: " + addr);
    console.log(Instruction.parse(addr).toString());
    Interceptor.attach(addr, {
        onEnter: function (args) {
            if(args[1].readCString().indexOf("_")!=0)
            console.log(args[1].readCString())
        }
    })
    //endline
    
    Interceptor.attach(strncmp, {
        onEnter: function (args) {
            var s1 = args[0].readCString();
            var s2 = args[1].readCString();
            var n = args[2].toInt32();
            console.log("strncmp called: " + s1 + " , " + s2 + " , " + n);
        }
    });

}, 1)

hook后可以看见一个关键的东西就是

alt text

https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/services/core/java/com/android/server/pm/ComputerEngine.java;l=2018;drc=61197364367c9e404c7da6900658f1b16c42d0da

alt text

可以理解为通过uid来进行查询的,然后再通过GetPackageInfo来看是否存在来看是否有隐藏的app