微信小程序逆向分析与加密算法破解
一、题目背景与初步分析
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模块来解析二进制数据:
python
#!/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 执行解包
运行解包脚本:
bash
python3 unpacker.py
输出结果:
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了解小程序结构:
json
{
"entryPagePath": "pages/index/index.html",
"pages": ["pages/index/index", "pages/logs/logs"],
...
}
可以看到入口页面是pages/index/index,这应该是我们的重点分析对象。
4.2 分析关键文件
打开chunk_0.appservice.js,这个文件包含了index页面的逻辑代码。虽然代码经过了混淆,但我们仍能识别出关键函数。
在第2行找到了核心逻辑(为便于阅读,这里进行了格式化):
javascript
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 完整提取加密逻辑
javascript
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 = AA ^ A = 0
为什么用异或:
- 加密和解密使用相同的运算
- 简单高效
- 数学上具有对称性
代码中的混淆 :
虽然代码中有三种switch-case分支:
javascript
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):
javascript
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 实现循环右移
python
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
6.3 完整解密函数
python
def decrypt(cipher, key):
"""
解密函数
参数:
cipher: 密文字节数组
key: 密钥字符串
返回:
解密后的明文字符串
"""
# 步骤1: 密钥转ASCII码数组
key_array = [ord(c) for c in key]
key_length = len(key_array)
# 步骤2: 计算移位参数 (与加密时相同)
c = sum(key_array) % 8
# 步骤3: 逐字节解密
plaintext = []
for position, cipher_byte in enumerate(cipher):
cipher_byte &= 0xFF
# 3a: 还原循环左移 → 执行循环右移
after_rotate = rot_right(cipher_byte, c)
# 3b: 还原异或 (异或是自反运算)
plain_code = after_rotate ^ key_array[position % key_length]
# 3c: 转换为字符
plaintext.append(chr(plain_code))
return ''.join(plaintext)
6.4 执行解密
python
# 从小程序代码中提取的数据
cipher = [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]
key = "newKey2025!"
# 解密
flag = decrypt(cipher, key)
print(f"Flag: {flag}")
运行结果:
======================================================================
CTF题目: EZMiniAPP - 微信小程序逆向解密
======================================================================
[输入] 密文数组:
[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]
长度: 25 字节
[输入] 密钥:
newKey2025!
长度: 11 字符
======================================================================
开始解密...
======================================================================
[调试] 密钥ASCII码数组: [110, 101, 119, 75, 101, 121, 50, 48, 50, 53, 33]
[调试] 密钥数组和: 861
[调试] 移位参数c: 5
[调试] 位置0:
密文字节: 1 (0b00000001)
右移5位后: 8 (0b00001000)
密钥字节: 110
异或结果: 102 (ASCII: 'f')
[调试] 位置1:
密文字节: 33 (0b00100001)
右移5位后: 9 (0b00001001)
密钥字节: 101
异或结果: 108 (ASCII: 'l')
[调试] 位置2:
密文字节: 194 (0b11000010)
右移5位后: 22 (0b00010110)
密钥字节: 119
异或结果: 97 (ASCII: 'a')
======================================================================
解密完成!
======================================================================
[结果] Flag: flag{JustEasyMiniProgram}
======================================================================
6.5 验证解密正确性
为了确保解密结果正确,我们实现完整的加密函数,将解密得到的flag重新加密:
python
def encrypt(plaintext, key):
"""完整复现JavaScript的加密算法"""
key_array = [ord(c) for c in key]
key_length = len(key_array)
c = sum(key_array) % 8
result = []
for position in range(len(plaintext)):
# 异或
plain_code = ord(plaintext[position])
u = plain_code ^ key_array[position % key_length]
# 循环左移
if c == 5:
h = 255 & (u << 5 | u >> 3 & 31)
# ... 其他case ...
result.append(h)
return result
# 验证
encrypted = encrypt("flag{JustEasyMiniProgram}", "newKey2025!")
original = [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]
if encrypted == original:
print("✓ 验证成功!解密结果正确!")
运行结果:
======================================================================
加密验证 - 验证解密结果的正确性
======================================================================
[输入] 明文: flag{JustEasyMiniProgram}
[输入] 密钥: 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]
[对比] 原始密文:
[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]
======================================================================
✓ 验证成功!解密结果正确!
======================================================================
Flag: flag{JustEasyMiniProgram}
完美!验证通过,证明我们的解密算法完全正确。
七、知识点总结与技术深化
7.1 二进制文件解析技术
Python struct模块常用格式:
| 格式字符 | C类型 | Python类型 | 字节数 |
|---|---|---|---|
| B | unsigned char | integer | 1 |
| H | unsigned short | integer | 2 |
| I | unsigned int | integer | 4 |
| Q | unsigned long long | integer | 8 |
字节序标识:
| 标识 | 字节序 | 说明 |
|---|---|---|
| < | 小端序 | Little-Endian |
| > | 大端序 | Big-Endian |
| = | 本机序 | Native |
示例:
python
# 大端序读取4字节无符号整数
offset = struct.unpack('>I', f.read(4))[0]
# 小端序读取2字节无符号短整数
value = struct.unpack('<H', f.read(2))[0]
7.2 位运算详解
7.2.1 异或运算(XOR)
运算规则:
0 ^ 0 = 0
0 ^ 1 = 1
1 ^ 0 = 1
1 ^ 1 = 0
重要性质:
- 交换律:
A ^ B = B ^ A - 结合律:
(A ^ B) ^ C = A ^ (B ^ C) - 自反性:
A ^ B ^ B = A - 恒等律:
A ^ 0 = A - 归零律:
A ^ A = 0
在加密中的应用:
python
# 加密
cipher = plaintext ^ key
# 解密(使用相同的key)
plaintext = cipher ^ key
# 证明:
# plaintext ^ key ^ key = plaintext
7.2.2 移位运算
左移(<<):
x << n # 左移n位,右侧补0
5 << 2 # 0b00000101 → 0b00010100 (5 → 20)
右移(>>):
x >> n # 右移n位,左侧补0
20 >> 2 # 0b00010100 → 0b00000101 (20 → 5)
循环移位的实现:
python
# 循环左移n位
def rotate_left(x, n):
return ((x << n) | (x >> (8 - n))) & 0xFF
# 循环右移n位
def rotate_right(x, n):
return ((x >> n) | (x << (8 - n))) & 0xFF
7.2.3 位掩码
作用:提取或保留特定的位
python
x & 0xFF # 保留低8位 (0-255)
x & 0x0F # 保留低4位 (0-15)
x & 0x01 # 保留最低位 (0或1)
# 示例
value = 0b11010110
low_4_bits = value & 0x0F # 0b00000110 = 6
7.3 代码混淆识别
本题中使用的混淆技巧:
7.3.1 等价分支混淆
javascript
switch(e % 4) {
case 0: t += 1 * a[e]; break; // 等价于 t += a[e]
case 1: t += a[e] + 0; break; // 等价于 t += a[e]
case 2: t += 0 | a[e]; break; // 等价于 t += a[e]
case 3: t += 0 ^ a[e]; break; // 等价于 t += a[e]
}
所有分支实际效果相同!
7.3.2 冗余操作混淆
javascript
switch(o % 3) {
case 0: u = a ^ b; break;
case 1: u = b ^ a; break; // 与case 0相同
case 2: u = a ^ b; break; // 与case 0相同
}
利用异或的交换律,制造"不同"的假象。
7.3.3 变量命名混淆
使用无意义的单字母变量名:a, t, e, n, r, i, s, c, o, u, h
识别方法:
- 分析每个分支的实际计算结果
- 简化位运算表达式
- 找出运算的数学本质
7.4 逆向分析方法论
┌─────────────────┐
│ 静态分析 │ 阅读代码,理解逻辑
├─────────────────┤
│ 动态分析 │ 运行代码,观察行为
├─────────────────┤
│ 数学分析 │ 找出运算的逆运算
├─────────────────┤
│ 验证测试 │ 确认解密正确性
└─────────────────┘
具体步骤:
- 识别加密算法类型:对称/非对称、流密码/分组密码
- 提取关键参数:密钥、初始向量、轮数等
- 理解运算流程:每一步的数学含义
- 推导逆运算:找到每个步骤的逆操作
- 实现解密:编写代码实现逆运算
- 验证结果:重新加密检验
八、完整解题流程回顾
步骤总结
第一步:文件分析
├─ 识别wxapkg格式
└─ 了解文件结构
第二步:解包提取
├─ 编写解包工具 (Python + struct)
├─ 解析文件头和索引
└─ 提取所有文件
第三步:代码定位
├─ 查看小程序配置
├─ 找到入口页面
└─ 定位加密函数
第四步:算法分析
├─ 提取enigmaticTransformation函数
├─ 理解加密流程
│ ├─ 密钥处理
│ ├─ 参数计算
│ └─ 逐字符加密
└─ 识别代码混淆
第五步:逆向解密
├─ 推导逆运算
│ ├─ 循环左移 → 循环右移
│ └─ 异或 → 异或
├─ 实现解密函数
└─ 获取flag
第六步:验证结果
├─ 实现加密函数
├─ 重新加密flag
└─ 对比原始密文
九、扩展学习资源
9.1 相关技术领域
-
移动应用安全
- Android APK逆向
- iOS IPA分析
- 微信小程序/支付宝小程序
-
密码学
- 对称加密算法(AES、DES、RC4)
- 非对称加密算法(RSA、ECC)
- 哈希函数(MD5、SHA系列)
-
逆向工程
- 静态分析工具(IDA Pro、Ghidra)
- 动态调试(GDB、OllyDbg)
- 反编译技术
-
代码混淆
- 控制流平坦化
- 虚拟机保护
- 符号混淆
9.2 推荐工具
必备工具:
- Python 3.x:脚本编写
- wxappUnpacker:专业的微信小程序解包工具
- VS Code:代码编辑器
- 010 Editor:十六进制编辑器
进阶工具:
- Frida:动态插桩框架
- jadx:APK反编译
- Hopper Disassembler:反汇编工具
十、总结
本题是一道非常经典的Mobile入门级CTF题目,综合考察了:
10.1 核心能力
- 文件格式分析:理解wxapkg的二进制结构
- 代码阅读:从混淆的JavaScript中提取关键逻辑
- 算法理解:掌握异或、循环移位等基本运算
- 逆向思维:从加密推导出解密过程
- 编程实现:用Python实现解密工具
10.2 总结
这道题虽然难度不高,但涉及的知识点非常全面,从二进制文件解析到算法逆向,从代码混淆识别到解密实现,每一步都是实际渗透测试和安全研究中的常见场景。
通过完整复现这道题,我们不仅获得了flag,更重要的是掌握了一套完整的逆向分析方法。这些技能和思路可以应用到更多实际场景中,比如:
- 分析恶意软件的通信加密
- 逆向闭源应用的协议
- 审计移动应用的安全性
- 破解软件的授权机制
安全研究是一个不断学习的过程,希望这篇详细的技术解析能够帮助初学者建立起对移动应用逆向的初步认识,并激发对信息安全的兴趣!
作者声明 :
本文仅用于安全研究和技术交流,请勿用于非法用途。