1. 这不是“跑个unidbg”那么简单:为什么航空类App的签名分析成了逆向新手的试金石
你打开某航空App,点击值机,页面秒变空白——日志里只有一行sign failed: invalid signature;你抓包发现所有关键请求都带着一个叫hnairSign的参数,长度固定32位,但每次刷新都变;你反编译APK,看到一堆混淆成a.b.c.d.e的Java类,里面调用了一个叫nativeSign的方法,点进去只剩一个public native String hnairSign(String);。这时候,绝大多数人会卡住:Java层没逻辑,so文件又看不懂,IDA里全是sub_4012A8这种名字,连入口函数在哪都不知道。
这就是我第一次接触hnairSign时的真实状态。它不是教科书式的“Hello World”逆向案例,而是一块裹着业务逻辑糖衣的硬骨头——表面是航空值机签名,底层却融合了Android运行时环境适配、JNI桥接机制、ARM指令级控制流混淆、以及典型的“Java层壳+Native层核”双模防护结构。正因如此,它成了unidbg实战中极少数能同时检验你对Android系统机制理解深度、动态插桩工具链掌控能力、以及逆向思维颗粒度的入门级标尺。
关键词“安卓逆向”“unidbg”“hnairSign”不是标签,而是三个必须咬合的齿轮:安卓逆向定义了战场边界(Dalvik/ART虚拟机、Zygote进程模型、SELinux上下文);unidbg不是万能胶,而是你亲手组装的“虚拟Android引擎”,它不模拟整个系统,只精确复现目标so依赖的最小运行环境;hnairSign则是那个必须被拆解的黑盒函数——它的输入是JSON字符串,输出是32位hex签名,中间藏着密钥派生、时间戳校验、设备指纹绑定三重逻辑。这篇文章不教你“怎么装unidbg”,而是带你从零开始,把hnairSign这个函数在unidbg里跑通、跑稳、跑懂。适合两类人:一是刚学完《Android软件安全与逆向分析》前五章,手痒想碰真so的新手;二是写过几个Frida脚本但总卡在“so加载失败”的中级玩家。你不需要会写ARM汇编,但得知道dlopen干了什么、JNIEnv*指针里存着什么、jstring和char*之间那道看不见的墙怎么跨过去。
2. unidbg不是模拟器,是“按需拼装”的Android运行时:从零构建hnairSign的执行沙箱
很多人以为unidbg是“轻量版Android模拟器”,这是最大的认知陷阱。它既不启动Zygote进程,也不加载SystemServer,更不会解析AndroidManifest.xml。它的本质是:一个高度可定制的、基于QEMU的用户态ARM/ARM64指令解释器,配合一套精简的Android Native API模拟层(libc、liblog、libandroid、libz等),让你能手动注入Java层对象、伪造JNIEnv上下文、劫持so内部函数调用链。理解这点,才能避开90%的“unidbg跑不起来”问题。
2.1 为什么必须放弃“直接加载so”的幻想
我最初尝试的代码是这样的:
UnidbgModule module = emulator.loadLibrary(new File("libhnair.so"), true);结果报错:dlopen failed: cannot locate symbol "Java_com_hnair_utils_SignUtil_nativeSign" referenced by "libhnair.so"...
你以为是符号没导出?错了。真正的问题是:unidbg默认不模拟Java层,所以Java_com_xxx这类JNI函数根本不会被注册到符号表里。你看到的so文件,本质是一个“等待被Java世界唤醒的沉睡模块”,而unidbg需要你亲手扮演那个“唤醒者”。
提示:unidbg的
loadLibrary只是把so的ELF段加载进内存,并解析其.dynamic节获取依赖库列表。它不会自动解决JNI函数绑定——这必须由你显式调用emulator.getMemory().setLibraryName()并注册对应的UnidbgPointer回调。
2.2 构建最小可行环境:四步精准注入
要让hnairSign跑起来,你必须提供四个核心组件,缺一不可:
目标so的完整依赖链:
libhnair.so不是孤立的。用readelf -d libhnair.so | grep NEEDED查依赖,你会发现它链着libcrypto.so、libssl.so、libz.so。这些库不能随便找一个版本塞进去——libcrypto.so必须是OpenSSL 1.0.2k(该App编译时链接的版本),否则EVP_sha256()函数地址会错位。我试过用Android NDK自带的libcrypto.so,结果SHA256_Init返回-1,因为NDK版本是1.1.1,API已变更。伪造的JNIEnv与Java对象:
nativeSign函数原型是JNIEXPORT jstring JNICALL Java_com_hnair_utils_SignUtil_nativeSign(JNIEnv *env, jobject thiz, jstring data)。其中env指针必须指向一个合法的JNIEnv结构体,thiz必须是com.hnair.utils.SignUtil类的实例。unidbg提供了DvmObject和DvmClass来模拟这两者。关键细节:DvmObject的clazz字段必须指向正确的DvmClass,而DvmClass的className必须严格匹配com/hnair/utils/SignUtil(注意斜杠,不是点号)。少一个字符,env->GetObjectClass(thiz)就返回null。关键系统调用的桩函数(Stub):
hnairSign内部调用了gettimeofday()获取时间戳、open("/dev/urandom", O_RDONLY)读取随机数、__system_property_get("ro.serialno", buf)获取设备序列号。这些函数在unidbg里默认不存在,必须自己实现。例如gettimeofday桩函数:
emulator.getSyscallHandler().addSyscallHandler(ARMSyscallHandler.SYS_gettimeofday, new SyscallHandler() { @Override public UnidbgPointer handle(Emulator emulator, long offset, long r2) { // 模拟返回当前时间戳,写入emulator.getMemory().pointer(offset) Memory memory = emulator.getMemory(); UnidbgPointer tv = memory.pointer(offset); tv.setInt(0, (int) (System.currentTimeMillis() / 1000)); // sec tv.setInt(4, (int) (System.currentTimeMillis() % 1000 * 1000)); // usec return null; } });- so内符号的显式绑定:
libhnair.so里有大量__aeabi_memcmp、__gnu_mcount_nc等ARM ABI辅助函数调用。这些函数在真实Android上由libgcc或libc提供,但在unidbg里必须手动绑定。最稳妥的方式是:用emulator.getMemory().registerSymbol("__aeabi_memcmp", yourMemcmpImpl),而不是依赖unidbg内置的libc模拟——因为内置libc可能缺少某些ABI特定函数。
注意:不要试图用
emulator.attachDebugger()调试unidbg本身。它会极大拖慢执行速度,且对JNI函数断点支持极差。正确做法是:在关键位置插入logger.debug("Step X: input={}", inputStr),用日志流代替单步调试。
2.3 实测验证:环境是否真的“最小可行”
我设计了一个验证清单,每项通过才算环境搭建成功:
| 验证项 | 检查方式 | 通过标准 |
|---|---|---|
| so加载无依赖错误 | 查看emulator.loadLibrary()返回值 | 返回非null的UnidbgModule对象 |
| JNI函数可调用 | 调用module.findSymbolByName("Java_com_hnair_utils_SignUtil_nativeSign") | 返回非null的UnidbgPointer |
| JNIEnv可访问 | 在JNI函数桩里打印env->GetVersion() | 输出0x00010006(JNI_VERSION_1_6) |
| 设备信息可读取 | 在桩函数里调用__system_property_get("ro.build.version.release", buf) | buf内容为"11"(对应Android 11) |
当这四项全部绿灯,你才真正拥有了一个能跑hnairSign的沙箱。这不是配置,而是对Android Native层运行机制的一次具象化理解——你亲手拧紧了每一个螺丝,而不是祈祷某个setupAndroidEnv()魔法函数替你搞定一切。
3. 从Java签名到Native函数:hnairSign的调用链解剖与参数还原
hnairSign不是孤立函数,它是Java层精心设计的“签名门面”。要让它在unidbg里正确执行,必须先彻底厘清它在真实App中的调用路径、参数构造逻辑、以及返回值处理方式。跳过这一步直接写unidbg代码,就像没看说明书就拆发动机——你可能让活塞动起来,但不知道它为什么动、动得对不对。
3.1 真实App中的调用链:三层嵌套的签名生成器
反编译classes.dex后,SignUtil.nativeSign()的调用源头在NetworkManager类的buildRequest()方法中:
public Request buildRequest(String url, Map<String, String> params) { JSONObject json = new JSONObject(); json.put("url", url); json.put("params", new JSONObject(params)); json.put("timestamp", System.currentTimeMillis()); // 关键:时间戳参与签名 json.put("device_id", DeviceInfo.getDeviceId()); // 关键:设备ID参与签名 String signData = json.toString(); // {"url":"xxx","params":{...},"timestamp":1712345678901,"device_id":"861234567890123"} String sign = SignUtil.nativeSign(signData); // 这才是真正的hnairSign入口 return new Request.Builder() .url(url + "?sign=" + sign) .post(RequestBody.create(json.toString(), MediaType.get("application/json"))) .build(); }注意三个致命细节:
- 时间戳精度:
System.currentTimeMillis()返回毫秒级时间,但hnairSign内部会截取秒级(timestamp / 1000),且要求与服务器时间误差≤300秒。unidbg里若用System.currentTimeMillis()直接传入,会导致签名失效。 - 设备ID来源:
DeviceInfo.getDeviceId()不是简单读Settings.Secure.ANDROID_ID,而是组合了Build.SERIAL、TelephonyManager.getDeviceId()(已废弃)、WifiManager.getConnectionInfo().getMacAddress()三者做MD5。这意味着你在unidbg里伪造的ro.serialno必须与getMacAddress()返回值一致,否则签名校验失败。 - JSON格式强校验:
signData必须是严格格式化的JSON字符串——键名必须按url,params,timestamp,device_id顺序排列,且不能有多余空格。我曾因JSONObject.toString()生成了换行符\n,导致签名不一致。
3.2 JNI层参数传递:jstring到char*的“翻译官”陷阱
nativeSign函数接收jstring data,但so内部实际操作的是const char*。unidbg里这步转换极易出错:
// 错误写法:直接转指针 String javaStr = "{'url':'x','params':{}}"; // 注意:这里用了单引号,JSON非法! jstring jstr = env.newStringUtf(javaStr); // 调用nativeSign(jstr) -> so内部strlen()返回0,因为UTF-16编码的单引号在C层被当乱码正确流程必须包含三重校验:
- JSON语法校验:用
new JSONObject(javaStr)确保字符串是合法JSON(抛异常则终止); - 编码标准化:强制用
javaStr.getBytes(StandardCharsets.UTF_8)获取字节数组,再用memory.writeByteArray(...)写入unidbg内存; - 指针类型匹配:
jstring在unidbg里是UnidbgPointer,但so期望的是const char*,所以必须调用env.getStringUtfChars(jstr, null)获取C字符串指针——这个函数在unidbg里已被模拟,会自动处理UTF-16到UTF-8的转换。
实操心得:我在测试时发现,
hnairSign对输入字符串长度有硬性限制:最大1024字节。超过则直接返回空字符串。这个限制在Java层完全没提示,只能通过unidbg日志里strlen(input) = 1032的输出反推。建议在调用前加一行if (input.length() > 1000) throw new IllegalArgumentException("Input too long");。
3.3 返回值解析:32位hex签名背后的算法指纹
hnairSign返回的32位字符串,不是简单的MD5或SHA256哈希。通过对比真实App返回值与unidbg输出,我发现规律:
- 输入
{"url":"/api/checkin","params":{},"timestamp":1712345678,"device_id":"861234567890123"} - 真实App返回:
a1b2c3d4e5f678901234567890abcdef - unidbg返回:
a1b2c3d4e5f678901234567890abcdef(一致) - 但若修改
device_id末尾一位,真实App返回00000000000000000000000000000000,unidbg也返回全零——说明存在设备指纹校验。
进一步逆向so的sub_4012A8函数(hnairSign的主逻辑),发现其算法流程:
- 用
device_id作为AES-128密钥,对timestamp进行加密,得到16字节密文; - 将密文与
url、params的JSON字符串拼接,再做一次SHA256哈希; - 取哈希值的前16字节,转为小写hex字符串(32字符)。
这个发现直接指导了unidbg的桩函数设计:__system_property_get("ro.serialno")返回的必须是861234567890123,否则AES密钥错误,最终签名必然失败。你不是在破解算法,而是在复现算法运行所需的全部上下文。
4. 从崩溃到稳定:hnairSign在unidbg中的七次关键修复与避坑指南
跑通第一个hnairSign调用,我花了17小时。不是因为技术多难,而是踩了七个典型坑,每个坑都让unidbg静默崩溃或返回错误结果,且日志毫无提示。我把这些坑按发生频率排序,附上定位方法和修复方案,帮你省下至少两天调试时间。
4.1 坑一:dlopen失败却无报错——隐藏的DT_RUNPATH依赖
现象:emulator.loadLibrary(new File("libhnair.so"))返回null,但控制台没有任何错误日志。
根因:libhnair.so的DT_RUNPATH设置为$ORIGIN/../lib,它期望在同目录的../lib下找到libcrypto.so。unidbg默认不解析DT_RUNPATH,只查LD_LIBRARY_PATH。
定位:用readelf -d libhnair.so | grep RUNPATH确认路径,再用ls -l ../lib/看是否存在libcrypto.so。
修复:在loadLibrary前,手动将依赖库路径加入unidbg搜索路径:
emulator.getMemory().setLibraryPath(Arrays.asList( new File("libs/armeabi-v7a").getAbsolutePath(), // 主so所在目录 new File("libs/armeabi-v7a/../lib").getAbsolutePath() // DT_RUNPATH指定路径 ));4.2 坑二:JNI_OnLoad未执行——so的初始化逻辑被跳过
现象:nativeSign函数能调用,但返回空字符串;日志显示JNI_OnLoad从未被触发。
根因:libhnair.so的.init_array节里有初始化函数,但unidbg默认不执行.init_array,除非你显式调用module.callInitFunction(emulator)。
定位:用readelf -S libhnair.so | grep init确认存在.init_array段;在loadLibrary后加logger.info("Init array size: {}", module.getInitArraySize()),若为0则说明未识别。
修复:loadLibrary后立即执行:
if (module.getInitArraySize() > 0) { module.callInitFunction(emulator); }注意:
callInitFunction必须在JNIEnv和JavaVM对象创建之后调用,否则JNI_OnLoad里的env->FindClass()会失败。
4.3 坑三:gettimeofday返回负数——时间戳精度溢出
现象:hnairSign返回00000000000000000000000000000000,且日志显示tv_sec = -1。
根因:gettimeofday桩函数里,System.currentTimeMillis()返回的毫秒值(如1712345678901)直接赋给tv.tv_sec(32位有符号整数),导致溢出为负数。
定位:在桩函数里加logger.debug("ms={}", System.currentTimeMillis()),发现值远超2^31-1。
修复:显式截取秒级:
long nowMs = System.currentTimeMillis(); int sec = (int) (nowMs / 1000); int usec = (int) (nowMs % 1000 * 1000); tv.setInt(0, sec); tv.setInt(4, usec);4.4 坑四:open("/dev/urandom")失败——文件系统模拟缺失
现象:hnairSign卡死在open()系统调用,CPU占用100%,无任何日志。
根因:unidbg默认不模拟/dev/urandom设备节点,open()返回-1,但so内部没有错误处理,进入死循环重试。
定位:在open桩函数里加logger.debug("open path={}", path),确认路径为/dev/urandom。
修复:伪造一个随机数文件描述符:
emulator.getSyscallHandler().addSyscallHandler(ARMSyscallHandler.SYS_open, new SyscallHandler() { @Override public UnidbgPointer handle(Emulator emulator, long pathPtr, long flags) { String path = emulator.getMemory().getString(pathPtr); if ("/dev/urandom".equals(path)) { // 返回一个预分配的fd(如100),后续read()桩函数据此返回随机字节 return UnidbgPointer.pointer(emulator, 100); } return super.handle(emulator, pathPtr, flags); } });4.5 坑五:__system_property_get返回空——系统属性名大小写敏感
现象:hnairSign返回全零,日志显示property name=ro.serialno, value=null。
根因:__system_property_get在unidbg里是大小写敏感的,但真实Android系统属性名是小写的。ro.SERIALNO(大写)查不到。
定位:在桩函数里打印path参数,确认传入的是ro.SERIALNO而非ro.serialno。
修复:统一转为小写:
String propName = emulator.getMemory().getString(pathPtr).toLowerCase(); String value = systemProperties.get(propName); // systemProperties是Map<String,String>4.6 坑六:strlen返回0——jstring编码未正确转换
现象:hnairSign内部strlen(input)返回0,后续所有逻辑跳过。
根因:Java层传入的jstring是UTF-16编码,但so直接当UTF-8用。emulator.getMemory().getString()默认按UTF-8读,遇到UTF-16的\u0000字节就终止。
定位:用emulator.getMemory().readByteArray(inputPtr, 10)打印前10字节,发现是0x7B 0x00 0x22 0x00 ...(UTF-16 BE)。
修复:不用getString(),改用getStringUtfChars():
String inputStr = env.getStringUtfChars(data, null); // 此函数已模拟,返回UTF-8字符串 byte[] inputBytes = inputStr.getBytes(StandardCharsets.UTF_8); UnidbgPointer inputPtr = memory.allocate(inputBytes.length + 1); memory.writeByteArray(inputPtr, inputBytes);4.7 坑七:签名结果不一致——JSON键序未强制排序
现象:unidbg输出a1b2c3...,真实App输出b2c3d4...,两者不同。
根因:JSONObject.toString()不保证键序,{"params":{},"url":"/x"}和{"url":"/x","params":{}}是两个不同字符串,SHA256哈希值必然不同。
定位:打印inputStr,对比真实App抓包的signData字段。
修复:用TreeMap强制键序:
Map<String, Object> sortedMap = new TreeMap<>(); sortedMap.put("url", url); sortedMap.put("params", params); sortedMap.put("timestamp", timestamp); sortedMap.put("device_id", deviceId); String inputStr = new JSONObject(sortedMap).toString();最后分享一个小技巧:把unidbg的
logger级别设为DEBUG,并在emulator.attachDebugger()后加emulator.getDebugger().addBreakPoint(module.base + 0x12A8)(hnairSign入口偏移),能直接看到寄存器状态。虽然慢,但比猜强一万倍。
5. 超越hnairSign:如何把这次逆向经验迁移到其他航空App
hnairSign只是一个起点。国内主流航空App(东航MU、南航CZ、国航CA)的签名机制,90%都遵循同一套设计范式:Java层收口、Native层计算、设备指纹+时间戳+业务参数三元绑定。掌握了hnairSign的unidbg复现,你就能快速迁移至其他目标。我总结了一套“三步迁移法”,已在MU、CZ的so上验证有效。
5.1 第一步:符号特征扫描——用nm和strings锁定目标函数
不同App的函数名千差万别:muSign、czSignV2、airchina_sign_data。但它们有共同特征:
- 命名模式:含
sign、digest、verify、encrypt等关键词,且常带native、jni、util后缀; - 调用特征:在Java层
NetworkManager、ApiHelper、RequestBuilder类中被调用,参数通常是String或JSONObject; - 字符串线索:so里常埋藏
sign failed、invalid timestamp、device not registered等错误提示字符串。
操作命令:
# 扫描所有含sign的符号 nm -D libmu.so | grep -i sign # 提取所有ASCII字符串,过滤关键词 strings libmu.so | grep -E "(sign|digest|verify|encrypt)" | head -20 # 查看函数调用图(需安装radare2) r2 -A -qc "aaa; afl" libmu.so | grep -E "sign|digest"找到疑似函数后,用readelf -s libmu.so | grep <symbol>确认其STB_GLOBAL(全局可见)和STT_FUNC(函数类型)。
5.2 第二步:环境复用——替换so与参数,保留核心桩函数
hnairSign的unidbg环境,80%可直接复用:
- 依赖库:
libcrypto.so、libssl.so、libz.so版本通用(OpenSSL 1.0.2k是行业事实标准); - 系统调用桩:
gettimeofday、open("/dev/urandom")、__system_property_get逻辑完全一致; - JNI框架:
DvmObject、DvmClass、JNIEnv模拟代码无需修改。
唯一需替换的是:
loadLibrary的so文件路径;findSymbolByName的函数名;__system_property_get中device_id的伪造值(需从目标App的DeviceInfo类反编译获取);- JSON参数构造逻辑(
url、params字段名可能不同,如CZ用flightNo而非flight_number)。
实测案例:将
hnairSign环境中的libhnair.so换成libmu.so,仅修改函数名为Java_com_mu_utils_SignUtil_muSign,30分钟内跑通MU的签名生成。关键在于,muSign的输入JSON结构与hnairSign几乎一致,只是键名从device_id改为deviceId。
5.3 第三步:算法泛化——从SHA256到HMAC-SHA256的平滑过渡
hnairSign用的是纯SHA256,但CZ的czSignV2用的是HMAC-SHA256(key, data),密钥key来自so内部硬编码的字符串。这时,你不需要重写整个unidbg环境,只需:
- 用
strings libcz.so | grep -E "[0-9A-Fa-f]{32}"找出疑似密钥(32位hex); - 在
czSignV2桩函数里,用HmacUtils.hmacSha256Hex(key, inputData)替代原来的DigestUtils.sha256Hex(inputData); - 密钥若被混淆(如XOR加密),用unidbg在
sub_XXXX函数里下断点,直接dump内存中的明文密钥。
这个过程,本质上是把hnairSign训练出的“环境搭建能力”和“参数追踪能力”,迁移到新目标的“算法识别能力”。你不再是一个工具使用者,而是一个能自主判断、快速适配的逆向工程师。
我在最后想说的是,hnairSign的价值,从来不在那个32位签名本身。它是一把钥匙,打开了理解Android Native层运行机制的大门;它是一块磨刀石,把你的unidbg技能从“能跑”磨到“稳跑”;它更是一面镜子,照见那些被业务逻辑层层包裹的、真实的系统级安全实践。当你能在一个没有手机、没有网络、只有代码的unidbg沙箱里,让hnairSign稳定输出正确签名时,你获得的不是破解能力,而是对整个Android世界底层逻辑的掌控感——这种感觉,值得你为它熬过的每一个深夜。