0%

Tauri 逆向

tauri 逆向

本教程应该适用于Tauri v2.0以上版本,当前时间为2025.09.19,版本为:

image-20250919140346880

其他2.0的细小版本没试过,但应该一样

exe

首先有这样一个tauri编写的exe程序

image-20250919094318489

运行后是这样的:

image-20250919094400881

分析过程

首先看看输出是什么

image-20250919094604711

直接分析不好分析,所以需要把前端的源码拿出来

用IDA打开

Shift + F12直接搜字符串,tauri程序一般的入口都是index.html

程序比较大,等IDA全都分析完之后再搜

image-20250919094937639

image-20250919095005970

搜出来了以上这些东西

image-20250919100004896

一般来说,有一个文件名,后面是一堆十六进制看起来好像是什么内容又看起来好像被加密了的,就是资源文件内容所在的地方,如下图:

image-20250919100132514

通过View→Open subviews→Hex dump来打开十六进制视图

image-20250919100213019

image-20250919100319004

可以看到这里的index.html,上下翻翻还有其他的资源文件

image-20250919100517892

dump都是用同样的方法

因为IDA选中十六进制部分只能是一个长方形的区域块,所以难免会多选一部分(红色方框内是多的部分)

image-20250919120653099

保存的这部分打开后看一下,前面还有……/index.html部分,这里不是brotli压缩的部分,需要删掉,或者在解压的时候直接跳过

image-20250919120954359

这时候就要问了,这个文件的大小怎么确定呢?

首先,肯定是有个地方存着这部分文件的大小的,看了有一篇文章写到:

image-20250919121128485

但是我在IDA中查看的时候并没有发现一个类似的文件表结构,动态调试的时候貌似有一个rbx的某一步动态存储了这个文件的大小,但是由于比较复杂(其实是我懒)所以没有继续动态调试

但是稍加分析得出下面两种结论:

1.这个文件后面还有其他文件

比如之前的./styles.css部分,css这部分的brotli部分结束了之后后面紧接着是/main.js,所以styles.css的结束就是/main.js的前面,如下图

image-20250919121639314

2.这个文件后面没有其他文件

就比如这个index.html,他后面不再有其他文件的开头作为他自己的结束的标志,所以只能大体找到一部分

但是其实根据IDA反编译出来的进行猜测,也能大概猜出最后一个文件的结尾是在哪里,后面颜色不一样还都是……000000……0000000……,并且数据看起来就不是一个正常文件,像是其他部分的一个什么头,所以红框内的应该就是index.html的结尾

image-20250919122014317

那么文件大小大概找到了之后,其实也可以直接写一个Python脚本进行解密了

由于我们dump出来的文件可能带有前面的不是brotli的部分和后面也不是brotli的部分,所以需要跳过前面或后面的一部分字节,找到中间真正的brotli压缩的部分。这时候使用Python是比较方便的,直接遍历,如果解压不了那就是偏移还不够,前后一起偏移,一点一点试,肯定能试出来

那么直接写一个Python小脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
#!/usr/bin/env python3
import brotli
import sys
import argparse
import os
from typing import List, Tuple

def find_all_brotli_segments(data: bytes, min_size: int = 10, max_scan: int = 10000) -> List[Tuple[bytes, int, int]]:
"""
在二进制数据中查找所有可能的brotli压缩段

Args:
data: 原始字节数据
min_size: brotli段的最小大小
max_scan: 最大扫描长度(避免过长搜索)

Returns:
list: [(解压后的数据, 开始位置, 结束位置), ...]
"""
found_segments = []
data_len = len(data)

print(f"开始扫描 {data_len} 字节的数据...")
print(f"最小段大小: {min_size}, 最大扫描长度: {max_scan}")

# 遍历所有可能的起始位置
for start_pos in range(data_len - min_size):
if start_pos % 1000 == 0:
print(f"扫描进度: {start_pos}/{data_len} ({start_pos/data_len*100:.1f}%)", end='\r')

# 限制扫描长度以提高效率
max_end = min(start_pos + max_scan, data_len)

# 尝试不同的结束位置
for end_pos in range(start_pos + min_size, max_end + 1):
try:
candidate = data[start_pos:end_pos]

# 尝试解压
decompressed = brotli.decompress(candidate)

