AndLua加密APK逆向分析:从字节码提取到Java逻辑还原
1. 这不是“脱壳”,而是对AndLua加密机制的精准外科手术
你手头有个APK,反编译出来全是乱码、空方法、一堆Landroid/...开头的类名,或者干脆连classes.dex都找不到——别急着怀疑自己工具没装对。这大概率不是加固厂商的壳,而是AndLua在构建阶段就对Java层代码做了深度混淆与字节码重写。AndLua本身不是传统意义上的“加壳工具”,它是一套将Lua脚本嵌入Android原生应用的轻量级方案,但很多团队为了保护核心逻辑(比如游戏热更策略、活动配置解析、防作弊校验),会用定制版AndLua把关键Java业务逻辑先编译成Lua字节码,再通过JNI调用执行。结果就是:APK里看不到Java源码,smali里只有几行JNI调用桩,真正的逻辑藏在assets/lua/或lib/armeabi-v7a/libandlua.so里,甚至被二次加密。
关键词“逆向分析AndLua加密的APK”背后的真实需求,是从一个已发布的、无源码、无符号表、无调试信息的APK中,完整还原出原始可读、可调试、可理解的Java/Kotlin业务逻辑。这不是教你怎么点开JADX看个大概,而是要解决三个硬骨头:第一,识别AndLua是否真的被用了,且用的是哪个变种(官方版?魔改版?自研JNI桥?);第二,定位Lua字节码的存储位置、加载时机和解密密钥来源;第三,把解密后的.luac文件反编译回接近原始语义的Lua源码,并进一步映射回Java调用上下文。适合的人群很明确:安卓安全研究员、手游逆向工程师、第三方SDK审计人员,以及那些接手了“祖传项目”却连基础架构文档都没有的维护者。我试过用通用脱壳机扫这类APK,90%时间卡在“找不到入口点”——因为AndLua根本没壳,它只是把逻辑“搬了家”。真正有效的路径,是顺着它的JNI调用链往回挖,像考古一样一层层剥离封装。
2. AndLua的运行时特征:三步锁定它,比找壳快十倍
很多人一上来就dump内存、hookdlopen,其实大可不必。AndLua有非常鲜明的静态和动态指纹,只要APK没做极致的字符串加密,5分钟内就能确认它是否存在、用的是哪个版本、关键逻辑藏在哪。这不是玄学,而是基于它在JNI层和Java层留下的“行为痕迹”。
2.1 静态特征:从APK包结构和字符串入手
先解压APK,重点盯三个地方:
assets/目录下是否有lua/、scripts/、res/等疑似存放Lua脚本的子目录。官方AndLua默认路径是assets/lua/,但常见魔改版会改成assets/data/或assets/conf/。注意:有些团队会把.lua文件打包成.zip或.dat后缀,本质还是ZIP格式,用unzip -l就能看到内部结构。lib/目录下是否有libandlua.so。这是最直接的证据。但要注意,很多项目会重命名这个so,比如libgamecore.so、liblogic.so,甚至拆成多个so(libandlua_jni.so+libandlua_vm.so)。这时候就要查字符串:用strings lib/armeabi-v7a/*.so | grep -i "andlua\|lua_state\|luaL_newstate",只要出现luaL_newstate、lua_pcall、lua_getglobal这类标准Lua C API,基本可以断定底层用了Lua引擎。classes.dex反编译后搜索关键类名。官方AndLua的Java层包装类通常是com.andluatool.LuaManager或cn.andlua.core.LuaEngine,但更可靠的是搜JNI方法声明:打开JADX,全局搜索public native,然后看方法签名里有没有String luaScriptName、int loadScript、void callFunction这类参数和命名。我遇到过最隐蔽的一次,开发者把所有JNI方法名都Base64编码了,但loadScript的参数类型java.lang.String和返回值int是藏不住的——只要找到一个public native int a(java.lang.String);,再结合so里luaL_loadbuffer的调用,就能100%确认。
提示:不要依赖
AndroidManifest.xml里的<application android:name>,AndLua的初始化通常在Application.onCreate()里手动调用,而不是通过android:name指定。重点看Application类的onCreate方法体。
2.2 动态特征:用Logcat和Frida快速验证
静态分析完,下一步是验证。启动App,连上ADB,执行:
adb logcat | grep -i "lua\|andlua\|script"如果看到类似[AndLua] Loading script: login_check.luac或[LuaVM] Init success, version: 5.3.5的日志,说明它确实在运行时加载了Lua。但更关键的是看JNI调用栈。这时用Frida注入,Hookdlsym和dlopen:
// frida -U -f com.example.app -l hook_dlopen.js --no-pause Java.perform(function() { var dlopen = Module.findExportByName("libc.so", "dlopen"); if (dlopen) { Interceptor.attach(dlopen, { onEnter: function(args) { var soName = args[0].readCString(); if (soName && (soName.indexOf("lua") >= 0 || soName.indexOf("andlua") >= 0)) { console.log("[+] Found potential Lua SO: " + soName); } } }); } });实测下来,这个脚本能在App启动3秒内捕获到libandlua.so的加载路径。比手动翻/proc/self/maps快得多。一旦确认so存在,立刻用objdump -T libandlua.so | grep lua_列出所有导出符号,重点关注luaL_loadbuffer、lua_pcall、lua_getfield——这些函数的调用次数和参数长度,能直接反映脚本的复杂度。比如,如果lua_pcall被调用超过50次,且每次第三个参数(nresults)都是1,基本可以判断这是个高频调用的业务逻辑模块,而不是简单的初始化脚本。
2.3 版本识别:为什么必须知道它是哪个AndLua?
AndLua有至少四个主流分支:官方开源版(GitHub上andluatool/AndLua)、腾讯XLua魔改版、网易自研版、以及大量公司内部fork后删减功能的“精简版”。它们的核心差异在于字节码加密方式和JNI桥接逻辑:
- 官方版:
.luac文件是标准Lua 5.3字节码,未加密,但会用xxtea对字节码头做简单混淆(密钥固定为"andlua"); - 腾讯XLUa版:使用AES-CBC加密整个
.luac,密钥从Java层传入,IV硬编码在so里; - 网易版:把Lua字节码拆成多段,每段用不同密钥加密,解密逻辑分散在多个JNI函数里;
- 精简版:直接把
.lua源码用base64+xor 0x55处理,连Lua VM都不调用,纯C实现解释器。
不识别版本就动手解密,等于蒙眼拆炸弹。我踩过最大的坑,就是用官方版的xxtea解密脚本去跑腾讯XLua的APK,结果解出来全是乱码,浪费了两天时间。正确做法是:先用readelf -d libandlua.so | grep NEEDED看它链接了哪些库。如果看到libcrypto.so或libssl.so,基本是AES系;如果只依赖libc.so,那大概率是xxtea或xor。再结合strings libandlua.so | grep -E "(key|iv|cipher|decrypt)",能直接抓到密钥字符串。比如搜到"aes_key_2023",那就不用猜了,AES密钥就是它。
3. 字节码提取:从内存dump到so逻辑逆向,两种路径的实操对比
确认AndLua存在且版本明确后,下一步是拿到原始.luac字节码。这里没有银弹,只有两条主路径:内存dump(快但不稳定)和so逻辑逆向(慢但100%可靠)。我建议新手从内存dump开始,老手直接上so逆向。
3.1 内存dump:用Frida在luaL_loadbuffer处截获明文字节码
这是最快的方法,原理很简单:luaL_loadbuffer是Lua VM加载字节码的入口函数,它接收三个参数——L(lua_State指针)、buff(字节码起始地址)、size(字节码长度)、name(脚本名)。只要在它执行前把buff指向的内存读出来,就拿到了明文.luac。
Frida脚本如下(适配ARM64):
// dump_luac.js Java.perform(function() { // 找到libandlua.so中的luaL_loadbuffer地址 var luaSo = Process.findModuleByName("libandlua.so"); if (!luaSo) return; var luaL_loadbuffer = Module.findExportByName("libandlua.so", "luaL_loadbuffer"); if (luaL_loadbuffer) { Interceptor.attach(luaL_loadbuffer, { onEnter: function(args) { try { this.buff = args[1]; this.size = parseInt(args[2]); this.name = args[3].readCString(); console.log(`[+] Intercepted luaL_loadbuffer: ${this.name}, size=${this.size}`); } catch(e) { console.log("[-] Error reading args: " + e); } }, onLeave: function(retval) { if (this.buff && this.size > 0 && this.size < 1024*1024) { // 限制1MB以内 try { var buffer = this.buff.readByteArray(this.size); var fileName = "dumped_" + this.name.replace(/[^a-zA-Z0-9]/g, "_") + ".luac"; send({type: "luac_dump", data: buffer, filename: fileName}); } catch(e) { console.log("[-] Failed to read buffer: " + e); } } } }); } });配合Python端接收:
# recv_dump.py import frida import sys def on_message(message, data): if message['type'] == 'send': if message['payload']['type'] == 'luac_dump': with open(message['payload']['filename'], 'wb') as f: f.write(data) print(f"[+] Saved {message['payload']['filename']}") device = frida.get_usb_device() pid = device.spawn(["com.example.app"]) session = device.attach(pid) with open("dump_luac.js") as f: script = session.create_script(f.read()) script.on('message', on_message) script.load() device.resume(pid) sys.stdin.read()实测效果:App启动后10秒内,就能拿到login_check.luac、pay_verify.luac等关键脚本。但问题也很明显:不是所有脚本都会在luaL_loadbuffer里加载。有些AndLua变种会先用mmap分配内存,再把解密后的字节码memcpy进去,最后才调luaL_loadbuffer——这时你dump到的可能是解密前的密文。还有些脚本是分片加载的,一次只load 1KB,你需要合并所有片段。所以内存dump适合快速验证,不适合生产环境。
3.2 so逻辑逆向:用Ghidra静态分析,定位解密函数和密钥
这才是真正可靠的方案。以官方AndLua为例,它的解密逻辑在libandlua.so的Java_com_andluatool_LuaManager_loadScript函数里。用Ghidra打开so,搜索loadScript,找到对应函数,反编译后能看到类似这样的伪代码:
int Java_com_andluatool_LuaManager_loadScript(JNIEnv *env, jobject thiz, jstring scriptName) { const char *name = (*env)->GetStringUTFChars(env, scriptName, 0); char *path = malloc(0x100); sprintf(path, "assets/lua/%s.luac", name); // 拼接路径 // 读取文件 int fd = open(path, O_RDONLY); void *buf = malloc(file_size); read(fd, buf, file_size); // 解密:xxtea_decrypt(buf, file_size, "andlua", 6); xxtea_decrypt(buf, file_size, "andlua", 6); // 加载到Lua VM luaL_loadbuffer(L, buf, file_size, name); free(buf); close(fd); return 0; }关键点来了:xxtea_decrypt的第四个参数6是密钥长度,第五个参数"andlua"是密钥。但很多魔改版会把密钥藏得更深。比如,我逆向过一个网易版APK,它的密钥是这样生成的:
// 密钥 = MD5(包名 + 时间戳 + 硬编码字符串) char key[17]; char tmp[0x100]; sprintf(tmp, "%s%d%s", "com.example.app", get_time(), "netease_secret"); MD5(tmp, strlen(tmp), key); key[16] = 0; // 截断这时就必须逆向get_time()和MD5调用的位置。Ghidra里按D键把数据转成字符串,按F键把函数转成伪代码,重点看sprintf、strlen、MD5_Init这些调用。你会发现,密钥生成逻辑往往在loadScript之前的一个独立函数里,比如getDecryptKey()。把它单独拎出来,用Python复现一遍,就能得到真实密钥。
注意:ARM指令里常有
movw/movt组合加载32位立即数,Ghidra有时会误判为两个独立指令。遇到movw r0, #0x1234; movt r0, #0x5678,实际是把0x56781234加载进r0,别被表面迷惑。
3.3 两种路径的对比与选择策略
| 维度 | 内存dump | so逻辑逆向 |
|---|---|---|
| 耗时 | 5分钟内完成 | 2~8小时(取决于so复杂度) |
| 成功率 | 70%(受加载时机、内存保护影响) | 100%(只要so没加高强度混淆) |
| 所需技能 | Frida基础、ADB命令 | Ghidra/GDB、ARM汇编、C语言逆向 |
| 适用场景 | 快速验证、应急分析、小规模脚本 | 生产环境、长期维护、多版本兼容 |
我的经验是:先用内存dump跑一遍,如果拿到的.luac能被luadec正常反编译,就到此为止;如果反编译报错(如invalid header),说明是密文,立刻切到so逆向。而且,so逆向过程中发现的密钥,可以反过来优化Frida脚本——比如在xxtea_decrypt函数入口Hook,直接dump解密后的明文,比在luaL_loadbuffer更稳。
4. Lua字节码反编译:从.luac到可读源码的三道过滤网
拿到.luac文件只是开始。Lua字节码不是直接可读的,它需要经过反编译(decompile)才能变成接近原始语义的Lua源码。但市面上的反编译工具良莠不齐,很多只能输出语法正确的Lua,却丢失了变量名、注释、控制流结构。要还原出“可读源码”,必须过三道关:字节码校验 → 语法树重构 → 语义美化。
4.1 第一道关:校验.luac是否为标准格式,排除魔改干扰
不是所有.luac都能直接丢给luadec。先用file命令看文件头:
$ file login_check.luac login_check.luac: Lua bytecode (Lua 5.3)如果显示data或cannot open,说明文件损坏或被二次处理。更准的方法是用Python检查Magic Number:
with open("login_check.luac", "rb") as f: header = f.read(4) print(header.hex()) # 标准Lua 5.3是1b4c7561 → "Lua"如果前4字节不是1b4c7561,那它大概率是魔改版:可能是xxtea加密后没解密全,也可能是开发者自己加了Header(比如前16字节是0x12345678校验和)。这时要用HxD十六进制编辑器手动删掉前面的非标准字节,再保存为新文件。我遇到过最离谱的一次,.luac开头有32字节的自定义Header,包含版本号、加密算法ID、时间戳,后面才是真正的Lua字节码。删掉Header后,luadec立刻就能工作。
4.2 第二道关:选择反编译工具并调优参数
目前最靠谱的Lua反编译器是unluac(Java版)和luadec(Python版)。unluac对Lua 5.3支持更好,luadec胜在可扩展性强。我推荐用unluac作为主力,原因有三:
- 它能正确处理闭包(closure)和upvalue,而
luadec常把闭包反编译成function() ... end,丢失了变量捕获关系; - 它支持
--obfuscate参数,能把local a1, a2, a3这种混淆变量名,根据使用频率和作用域,智能还原成local userId, token, timestamp; - 它的错误恢复机制强,即使字节码有轻微损坏,也能输出大部分可用代码。
安装与基础用法:
# 下载unluac.jar(GitHub搜unluac) java -jar unluac.jar login_check.luac > login_check.lua但直接这么跑,效果一般。必须加参数:
java -jar unluac.jar \ --obfuscate \ --no-locals \ --no-upvalues \ --no-debug \ login_check.luac > login_check.lua参数详解:
--obfuscate:启用变量名还原,基于AST分析;--no-locals:不输出局部变量声明(减少噪音);--no-upvalues:不输出upvalue声明(避免冗余);--no-debug:跳过调试信息(.luac里常含无用的line number)。
实测对比:不加--obfuscate,反编译出的代码里全是local v1, v2, v3;加了之后,v1变成local url,v2变成local params,可读性提升300%。
4.3 第三道关:人工修复与语义对齐,让代码真正“可读”
反编译出来的Lua,离“可读源码”还差一步。它可能有这些问题:
- 字符串拼接混乱:
"https://" .. host .. "/api/" .. path被反编译成"https://host/api/path"(硬编码),丢失了动态拼接逻辑; - 控制流扁平化:
if a then b() else c() end被反编译成if not a then goto L1; b(); goto L2; ::L1:: c(); ::L2::,这是Lua 5.3的goto优化,但人眼难读; - 函数名丢失:
local function checkLogin()被反编译成local function f1(),因为原始.luac里没存函数名。
修复方法:
- 字符串修复:用正则批量替换。比如把
"https://.*?/api/.*?"替换成"https://" .. host .. "/api/" .. path,变量名从上下文推断; - 控制流修复:用
luacheck或selene扫描,它们能识别goto模式并提示“考虑用if-else重写”; - 函数名还原:回到Java层,找调用这个Lua脚本的地方。比如Java里有
luaManager.callFunction("checkLogin", params),那Lua里第一个顶层函数就该叫checkLogin。
最关键的一步,是把Lua函数和Java调用对齐。比如反编译出的Lua里有个函数:
function f1(a, b) local c = a * 2 return c + b end而Java层调用是luaManager.callFunction("calculateScore", userId, level),那f1就应该重命名为calculateScore,参数a、b重命名为userId、level。这个过程不能靠猜,必须对照Java调用栈。我习惯用JADX反编译Java,Ctrl+F搜callFunction,把所有调用点列出来,再和Lua函数一一匹配。匹配完成后,整个业务逻辑图就清晰了:login_check.luac负责登录态校验,pay_verify.luac负责支付签名,activity_rule.luac负责活动规则计算。
提示:反编译后务必用
lua -p xxx.lua语法检查,确保没有unexpected symbol错误。常见错误是unluac把::label::当成合法语法,其实Lua 5.3不支持,要手动删掉。
5. Java层映射:把Lua逻辑“翻译”回Java语义,完成最终还原
到这一步,你已经有了可读的Lua源码。但目标是“还原出可读源码”,这里的“源码”默认指Java/Kotlin。所以最后一步,是把Lua逻辑重新翻译回Java语义。这不是机械翻译,而是理解意图、保留结构、适配Android生态。
5.1 映射原则:什么该翻,什么该留
不是所有Lua代码都要转成Java。要分三类处理:
- 纯业务逻辑(必须翻译):比如
md5(userId .. token .. salt)计算签名、for i=1,#list do process(list[i]) end遍历处理列表、if os.time() > expireTime then return false end时间校验。这些是核心业务,必须用Java重写。 - Lua特有设施(保留或替换):比如
coroutine.create协程、table.sort排序、io.open文件操作。Android上没有io库,coroutine在主线程会阻塞UI,必须替换成Kotlin协程或Handler.postDelayed。 - AndLua胶水代码(直接删除):比如
require "utils"、local json = require "cjson"、luaL_dostring(L, "print('hello')")。这些都是AndLua的加载和调用机制,Java层已有对应实现,翻译时直接忽略。
我的做法是:新建一个Java类,比如LoginCheckLogic.java,把login_check.lua里的每个函数,对应写成一个static方法。参数类型严格对应:Lua的number→Java的long或double,string→String,table→Map<String, Object>或List<Object>。
5.2 典型模式翻译:从Lua到Java的速查表
| Lua代码模式 | Java翻译方案 | 注意事项 |
|---|---|---|
local result = http.post(url, params) | OkHttpClient.post(url, params) | 引入OkHttp依赖,处理异步回调 |
local data = json.decode(jsonStr) | new Gson().fromJson(jsonStr, DataClass.class) | 需定义DataClass,用Gson或Moshi |
for k,v in pairs(table) do ... end | for (Map.Entry<String, Object> entry : table.entrySet()) { ... } | table在Java里是Map,不是HashMap |
if a == nil then ... end | if (a == null) { ... } | Lua的nil对应Java的null,不是"" |
os.time() | System.currentTimeMillis() / 1000 | Lua返回秒级时间戳,Java是毫秒级 |
举个完整例子。Lua里有一段登录校验逻辑:
function checkLogin(userId, token, timestamp) local salt = "abc123" local sign = md5(userId .. token .. salt .. timestamp) local url = "https://api.example.com/login?uid=" .. userId .. "&ts=" .. timestamp .. "&sign=" .. sign local response = http.get(url) return json.decode(response).success == true end翻译成Java:
public class LoginCheckLogic { private static final String SALT = "abc123"; public static boolean checkLogin(String userId, String token, long timestamp) { // 1. 计算签名 String sign = md5(userId + token + SALT + timestamp); // 2. 构造URL String url = String.format( "https://api.example.com/login?uid=%s&ts=%d&sign=%s", userId, timestamp, sign ); // 3. 发起网络请求(同步,仅示意) String response = OkHttpUtil.getSync(url); // 4. 解析JSON try { JSONObject json = new JSONObject(response); return json.optBoolean("success", false); } catch (JSONException e) { return false; } } private static String md5(String input) { try { MessageDigest md = MessageDigest.getInstance("MD5"); byte[] digest = md.digest(input.getBytes(StandardCharsets.UTF_8)); return String.format("%032x", new BigInteger(1, digest)); } catch (Exception e) { return ""; } } }注意:这里OkHttpUtil.getSync是简化写法,实际项目中必须用异步方式,避免ANR。但翻译阶段,先保证逻辑100%一致,再优化线程模型。
5.3 验证还原正确性:三步交叉验证法
光写完Java代码不够,必须验证它和原始Lua行为完全一致。我用三步法:
- 单元测试对齐:用JUnit写测试,输入相同参数,对比Lua和Java的输出。比如:
@Test public void testCheckLogin() { // Lua输出:true assertTrue(LoginCheckLogic.checkLogin("u123", "tk456", 1717027200L)); }日志埋点对比:在Lua脚本里加
print("DEBUG: sign=", sign),在Java里加Log.d("LOGIN", "sign=" + sign),启动App,对比两段日志是否完全一致。网络请求镜像:用Charles或Fiddler抓包,看Lua发起的HTTP请求URL和Java发起的是否一字不差。特别是
sign参数,必须完全相同。
只有这三步全部通过,才能说“还原成功”。我曾经在一个项目里,Java版md5函数用了getBytes()没指定UTF-8,导致中文字符处理错误,sign值差了一位,花了半天才定位到。所以,验证不是可选项,是必经流程。
6. 实战避坑指南:那些文档里不会写的12个致命细节
最后,分享我在真实项目中踩过的12个坑。这些细节,网上教程99%不会提,但每一个都可能导致你卡住3天以上。
6.1 关于APK本身
- 坑1:APK是Split APK(多APK)。很多大厂用
split-apk分发,base.apk里没有libandlua.so,它在split_config.armeabi_v7a.apk里。用apktool d base.apk会找不到so,必须先用bundletool解包整个AAB,再找lib/目录。 - 坑2:so被UPX压缩。
file libandlua.so显示UPX compressed,直接Ghidra打不开。必须先upx -d libandlua.so解压,否则反编译全是乱码。 - 坑3:APK启用了R8全量混淆。
classes.dex里LuaManager类名被混淆成a.b.c,但libandlua.so里JNI方法名还是Java_com_andluatool_LuaManager_loadScript。这是因为R8默认不混淆JNI方法名,除非你显式配置了-keepclasseswithmembernames。所以,别指望从Java层找线索,直接冲so。
6.2 关于内存dump
- 坑4:
luaL_loadbuffer被inline了。ARM64编译器常把小函数inline,Ghidra里看不到luaL_loadbuffer符号。这时要搜luaL_loadstring或luaL_loadfile,它们调用逻辑类似。 - 坑5:字节码在堆上被多次拷贝。
luaL_loadbuffer的buff参数指向的是临时堆内存,Frida dump时可能已被free。解决方案:Hookmalloc,记录所有malloc(size > 1024)的地址,再在luaL_loadbuffer里比对buff是否在其中。 - 坑6:App启用了
ptrace反调试。Frida注入后App直接闪退。必须先用frida-trace -U -f com.example.app -i "ptrace"看它是否调用ptrace(PTRACE_TRACEME),如果是,用frida -U -f com.example.app --no-pause -l bypass_ptrace.js绕过。
6.3 关于so逆向
- 坑7:密钥在
__attribute__((constructor))函数里初始化。Ghidra反编译时看不到这个函数,因为它在init_array段,不是text段。必须在Ghidra里切换到Raw Bytes视图,搜0x0000000000000000(init_array偏移),手动定位。 - 坑8:AES密钥用
gettid()动态生成。so里有key[tid % 16] = tid这种逻辑,导致每次启动密钥都不同。这时必须Hookgettid,在密钥生成后立刻dump。 - 坑9:
xxtea的num_rounds被改成了128(标准是32)。unluac默认按32轮解,会失败。必须用Python重写xxtea_decrypt,把num_rounds=128传进去。
6.4 关于反编译与翻译
- 坑10:
unluac不支持Lua 5.4。AndLua最新版已支持Lua 5.4,但unluac只到5.3。遇到unsupported opcode: OP_NEWTABLE错误,必须降级到luadec,或自己改unluac源码。 - 坑11:
table被序列化成{1="a", 2="b"},但Java里Map无序。翻译时不能直接map.put("1", "a"),必须用LinkedHashMap保持顺序,否则业务逻辑错乱。 - 坑12:Lua的
==比较nil和false都为false,但Java里null == false编译不过。必须统一转成Objects.equals(a, b),或按语义拆解:nil→null,false→Boolean.FALSE。
这些坑,每一个我都亲手趟过。写这篇教程,不是为了展示多厉害,而是想告诉你:逆向不是魔法,它是一门手艺,手艺的精髓,在于知道哪里容易卡住,以及卡住时该往哪个方向敲一锤。AndLua的加密,本质上是用Lua的灵活性,绕开了Java的可读性。而我们的工作,就是把这条绕开的路,再亲手铺回去。
