解析2025强网拟态EZMiniAPP
微信小程序逆向分析与加密算法破解一、题目背景与初步分析
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))
f.read(4)# 跳过Info1
f.read(4)# 跳过Info2
# 读取数据区偏移量 (大端序,用'>I'表示)
data_section_offset = struct.unpack('>I', f.read(4))
f.read(1)# 跳过保留字节
# 读取文件数量
file_count = struct.unpack('>I', f.read(4))
# 读取文件列表
file_list = []
for i in range(file_count):
# 文件名长度
name_len = struct.unpack('>I', f.read(4))
# 文件名 (UTF-8编码)
name = f.read(name_len).decode('utf-8')
# 文件偏移和大小
offset = struct.unpack('>I', f.read(4))
size = struct.unpack('>I', f.read(4))
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 执行解包
运行解包脚本:
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了解小程序结构:
{
"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()
? wx.showToast({title: "Right", icon: "success", duration: 2e3})
: wx.showToast({title: "Wrong", icon: "error", duration: 2e3});
}
}
});关键发现:
[*]密钥:"newKey2025!"
[*]预期密文:
[*]加密函数: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; break;
case 1: t += a + 0; break;
case 2: t += 0 | a; break;// 按位或0
case 3: t += 0 ^ a; 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;
break;
case 1:
u = i ^ a.charCodeAt(o);
break;
case 2:
e = a.charCodeAt(o);
n = i;
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!" →
↓
步骤2: 计算移位参数
- 对密钥数组各元素求和
- sum = 110+101+119+75+101+121+50+48+50+53+33 = 861
- c = 861 % 8 = 5
↓
步骤3: 逐字符加密
对于每个明文字符:
3.1 异或运算
- plain_char ^ key → 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;
case 1: u = i ^ a.charCodeAt(o);
case 2: u = (a.charCodeAt(o)) ^ (i);但由于异或的交换律(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 05.3.3 完整加密示例
让我们完整演示flag第一个字符'f'的加密过程:
明文字符: 'f'
↓
1. 获取ASCII码
'f' → 102 → 0b01100110
2. 异或运算 (位置0,使用key='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 = 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示例:
# 大端序读取4字节无符号整数offset = struct.unpack('>I', f.read(4))# 小端序读取2字节无符号短整数value = struct.unpack(' 用心讨论,共获提升! 这个有用。 谢谢分享,试用一下 谢谢楼主提供!
页:
[1]