# 检查是否与已找到的段重叠
is_overlapping = False
for _, existing_start, existing_end in found_segments:
if not (end_pos <= existing_start or start_pos >= existing_end):
is_overlapping = True
break

if not is_overlapping:
found_segments.append((decompressed, start_pos, end_pos))
print(f"\n找到brotli段 #{len(found_segments)}: 位置 {start_pos}-{end_pos} (长度:{end_pos-start_pos}), 解压后:{len(decompressed)}字节")

# 跳过这个段的剩余部分以避免重复检测
start_pos = end_pos - 1
break

except Exception:
continue

print(f"\n扫描完成! 总共找到 {len(found_segments)} 个brotli段")
return found_segments

def extract_multiple_brotli(file_path: str, output_dir: str = None, min_size: int = 10,
max_scan: int = 10000, save_raw: bool = False) -> bool:
"""
从文件中提取所有brotli段

Args:
file_path: 输入文件路径
output_dir: 输出目录
min_size: 最小brotli段大小
max_scan: 最大扫描长度
save_raw: 是否保存原始压缩数据

Returns:
bool: 是否成功
"""
try:
print(f"读取文件: {file_path}")

# 读取二进制文件
with open(file_path, 'rb') as f:
data = f.read()

print(f"文件大小: {len(data)} 字节")

# 设置输出目录
if output_dir is None:
base_name = os.path.splitext(os.path.basename(file_path))[0]
output_dir = f"{base_name}_extracted"

# 创建输出目录
os.makedirs(output_dir, exist_ok=True)
print(f"输出目录: {output_dir}")

# 查找所有brotli段
segments = find_all_brotli_segments(data, min_size, max_scan)

if not segments:
print("未找到任何brotli压缩段")
return False

# 保存每个段
for i, (decompressed_data, start_pos, end_pos) in enumerate(segments, 1):
# 保存解压后的数据
decompressed_file = os.path.join(output_dir, f"segment_{i:03d}_decompressed.dat")
with open(decompressed_file, 'wb') as f:
f.write(decompressed_data)

print(f"段 {i}: 位置 {start_pos:06d}-{end_pos:06d}, 原始:{end_pos-start_pos:6d}字节, 解压:{len(decompressed_data):6d}字节")
print(f" 解压数据保存到: {decompressed_file}")

# 如果需要,保存原始压缩数据
if save_raw:
raw_file = os.path.join(output_dir, f"segment_{i:03d}_raw.br")
with open(raw_file, 'wb') as f:
f.write(data[start_pos:end_pos])
print(f" 原始数据保存到: {raw_file}")

# 尝试显示解压内容的预览
try:
if len(decompressed_data) > 0:
# 尝试作为文本显示
try:
preview = decompressed_data.decode('utf-8')[:100]
print(f" 内容预览: {repr(preview)}{'...' if len(decompressed_data) > 100 else ''}")
except UnicodeDecodeError:
# 显示十六进制
hex_preview = decompressed_data[:20].hex()
print(f" 十六进制预览: {hex_preview}{'...' if len(decompressed_data) > 20 else ''}")
except Exception:
pass

print()

# 生成摘要文件
summary_file = os.path.join(output_dir, "extraction_summary.txt")
with open(summary_file, 'w', encoding='utf-8') as f:
f.write(f"Brotli提取摘要\n")
f.write(f"================\n")
f.write(f"源文件: {file_path}\n")
f.write(f"文件大小: {len(data)} 字节\n")
f.write(f"找到段数: {len(segments)}\n")
f.write(f"扫描参数: 最小大小={min_size}, 最大扫描长度={max_scan}\n\n")

for i, (decompressed_data, start_pos, end_pos) in enumerate(segments, 1):
f.write(f"段 {i}:\n")
f.write(f" 位置: {start_pos} - {end_pos}\n")
f.write(f" 原始大小: {end_pos - start_pos} 字节\n")
f.write(f" 解压大小: {len(decompressed_data)} 字节\n")
f.write(f" 压缩比: {len(decompressed_data)/(end_pos-start_pos):.2f}x\n")
f.write(f" 解压文件: segment_{i:03d}_decompressed.dat\n")
if save_raw:
f.write(f" 原始文件: segment_{i:03d}_raw.br\n")
f.write("\n")

print(f"提取摘要保存到: {summary_file}")
print(f"\n总计提取了 {len(segments)} 个brotli段")

return True

