逆向工程入门:从网络请求到Token生成算法的完整解析
1. 项目概述:从“黑盒”到“白盒”的逆向旅程
在移动应用和Web前端开发领域,为了保护核心业务逻辑和接口安全,开发者们常常会使用代码混淆和参数加密技术。这就像给自家的保险箱加了一把复杂的锁,并把它藏在一个不起眼的角落里。而我们今天要聊的“逆向工程”,本质上就是扮演一个“开锁匠”和“寻宝者”的角色,目标不是破坏,而是理解这套安全机制是如何运作的。这个名为“简单混淆和token参数加密”的案例,就是一个非常典型的入门场景,它几乎涵盖了客户端安全对抗中最基础、也最核心的几个环节。
简单来说,这个案例模拟了这样一个过程:一个应用(可能是网页,也可能是App)在向服务器请求数据时,并非直接发送明文参数(比如你的用户ID、查询关键词),而是会将参数进行某种加密或编码,并附上一个名为token或sign的签名值。服务器收到后,会用同样的逻辑验签,以此判断请求是否合法、是否被篡改。我们的逆向目标,就是把这个“某种加密”和生成token的逻辑给还原出来。这听起来像是黑客行为,但实际上,对于安全研究人员、测试工程师、甚至是希望与第三方接口对接的开发者来说,这是一项非常重要的技能。它能帮你进行安全审计、编写自动化测试脚本、或者分析竞品的接口协议。
通过这个案例,你将能亲手剥开一层看似神秘的技术外壳,看到其内部相对简单的运作原理。这对于初学者建立逆向思维、熟悉常用工具链、理解常见加密模式有着不可替代的作用。无论你是刚入门的安全爱好者,还是希望提升排错能力的后端开发,这篇文章都将带你走完一个完整的、可复现的逆向流程。
2. 核心思路与工具选型:如何定位关键逻辑
面对一个已经混淆和加密的应用,盲目地一头扎进代码海是低效的。一个清晰的逆向思路往往比掌握一百个工具更重要。我们的核心思路可以概括为“由外而内,动态追踪”。
2.1 逆向分析的核心思路拆解
首先,要明确目标。我们不是要破解整个应用,而是找到特定参数(尤其是token)的生成位置和算法。因此,最直接的入口就是网络请求。使用抓包工具(如Charles、Fiddler或mitmproxy)拦截应用发出的请求,你会发现原本应该清晰的参数变得面目全非,多出了一堆看似随机的字符串,其中很可能就包含我们的目标——token。
接下来的关键一步是寻找突破口。一个常见的技巧是“对比分析法”。你可以尝试:
- 相同操作,不同输入:比如搜索“苹果”和“香蕉”,对比两次请求中
token和相关参数的变化。如果token完全不同,说明它很可能与输入参数有关。 - 相同输入,不同时间:隔几分钟重复同样的搜索,如果
token变了,可能加入了时间戳。 - 清空缓存/重新登录:观察
token是否与登录态(如session或另一个固定token)有关。
通过对比,你可以初步判断token的生成依赖哪些因素:是全部请求参数?是用户身份?还是包含了一个时间变量?这个判断将极大地缩小后续静态分析的代码搜索范围。
2.2 工具链的选择与搭配
工欲善其事,必先利其器。根据目标平台的不同,工具链的选型也不同。
对于Web前端(JS逆向):
- 抓包/调试:浏览器开发者工具(Chrome DevTools)是首选。它的Network面板可以查看所有请求,Sources面板可以调试JavaScript,Console面板可以执行任意代码进行测试。
- 反混淆/格式化:线上JS代码经常被压缩(Minify)和混淆(Obfuscate),变量名变成
a, b, c,逻辑被拆分。浏览器Sources面板自带的美化(Pretty Print)功能能解决压缩问题。对于混淆,虽然有一些反混淆工具,但更多时候需要依靠调试技巧人脑还原。 - Hook与拦截:可以使用浏览器插件或Fiddler等工具的脚本功能,在JS代码执行前注入调试代码,拦截关键函数的输入输出,这是定位加密函数的“大杀器”。
对于Android应用(APK逆向):
- 抓包:仍然需要Charles/Fiddler,并配合在手机或模拟器上安装代理证书。
- 反编译与静态分析:
Jadx-GUI是目前最友好、强大的开源工具,它能将APK文件中的DEX字节码反编译成可读性很高的Java代码。对于简单的混淆(如ProGuard),它通常能处理得很好,变量名可能被混淆成a、b,但类和方法结构清晰。 - 动态调试:对于核心逻辑在Native层(C/C++)或加固过的应用,静态分析可能不够。这时需要用到
Frida或Xposed框架。它们可以在应用运行时,动态地注入代码、Hook(挂钩)指定函数,打印出函数的参数、返回值,甚至修改其逻辑。Frida尤其强大,支持全平台,是进行深度逆向的必备技能。
对于iOS应用:
- 工具链类似,抓包用Charles,静态分析可以用
IDA Pro、Hopper Disassembler或Ghidra分析二进制文件,动态调试则常用LLDB或Frida。
- 工具链类似,抓包用Charles,静态分析可以用
注意:在本案例中,我们假设面对的是一个使用了“简单混淆”的Web或Android应用。这意味着代码结构虽然被扰乱,但核心算法逻辑依然以高级语言(JavaScript/Java)的形式存在,并未被虚拟化或深度Native化,因此我们以静态分析(阅读反编译代码)为主,动态调试(Hook验证)为辅的策略是可行的。
2.3 为什么选择这样的分析路径?
选择“网络请求切入 -> 对比分析 -> 静态定位 -> 动态验证”这条路径,是因为它符合认知规律。网络请求是应用与外界交互的唯一窗口,也是加密参数的最终出口。从这里开始,是最高效的“顺藤摸瓜”。对比分析能让我们用最少的样本,归纳出加密逻辑的依赖关系,形成假设。静态分析则是根据假设,在代码中寻找证据。最后用动态调试验证,形成闭环。这个过程,本质上就是一次科学的“侦查”与“实验”。
3. 实战拆解:定位并还原Token生成逻辑
现在,我们进入实战环节。假设我们通过抓包,发现了一个向api.example.com/search发送的POST请求,其请求体如下:
{ "keyword": "e4b88be4b880e697a5e5a587e891a9", "page": "1", "timestamp": "1712345678", "token": "a1b2c3d4e5f67890fedcba9876543210" }很明显,keyword被编码了(看起来像Hex编码),timestamp是Unix时间戳,而token是一串32位的十六进制字符串,很像MD5或SHA-1的结果。
3.1 第一步:初步分析与假设
- 解码keyword:将
e4b88be4b880e697a5e5a587e891a9进行Hex解码,得到字节数据,再尝试用UTF-8解码,结果可能是中文。例如,解码后得到“下一日奇遇”。这验证了参数被简单编码过。 - 对比请求:我们再次搜索“测试”,抓包得到
keyword为e6b58be8af95(“测试”的Hex),timestamp更新了,token也完全不同。 - 提出假设:
token很可能是一个签名,由keyword(解码后)、page、timestamp等参数,按照一定顺序拼接后,再经过某种哈希算法(如MD5)计算得出。可能还混合了一个固定的密钥(salt)。
3.2 第二步:静态代码分析寻找线索
对于Web应用,我们打开浏览器开发者工具的Sources面板,搜索这个token值a1b2c3d4e5f67890fedcba9876543210。通常不会直接搜到,因为这是计算结果。我们应该搜索更可能出现的常量,比如token、sign、md5、sha1、CryptoJS(一个常用加密库)等。
假设我们搜索md5,在某个被混淆的JS文件(如app.obfuscated.js)中找到了如下代码片段:
function s(t) { var e = n.md5; return o(e(t)) } function getToken(e, n, a) { var i = c(e) + "&" + c(n) + "&" + c(a) + "&key=" + r; return s(i) }虽然变量名被混淆了(t, e, n, a, i, r, o, c, s),但逻辑依然可辨。getToken函数接收三个参数,用c函数处理(可能是编码或转字符串),然后用&连接,最后追加一个&key=和一个变量r。整个拼接后的字符串传给函数s,而s内部调用了n.md5,最后结果经过o函数处理(可能是转小写或Hex)。
这里的r很可能就是那个固定的密钥(salt)。我们需要找到r的值。在混淆代码中,它可能被定义在文件开头,或者从某个全局对象中获取。我们可以尝试在代码中搜索var r =或r =。
3.3 第三步:动态Hook验证与算法还原
静态分析给出了一个清晰的函数调用链和可能的算法(MD5)。现在需要用动态Hook来验证我们的猜想,并获取关键参数(如密钥r)。
我们使用浏览器Console或Fiddler的AutoResponder自定义脚本功能,在页面JS加载前注入Hook代码。以Console为例,在页面加载前(或通过断点暂停时),输入以下代码:
// 保存原始的 md5 函数 var originalMD5 = window.CryptoJS ? CryptoJS.MD5 : null; // 假设用了CryptoJS if (originalMD5) { // 重写或Hook console.log('[Hook] CryptoJS.MD5 found.'); // 更优雅的方式是Hook我们找到的 getToken 函数 } // 直接Hook我们找到的 getToken 函数(假设它挂载在 window 的某个对象下) // 我们需要先找到它的引用。如果找不到,可以尝试Hook Function.prototype.call var _getToken = null; // 通过遍历window对象来寻找(这是一种暴力但有时有效的方法) for (var key in window) { try { if (typeof window[key] === 'function' && window[key].toString().includes('md5')) { console.log('[Hook] Potential token function found:', key); _getToken = window[key]; // 替换它 window[key] = function() { console.log('[Hook] getToken called with args:', arguments); var result = _getToken.apply(this, arguments); console.log('[Hook] getToken result:', result); return result; } } } catch(e) {} }执行搜索操作,如果Hook成功,Console会打印出getToken被调用时的参数和返回值。通过对比多组参数和结果,我们就能100%确认算法逻辑。
假设我们Hook后,看到一次调用:
[Hook] getToken called with args: Arguments(3) ["下一日奇遇", "1", "1712345678", ...] [Hook] getToken result: "a1b2c3d4e5f67890fedcba9876543210"同时,我们通过断点或搜索,确定了密钥r的值为"my_secret_key_123"。
3.4 第四步:算法复现与验证
现在,我们可以在Python或Node.js中复现这个算法:
import hashlib import time def generate_token(keyword, page, timestamp, secret_key): # 1. 拼接字符串,注意顺序和分隔符要与JS中一致 raw_str = f"{keyword}&{page}&{timestamp}&key={secret_key}" # 2. 计算MD5(注意,JS中可能是先转UTF-8字节) m = hashlib.md5() m.update(raw_str.encode('utf-8')) # 3. 获取16进制的哈希值,并转为小写(如果JS结果是32位小写) token = m.hexdigest().lower() return token # 测试 secret = "my_secret_key_123" keyword_hex = "e4b88be4b880e697a5e5a587e891a9" keyword_bytes = bytes.fromhex(keyword_hex) keyword = keyword_bytes.decode('utf-8') # 得到“下一日奇遇” timestamp = "1712345678" page = "1" my_token = generate_token(keyword, page, timestamp, secret) print(f"Generated token: {my_token}") # 输出应该与抓包到的 token: a1b2c3d4e5f67890fedcba9876543210 一致如果输出一致,恭喜你,你已经成功逆向了这个简单的token生成算法。
实操心得:在复现时,最常遇到的“坑”是字符串编码和哈希输出格式。JS中的字符串到字节的转换(
UTF-8)、MD5计算后输出的是WordArray对象,可能需要调用.toString(CryptoJS.enc.Hex)才能得到十六进制字符串。而Python的hashlib.md5需要传入bytes,输出也是十六进制字符串。务必确保每一步的中间结果都和Hook打印出来的一致。另一个常见问题是参数顺序和拼接方式,&还是|,有没有额外的空格或换行,都需要严格对照。
4. 深入混淆代码:人脑反混淆的技巧
上面的案例中,混淆程度较轻,我们还能依稀辨认出md5、getToken等关键字。但在实际中,你可能会遇到所有变量名都被替换成无意义的单字母,控制流被平坦化(控制流平坦化是一种混淆技术,将原本线性的代码逻辑打散成一个个基本块,通过一个调度器来控制执行顺序,使代码难以阅读),字符串被加密存放的情况。这时,就需要一些人脑反混淆的技巧。
4.1 识别常见的混淆模式
- 变量名混淆:
userName->_0x1a2b3c或a。这其实影响不大,我们关注的是函数调用和字符串操作。 - 字符串常量加密:原本的
"md5"字符串可能被一个函数解密出来,如_0xabcd('0x12')。你需要找到这个解密函数,通常在代码最前面,把它复制到Console里运行,就能得到明文字符串映射表。 - 控制流平坦化:代码被拆分成很多个
switch-case块或if-else链,跳来跳去。对付它的诀窍是不要试图通读,而是用调试器单步执行,观察数据流。找到核心的计算部分(比如哈希函数调用、大循环),重点分析那一块。 - 死代码注入与代码膨胀:插入大量永远不会执行的代码,干扰分析。直接忽略即可。
4.2 利用调试器进行动态追踪
当静态代码难以阅读时,动态调试就成了你的眼睛。以浏览器开发者工具为例:
- 下断点:在可能的关键函数入口(如包含
encode、hash、sign等字眼的函数,或网络请求的发起函数XMLHttpRequest.send/fetch)处下断点。 - 观察调用栈(Call Stack):当断点触发时,查看调用栈。它能告诉你这个函数是被谁调用的,从而理清函数间的层级关系。
- 监视变量(Watch):将你关心的变量(如拼接中的中间字符串、最终的
token值)添加到监视面板,观察它们每一步的变化。 - 单步执行(Step Over/Into):一步步执行代码,跟踪数据的流向。遇到无关的、混淆的代码块(比如巨大的
switch),可以使用“Step Over”跳过,或者直接跳到下一个断点。
4.3 提炼核心逻辑,忽略干扰信息
逆向的核心是理解算法,而不是读懂每一行代码。你的大脑应该像一个过滤器,自动忽略那些为了混淆而添加的冗余赋值、无用的条件判断和复杂的变量传递。紧紧抓住几个关键点:
- 输入是什么?(哪些参数参与了计算)
- 输出是什么?(最终的
token) - 中间经过了哪些关键处理?(编码A -> 拼接B -> 加密C -> 编码D)
- 有没有固定的密钥或常量?(
key=后面的值,或者一个硬编码的数组)
只要理清了这四步,哪怕代码被混淆得再厉害,其核心骨架也已经在你手中了。
5. 常见问题与排查技巧实录
在实际操作中,你几乎一定会遇到下面这些问题。这里记录了我踩过的坑和总结的排查技巧。
5.1 问题一:Hook不到函数,Console里找不到任何线索
- 可能原因1:代码在闭包或Web Worker中执行。函数作用域受限,没有挂载到
window全局对象上。- 排查技巧:在Sources面板中,对所有JS文件(尤其是大的、混淆的)在开头部分设置“Event Listener Breakpoints”中的
Script->Script First Statement断点。刷新页面,代码会在最开始处暂停。然后单步执行,观察函数定义过程,找到目标函数被赋值给了哪个变量。
- 排查技巧:在Sources面板中,对所有JS文件(尤其是大的、混淆的)在开头部分设置“Event Listener Breakpoints”中的
- 可能原因2:代码被动态加载或执行(eval)。
- 排查技巧:在Network面板过滤
XHR或JS请求,查看是否有额外的JS文件在页面加载后请求。或者,在开发者工具设置中开启“JavaScript source maps”(如果存在)。也可以直接搜索eval(或Function(调用。
- 排查技巧:在Network面板过滤
- 可能原因3:目标函数名被高度混淆,且不包含任何可搜索的关键字。
- 排查技巧:放弃搜索函数名,转而搜索常量字符串。比如,如果
token最终要发送到api.example.com/search,那就搜索“/search”或“api.example.com”。找到发送请求的代码附近,向上追溯调用栈。
- 排查技巧:放弃搜索函数名,转而搜索常量字符串。比如,如果
5.2 问题二:算法复现结果总是和抓包结果对不上
这是最令人头疼,也最常见的问题。请按照以下清单逐项核对:
| 排查项 | 可能原因 | 验证方法 |
|---|---|---|
| 1. 参数完整性 | 漏掉了某个参与签名的参数(如User-Agent,Cookie中的某个值)。 | 仔细对比多个请求,找出所有值变化的参数。尝试将整个请求头或URL参数都纳入签名计算。 |
| 2. 参数顺序 | 拼接顺序错误。JS中可能是a+b+c,你写成了c+b+a。 | 通过Hook打印出拼接前的最终字符串,与你复现的字符串进行逐字符比对。 |
| 3. 编码问题 | JS和Python对字符串的编码处理不一致。例如,JS中“中文”参与计算时,可能是UTF-16,也可能是UTF-8字节。 | 在Hook时,不仅打印字符串,还打印其字符编码(如escape(str)或查看字节数组)。在Python复现时,使用.encode(‘utf-8’)或.encode(‘utf-16le’)明确指定。 |
| 4. 哈希算法与格式 | 猜错了哈希算法(MD5 vs SHA1 vs SHA256),或者输出格式不对(Hex大写 vs 小写,Base64 vs Hex)。 | 尝试常见的几种哈希算法。对比Hook得到的原始结果和你计算结果的长度和字符集。32位Hex是MD5,40位是SHA-1,64位是SHA-256。 |
| 5. 密钥(Salt)错误 | 密钥找错了,或者密钥本身也经过了某种编码。 | 通过Hook确认密钥的明文值。检查密钥是否在代码中被二次处理(如反转、截取、再编码)。 |
| 6. 时间戳精度 | 时间戳单位是秒还是毫秒?是否经过了取整或格式化? | Hook获取参与计算的时间戳值,与你本地生成的时间戳进行对比和换算。 |
| 7. 存在随机数 | 签名中混入了一个随机的nonce,每次请求都不同。 | 在请求参数或响应头中寻找一个长度固定、每次请求都变化的随机字符串。 |
5.3 问题三:面对复杂的加密算法(如AES、RSA)怎么办?
如果发现不是简单的哈希,而是对称加密(AES)或非对称加密(RSA),难度会提升,但思路不变。
- 定位算法:搜索
CryptoJS.AES、encrypt、decrypt、publicKey、RSA等关键字。 - 获取密钥:对于AES,关键是找到
key和iv(初始化向量)。它们可能是硬编码的,也可能是通过某个接口动态获取的。对于RSA,前端通常使用公钥加密,公钥可能以字符串或模数(modulus)、指数(exponent)的形式嵌在代码里。 - 模式与填充:AES有多种模式(CBC, ECB, GCM等)和填充方式(PKCS7, ZeroPadding等)。这些信息通常在调用加密库的代码中可以看到,例如
CryptoJS.AES.encrypt(data, key, {mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7})。必须完全一致才能解密成功。 - 工具辅助:一旦获取了算法、密钥、模式等所有参数,可以使用Python的
pycryptodome库或在线工具进行复现和验证。动态Hook获取加密前的明文和加密后的密文,是验证复现是否正确的唯一标准。
5.4 一个宝贵的调试技巧:本地模拟执行
当你已经将关键的加密函数定位并大致理解后,一个高效的方法是尝试将这段JS代码剥离出来,在Node.js或浏览器Console的独立环境中运行。
- 将目标函数及其所有依赖的函数、变量定义,复制到一个新的文本文件中。
- 补全缺失的全局对象。例如,如果代码中用到了
CryptoJS,你需要在环境里引入crypto-js库,或者用Node.js内置的crypto模块实现对应函数。 - 创建一个简单的测试脚本,调用这个函数,传入你从Hook中捕获的真实参数。
- 观察输出是否与真实
token一致。
这个过程能帮你彻底理清函数的所有依赖和内部逻辑,是最终复现算法前最可靠的验证步骤。它就像把发动机从汽车里拆下来单独测试,排除了整车其他电路的干扰。
