
1. 项目概述与核心价值最近在移动安全研究圈里抖音的注册流程加密特别是deviceid和iid这两个核心参数的生成一直是个热门话题。这两个参数不仅是设备身份的唯一标识更是后续几乎所有API请求的“通行证”其加密强度和生成逻辑直接关系到风控系统的有效性。网上虽然有不少零散的逆向分析文章但要么是针对旧版本要么只讲了某个片段缺乏一个从明文输入到最终密文输出的完整、连贯的拆解流程。对于刚入门的逆向工程师或者想深入理解大型App加密体系的朋友来说总感觉隔着一层纱。这篇文章我就结合自己最近对新版抖音App以某个主流版本为例的逆向实战把deviceid和iid的注册加密流程像剥洋葱一样一层层给你拆解清楚。我们不会停留在“这里调用了某个加密函数”的层面而是会深入探究为什么要设计这样的流程每个加密环节意图是什么在动态调试和静态分析中如何定位关键代码以及当我们尝试模拟生成时会遇到哪些坑又该如何绕过或解决无论你是从事移动应用安全评估、风控策略研究还是对Android逆向工程有浓厚兴趣的开发者相信这份从实战中总结的“地图”都能帮你更高效地理解这套复杂的加密体系。我们的目标不是教你如何“破解”而是通过理解其设计提升自身的安全防御意识和技术对抗能力。2. 逆向分析环境与工具链搭建工欲善其事必先利其器。逆向分析抖音这种级别的应用一个稳定、高效的工具环境是成功的一半。这里我分享一套我验证过、能跑通全流程的组合并解释为什么这么选。2.1 核心工具选型与配置1. 测试设备与系统我首选一台已经解锁Bootloader并刷入了Magisk的Android真机型号如Pixel 4 XL或小米某型号。为什么不用模拟器因为抖音等大型App的风控系统对模拟器、虚拟环境检测非常严格在模拟器上运行很多关键的加密逻辑可能根本不会执行或者会触发降级策略导致你分析的不是“完全体”。真机环境更贴近真实用户数据也更可靠。系统版本我选择Android 10或11这是一个在兼容性和新特性之间比较平衡的版本。2. 逆向分析框架Frida: 这是动态插桩的绝对主力。它的优势在于无需修改APK通过注入JavaScript脚本就能在运行时拦截、修改任意函数调用和内存数据。我们将用它来Hook Java层和Native层的加密函数动态获取参数和返回值。我使用的是Frida 15.x版本服务端frida-server需要推送到手机并运行。Objection: 基于Frida的命令行工具能快速完成内存搜索、类与方法枚举等重复性工作提升效率。但它更多是辅助深度分析还得靠自定义Frida脚本。Jadx/Ghidra: 静态分析双雄。Jadx用于快速反编译APK的Dex文件查看Java/Kotlin代码逻辑搜索关键字符串如“deviceid”、“iid”、“encrypt”。Ghidra则用于深入分析关键的Native So库通常加解密核心都在这里它的反编译和代码分析能力非常强大而且是开源的。3. 抓包与调试工具Charles/HTTP Toolkit: 用于HTTPS流量抓包。必须配置好手机代理和Charles的SSL证书确保能解密看到明文的请求和响应。抓包是我们观察输入输出的窗口是逆向的起点。adb (Android Debug Bridge): 基础中的基础用于安装应用、推送文件、获取日志等。注意所有工具请务必从官方渠道或可信源下载。在测试手机上安装Magisk后要使用Magisk Hide或最新的ZygiskLSPosed等方案对抖音App进行隐藏避免其检测到Root环境。这是一个持续的对抗过程抖音的检测手段也在更新。2.2 目标APK的准备与初步侦查首先我们需要获取目标抖音APK。可以通过官方应用商店下载或者使用一些APK提取工具从已安装的手机里提取。拿到APK后不要急着安装。使用apktool解包apktool d douyin.apk -o douyin_output。这能让我们看到资源文件、AndroidManifest.xml以及被转换为smali汇编的Dex文件。查看AndroidManifest.xml可以了解应用申请的权限、使用的组件有时还能发现一些引用的第三方库线索。使用jadx-gui打开APK直接加载APK文件Jadx会解压并反编译所有Dex。首先进行全局搜索关键词包括deviceid,device_id,install_id(iid的全称或变种)generate,create,encrypt,encode,sign一些可能的关键类名如DeviceRegisterManager,DeviceIdGenerator,CryptUtils等。 这一步的目的是找到可能与设备ID生成相关的Java/Kotlin类和方法为后续的Frida Hook提供目标。定位Native库在Jadx中搜索System.loadLibrary或nLoadLibrary调用或者在解包后的APK的lib/目录下查看有哪些.so文件。通常核心的加密算法会放在名字包含crypt、security、shield或业务相关缩写的So库中例如libcms.so,libsscronet.so等。把这些So库文件单独拿出来准备用Ghidra进行深入分析。这个准备阶段就像战前侦察摸清敌方的主要兵力Java代码和重武器Native库部署在哪里。3. 注册流程动态抓包与入口定位一切就绪后我们在配置好代理的手机上安装并启动抖音。关键一步首次启动或清除数据后启动因为这时才会触发完整的设备注册流程生成全新的deviceid和iid。3.1 网络请求拦截与关键接口识别打开Charles开始录制流量。然后打开抖音App如果是首次安装会先进入同意协议、获取设备权限等页面。在加载首页或推荐视频流之前App一定会发起若干次“静默”的网络请求这些就是注册和初始化请求。我们需要在纷杂的流量中找到最核心的那个。通常这个请求会有以下特征路径可能包含/device/register/、/service/2/device_register/、/passport/device/register/等关键词。方法通常是POST。参数请求体Body是加密的看起来是一串毫无规律的字符串可能是Base64编码的二进制数据或直接是Hex字符串。请求头Header里可能会带有客户端版本、设备型号等明文信息以及一个关键的X-Khronos时间戳和X-Gorgon或X-SS-STUB等签名字段这些是另一个话题但注册请求本身可能也受其保护。响应响应体同样被加密。解密后可能需要用到后续分析出的算法我们期望看到返回的JSON数据里包含device_id和install_id这两个字段以及它们的有效期等信息。通过对比多次全新安装的请求我们可以确定这个唯一的注册接口。记下它的URL和大致行为。3.2 Java层关键代码Hook与追踪确定了网络请求下一步就是找到在App内部是谁、在什么时候、用什么数据构造了这个请求。这里Frida大显身手。我们首先从抓包看到的明文信息入手。比如请求URL是固定的我们可以在Jadx里搜索这个URL的一部分。或者搜索可能用于网络请求的库如OkHttp、Retrofit的相关调用。找到疑似发起注册请求的Java类和方法后编写Frida脚本进行Hook。例如假设我们怀疑com.ss.android.deviceregister.b.a这个类的a方法是入口Java.perform(function() { var targetClass Java.use(com.ss.android.deviceregister.b.a); targetClass.a.overload(...).implementation function(arg1, arg2) { console.log([*] DeviceRegister.a() called!); console.log( Arg1: arg1); console.log( Arg2: JSON.stringify(arg2)); var result this.a(arg1, arg2); // 调用原方法 console.log( Result: result); return result; }; });这个脚本会打印该方法的传入参数和返回值。参数里很可能就包含了用于生成最终加密请求体的原始数据明文比如收集到的设备信息IMEI、Android ID、屏幕宽高、CPU信息等的一个集合。通过这种方式层层回溯我们可以找到设备信息被收集、组装的地方。通常会有一个“设备信息管理器”之类的单例类在App启动时收集所有信息。Hook这些信息收集方法就能拿到构建注册请求所需的原始明文数据集合。实操心得抖音的代码混淆非常严重类名和方法名可能毫无意义。不要依赖具体的类名而要依赖行为和调用栈。你可以Hook更底层的、通用的方法比如okhttp3.RequestBody.create()或某个特定JSON序列化库的toJson()方法然后打印调用栈 (Java.use(android.util.Log).getStackTraceString(Java.use(java.lang.Exception).$new())))从调用栈里向上寻找业务逻辑层。这是一个需要耐心的过程。4. DeviceID生成逻辑的深度拆解deviceid通常是一个长度固定的字符串看起来像随机生成的但实则与设备硬件信息强相关并且设计为在设备首次安装后基本保持不变除非用户清除应用数据。4.1 明文信息采集与归一化通过上一步的Hook我们大概率能拿到一个Map或JSONObject里面包含了数十个字段。这些字段可以归为几类硬件标识符imei可能取多个、android_id、serial、mac_addressWi-Fi/BT等。注意在高版本Android上由于隐私限制很多标识符无法直接获取或返回假值。设备属性brand品牌、model型号、board、hardware、manufacturer、product。系统与构建信息os_version系统版本、sdk_intAPI级别、build_id、incremental_version。屏幕与传感器display屏幕分辨率、density密度、screen_size。可能还包括传感器列表。应用与安装信息app_version抖音版本号、version_code、channel渠道号、package_name。其他环境信息timezone时区、locale语言地区、root_status是否Root、sim_countSIM卡数量、network_type网络类型等。抖音的采集策略通常是“广撒网”尽可能多地收集信息即使某些字段在高版本系统上失效。采集到这些信息后并不会直接使用而是会进行归一化处理。例如将品牌型号字符串统一转成小写并去除空格将屏幕分辨率格式化为宽x高的固定字符串将布尔值如是否Root转换为0/1。4.2 拼接、排序与首次哈希归一化后的数据会被拼接成一个大的字符串。但拼接的顺序是关键为了确保同一台设备每次生成的字符串一致字段通常会按照**字母顺序Key的字典序**进行排序后再拼接。假设采集到的数据是{“brand”:”xiaomi”, “model”:”mi10”, “android_id”:”a1b2c3d4”}排序后按keyvalue的形式用连接android_ida1b2c3d4brandxiaomimodelmi10这个拼接后的字符串我们称之为“设备指纹明文”。接下来会对这个明文字符串进行一次哈希计算通常是MD5或SHA-256。哈希的目的是将不定长、可读的字符串转换成一个定长、不可逆的摘要。这个哈希值Hex格式可以看作是该设备在当前信息集下的“指纹摘要”。注意事项这里有一个常见的坑。你可能会发现按照你理解的字段和顺序拼接后计算出的MD5和App内部计算的不一样。原因可能有字段遗漏或多余你Hook到的字段可能不全或者App内部偷偷添加了一些隐藏字段如某些系统属性的读取。排序规则不同可能不是简单的字典序而是自定义的顺序。格式化差异keyvalue中的等号两边是否有空格字符串的编码是UTF-8还是其他哈希前的预处理可能在拼接的字符串前后加了特定的前缀或后缀Salt。4.3 核心加密与最终生成上一步得到的哈希值比如一个32位的MD5 Hex字符串并不是最终的deviceid。它还需要经过一步或几步核心的加密变换这个变换通常发生在Native层.so库。我们需要在Jadx中搜索上一步哈希值作为变量被传递到哪里。很可能会发现它被传入一个native声明的方法例如public static native String encryptDeviceInfo(String fingerprintHash, byte[] someKey);这就是突破口。我们需要找到对应的Native函数。在Jadx中查看这个类的静态初始化块static {}通常会看到System.loadLibrary(“crypt”)之类的语句指明了加载的So库名。接下来使用Ghidra加载这个So库。在导出函数Exports或符号表中根据JNI函数命名规则Java_包名_类名_方法名寻找对应的函数。分析这个Native函数你会发现它可能在做以下事情之一对称加密使用一个硬编码在So库中或从服务器下发的密钥AES Key对哈希值或其派生值进行AES加密可能是ECB或CBC模式。加密后的二进制数据再经过Base64或Hex编码生成最终的deviceid。非对称加密使用一个固定的RSA公钥对哈希值进行加密。由于RSA加密结果较长最终的deviceid可能是加密后数据的截断或再哈希。自定义算法可能是基于哈希值进行一系列位运算、查表替换等混淆操作生成一个特定格式的字符串。这种算法逆向难度较大需要动态调试Frida Hook Native函数来观察输入输出然后进行算法还原。动态验证编写Frida脚本同时Hook Java层传入哈希值的方法和Native层的对应函数。在Native函数入口打印传入的哈希值在出口打印返回的deviceid。这样就能100%确认这个Native函数就是生成deviceid的最后一步。然后可以尝试将哈希值和可能的密钥通过逆向So库或内存Dump获得在Python中复现加密过程看能否得到相同的结果。5. IID (Install ID) 生成流程剖析iid(install_id) 与deviceid不同它更像是一次安装会话的标识。通常每次App全新安装或清除数据后都会生成一个新的iid。它的生成流程可能与deviceid有重叠但也有其独特之处。5.1 与DeviceID的关联与区分首先需要明确iid并不是独立于deviceid的。在注册请求的响应中服务器通常会同时返回这两个ID。它们的关联在于iid的生成很可能依赖于deviceid或生成deviceid的中间产物。一种常见的模式是iid F(deviceid, timestamp, random)。其中F是一个函数它接收deviceid或其一部分、当前时间戳和一个随机数经过某种计算可能是哈希、加密或简单拼接后生成iid。这样设计的好处是唯一性结合设备指纹和时间戳能保证每次安装的唯一性。关联性服务器可以通过iid关联到具体的deviceid从而知道是哪个设备的哪次安装。时效性iid可能有一定的有效期过期后需要刷新但通常不频繁。5.2 时间戳、随机数与混淆算法在动态分析中我们可以重点关注注册请求之前App是否生成了高精度时间戳如System.currentTimeMillis()或随机数java.util.UUID.randomUUID().toString()或java.security.SecureRandom。Hook这些时间戳和随机数生成函数记录下它们的值。然后在收到服务器注册响应时Hook解析响应JSON并保存iid的方法。对比时间戳、随机数、deviceid和最终iid的值寻找规律。混淆算法可能相对简单。例如可能是iid MD5(deviceid “:” timestamp “:” randomStr).substring(8, 24)即将设备ID、时间戳和随机字符串用冒号连接取MD5哈希值的中间16位。这种算法在客户端可以快速生成在服务端也可以通过相同逻辑验证。另一种可能是iid完全由服务端生成并下发给客户端。客户端只是在注册时上传了设备信息服务器根据这些信息结合自己的逻辑可能包括数据库序列号生成一个唯一的iid返回。如果是这种情况客户端本地就没有生成算法逆向的重点就变成了理解服务器下发的iid的格式和可能蕴含的信息如是否包含时间戳编码、是否带有签名等。5.3 服务端交互与本地存储逻辑无论iid是客户端生成还是服务端生成注册接口的响应体解密后一定会包含device_id和install_id这两个字段。App在收到后会将其持久化存储到本地。存储的位置通常是SharedPreferences: 这是最常用的方式。你可以HookSharedPreferences.Editor.putString()方法观察哪个Key保存了这些ID。Key的名字可能是device_id_str,install_id,uid等。数据库(SQLite): 也可能存入某个数据库表中。文件或自定义存储: 较少见但有可能。存储后后续所有的网络请求都会从存储中读取这两个ID并添加到请求头如X-DEVICE-ID,X-INSTALL-ID或请求体参数中。排查技巧如果发现Hook注册响应解析后拿到了iid但后续请求中带的iid却不一样可能有以下原因缓存或延迟生效App可能有一个内存缓存注册后不是立即使用新的ID而是在下次启动或某个时机才更新。多个ID抖音可能为不同的业务域使用不同的“安装ID”需要仔细区分。ID刷新在某些条件下如长时间未使用、检测到环境变化App可能会主动调用某个刷新接口获取新的iid。你需要搜索网络请求中是否有/refresh/install_id/之类的接口。6. 加密请求体的构造与整体流程串联现在我们有了生成deviceid和iid的清晰路径。但回顾最初的抓包注册请求的请求体Body是加密的。我们之前Hook到的那些明文设备信息最终去了哪里它们就是被用来构造这个加密请求体的。6.1 明文JSON的组装与序列化App会将收集、归一化后的设备信息加上可能已经本地生成的deviceid和iid如果是客户端生成模式以及其他注册所需的参数如应用版本、渠道号组装成一个大的JSON对象。这个JSON对象的结构可能非常复杂包含数十个字段。我们可以通过Hook JSON序列化库如Gson的toJson方法来捕获这个完整的明文JSON。这个JSON就是即将被加密的“原始数据”。6.2 多层嵌套加密策略解析抖音不会简单地将这个JSON字符串直接加密。为了对抗自动化分析和中间人攻击它很可能采用多层嵌套加密策略。这是一种常见的加固手段第一层自定义序列化或编码。可能不是标准的JSON而是某种自定义的二进制协议如Protocol Buffers、Thrift或者对JSON字符串进行简单的字节变换如异或操作、字节顺序重排。第二层对称加密AES。使用一个密钥Key和初始化向量IV对第一层处理后的数据进行AES加密。这个密钥和IV可能是固定的硬编码在So库中也可能是动态的从之前的某个初始化请求中获取。第三层非对称加密RSA或签名。有时会对AES加密后的数据或者AES密钥本身再用一个RSA公钥进行加密。或者对整个数据包计算一个签名如HMAC-SHA256将签名附在数据包后面一起发送。第四层编码。最后将加密后的二进制数据转换为适合网络传输的格式通常是Base64编码也可能直接是Hex字符串。我们的抓包工具看到的就是这第四层编码后的结果。6.3 从明文到密文的完整链路还原要还原整个链路我们需要逆向分析从JSON对象生成到最终发出网络请求的每一个环节。这需要综合运用静态分析和动态调试定位加密入口从发起网络请求的代码如OkHttp的Call.execute()往回追溯找到构建RequestBody的地方。HookRequestBody.create()方法查看传入的数据是什么。这时你可能看到的是已经加密后的字节数组或字符串。寻找加密函数从构建RequestBody的地方继续向上回溯调用栈找到执行加密操作的函数。这个函数很可能接收明文JSON字符串或字节数组作为输入输出加密后的字符串。Hook加密函数用Frida Hook这个函数打印其输入明文和输出密文。这是最关键的一步它直接连通了明文和密文。分析加密函数内部这个加密函数内部可能会调用多个辅助函数分别完成我们上面说的第一、二、三层处理。我们需要逐一分析或Hook这些辅助函数。第一层处理可能是一个叫preprocess()或encode()的方法输入JSON字符串输出字节数组。第二层AES加密调用Cipher.getInstance(“AES/CBC/PKCS5Padding”)等方法。HookCipher.doFinal()可以拿到使用的Key和IV。注意Key和IV可能来自其他函数调用或静态变量需要继续追溯源头。第三层RSA加密/签名可能调用Cipher.getInstance(“RSA/ECB/PKCS1Padding”)或Mac.getInstance(“HmacSHA256”)。同样需要Hook并获取公钥或密钥。算法复现在Python中使用获取到的Key、IV、公钥等参数按照分析出的流程自定义编码 - AES - RSA/签名 - Base64尝试对一份样本明文进行加密看结果是否与Hook捕获的密文一致。这个过程极具挑战性因为代码混淆和流程拆分可能非常复杂。有时整个加密流程会被打包到一个Native函数中这就需要深入分析So库。但无论如何动态Hook是照亮黑暗的灯塔它能让你确切地知道数据在每一步的形态。7. 常见问题、对抗手段与排查技巧实录在实际逆向过程中你会遇到各种各样的问题。这里记录一些典型问题和我的解决思路。7.1 反调试与反Hook检测绕过抖音这类应用集成了强大的反调试和反Hook机制。直接附加Frida或调试器App可能会崩溃或行为异常。常见检测点检测调试器通过android.os.Debug.isDebuggerConnected()、/proc/self/status中的TracerPid字段、ptrace自身等方式。检测Frida检测常见的Frida特征如特定端口27042、特定文件、内存中Frida相关字符串、libfrida库的加载等。代码完整性校验检查自身Dex或So文件是否被修改校验签名。绕过策略使用高隐蔽性方案使用基于Magisk的隐藏模块如 Shamiko、Hide My Applist对Frida和调试器进行深度隐藏。或者使用非标准的Frida启动方式。Hook检测函数用Frida提前Hook这些检测函数如isDebuggerConnected使其返回false。对抗内存扫描修改Frida的默认端口和特征字符串。这需要定制Frida的编译。时序干扰有些检测在子线程中循环进行。可以尝试HookThread.sleep或Thread.start来干扰检测线程的运行。实操心得这是一个猫鼠游戏。没有一劳永逸的方法。我的策略是“以快打快”在App启动初期、反检测机制还未完全启动时就完成关键的Hook。可以编写一个启动脚本在App的Application.onCreate()方法被调用时立即执行我们的Hook代码。有时关闭App的“开发者选项”和“USB调试”也能降低检测强度。7.2 代码混淆与动态加载带来的挑战代码混淆让类名、方法名、字段名变得毫无意义增加了静态分析的难度。动态加载Dex加密、So壳则让核心代码在运行时才解密并加载到内存静态分析直接看APK是看不到的。应对方法动态脱壳对于Dex加密可以在运行时HookDexClassLoader或PathClassLoader的加载方法将解密后的Dex文件从内存中Dump出来。对于So壳可以Hookdlopen或mmap等系统调用Dump解密后的So库。字符串解密混淆的代码中字符串常量通常也被加密。可以寻找字符串解密的函数并Hook它在运行时打印出解密后的字符串这能极大帮助理解代码逻辑。调用图分析在Jadx或Ghidra中即使名字是混淆的但方法的调用关系是清晰的。从一个已知的入口点如点击事件监听器开始逐步绘制调用图可以理清业务逻辑脉络。特征匹配关注不变的东西如网络请求的URL字符串可能被加密但解密后不变、第三方库的特定方法签名、系统API的调用模式等。7.3 密钥隐藏与白盒加密实践最核心的加密密钥不会以明文形式出现在代码中。它们可能被分段存储拆分成多个部分藏在字符串常量、资源文件或So库的不同位置运行时拼接。动态生成通过一个确定的算法如哈希某个固定字符串在运行时计算得出。白盒加密将密钥与算法深度融合密钥被编码在庞大的查找表中传统的“提取密钥”思路失效。整个加密过程就是一个巨大的查找表网络。应对方法内存Dump在密钥被拼接或计算出来之后即将用于加密之前通过Frida脚本读取相关变量的内存值。这是最直接有效的方法。算法还原对于白盒加密如果只是用于加密一个固定数据如设备信息哈希可以尝试“黑盒”方式将加密函数看作一个黑盒通过输入大量已知数据记录输入输出尝试用机器学习或符号执行的方法来拟合这个函数。但这难度极高。寻找密钥派生源头逆向密钥拼接或生成的算法。可能源头只是一个简单的字符串如“android_key_2023”经过多次哈希和变换后成为最终密钥。通过Hook每一步变换可以追溯到源头。7.4 问题排查速查表问题现象可能原因排查思路Hook后App立刻崩溃1. Hook时机太晚反检测已生效。2. Hook的函数被内联或优化。3. Frida脚本语法错误或类型不对。1. 尝试更早Hook如Application构造函数。2. 尝试Hook该函数的调用者或使用更底层的Hook如Inline Hook。3. 检查Overload是否正确使用Frida -U -f com.ss.android.ugc.aweme -l script.js --no-pause在启动时注入并查看日志。抓包看到请求但Hook不到加密函数1. 加密在Native层完成。2. 使用了自定义的HTTP客户端绕过了常用库。1. 在So库中搜索encrypt、AES、enc等导出函数名或用Frida Hooklibc的malloc/free观察加密前后内存分配大小变化来定位函数。2. 搜索Socket、URLConnection等更底层的网络API调用。复现的加密结果与抓包不一致1. 密钥或IV错误。2. 加密模式或填充方式不对如CBC/ECB, PKCS5/PKCS7。3. 明文数据有细微差别如多一个空格、编码不同。4. 加密前有多一层预处理如压缩、自定义编码。1. 动态Hook确认Key/IV。2. 查看Cipher.getInstance()的参数字符串。3. 将Hook到的明文字节数组完整打印出来Hex格式与Python中组装的进行逐字节比较。4. Hook加密函数之前的所有数据处理函数。deviceid本地生成与服务端返回不同1. 客户端生成的只是候选值服务端有权修正或覆盖。2. 存在多个版本的生成算法服务端根据客户端版本选择。3. 本地生成后在发送前又经过了一次编码或混淆。1. 对比客户端生成值和服务端返回值看是否是固定映射关系。2. 检查注册请求中是否携带了算法版本标识。3. Hook网络请求序列化函数查看最终发出的deviceid值。逆向分析是一个需要耐心、细心和系统化方法的过程。从明文到密文的每一步都可能是对抗的战场。理解这套流程不仅能满足技术好奇心更能让我们深刻体会到大型互联网应用在安全与风控上的投入与设计思路。对于开发者而言这也是学习如何更好地保护自己应用的一堂实战课。记住我们的目的是学习和研究请在法律和道德允许的范围内进行所有操作。