except FileNotFoundError:
print(f"错误: 文件 '{file_path}' 不存在")
return False
except Exception as e:
print(f"错误: {e}")
return False

def main():
parser = argparse.ArgumentParser(
description='从二进制文件中提取所有brotli压缩段',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog='''
使用示例:
python multi_brotli_extractor.py file.bin
python multi_brotli_extractor.py file.bin --output-dir extracted --save-raw
python multi_brotli_extractor.py file.bin --min-size 50 --max-scan 50000
'''
)

parser.add_argument('input_file', help='输入的二进制文件路径')
parser.add_argument('--output-dir', '-o', type=str,
help='输出目录路径 (默认: {filename}_extracted)')
parser.add_argument('--min-size', '-m', type=int, default=10,
help='brotli段的最小大小 (默认: 10)')
parser.add_argument('--max-scan', '-s', type=int, default=10000,
help='单个段的最大扫描长度 (默认: 10000)')
parser.add_argument('--save-raw', '-r', action='store_true',
help='同时保存原始压缩数据')
parser.add_argument('--quiet', '-q', action='store_true',
help='减少输出信息')

args = parser.parse_args()

if args.quiet:
# 重定向print到null
import os
sys.stdout = open(os.devnull, 'w')

success = extract_multiple_brotli(
args.input_file,
args.output_dir,
args.min_size,
args.max_scan,
args.save_raw
)

if args.quiet:
sys.stdout = sys.__stdout__

sys.exit(0 if success else 1)

if __name__ == '__main__':
main()

上面这个脚本可以同时处理存在多段brotli的二进制数据

所以,比如从这里开始选:

image-20250919124544923

一直选到index.html的后面部分,然后直接运行脚本

image-20250919124636155

然后就直接保存下了三段解出的数据

image-20250919124736658

分别就是styles.css,main.js,index.html

下面还有一个脚本,是直接解析PE文件中的.rdata段中的所有brotli数据的,但是这个脚本运行起来非常慢,仅供参考

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
#!/usr/bin/env python3
import brotli
import sys
import argparse
import os
import struct
from typing import List, Tuple, Optional

class PEParser:
"""简单的PE文件解析器,用于提取.rdata段"""

def __init__(self, file_path: str):
self.file_path = file_path
self.sections = []

def parse(self) -> bool:
"""解析PE文件结构"""
try:
with open(self.file_path, 'rb') as f:
# 读取DOS头
dos_header = f.read(64)
if dos_header[:2] != b'MZ':
print("错误: 不是有效的PE文件 (缺少MZ签名)")
return False

# 获取PE头偏移
pe_offset = struct.unpack('<I', dos_header[60:64])[0]

# 跳到PE头
f.seek(pe_offset)
pe_signature = f.read(4)
if pe_signature != b'PE\x00\x00':
print("错误: 不是有效的PE文件 (缺少PE签名)")
return False

# 读取COFF头
coff_header = f.read(20)
machine, num_sections, timestamp, ptr_to_sym, num_symbols, opt_header_size, characteristics = struct.unpack('<HHIIIHH', coff_header)

# 跳过可选头
f.seek(pe_offset + 24 + opt_header_size)

# 读取节表
for i in range(num_sections):
section_header = f.read(40)
if len(section_header) < 40:
break

name = section_header[:8].rstrip(b'\x00').decode('ascii', errors='ignore')
virtual_size, virtual_address, size_of_raw_data, ptr_to_raw_data = struct.unpack('<IIII', section_header[8:24])

self.sections.append({
'name': name,
'virtual_size': virtual_size,
'virtual_address': virtual_address,
'size_of_raw_data': size_of_raw_data,
'ptr_to_raw_data': ptr_to_raw_data
})

print(f"成功解析PE文件,找到 {len(self.sections)} 个节:")
for section in self.sections:
print(f" {section['name']:10} - 大小: {section['size_of_raw_data']:8} 字节, 偏移: 0x{section['ptr_to_raw_data']:08x}")

return True

except Exception as e:
print(f"解析PE文件时出错: {e}")
return False

def get_rdata_section(self) -> Optional[Tuple[int, int]]:
"""获取.rdata段的偏移和大小"""
for section in self.sections:
if section['name'].lower() == '.rdata':
return section['ptr_to_raw_data'], section['size_of_raw_data']
return None

