微信小程序逆向分析与加密算法破解
一、题目背景与初步分析
1.1 题目描述
本题是一道Mobile类别的CTF挑战题,题目提供了一个文件:__APP__.wxapkg。
1.2 什么是wxapkg文件
.wxapkg是微信小程序的打包文件格式。微信小程序是运行在微信客户端内的轻量级应用程序,其代码包就以这种特殊格式分发。
wxapkg文件的特点:
- 二进制格式,无法直接用文本编辑器查看
- 包含小程序的所有资源:JavaScript代码、页面模板、样式表、配置文件等
- 有特定的文件结构:包含文件头、索引区和数据区
1.3 解题思路
- 解包wxapkg文件,提取其中的代码
- 分析JavaScript代码,找到加密逻辑
- 理解加密算法的工作原理
- 编写解密脚本,获取flag
二、wxapkg文件格式详解
2.1 文件结构分析
一个标准的wxapkg文件由三部分组成:- ┌─────────────────────────────────────┐
- │ 文件头部 (Header) │
- ├─────────────────────────────────────┤
- │ - First Mark (1字节): 标识字节 │
- │ - Info1 (4字节): 信息段 │
- │ - Info2 (4字节): 信息段 │
- │ - Data Offset (4字节): 数据区偏移 │
- │ - Reserved (1字节): 保留字节 │
- ├─────────────────────────────────────┤
- │ 索引区 (Index Section) │
- ├─────────────────────────────────────┤
- │ - File Count (4字节): 文件数量 │
- │ - File List: 文件列表 │
- │ * Name Length (4字节) │
- │ * Name (变长): 文件名 │
- │ * Offset (4字节): 文件偏移 │
- │ * Size (4字节): 文件大小 │
- ├─────────────────────────────────────┤
- │ 数据区 (Data Section) │
- ├─────────────────────────────────────┤
- │ 各个文件的实际数据内容 │
- └─────────────────────────────────────┘
复制代码 关键技术点:
- 多字节整数使用大端序(Big-Endian)存储
- 文件偏移量是从wxapkg文件开头计算的绝对位置
- 文件名是UTF-8编码的字符串
2.2 为什么需要解包
wxapkg是二进制打包格式,直接查看只能看到乱码。我们需要:
- 解析文件头,获取文件列表信息
- 根据偏移量和大小,提取每个文件的数据
- 还原成原始的目录结构
三、实战:解包wxapkg文件
3.1 编写解包工具
我们使用Python的struct模块来解析二进制数据:- #!/usr/bin/env python3
- import struct
- import os
- def unpack_wxapkg(wxapkg_file, output_dir):
- """解包微信小程序 wxapkg 文件"""
- with open(wxapkg_file, 'rb') as f:
- # 读取头部信息
- first_mark = struct.unpack('B', f.read(1))[0]
- f.read(4) # 跳过Info1
- f.read(4) # 跳过Info2
- # 读取数据区偏移量 (大端序,用'>I'表示)
- data_section_offset = struct.unpack('>I', f.read(4))[0]
- f.read(1) # 跳过保留字节
- # 读取文件数量
- file_count = struct.unpack('>I', f.read(4))[0]
- # 读取文件列表
- file_list = []
- for i in range(file_count):
- # 文件名长度
- name_len = struct.unpack('>I', f.read(4))[0]
- # 文件名 (UTF-8编码)
- name = f.read(name_len).decode('utf-8')
- # 文件偏移和大小
- offset = struct.unpack('>I', f.read(4))[0]
- size = struct.unpack('>I', f.read(4))[0]
- file_list.append({
- 'name': name,
- 'offset': offset,
- 'size': size
- })
- # 创建输出目录
- if not os.path.exists(output_dir):
- os.makedirs(output_dir)
- # 解包每个文件
- for file_info in file_list:
- name = file_info['name'].lstrip('/')
- file_path = os.path.join(output_dir, name)
- file_dir = os.path.dirname(file_path)
- # 创建文件所在目录
- if file_dir and not os.path.exists(file_dir):
- os.makedirs(file_dir)
- # 读取并写入文件数据
- f.seek(file_info['offset'])
- file_data = f.read(file_info['size'])
- with open(file_path, 'wb') as out_f:
- out_f.write(file_data)
- print(f"Extracted: {file_info['name']}")
复制代码 技术要点解释:
- struct.unpack('B', data):解包1个无符号字节
- struct.unpack('>I', data):解包4字节无符号整数(大端序)
- >表示大端序
- I表示无符号整数(unsigned int)
- decode('utf-8'):将字节序列解码为UTF-8字符串
3.2 执行解包
运行解包脚本:输出结果:- Unpacking __APP__.wxapkg...
- First mark: 190
- Data section offset: 170832
- File count: 24
- File 1: /__debug__/__jscore-debug__.png, offset: 907, size: 178
- ...
- File 11: /chunk_0.appservice.js, offset: 65008, size: 15834
- ...
- Extracted: /chunk_0.appservice.js
- ...
- Done!
复制代码 成功解包出24个文件!其中最关键的是chunk_0.appservice.js。
3.3 解包后的文件结构
- unpacked/
- ├── __debug__/ # 调试文件
- ├── app-config.json # 小程序配置
- ├── app-service.js # 服务层主文件
- ├── appservice.app.js # 应用逻辑
- ├── chunk_0.appservice.js # ★ 关键:包含页面逻辑
- ├── chunk_1.appservice.js # 代码分块
- ├── common.app.js # 公共代码
- ├── pages/ # 页面目录
- │ ├── index/ # 首页
- │ │ ├── index.html
- │ │ └── index.wxss
- │ └── logs/ # 日志页
- │ ├── logs.html
- │ └── logs.wxss
- └── page-frame.html # 页面框架
复制代码 四、代码分析:定位加密逻辑
4.1 查看小程序配置
首先查看app-config.json了解小程序结构:- {
- "entryPagePath": "pages/index/index.html",
- "pages": ["pages/index/index", "pages/logs/logs"],
- ...
- }
复制代码 可以看到入口页面是pages/index/index,这应该是我们的重点分析对象。
4.2 分析关键文件
打开chunk_0.appservice.js,这个文件包含了index页面的逻辑代码。虽然代码经过了混淆,但我们仍能识别出关键函数。
在第2行找到了核心逻辑(为便于阅读,这里进行了格式化):- Page({
- data: {
- inputValue: "",
- animationData: {}
- },
- // 输入框变化处理
- onInputChange: function(a) {
- this.setData({inputValue: a.detail.value});
- },
- // ★ 关键:加密函数
- enigmaticTransformation: function(a, t) {
- // a: 明文
- // t: 密钥
- // ... 加密逻辑 ...
- },
- // 自定义加密入口
- customEncrypt: function(a, t) {
- return this.enigmaticTransformation(a, t);
- },
- // ★ 验证逻辑
- onCheck: function() {
- var a = this.data.inputValue;
- if ("" !== a.trim()) {
- var t = this.customEncrypt(a, "newKey2025!");
- console.log(t);
- JSON.stringify(t) === JSON.stringify([1, 33, 194, 133, 195, 102, 232, 104, 200, 14, 8, 163, 131, 71, 68, 97, 2, 76, 72, 171, 74, 106, 225, 1, 65])
- ? wx.showToast({title: "Right", icon: "success", duration: 2e3})
- : wx.showToast({title: "Wrong", icon: "error", duration: 2e3});
- }
- }
- });
复制代码 关键发现:
- 密钥:"newKey2025!"
- 预期密文:[1, 33, 194, 133, 195, 102, 232, 104, 200, 14, 8, 163, 131, 71, 68, 97, 2, 76, 72, 171, 74, 106, 225, 1, 65]
- 加密函数:enigmaticTransformation
五、深入分析
5.1 完整提取加密逻辑
- enigmaticTransformation: function(a, t) {
- // 步骤1: 将密钥转换为ASCII码数组
- i = Array.from(t).map(function(a) {
- return a.charCodeAt(0);
- });
- s = i.length;
- // 步骤2: 计算循环移位参数c
- c = function(a) {
- for (var t = 0, e = 0; e < a.length; e++) {
- switch(e % 4) {
- case 0: t += 1 * a[e]; break;
- case 1: t += a[e] + 0; break;
- case 2: t += 0 | a[e]; break; // 按位或0
- case 3: t += 0 ^ a[e]; break; // 按位异或0
- }
- }
- return t;
- }(i) % 8;
- // 步骤3: 逐字符加密
- r = [];
- for (o = 0; o < a.length; o++) {
- var u;
- // 3.1: 异或运算
- switch(o % 3) {
- case 0:
- u = a.charCodeAt(o) ^ i[o % s];
- break;
- case 1:
- u = i[o % s] ^ a.charCodeAt(o);
- break;
- case 2:
- e = a.charCodeAt(o);
- n = i[o % s];
- u = e ^ n;
- break;
- }
- // 3.2: 循环左移
- var h;
- switch(c) {
- case 0: h = u; break;
- case 1: h = 255 & (u << 1 | u >> 7 & 1); break;
- case 2: h = 255 & (u << 2 | u >> 6 & 3); break;
- case 3: h = 255 & (u << 3 | u >> 5 & 7); break;
- case 4: h = 255 & (u << 4 | u >> 4 & 15); break;
- case 5: h = 255 & (u << 5 | u >> 3 & 31); break;
- case 6: h = 255 & (u << 6 | u >> 2 & 63); break;
- case 7: h = 255 & (u << 7 | u >> 1 & 127); break;
- default: h = 255 & (u << c | u >> (8 - c)); break;
- }
- // 3.3: 添加到结果数组
- r.push(h);
- }
- return r;
- }
复制代码 5.2 算法流程图
- 输入: 明文字符串, 密钥字符串
- ↓
- 步骤1: 密钥处理
- - 将密钥转为ASCII码数组
- - key = "newKey2025!" → [110, 101, 119, 75, 101, 121, 50, 48, 50, 53, 33]
- ↓
- 步骤2: 计算移位参数
- - 对密钥数组各元素求和
- - sum = 110+101+119+75+101+121+50+48+50+53+33 = 861
- - c = 861 % 8 = 5
- ↓
- 步骤3: 逐字符加密
- 对于每个明文字符:
- 3.1 异或运算
- - plain_char ^ key[i % key_length] → u
- 3.2 循环左移
- - rotate_left(u, c) → h
- 3.3 添加到结果
- - result.append(h)
- ↓
- 输出: 密文字节数组
复制代码 5.3 关键技术点详解
5.3.1 异或运算(XOR)
基本性质:
- A ^ B = C 则 C ^ B = A(自反性)
- A ^ 0 = A
- A ^ A = 0
为什么用异或:
- 加密和解密使用相同的运算
- 简单高效
- 数学上具有对称性
代码中的混淆:
虽然代码中有三种switch-case分支:- case 0: u = a.charCodeAt(o) ^ i[o % s];
- case 1: u = i[o % s] ^ a.charCodeAt(o);
- case 2: u = (a.charCodeAt(o)) ^ (i[o % s]);
复制代码 但由于异或的交换律(A ^ B = B ^ A),这三种方式结果完全相同!这是一种代码混淆技巧,目的是增加逆向分析的难度。
5.3.2 循环左移(Rotate Left)
什么是循环左移:
将一个字节的所有位向左移动n位,左侧溢出的位移到右侧。- 原始: a b c d e f g h
- ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓
- 左移3位: d e f g h a b c
复制代码 实现原理:
以左移5位为例(本题中c=5):- h = 255 & (u << 5 | u >> 3 & 31)
复制代码 分解步骤:- 假设 u = 0b10110011 (179)
- 步骤1: u << 5 (左移5位)
- 10110011 → 01100000 (96)
- (左侧5位溢出)
- 步骤2: u >> 3 (右移3位,8-5=3)
- 10110011 → 00010110 (22)
- 步骤3: (u >> 3) & 31 (取低5位)
- 00010110 & 00011111 = 00010110 (22)
- 步骤4: 左移结果 | 右移结果
- 01100000 | 00010110 = 01110110 (118)
- 步骤5: & 255 (确保在0-255范围)
- 01110110 & 11111111 = 01110110 (118)
- 结果: 10110011 循环左移5位 → 01110110
复制代码 图示说明:- 原始字节: 1 0 1 1 0 0 1 1
- ╰─────────╯╰──╯
- ↓ ↓
- 左移5位后: 0 1 1 0 0 0 0 0 (左移部分)
- ↓
- 右移3位后: 0 0 0 1 0 1 1 0 (溢出部分)
- ↓ 按位或
- 最终结果: 0 1 1 1 0 1 1 0
复制代码 5.3.3 完整加密示例
让我们完整演示flag第一个字符'f'的加密过程:- 明文字符: 'f'
- ↓
- 1. 获取ASCII码
- 'f' → 102 → 0b01100110
- 2. 异或运算 (位置0,使用key[0]='n'=110)
- 102 ^ 110 = 0b01100110 ^ 0b01101110
- = 0b00001000
- = 8
- 3. 循环左移5位
- u = 8 = 0b00001000
- 左移5位: 8 << 5 = 0b00000000 = 0
- 右移3位: 8 >> 3 = 0b00000001 = 1
- 取低5位: 1 & 31 = 1
- 按位或: 0 | 1 = 1
- h = 1
- 4. 输出密文
- cipher[0] = 1 ✓
复制代码 验证成功!预期密文的第一个元素确实是1。
六、逆向解密:编写解密脚本
6.1 解密思路
加密过程是:明文 → 异或 → 循环左移 → 密文
解密过程是逆运算:密文 → 循环右移 → 异或 → 明文
关键认识:
- 循环左移的逆运算是循环右移
- 异或的逆运算仍是异或(因为 (A ^ B) ^ B = A)
6.2 实现循环右移
- def rot_right(x, n):
- """
- 循环右移函数
- 参数:
- x: 待移位的字节值
- n: 右移位数
- 返回:
- 循环右移n位后的结果
- """
- x &= 0xFF # 确保在0-255范围内
- return ((x >> n) | (x << (8 - n))) & 0xFF
复制代码 运行结果:- 循环右移 = 右移n位 | 左移(8-n)位
- 例如右移5位:
- 原始: a b c d e f g h
- ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓
- 右移5位: 0 0 0 0 0 a b c (右侧5位溢出)
- 左移3位: f g h 0 0 0 0 0 (将溢出位移回)
- ↓ 按位或
- 结果: f g h 0 0 a b c
复制代码 完美!验证通过,证明我们的解密算法完全正确。
七、知识点总结与技术深化
7.1 二进制文件解析技术
Python struct模块常用格式:
格式字符C类型Python类型字节数Bunsigned charinteger1Hunsigned shortinteger2Iunsigned intinteger4Qunsigned long longinteger8字节序标识:
标识字节序说明</tdtd小端序/tdtdLittle-Endian/td/trtrtd>大端序Big-Endian=本机序Native示例:
[code]# 大端序读取4字节无符号整数offset = struct.unpack('>I', f.read(4))[0]# 小端序读取2字节无符号短整数value = struct.unpack(' |