news 2026/5/26 11:32:36

unidbg实战:Android航空App签名函数hnairSign逆向解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
unidbg实战:Android航空App签名函数hnairSign逆向解析

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*指针里存着什么、jstringchar*之间那道看不见的墙怎么跨过去。

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跑起来,你必须提供四个核心组件,缺一不可:

  1. 目标so的完整依赖链libhnair.so不是孤立的。用readelf -d libhnair.so | grep NEEDED查依赖,你会发现它链着libcrypto.solibssl.solibz.so。这些库不能随便找一个版本塞进去——libcrypto.so必须是OpenSSL 1.0.2k(该App编译时链接的版本),否则EVP_sha256()函数地址会错位。我试过用Android NDK自带的libcrypto.so,结果SHA256_Init返回-1,因为NDK版本是1.1.1,API已变更。

  2. 伪造的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提供了DvmObjectDvmClass来模拟这两者。关键细节:DvmObjectclazz字段必须指向正确的DvmClass,而DvmClassclassName必须严格匹配com/hnair/utils/SignUtil(注意斜杠,不是点号)。少一个字符,env->GetObjectClass(thiz)就返回null。

  3. 关键系统调用的桩函数(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; } });
  1. 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.SERIALTelephonyManager.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层被当乱码

正确流程必须包含三重校验:

  1. JSON语法校验:用new JSONObject(javaStr)确保字符串是合法JSON(抛异常则终止);
  2. 编码标准化:强制用javaStr.getBytes(StandardCharsets.UTF_8)获取字节数组,再用memory.writeByteArray(...)写入unidbg内存;
  3. 指针类型匹配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的主逻辑),发现其算法流程:

  1. device_id作为AES-128密钥,对timestamp进行加密,得到16字节密文;
  2. 将密文与urlparams的JSON字符串拼接,再做一次SHA256哈希;
  3. 取哈希值的前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.soDT_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必须在JNIEnvJavaVM对象创建之后调用,否则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 第一步:符号特征扫描——用nmstrings锁定目标函数

不同App的函数名千差万别:muSignczSignV2airchina_sign_data。但它们有共同特征:

  • 命名模式:含signdigestverifyencrypt等关键词,且常带nativejniutil后缀;
  • 调用特征:在Java层NetworkManagerApiHelperRequestBuilder类中被调用,参数通常是StringJSONObject
  • 字符串线索:so里常埋藏sign failedinvalid timestampdevice 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.solibssl.solibz.so版本通用(OpenSSL 1.0.2k是行业事实标准);
  • 系统调用桩gettimeofdayopen("/dev/urandom")__system_property_get逻辑完全一致;
  • JNI框架DvmObjectDvmClassJNIEnv模拟代码无需修改。

唯一需替换的是:

  • loadLibrary的so文件路径;
  • findSymbolByName的函数名;
  • __system_property_getdevice_id的伪造值(需从目标App的DeviceInfo类反编译获取);
  • JSON参数构造逻辑(urlparams字段名可能不同,如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环境,只需:

  1. strings libcz.so | grep -E "[0-9A-Fa-f]{32}"找出疑似密钥(32位hex);
  2. czSignV2桩函数里,用HmacUtils.hmacSha256Hex(key, inputData)替代原来的DigestUtils.sha256Hex(inputData)
  3. 密钥若被混淆(如XOR加密),用unidbg在sub_XXXX函数里下断点,直接dump内存中的明文密钥。

这个过程,本质上是把hnairSign训练出的“环境搭建能力”和“参数追踪能力”,迁移到新目标的“算法识别能力”。你不再是一个工具使用者,而是一个能自主判断、快速适配的逆向工程师。

我在最后想说的是,hnairSign的价值,从来不在那个32位签名本身。它是一把钥匙,打开了理解Android Native层运行机制的大门;它是一块磨刀石,把你的unidbg技能从“能跑”磨到“稳跑”;它更是一面镜子,照见那些被业务逻辑层层包裹的、真实的系统级安全实践。当你能在一个没有手机、没有网络、只有代码的unidbg沙箱里,让hnairSign稳定输出正确签名时,你获得的不是破解能力,而是对整个Android世界底层逻辑的掌控感——这种感觉,值得你为它熬过的每一个深夜。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/5/26 11:32:35

独立开发者如何借助Taotoken多模型能力优化个人项目选型成本

&#x1f680; 告别海外账号与网络限制&#xff01;稳定直连全球优质大模型&#xff0c;限时半价接入中。 &#x1f449; 点击领取海量免费额度 独立开发者如何借助Taotoken多模型能力优化个人项目选型成本 对于独立开发者而言&#xff0c;无论是构建个人项目原型&#xff0c;…

作者头像 李华
网站建设 2026/5/26 11:32:18

Selenium实战:破解美团SPA反爬与前端交互逻辑

1. 这不是“绕过反爬”&#xff0c;而是理解美团前端交互逻辑的实战切口很多人看到“Selenium反爬美团”这个标题&#xff0c;第一反应是&#xff1a;又一个教你怎么“破解网站”的教程&#xff1f;其实完全相反——这恰恰是一次对现代Web应用交互机制的深度解剖。我带团队做过…

作者头像 李华
网站建设 2026/5/26 11:32:18

告别重装!用DISM命令给内网Win7电脑批量打补丁(附一键脚本)

内网环境Win7批量离线补丁部署实战指南痛点与解决方案概述在封闭的内网环境中&#xff0c;Windows 7系统的补丁更新一直是IT运维人员的噩梦。无法连接互联网、缺乏专业补丁分发系统、老旧硬件性能瓶颈等问题交织在一起&#xff0c;形成了典型的运维困局。更棘手的是&#xff0c…

作者头像 李华