def extract_rdata(self) -> Optional[bytes]:
"""提取.rdata段的数据"""
rdata_info = self.get_rdata_section()
if rdata_info is None:
print("错误: 未找到.rdata段")
return None

offset, size = rdata_info
try:
with open(self.file_path, 'rb') as f:
f.seek(offset)
rdata = f.read(size)
print(f"成功提取.rdata段: 偏移 0x{offset:08x}, 大小 {size} 字节")
return rdata
except Exception as e:
print(f"提取.rdata段时出错: {e}")
return None

def find_all_brotli_segments(data: bytes, min_size: int = 10, max_scan: int = 10000,
data_offset: int = 0) -> List[Tuple[bytes, int, int]]:
"""
在二进制数据中查找所有可能的brotli压缩段

Args:
data: 原始字节数据
min_size: brotli段的最小大小
max_scan: 最大扫描长度(避免过长搜索)
data_offset: 数据在原文件中的偏移量(用于显示正确的文件位置)

Returns:
list: [(解压后的数据, 开始位置, 结束位置), ...]
"""
found_segments = []
data_len = len(data)

print(f"开始扫描 {data_len} 字节的数据...")
print(f"最小段大小: {min_size}, 最大扫描长度: {max_scan}")
if data_offset > 0:
print(f"数据偏移: 0x{data_offset:08x}")

# 遍历所有可能的起始位置
for start_pos in range(data_len - min_size):
if start_pos % 1000 == 0:
print(f"扫描进度: {start_pos}/{data_len} ({start_pos/data_len*100:.1f}%)", end='\r')

# 限制扫描长度以提高效率
max_end = min(start_pos + max_scan, data_len)

# 尝试不同的结束位置
for end_pos in range(start_pos + min_size, max_end + 1):
try:
candidate = data[start_pos:end_pos]

# 尝试解压
decompressed = brotli.decompress(candidate)

# 检查是否与已找到的段重叠
is_overlapping = False
for _, existing_start, existing_end in found_segments:
if not (end_pos <= existing_start or start_pos >= existing_end):
is_overlapping = True
break

if not is_overlapping:
# 计算在原文件中的绝对位置
abs_start = data_offset + start_pos
abs_end = data_offset + end_pos
found_segments.append((decompressed, abs_start, abs_end))
print(f"\n找到brotli段 #{len(found_segments)}: 位置 0x{abs_start:08x}-0x{abs_end:08x} (长度:{abs_end-abs_start}), 解压后:{len(decompressed)}字节")

# 跳过这个段的剩余部分以避免重复检测
start_pos = end_pos - 1
break

except Exception:
continue

print(f"\n扫描完成! 总共找到 {len(found_segments)} 个brotli段")
return found_segments

def extract_multiple_brotli(file_path: str, output_dir: str = None, min_size: int = 10,
max_scan: int = 10000, save_raw: bool = False,
scan_rdata: bool = False) -> bool:
"""
从文件中提取所有brotli段

Args:
file_path: 输入文件路径
output_dir: 输出目录
min_size: 最小brotli段大小
max_scan: 最大扫描长度
save_raw: 是否保存原始压缩数据
scan_rdata: 是否只扫描PE文件的.rdata段

Returns:
bool: 是否成功
"""
try:
print(f"读取文件: {file_path}")

data_offset = 0 # 数据在原文件中的偏移

if scan_rdata:
# 解析PE文件并提取.rdata段
print("模式: 扫描PE文件的.rdata段")
parser = PEParser(file_path)
if not parser.parse():
return False

data = parser.extract_rdata()
if data is None:
return False

# 获取.rdata段在文件中的偏移
rdata_info = parser.get_rdata_section()
if rdata_info:
data_offset = rdata_info[0]
else:
# 读取整个二进制文件
print("模式: 扫描整个二进制文件")
with open(file_path, 'rb') as f:
data = f.read()

print(f"数据大小: {len(data)} 字节")

# 设置输出目录
if output_dir is None:
base_name = os.path.splitext(os.path.basename(file_path))[0]
suffix = "_rdata_extracted" if scan_rdata else "_extracted"
output_dir = f"{base_name}{suffix}"

# 创建输出目录
os.makedirs(output_dir, exist_ok=True)
print(f"输出目录: {output_dir}")

# 查找所有brotli段
segments = find_all_brotli_segments(data, min_size, max_scan, data_offset)

if not segments:
print("未找到任何brotli压缩段")
return False

