复合二进制文档 - msi文件信息提取(下篇)

使用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倍数。

graph TD A["Property Table"] --> B["Key Stream IDs Section
(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: 正常大小条目;
graph TD A["String Pool Entry (4 bytes)"] --> B["Size (2 bytes)
字符串大小"] 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信息并不是特定表中的特定字段,而是用户自定义字段,可以存储在任意表的任意字段。

相关推荐
上单带刀不带妹18 分钟前
前端安全问题怎么解决
前端·安全
Fly-ping21 分钟前
【前端】JavaScript 的事件循环 (Event Loop)
开发语言·前端·javascript
SunTecTec1 小时前
IDEA 类上方注释 签名
服务器·前端·intellij-idea
在逃的吗喽1 小时前
黑马头条项目详解
前端·javascript·ajax
袁煦丞1 小时前
有Nextcloud家庭共享不求人:cpolar内网穿透实验室第471个成功挑战
前端·程序员·远程工作
小磊哥er2 小时前
【前端工程化】前端项目开发过程中如何做好通知管理?
前端
拾光拾趣录2 小时前
一次“秒开”变成“转菊花”的线上事故
前端
你我约定有三2 小时前
前端笔记:同源策略、跨域问题
前端·笔记
JHCan3332 小时前
一个没有手动加分号引发的bug
前端·javascript·bug
pe7er2 小时前
懒人的代码片段
前端