使用JS解析msi文档的重要属性
系列文章分为上中下三篇,教你怎么使用纯js解析复合二进制文档(doc,ppt,msi)
关于msi文件
msi文件是Windows系统安装包文件格式的一种,其也是基于复合二进制文档格式构建。因此,msi文件可以按照复合二进制文档的解析方式的提取其中的重要数据。
另外,msi的本质是一个关系数据库,遵循Windows Installer数据库规范,其使用流或者短流的形式存储了多张数据表。
读取某张表的内容时,找的表对应的同名目录即可;
msi文件的核心目录
- _Validation:存储MSI文件的验证信息(如文件签名、摘要)。
- _Tables:定义所有数据库表的名称和结构(列定义)。
- _Columns:存储表中各列的数据类型和属性。
- _StringData:存储所有字符串池(用于优化重复字符串的存储)。
- _StringPool:字符串池的索引结构。
msi数据库表相关目录
- Feature:安装的功能(Features)定义。
- Component:组件信息(如文件、注册表项等资源的逻辑分组)。
- File:要安装的文件列表及其属性。
- Directory:定义目标系统的目录结构。
- Registry:需要写入的注册表项。
- Property:安装属性(如ProductName、ProductCode)。
- Shortcut:快捷方式定义。
- CustomAction:自定义动作(安装过程中的脚本或可执行操作)。
tips:这些表的名称或者结构通常是有开发者自己定义,并非上述提及的全部。
解析安装属性
分析
安装属性通常记录在Property表中,那只解析这张表就可以了吗?显然不是的。
msi的的核心目录中两个值得特别注意的目录:_StringData和_StringPool。官方对这两个目录的解释是:字符串池的索引结构和存储所有字符串池。
也就是说,当我们解析出Property表中数据后,得到的还是二进制数据,要将其装换成明文数据,还需要结合_StringData和_StringPool中的内容。
解析前准备
实现一个通用方法,通过目录入口的名称读取流或者短流数据。
在上一篇文章中,我们解析并构建了目录表directoryTable,并且每个目录节点的数据结构如下:
js
interface Directory {
nameArray: ArrayBuffer;
name: string;
objectType: DirectoryObjectType;
startSector: number;
streamSize: number;
isMiniStream: boolean;
}
基于此,该方法可以实现起来就很简答了:
js
async function readStreamByName(name: string): Promise<Uint8Array> {
for (let i = 0; i < directoryTable.length; i++) {
const directory = directoryTable[i];
if (directory.name === name) {
if (directory.isMiniStream) { // 普通流与短流的界限
return readMiniStream(directory.startSector, directory.streamSize); // 读取普通流数据,上篇文章已经做了实现
} else {
return readStream(directory.startSector, directory.streamSize); // 读取短流数据,上篇文章也已经做了实现
}
}
}
}
解析Property表
Property表的二进制结构: 数据的前半部分存储属性key,每个key都是16位无符号整数(2字节);后半部分存储属性value,通key一样是16位无符号整数。 每个key和value通过存储顺序一一对应:key1:value1。 Property表的二进制数据大小一定是4倍数。
(countOfKeys × 2 bytes)"] A --> C["Value Stream IDs Section
(countOfKeys × 2 bytes)"] B --> D["Key1 ID (2 bytes)"] B --> E["Key2 ID (2 bytes)"] B --> F["..."] B --> G["KeyN ID (2 bytes)"] C --> H["Value1 ID (2 bytes)"] C --> I["Value2 ID (2 bytes)"] C --> J["..."] C --> K["ValueN ID (2 bytes)"] style A fill:#e1f5fe style B fill:#f3e5f5 style C fill:#e8f5e8
因此在读取到其二进制数据后,解析过程如下:
js
function processProperties(array: Uint8Array): void {
const view = new DataView(array.buffer);
const propertyKeyValueMap = {};
const propertyRequiredStreamIds = [];
if (view.byteLength % 4 != 0) {
return // 完整性校验
}
const countOfKeys = view.byteLength / 4;
for (let i = 0; i < countOfKeys; i++) {
const offset = i * 2; // 计算key的偏移量
const keyStreamId = view.getUint16(offset, true); // key 对应 streamId,会在字符串池中用到
const valueStreamId = view.getUint16(countOfKeys * 2 + offset, true); // 计算value的偏移量及streamId
propertyKeyValueMap[keyStreamId] = valueStreamId;
propertyRequiredStreamIds.push(keyStreamId);
propertyRequiredStreamIds.push(valueStreamId);
}
}
stringPool和stringData
stringPool 的二进制结构: 字符池前四字节为固定数据,后续由一些列连续的4字节的节点构成; 前两个字节是十六位无符号整数,定义了字符编码格式。utf8编码对应的值为:65001; 一个节点的前两字节表示字符大小size(十六位无符号整数),后两个字节引用/高位大小ref(十六位无符号整数);size和ref不同的值有不同的含义:
- size=0 & ref=0: 空条目;
- size=0 & ref≠0: size的高位的16位(条目大小超过16位精度);
- size≠0: 正常大小条目;
字符串大小"] A --> C["Ref (2 bytes)
引用/高位大小"] D["Special Cases"] --> E["size=0, ref=0
空条目"] D --> F["size=0, ref≠0
大小的高16位"] D --> G["size≠0
正常条目大小"] style A fill:#e1f5fe style B fill:#f3e5f5 style C fill:#e8f5e8 style D fill:#fff3e0
stringData 各种字符的二进制表示,按照偏移量按需提取。
代码实现如下:
js
function processStrings(stringPoolArray: Uint8Array, stringDataArray: Uint8Array) {
const streamBytes = []; // 保存字符串的二进制数据,index表示streamId
const stringPool = new DataView(stringPoolArray.buffer);
const stringData = new DataView(stringDataArray.buffer);
const codePage = stringPool.getUint16(0, true); // 字符串的编码格式
const itemCount = stringPool.byteLength / 4; // 字符串池索引条数
let streamId = 1; // 第一个索引的是从1开始
let higherDwordOfSize = 0; // 大字符串的高位大小
let stringDataOffset = 0; // 在stringData中的偏移
for (let i = 1; i < itemCount; i++) {
const stringPoolOffset = i * 4;
let size = stringPool.getUint16(stringPoolOffset, true);
const ref = stringPool.getUint16(stringPoolOffset + 2, true);
if (size === 0 && ref === 0) { // 空条目
streamId++;
continue;
}
// 当字符串大小超过65535字节时,使用这种机制
// ref << 16 将ref值左移16位,作为大小的高16位
// 不递增streamId,等待下一个条目来提供完整大小
if (size === 0 && ref !== 0) {
higherDwordOfSize = ref << 16;
continue;
}
size += higherDwordOfSize;
higherDwordOfSize = 0;
const streamRequired = propertyRequiredStreamIds.some((s) => s === streamId);
if (streamRequired) {
// 在stringData提取二进制数据
const data = stringData.buffer.slice(stringDataOffset, stringDataOffset + size);
streamBytes[streamId] = data;
}
stringDataOffset += size;
streamId++;
}
return streamBytes;
}
将字符串的二进制数字转化为明文
前面的几个方法,已经将字符串的二进制数据保存在了streamBytes中,通过特定的index(streamId)就可以拿到。 当字符串时utf8编码时,转换成明文的方法如下:
js
function textDecode(array: ArrayBuffer|undefined, ): string {
const view = new Uint8Array(array);
const encoded = Array.from(view)
.map(a => "%" + ("0" + a.toString(16)).slice(-2))
.join('');
const result = decodeURIComponent(encoded);
return result;
}
当是其他编码时,可以自行查阅文档后转换。
总结
本文档主要介绍了如何从二进制数据中提取字符串流,并将其转换为明文字符串。首先,通过遍历字符串池(stringPool)和字符串数据(stringData),根据特定的规则(如高位补齐、流ID判断等)将二进制数据分割并存储到streamBytes中。随后,提供了textDecode方法,将UTF-8编码的二进制数据解码为明文字符串。对于其他编码格式,可根据实际需求查阅相关文档进行转换。整体流程实现了从二进制到明文字符串的完整解析过程。
最后,为什么解析不了msi安装包的icon信息:
答:msi实则是一个以符合二进制文档格式要求的关系数据库,icon信息并不是特定表中的特定字段,而是用户自定义字段,可以存储在任意表的任意字段。