# 保存每个段
for i, (decompressed_data, start_pos, end_pos) in enumerate(segments, 1):
# 保存解压后的数据
decompressed_file = os.path.join(output_dir, f"segment_{i:03d}_decompressed.dat")
with open(decompressed_file, 'wb') as f:
f.write(decompressed_data)

print(f"段 {i}: 位置 0x{start_pos:08x}-0x{end_pos:08x}, 原始:{end_pos-start_pos:6d}字节, 解压:{len(decompressed_data):6d}字节")
print(f" 解压数据保存到: {decompressed_file}")

# 如果需要,保存原始压缩数据
if save_raw:
raw_file = os.path.join(output_dir, f"segment_{i:03d}_raw.br")
# 从原文件读取对应的数据段
with open(file_path, 'rb') as f:
f.seek(start_pos)
raw_data = f.read(end_pos - start_pos)
with open(raw_file, 'wb') as f:
f.write(raw_data)
print(f" 原始数据保存到: {raw_file}")

# 尝试显示解压内容的预览
try:
if len(decompressed_data) > 0:
# 尝试作为文本显示
try:
preview = decompressed_data.decode('utf-8')[:100]
print(f" 内容预览: {repr(preview)}{'...' if len(decompressed_data) > 100 else ''}")
except UnicodeDecodeError:
# 显示十六进制
hex_preview = decompressed_data[:20].hex()
print(f" 十六进制预览: {hex_preview}{'...' if len(decompressed_data) > 20 else ''}")
except Exception:
pass

print()

# 生成摘要文件
summary_file = os.path.join(output_dir, "extraction_summary.txt")
with open(summary_file, 'w', encoding='utf-8') as f:
f.write(f"Brotli提取摘要\n")
f.write(f"================\n")
f.write(f"源文件: {file_path}\n")
f.write(f"扫描模式: {'PE .rdata段' if scan_rdata else '整个文件'}\n")
f.write(f"数据大小: {len(data)} 字节\n")
if scan_rdata and data_offset > 0:
f.write(f".rdata段偏移: 0x{data_offset:08x}\n")
f.write(f"找到段数: {len(segments)}\n")
f.write(f"扫描参数: 最小大小={min_size}, 最大扫描长度={max_scan}\n\n")

for i, (decompressed_data, start_pos, end_pos) in enumerate(segments, 1):
f.write(f"段 {i}:\n")
f.write(f" 位置: 0x{start_pos:08x} - 0x{end_pos:08x}\n")
f.write(f" 原始大小: {end_pos - start_pos} 字节\n")
f.write(f" 解压大小: {len(decompressed_data)} 字节\n")
f.write(f" 压缩比: {len(decompressed_data)/(end_pos-start_pos):.2f}x\n")
f.write(f" 解压文件: segment_{i:03d}_decompressed.dat\n")
if save_raw:
f.write(f" 原始文件: segment_{i:03d}_raw.br\n")
f.write("\n")

print(f"提取摘要保存到: {summary_file}")
print(f"\n总计提取了 {len(segments)} 个brotli段")

return True

except FileNotFoundError:
print(f"错误: 文件 '{file_path}' 不存在")
return False
except Exception as e:
print(f"错误: {e}")
return False

def main():
parser = argparse.ArgumentParser(
description='从二进制文件中提取所有brotli压缩段',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog='''
使用示例:
# 扫描整个文件
python multi_brotli_extractor.py file.bin

# 只扫描PE文件的.rdata段
python multi_brotli_extractor.py program.exe --scan-rdata

# 自定义参数
python multi_brotli_extractor.py file.bin --output-dir extracted --save-raw --min-size 50
'''
)

parser.add_argument('input_file', help='输入的二进制文件路径')
parser.add_argument('--output-dir', '-o', type=str,
help='输出目录路径 (默认: {filename}_extracted)')
parser.add_argument('--min-size', '-m', type=int, default=10,
help='brotli段的最小大小 (默认: 10)')
parser.add_argument('--max-scan', '-s', type=int, default=10000,
help='单个段的最大扫描长度 (默认: 10000)')
parser.add_argument('--save-raw', '-r', action='store_true',
help='同时保存原始压缩数据')
parser.add_argument('--scan-rdata', action='store_true',
help='只扫描PE文件的.rdata段')
parser.add_argument('--quiet', '-q', action='store_true',
help='减少输出信息')

args = parser.parse_args()

if args.quiet:
# 重定向print到null
import os
sys.stdout = open(os.devnull, 'w')

success = extract_multiple_brotli(
args.input_file,
args.output_dir,
args.min_size,
args.max_scan,
args.save_raw,
args.scan_rdata
)

if args.quiet:
sys.stdout = sys.__stdout__

sys.exit(0 if success else 1)

if __name__ == '__main__':
main()

半分钟才扫描0.2%,所以还是建议手动找到大体位置,然后dump下来,用第一段脚本直接处理少量数据

image-20250919130806566

还有一种是图片资源,这个应该怎么找呢,其实和index,html,main.js等等是一样的,都在同一个地方

image-20250919140146802

但是解压图片部分就不能用前面的可以寻找多段brotli数据的脚本了,因为可能被识别成多个拆分的部分,所以需要用解压单brotli的脚本来解压:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
#!/usr/bin/env python3
import brotli
import binascii
import sys
import argparse
import os
from typing import Optional, Tuple

def hex_to_bytes(hex_string: str) -> bytes:
"""将十六进制字符串转换为字节"""
# 移除所有空白字符
hex_string = ''.join(hex_string.split())

# 确保是偶数长度
if len(hex_string) % 2 != 0:
raise ValueError("十六进制字符串长度必须为偶数")

try:
return binascii.unhexlify(hex_string)
except binascii.Error as e:
raise ValueError(f"无效的十六进制格式: {e}")

def find_brotli_data(data: bytes, head_offset: int = 0, tail_offset: int = 0) -> Optional[Tuple[bytes, int, int]]:
"""
在数据中查找有效的brotli压缩部分

Args:
data: 原始字节数据
head_offset: 从开头跳过的字节数
tail_offset: 从结尾跳过的字节数

Returns:
tuple: (解压后的数据, 开始位置, 结束位置) 或 None
"""
if len(data) <= head_offset + tail_offset:
print(f"错误: 数据长度({len(data)})小于偏移量总和({head_offset + tail_offset})")
return None

# 确定搜索范围
search_start = head_offset
search_end = len(data) - tail_offset
search_data = data[search_start:search_end]

print(f"搜索范围: {search_start} - {search_end} (长度: {len(search_data)} 字节)")

# 从不同的起始位置开始尝试
for start_pos in range(len(search_data)):
if start_pos % 1000 == 0 and start_pos > 0:
print(f"搜索进度: {start_pos}/{len(search_data)} ({start_pos/len(search_data)*100:.1f}%)", end='\r')

# 尝试不同的结束位置
for end_pos in range(start_pos + 10, len(search_data) + 1): # 至少10字节
try:
candidate = search_data[start_pos:end_pos]
decompressed = brotli.decompress(candidate)

# 计算在原数据中的绝对位置
abs_start = search_start + start_pos
abs_end = search_start + end_pos

print(f"\n成功找到brotli数据!")
print(f"位置: {abs_start} - {abs_end}")
print(f"压缩大小: {abs_end - abs_start} 字节")
print(f"解压大小: {len(decompressed)} 字节")
print(f"压缩比: {len(decompressed)/(abs_end - abs_start):.2f}x")

return decompressed, abs_start, abs_end

except Exception:
continue

print("\n未找到有效的brotli压缩数据")
return None

def process_hex_file(file_path: str, head_offset: int = 0, tail_offset: int = 0,
output_file: str = None, save_extracted: bool = False) -> bool:
"""
处理十六进制文件

Args:
file_path: 输入文件路径
head_offset: 头部偏移
tail_offset: 尾部偏移
output_file: 输出文件路径
save_extracted: 是否保存提取的brotli原始数据

Returns:
bool: 是否成功
"""
try:
print(f"读取文件: {file_path}")

# 检测文件类型并读取
try:
# 先尝试按文本文件读取
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()

# 检查是否为十六进制文本
sample = content.replace(' ', '').replace('\n', '').replace('\r', '').replace('\t', '')[:100]
if all(c in '0123456789abcdefABCDEF' for c in sample):
print("检测到十六进制文本文件")
data = hex_to_bytes(content)
else:
raise ValueError("不是十六进制文本")

except (UnicodeDecodeError, ValueError):
# 按二进制文件读取
print("检测到二进制文件")
with open(file_path, 'rb') as f:
data = f.read()

print(f"数据大小: {len(data)} 字节")
print(f"头部偏移: {head_offset} 字节")
print(f"尾部偏移: {tail_offset} 字节")

# 查找brotli数据
result = find_brotli_data(data, head_offset, tail_offset)

if result is None:
return False

decompressed_data, start_pos, end_pos = result

# 设置输出文件名
if output_file is None:
base_name = os.path.splitext(file_path)[0]
output_file = f"{base_name}_decompressed.dat"

# 保存解压数据
with open(output_file, 'wb') as f:
f.write(decompressed_data)
print(f"解压数据已保存到: {output_file}")

# 如果需要,保存提取的原始brotli数据
if save_extracted:
extracted_file = f"{os.path.splitext(output_file)[0]}_brotli.br"
with open(extracted_file, 'wb') as f:
f.write(data[start_pos:end_pos])
print(f"原始brotli数据已保存到: {extracted_file}")

# 显示内容预览
print("\n解压内容预览:")
try:
# 尝试作为文本显示
text_preview = decompressed_data.decode('utf-8')[:500]
print(text_preview)
if len(decompressed_data) > 500:
print("... (内容截断)")
except UnicodeDecodeError:
# 显示十六进制
hex_preview = decompressed_data[:100].hex()
print(f"十六进制: {hex_preview}")
if len(decompressed_data) > 100:
print("... (内容截断)")

return True

except FileNotFoundError:
print(f"错误: 文件 '{file_path}' 不存在")
return False
except Exception as e:
print(f"错误: {e}")
return False

def main():
parser = argparse.ArgumentParser(
description='从十六进制或二进制文件中提取并解压brotli数据',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog='''
使用示例:
# 基本用法
python brotli_hex_decoder.py data.hex

# 指定偏移量
python brotli_hex_decoder.py data.hex --head-offset 10 --tail-offset 5

# 指定输出文件并保存原始数据
python brotli_hex_decoder.py data.hex -o output.txt --save-extracted

# 处理二进制文件
python brotli_hex_decoder.py data.bin --head-offset 100
'''
)

parser.add_argument('input_file', help='输入文件路径(十六进制文本或二进制文件)')
parser.add_argument('--head-offset', type=int, default=0,
help='从文件开头跳过的字节数 (默认: 0)')
parser.add_argument('--tail-offset', type=int, default=0,
help='从文件结尾跳过的字节数 (默认: 0)')
parser.add_argument('--output', '-o', type=str,
help='输出文件路径 (默认: {input}_decompressed.dat)')
parser.add_argument('--save-extracted', '-s', action='store_true',
help='保存提取的原始brotli数据')
parser.add_argument('--verbose', '-v', action='store_true',
help='显示详细信息')

args = parser.parse_args()

if args.verbose:
print(f"输入文件: {args.input_file}")
print(f"头部偏移: {args.head_offset}")
print(f"尾部偏移: {args.tail_offset}")
print(f"输出文件: {args.output or '自动生成'}")
print(f"保存原始数据: {args.save_extracted}")
print("-" * 50)

success = process_hex_file(
args.input_file,
args.head_offset,
args.tail_offset,
args.output,
args.save_extracted
)

sys.exit(0 if success else 1)

if __name__ == '__main__':
main()

仍然是复制大概整个图片部分,然后保存到一个文件,直接运行脚本跑一下:

image-20250919140805643

从文件头来看,应该就是一个png文件了

image-20250919140841391

apk

这里的apk拿之前写的一个统计工作量的小软件说一下

image-20250920221246632

其实这种h5的apk主要就是看他的html代码,所以还是找他的这些资源文件的存放位置

分析过程

根据之前的exe分析,在apk中应该也是用了brotli压缩,那么跟exe应该很像,保存在一个充斥着二进制的地方,所以不禁联想到.so文件

首先找一下程序的入口页面

image-20250920221632867

能直接找到tauri.conf.json这个源文件

然后找一个.so文件,进去直接搜index.html

image-20250920221704550

能找到这个地方:

image-20250920221820425

brotli压缩格式貌似没有什么特殊的魔数,一般就是会出现xB这么个格式,1B、5B都见过,所以感觉就是这里,往下翻翻,到这儿应该就结束了

image-20250920221959707

然后把这一块复制出来

image-20250920222048085

还是用之前的脚本跑一下:

image-20250920222139181

也是非常简单的