想要传达给你的爱恋的pac封包解包

逆向工程 实用技术 算法Koikakepacpac封包想要传达给你的爱恋SoftpalADVSystem
69 编辑于

Koikake PAC 资源包格式逆向分析

其实Garbro已经有现成了,这里重复造轮子学习一下吧,多学也不是坏事

摘要

本文整理 Koikake 使用的 .pac 资源包格式、逆向分析路径、核心数据结构、关键伪汇编、核心伪 C 逻辑,以及解包器实现时需要注意的边界检查。

内容尽量兼顾可读性和技术细节。即使刚接触资源包逆向,也可以顺着分析过程理解:

  • 为什么不能只根据十六进制窗口里的明文字符串直接推断文件格式。

  • 如何从 Game.exe 追踪到真正处理资源包的 PAL.dll

  • PalFileCreate / PalFileCreateEx 在资源系统中的作用。

  • PAC 文件头、桶表、目录项、文件数据之间的关系。

  • 游戏如何根据文件名在 PAC 中定位资源。

  • 如何根据程序读取逻辑写出等价解包器。

分析结论

Koikake 的 PAC 是一种索引式资源容器。它不是单纯的“文件名表 + 数据区”线性结构,而是在主头后放置一张桶表,游戏运行时根据请求文件名的首字节进入对应桶,再在该桶对应的目录项范围中查找目标文件。

PAC 总体结构如下:

text
offset      size                  description
0x00000000  4                     magic, 固定为 "PAC "
0x00000004  4                     reserved / unknown
0x00000008  4                     entry_count, 目录项数量
0x0000000C  255 * 8               bucket table
0x00000804  entry_count * 40      directory entries
...         variable              file data

关键常量:

c
#define PAC_MAGIC              "PAC "
#define PAC_HEADER_SIZE        12
#define PAC_BUCKET_COUNT       255
#define PAC_BUCKET_SIZE        8
#define PAC_BUCKET_TABLE_OFF   0x0C
#define PAC_DIRECTORY_OFF      0x804
#define PAC_ENTRY_SIZE         40
#define PAC_NAME_SIZE          32

桶表项结构:

c
typedef struct PacBucket {
    uint32_t count;
    uint32_t start_index;
} PacBucket;

目录项结构:

c
typedef struct PacEntry {
    char     name[32];
    uint32_t size;
    uint32_t offset;
} PacEntry;

所有整数均为 little-endian。文件数据在 PAC 层没有发现压缩或加密,按目录项中的 offsetsize 直接读取即可。

逆向目标

目标是还原 .pac 容器格式,并写出通用解包器,使其可以完成以下操作:

powershell
python koikake_pac_extract.py bgm.pac

以及批量处理:

powershell
python koikake_pac_extract.py pac_folder

由于 PAC 层只是容器层,解包后得到的 .ogg.csv.bmp 等文件通常可以直接识别;.pgd.ani.mix 等扩展名属于下一层私有格式,不在本文主要讨论范围内。

样本初步观察

对 PAC 文件开头进行十六进制观察,可以看到类似内容:

text
50 41 43 20 00 00 00 00 1E 00 00 00 ...

前四字节为:

text
50 41 43 20 = "PAC "

这里需要注意第四字节是空格字符 0x20,因此魔数不是 "PAC",而是 "PAC "

接下来的四字节通常为零或未知保留字段;再后面的四字节是一个小端整数。以 1E 00 00 00 为例,值为 0x1E,十进制是 30。结合 bgm.pac 样本可以验证该值对应目录项数量。

初步可以提出假设:

text
0x00  magic
0x04  unknown
0x08  entry_count

但此时还不能确定目录在哪里,也不能确定目录项大小。

初期误判与修正

在某些 PAC 中,可以在 0x1000 附近看到明文文件名,例如:

text
ANI_BK_SCROLL010L.ANI
ANI_BK_SCROLL010R.ANI
ARCHIVE.DAT

这很容易诱导出一个初步假设:

text
目录表从 0x1000 附近开始,每个目录项连续保存文件名、大小、偏移。

该假设只解释了部分现象,但无法解释另一些 PAC,例如音频包在相同区域看起来像随机数据。最初也曾尝试把目录起点设置为 0x10000x0FFC,再用不同目录项大小进行解析,结果会出现以下问题:

  • 解析数项后文件名错位。

  • offset + size 超出 PAC 文件末尾。

  • 目录项数量与头部 entry_count 不一致。

  • 某些包完全无法得到合理文件名。

这些现象说明:单靠“看到明文字符串的位置”推断目录起点是不可靠的。必须回到程序本身,看它实际如何 seekread

从 Game.exe 追踪到 PAL.dll

在主程序反编译结果中搜索以下关键词:

text
.pac
archive.dat
PalFileCreate
ReadFile
SetFilePointer

可以发现游戏读取资源时大量调用:

c
PalFileCreate("Script.src", ...);
PalFileCreate("Text.dat", ...);
PalFileCreate("File.dat", ...);

这说明游戏上层代码并不直接用 Windows CreateFileA 打开 PAC 内文件,而是通过 PalFileCreate 这个抽象接口读取资源。

从导入表或导出表可知:

text
PalFileCreate
PalFileCreateEx
PalFileSetFilePointer
PalFileGetFullPath

这些函数由 PAL.dll 提供。因此 PAC 容器的核心读取算法不在主程序本身,而在 PAL.dll

这是资源包逆向中非常重要的判断:当主程序通过引擎 DLL 或资源系统 DLL 读取文件时,真正的格式解析函数通常在该 DLL 里。

PalFileCreate 与 PalFileCreateEx

PalFileCreate 是一个薄封装。它把默认参数压栈后调用 PalFileCreateEx

伪汇编可表达为:

asm
PalFileCreate:
    push out_info
    push flags
    push creation_disposition
    push share_mode
    push desired_access
    push filename
    call PalFileCreateEx
    ret

PalFileCreateEx 的职责更完整。它大致执行以下流程:

text
输入一个逻辑文件名,例如 "BGM00.OGG"
先尝试按普通文件路径打开
如果普通文件存在,直接返回普通文件句柄
如果普通文件不存在,拼接或遍历已挂载的 PAC 包
进入 PAC 内部目录查找文件名
找到后把文件指针设置到资源数据位置
返回 PAC 句柄和资源大小信息

用伪 C 表示:

c
HANDLE PalFileCreateEx(
    const char *logical_name,
    DWORD desired_access,
    DWORD share_mode,
    DWORD creation_disposition,
    DWORD flags,
    PalFileInfo *out_info
) {
    char normalized_name[260];
    normalize_to_uppercase(normalized_name, logical_name);

    HANDLE h = CreateFileA(
        normalized_name,
        desired_access,
        share_mode,
        NULL,
        creation_disposition,
        flags,
        NULL
    );

    if (h != INVALID_HANDLE_VALUE) {
        DWORD file_size = GetFileSize(h, NULL);
        out_info->base_offset = 0;
        out_info->size = file_size;
        out_info->end_offset = file_size;
        return h;
    }

    return open_from_pac_archives(normalized_name, out_info);
}

PalFileCreateEx 由此证明:PAC 内文件对上层而言被伪装成普通文件。游戏脚本或资源加载器只需要请求逻辑文件名,底层自动在普通目录和 PAC 容器中查找。

核心函数:PAC 内文件查找

PalFileCreateEx 内部会调用一个更底层的查找函数。根据反汇编行为,可以将其抽象为:

c
HANDLE OpenFileFromPac(
    const char *pac_base_name,
    const char *request_name,
    DWORD desired_access,
    DWORD share_mode,
    DWORD creation_disposition,
    DWORD flags,
    PalFileInfo *out_info
);

该函数负责:

  • 拼接 .pac 扩展名。

  • 打开 PAC 文件。

  • 读取桶表。

  • 根据请求文件名首字节定位桶。

  • 在桶范围内查找目录项。

  • 读取命中的 sizeoffset

  • 将 PAC 文件指针移动到数据偏移。

软件运行时如何读取资源

从软件行为角度看,PAC 资源系统并不是“先把整个包解开到内存”,而是按需查找、按需定位、按需读取。

上层代码通常只知道逻辑文件名,例如:

c
PalFileCreate("BGM00.OGG", &info);
PalFileCreate("Text.dat", &info);
PalFileCreate("Script.src", &info);

上层并不关心这些文件到底是磁盘上的独立文件,还是 PAC 容器里的条目。这个抽象由 PAL.dll 完成。

运行时读取流程可以整理为:

text
游戏逻辑请求文件名
        |
        v
PalFileCreate
        |
        v
PalFileCreateEx
        |
        +--> 先尝试按普通文件打开
        |
        +--> 普通文件不存在时,进入 PAC 搜索流程
                  |
                  v
              构造或选择 pac 文件
                  |
                  v
              读取 PAC 桶表
                  |
                  v
              根据文件名首字节选择桶
                  |
                  v
              在桶范围内比较目录项文件名
                  |
                  v
              找到目录项后读取 size / offset
                  |
                  v
              SetFilePointer 到 offset
                  |
                  v
              返回 PAC 文件句柄

也就是说,PalFileCreate 返回的句柄有时是真正独立文件的句柄,有时是 PAC 文件本身的句柄。区别由附带的文件信息结构记录,例如当前资源的起始偏移、长度、结束偏移等。

简化后的运行时读取模型如下:

c
typedef struct PalFileInfo {
    uint32_t offset;      // 当前资源在真实文件中的起始偏移
    uint32_t end_offset;  // offset + size
    uint32_t size;        // 当前资源大小
} PalFileInfo;

当资源来自普通文件时:

text
offset     = 0
size       = GetFileSize(file)
end_offset = size

当资源来自 PAC 时:

text
offset     = entry.offset
size       = entry.size
end_offset = entry.offset + entry.size

随后上层调用 ReadFile 或引擎自己的读取封装时,只需要从当前句柄读取数据。底层已经把 PAC 文件指针移动到了资源数据起点。

这个设计的好处是:上层资源加载器可以用同一套读取接口处理普通文件和打包文件。

PAC 本体是否存在解密

结论:PAC 容器层没有发现实际数据加密,也没有发现目录项需要解密。

容易产生误解的原因有两个。

第一,某些 PAC 在 0x1000 附近看起来像乱码。这个现象不是加密导致的,而是因为初期观察的位置并不是目录起点。PAC 的真实目录起点是:

text
0x0C + 255 * 8 = 0x804

如果从错误位置切目录项,字段自然会错位,看起来就像“加密数据”。

第二,Game.exe 中确实存在一段 ROL/ROR/XOR 混淆逻辑。该逻辑容易被误认为是 PAC 解密算法,但经过样本验证,它不适用于 PAC 主体目录,也不能把 PAC 数据区还原成合理文件名。

因此需要区分两层概念:

text
PAC 容器层:
    负责文件名、大小、偏移、文件数据。
    当前分析未发现加密。

access.dat / 缓存索引层:
    游戏可能生成或读取的访问缓存。
    发现 ROL/ROR/XOR 混淆逻辑。
    该逻辑不是 PAC 本体解包所必需。

PAC 解包器只需要:

text
读取桶表
读取目录项
按 offset / size 复制数据

不需要对 PAC 文件内容做解密。

access.dat 混淆算法说明

虽然 PAC 解包不需要该算法,但为了说明“软件中确实存在混淆逻辑”,这里把相关算法整理出来。

写入方向的伪 C:

c
void encode_access_data(uint8_t *buf, uint32_t size) {
    uint32_t aligned = size;

    if ((aligned & 3) != 0) {
        aligned -= (aligned & 3);
    }

    uint8_t rotate = 4;

    for (uint32_t i = 0; i < aligned; i += 4) {
        uint32_t *p32 = (uint32_t *)(buf + i);

        *p32 ^= 0xFF987DEE;
        *p32 ^= 0x084DF873;

        buf[i] = ror8(buf[i], rotate);
        rotate++;
    }
}

读取方向的伪 C:

c
void decode_access_data(uint8_t *buf, uint32_t size) {
    uint32_t aligned = size;

    if ((aligned & 3) != 0) {
        aligned -= (aligned & 3);
    }

    uint8_t rotate = 4;

    for (uint32_t i = 0; i < aligned; i += 4) {
        uint32_t *p32 = (uint32_t *)(buf + i);

        buf[i] = rol8(buf[i], rotate);

        *p32 ^= 0x084DF873;
        *p32 ^= 0xFF987DEE;

        rotate++;
    }
}

对应伪汇编:

asm
; decode
mov  rotate, 4

loop:
    rol  byte ptr [buf], rotate
    xor  dword ptr [buf], 084DF873h
    xor  dword ptr [buf], FF987DEEh
    add  buf, 4
    inc  rotate
    sub  remain, 4
    jne  loop

这段算法的特征:

  • 每次处理 4 字节。

  • 只对每个 4 字节块的第一个字节执行 8-bit rotate。

  • 再对整个 32-bit 块做两次 XOR。

  • 长度按 4 字节对齐,尾部不足 4 字节的数据不参与该循环。

但再次强调:它不是 .pac 解包的必要步骤。PAC 本体目录和文件数据无需经过这段算法。

关键伪汇编

以下伪汇编不是逐字节原始反汇编,而是保留关键语义后的整理版本,便于理解。

打开 PAC

asm
; 普通文件打开失败后,构造 "<name>.pac"
wsprintfA(local_path, "%s%s", pac_base_name, ".pac")

CreateFileA(
    local_path,
    GENERIC_READ,
    FILE_SHARE_READ,
    NULL,
    OPEN_EXISTING,
    FILE_FLAG_RANDOM_ACCESS,
    NULL
)

cmp eax, INVALID_HANDLE_VALUE
je  open_failed
mov pac_handle, eax

读取桶表

asm
; 跳过 PAC 主头
SetFilePointer(pac_handle, 0x0C, 0, FILE_BEGIN)

; 读取 0x7F8 字节
; 0x7F8 = 2040
; 2040 / 8 = 255
ReadFile(
    pac_handle,
    bucket_table_buffer,
    0x7F8,
    &bytes_read,
    NULL
)

这是整个格式分析的关键证据。

它直接说明:

text
主头长度 = 0x0C
桶表大小 = 0x7F8
桶表项数量 = 0x7F8 / 8 = 255
目录表起点 = 0x0C + 0x7F8 = 0x804

根据文件名首字节选择桶

asm
movsx eax, byte ptr [request_name]
shl eax, 3
lea ecx, [bucket_table + eax]

mov edi, [ecx]      ; count
mov eax, [ecx + 4]  ; start_index

等价伪 C:

c
uint8_t bucket_id = (uint8_t)request_name[0];
PacBucket *bucket = &bucket_table[bucket_id];

uint32_t count = bucket->count;
uint32_t start = bucket->start_index;

计算目录项位置

目录项大小是 0x28,即 40 字节。

反汇编中可见类似计算:

asm
lea ecx, [index + index * 4]
shl ecx, 3

数学含义:

text
(index + index * 4) << 3
= index * 5 * 8
= index * 40

目录项文件偏移:

text
entry_offset = 0x804 + index * 40

读取并比较文件名

查找时会读取目录项前 0x20 字节,即 32 字节文件名:

asm
SetFilePointer(pac_handle, entry_offset, 0, FILE_BEGIN)
ReadFile(pac_handle, entry_name, 0x20, &bytes_read, NULL)

比较逻辑按 4 字节为单位比较,共比较 0x1C0x20 范围内的内容。整理成伪 C 就是:

c
int cmp = memcmp(request_name_fixed_32, entry.name, 32);

反汇编中还可以看到折半调整搜索范围的行为,说明同一桶内目录项按文件名排序,程序使用近似二分查找,而不是从头线性扫描。

读取命中项的大小和偏移

命中文件名后,程序继续读取两个 4 字节字段:

asm
ReadFile(pac_handle, &out_info->size,   4, &bytes_read, NULL)
ReadFile(pac_handle, &out_info->offset, 4, &bytes_read, NULL)

然后:

asm
mov eax, out_info->offset
add eax, out_info->size
mov out_info->end_offset, eax

SetFilePointer(pac_handle, out_info->offset, 0, FILE_BEGIN)
return pac_handle

这说明目录项后 8 字节顺序是:

text
uint32_t size;
uint32_t offset;

而不是 offset, size

完整伪 C:PAC 数据结构

c
#include <stdint.h>

#pragma pack(push, 1)

typedef struct PacHeader {
    char     magic[4];       // "PAC "
    uint32_t reserved;       // usually 0
    uint32_t entry_count;    // number of directory entries
} PacHeader;

typedef struct PacBucket {
    uint32_t count;          // number of entries in this bucket
    uint32_t start_index;    // first entry index in directory table
} PacBucket;

typedef struct PacEntry {
    char     name[32];       // uppercase file name, zero-padded
    uint32_t size;           // file size in bytes
    uint32_t offset;         // absolute offset in PAC file
} PacEntry;

#pragma pack(pop)

完整伪 C:PAC 查找算法

以下伪 C 尽量贴近 PAL.dll 的行为。

c
#define PAC_BUCKET_COUNT     255
#define PAC_BUCKET_TABLE_OFF 0x0C
#define PAC_BUCKET_TABLE_LEN 0x7F8
#define PAC_DIRECTORY_OFF    0x804
#define PAC_ENTRY_SIZE       0x28

typedef struct PalFileInfo {
    uint32_t offset;
    uint32_t end_offset;
    uint32_t size;
} PalFileInfo;

static void make_fixed_name(char out[32], const char *name) {
    memset(out, 0, 32);

    for (int i = 0; i < 32 && name[i] != '\0'; i++) {
        unsigned char c = (unsigned char)name[i];

        if (c >= 'a' && c <= 'z') {
            c -= 0x20;
        }

        out[i] = (char)c;
    }
}

static int compare_entry_name(const char fixed_name[32], const PacEntry *entry) {
    return memcmp(fixed_name, entry->name, 32);
}

HANDLE open_from_one_pac(
    const char *pac_path,
    const char *request_name,
    PalFileInfo *out_info
) {
    HANDLE h = CreateFileA(
        pac_path,
        GENERIC_READ,
        FILE_SHARE_READ,
        NULL,
        OPEN_EXISTING,
        FILE_FLAG_RANDOM_ACCESS,
        NULL
    );

    if (h == INVALID_HANDLE_VALUE) {
        return INVALID_HANDLE_VALUE;
    }

    PacHeader header;
    DWORD read_size;

    SetFilePointer(h, 0, NULL, FILE_BEGIN);
    ReadFile(h, &header, sizeof(header), &read_size, NULL);

    if (memcmp(header.magic, "PAC ", 4) != 0) {
        CloseHandle(h);
        return INVALID_HANDLE_VALUE;
    }

    PacBucket buckets[PAC_BUCKET_COUNT];

    SetFilePointer(h, PAC_BUCKET_TABLE_OFF, NULL, FILE_BEGIN);
    ReadFile(h, buckets, sizeof(buckets), &read_size, NULL);

    char fixed_name[32];
    make_fixed_name(fixed_name, request_name);

    unsigned int bucket_id = (unsigned char)fixed_name[0];
    if (bucket_id >= PAC_BUCKET_COUNT) {
        CloseHandle(h);
        return INVALID_HANDLE_VALUE;
    }

    PacBucket bucket = buckets[bucket_id];
    if (bucket.count == 0) {
        CloseHandle(h);
        return INVALID_HANDLE_VALUE;
    }

    uint32_t left = bucket.start_index;
    uint32_t right = bucket.start_index + bucket.count;

    while (left < right) {
        uint32_t mid = left + (right - left) / 2;
        uint32_t entry_off = PAC_DIRECTORY_OFF + mid * PAC_ENTRY_SIZE;

        PacEntry entry;
        SetFilePointer(h, entry_off, NULL, FILE_BEGIN);
        ReadFile(h, &entry, sizeof(entry), &read_size, NULL);

        int cmp = compare_entry_name(fixed_name, &entry);

        if (cmp == 0) {
            out_info->offset = entry.offset;
            out_info->size = entry.size;
            out_info->end_offset = entry.offset + entry.size;

            SetFilePointer(h, entry.offset, NULL, FILE_BEGIN);
            return h;
        }

        if (cmp < 0) {
            right = mid;
        } else {
            left = mid + 1;
        }
    }

    CloseHandle(h);
    return INVALID_HANDLE_VALUE;
}

说明:

  • 真实程序的比较过程不是标准库 memcmp 的直接调用,而是展开后的逐字比较/逐 DWORD 比较。

  • 真实程序内部的二分边界计算比上面伪 C 更绕,但语义等价:在桶指定范围中查找文件名。

  • 解包器不必复刻二分查找。解包器需要列出全部文件,直接读取目录表即可。

完整伪 C:解包器算法

解包器不需要按文件名单独查找,而是需要枚举 PAC 中所有目录项。

c
bool extract_pac(const char *pac_path, const char *output_dir) {
    FILE *fp = fopen(pac_path, "rb");
    if (!fp) {
        return false;
    }

    PacHeader header;
    fread(&header, 1, sizeof(header), fp);

    if (memcmp(header.magic, "PAC ", 4) != 0) {
        fclose(fp);
        return false;
    }

    PacBucket buckets[PAC_BUCKET_COUNT];

    fseek(fp, PAC_BUCKET_TABLE_OFF, SEEK_SET);
    fread(buckets, sizeof(PacBucket), PAC_BUCKET_COUNT, fp);

    uint32_t first = UINT32_MAX;
    uint32_t last = 0;

    for (uint32_t i = 0; i < PAC_BUCKET_COUNT; i++) {
        if (buckets[i].count == 0) {
            continue;
        }

        uint32_t start = buckets[i].start_index;
        uint32_t end = start + buckets[i].count;

        if (start < first) {
            first = start;
        }

        if (end > last) {
            last = end;
        }
    }

    if (first == UINT32_MAX) {
        fclose(fp);
        return true;
    }

    if (header.entry_count != 0 && last > header.entry_count) {
        last = header.entry_count;
    }

    for (uint32_t index = first; index < last; index++) {
        PacEntry entry;
        uint32_t entry_off = PAC_DIRECTORY_OFF + index * PAC_ENTRY_SIZE;

        fseek(fp, entry_off, SEEK_SET);
        fread(&entry, 1, sizeof(entry), fp);

        if (!validate_entry(entry, pac_file_size)) {
            fclose(fp);
            return false;
        }

        write_file_from_range(
            fp,
            output_dir,
            entry.name,
            entry.offset,
            entry.size
        );
    }

    fclose(fp);
    return true;
}

边界校验非常重要。正确实现至少应检查:

c
entry.offset + entry.size <= pac_file_size
entry.offset >= PAC_DIRECTORY_OFF + header.entry_count * PAC_ENTRY_SIZE

第一条防止读取越过文件末尾。第二条防止把目录区当作文件数据区读取。

Python 实现中的核心对应关系

Python 脚本中的常量:

python
BUCKET_TABLE_OFFSET = 12
BUCKET_COUNT = 255
BUCKET_ENTRY_SIZE = 8
DIRECTORY_OFFSET = BUCKET_TABLE_OFFSET + BUCKET_COUNT * BUCKET_ENTRY_SIZE
ENTRY_SIZE = 40

对应 C 结构:

c
PAC_BUCKET_TABLE_OFF = 0x0C
PAC_BUCKET_COUNT     = 255
PAC_BUCKET_SIZE      = 8
PAC_DIRECTORY_OFF    = 0x804
PAC_ENTRY_SIZE       = 40

读取桶表:

python
f.seek(BUCKET_TABLE_OFFSET)
bucket_raw = f.read(BUCKET_COUNT * BUCKET_ENTRY_SIZE)

解析桶表项:

python
count, start = struct.unpack_from("<II", bucket_raw, bucket * BUCKET_ENTRY_SIZE)

读取目录项:

python
raw = f.read(ENTRY_SIZE)
name = read_c_name(raw[:32])
file_size, offset = struct.unpack_from("<II", raw, 32)

这里的 "<II" 表示两个 little-endian uint32_t

关于文件名编码

目录项中的文件名字段长度固定为 32 字节。大多数资源名是 ASCII,例如:

text
BGM00.OGG
SE01_A001A.OGG
EV001A01.PGD
VO01_1001.OGG

考虑到日文游戏可能使用 Shift-JIS / CP932 编码,Python 实现中使用:

python
raw.decode("cp932", errors="replace")

这样既能正确处理 ASCII,也更适合 Windows 日文游戏常见资源名。

关于文件名安全

解包器不能无条件信任资源包里的文件名。如果直接把目录项名字拼接成输出路径,恶意或损坏的包可能造成路径穿越,例如:

text
..\..\target.txt

因此实现中应处理:

  • 去除绝对路径前缀。

  • 拒绝或替换 ..

  • 替换 Windows 不允许出现在文件名中的字符。

  • 将输出限制在用户选择的输出目录下。

这属于工具可靠性问题,不是 PAC 格式本身的一部分,但对解包器很重要。

如何验证格式结论

魔数验证

所有支持的样本都应满足:

text
file[0:4] == "PAC "

如果魔数不匹配,不能按该格式解析。

数量验证

entry_count 应与桶表覆盖范围一致:

text
max(bucket.start_index + bucket.count) <= entry_count

如果超出,需要怀疑:

  • 字节序错误。

  • 桶表起点错误。

  • 该文件不是同一格式。

  • 文件损坏。

偏移验证

每个目录项必须满足:

text
offset + size <= pac_file_size

如果大量条目违反该条件,通常说明 sizeoffset 顺序解析错了,或目录项大小推断错了。

本格式中正确顺序为:

text
name[32]
size
offset

不是:

text
name[32]
offset
size

数据魔数验证

解出的常见文件可以继续用自身魔数验证:

text
OGG:  4F 67 67 53 = "OggS"
BMP:  42 4D       = "BM"
CSV:  可读文本

如果音频资源解出后能被播放器识别,说明 PAC 层偏移和大小基本正确。

多样本验证

不能只用一个 PAC 样本验证格式。至少应覆盖:

  • 音频包

  • 图片包

  • 语音包

  • 系统资源包

  • 更新资源包

原因是某些包目录较短,有些包目录较长,有些包首字母分布集中,有些包文件数量巨大。只有跨样本验证通过,格式结论才可靠。

常见误区

误区一:看到明文文件名就认为目录从那里开始

明文字符串可能出现在目录区中间,也可能出现在文件数据中。必须结合程序的 SetFilePointerReadFile 行为确认结构起点。

误区二:只用单一样本推断格式

小包可能刚好让不完整的假设看起来成立。必须同时测试多个不同类型的 PAC。

误区三:把资源读取封装当成普通文件读取

PalFileCreate("BGM00.OGG") 不代表磁盘上真的存在 BGM00.OGG。它可能由底层资源系统映射到 PAC 内部。

误区四:忽略桶表

如果按线性目录从错误偏移扫描,可能在某些位置看到正确文件名,但整体无法稳定解析。桶表才是这个 PAC 格式的核心。

误区五:忽略字段顺序验证

目录项末尾两个字段都像整数。必须通过边界验证确定顺序。本格式是:

text
size first, offset second

后续逆向方向

PAC 解包完成后,可以继续分析解出的私有格式。

.PGD 可能是图片或纹理资源。建议从以下方向分析:

  • 文件头魔数。

  • 宽高字段。

  • 像素格式。

  • 是否有调色板。

  • 是否有压缩块。

  • 游戏中负责加载 .PGD 的函数。

.ANI 可能是动画描述。建议关注:

  • 帧数量。

  • 每帧持续时间。

  • 坐标或矩形字段。

  • 引用的图片资源名。

.MIX 可能是音频或语音混合配置。建议关注:

  • 条目数量。

  • 音量或声道参数。

  • 引用的 .OGG 或语音编号。

继续逆向时仍然使用同一方法:

text
观察样本
搜索扩展名
定位读取函数
记录 read/seek 顺序
还原结构体
写脚本验证
跨样本测试

反汇编证据附录

本节把关键证据整理成可审计的形式。这里不追求逐条还原所有机器指令,而是记录足以证明格式结构的关键指令模式。

PalFileCreate 调用 PalFileCreateEx

PalFileCreate 本身是一个薄封装,主要负责填入默认参数并转入 PalFileCreateEx

关键语义:

asm
push out_info
push flags
push creation_disposition
push share_mode
push desired_access
push logical_name
call PalFileCreateEx
ret

该证据说明:真正处理文件系统和 PAC 查找的函数不是 PalFileCreate,而是 PalFileCreateEx

普通文件失败后进入 PAC 查找

PalFileCreateEx 会先尝试普通文件路径:

asm
call CreateFileA
cmp  eax, INVALID_HANDLE_VALUE
jne  normal_file_ok

如果普通文件打开失败,才会构造 .pac 路径并进入 PAC 查找分支:

asm
wsprintfA(local_path, "%s%s", archive_name, ".pac")
call CreateFileA
cmp  eax, INVALID_HANDLE_VALUE
je   pac_open_failed

该证据说明:PAC 是普通文件系统的后备来源。上层请求逻辑文件名时,底层先查独立文件,再查资源包。

桶表读取证据

PAC 打开后,关键读取逻辑为:

asm
SetFilePointer(pac_handle, 0x0C, 0, FILE_BEGIN)
ReadFile(pac_handle, bucket_table, 0x7F8, &bytes_read, NULL)

由此可直接推导:

text
0x0C  = bucket table offset
0x7F8 = bucket table size
0x7F8 / 8 = 255 buckets

这是确认 PAC 不是简单线性目录的核心证据。

目录项大小证据

查找过程中存在如下计算:

asm
lea ecx, [index + index * 4]
shl ecx, 3

等价于:

text
(index + index * 4) << 3 = index * 40

所以目录项大小为 40 字节,即 0x28

文件名字段长度证据

命中检查前会读取目录项前 0x20 字节:

asm
ReadFile(pac_handle, entry_name, 0x20, &bytes_read, NULL)

因此文件名字段长度为:

text
0x20 = 32 bytes

结合目录项总大小 0x28,剩余 8 字节自然对应两个 uint32_t 字段。

size / offset 顺序证据

文件名命中后,程序连续读取两个 4 字节字段,并将其中一个作为资源长度,一个作为文件偏移:

asm
ReadFile(pac_handle, &size,   4, &bytes_read, NULL)
ReadFile(pac_handle, &offset, 4, &bytes_read, NULL)

end_offset = offset + size
SetFilePointer(pac_handle, offset, 0, FILE_BEGIN)

因此目录项末尾字段顺序为:

text
uint32_t size;
uint32_t offset;

不是 offset 在前。

样本验证对照

以下是用最终算法解析不同 PAC 后得到的结果摘要。该表用于证明算法跨样本成立,而不是只适用于单个小文件。

text
archive        entries   first_entry             last_entry
bgm.pac        30        BGM00.OGG               BGM_OP.OGG
bk.pac         107       BK000D.PGD              BK099D.PGD
data.pac       323       ANI_BKL_SCROLL000H.ANI  VO_MALE.CSV
em.pac         5         EMLA013.MPG             EMSA019.PGD
etc.pac        179       BACK_AYANE01.PGD        SYM_TATESEN.PGD
etc_cn.pac     36        ED_LOGO_HF.PGD          ROLL_SYUSYOU04.PGD
etc_tc.pac     36        ED_LOGO_HF.PGD          ROLL_SYUSYOU04.PGD
ev.pac         192       EV001A01.PGD            EV418A.PGD
face.pac       626       FA01A_A010.PGD          FA14A_A070.PGD
mask.pac       20        MASK00.BMP              MASK19.BMP
se.pac         449       SE01_A001A.OGG          SE99_T041A.OGG
st.pac         384       ST01A_A010.PGD          ST01C_D161.PGD
st2.pac        421       ST02A_A010.PGD          ST02C_D191.PGD
st3.pac        384       ST03A_A010.PGD          ST03C_D161.PGD
st4.pac        374       ST04A_A010.PGD          ST04D_D130.PGD
st5.pac        620       ST05A_A010.PGD          ST14A_A151.PGD
system.pac     209       ADVERTISEMENT.PGD       VO99_TEST.OGG
system_cn.pac  143       BGM_BASE.PGD            TITLE_BTN_SYSTEM.PGD
system_tc.pac  142       BGM_BASE.PGD            TITLE_BTN_SYSTEM.PGD
update.pac     52        CGBASE.CSV              VOMIX_E_05A.MIX
update2.pac    245       EV108A.PGD              EV417E.PGD
update3.pac    3586      VO01_1069.OGG           VO99_009.OGG
update4.pac    22        CG_BASE.PGD             TITLE_BTN_SCENE.PGD
update_cn.pac  11        REP_BASE.PGD            TITLE_BTN_SCENE.PGD
update_tc.pac  11        REP_BASE.PGD            TITLE_BTN_SCENE.PGD
voice.pac      11898     VO01_1001.OGG           VO99_010.OGG

注意:该表中的 first_entrylast_entry 是按目录索引或解析输出顺序观察到的首尾项,不一定代表文件数据在 PAC 中的物理首尾位置。

十六进制布局标注示例

以下是 PAC 头部布局的抽象标注,不依赖具体本地文件路径。

text
00000000  50 41 43 20  00 00 00 00  1E 00 00 00  ...
          |---------|  |---------|  |---------|
             magic      reserved    entry_count

0000000C  [bucket 0: count u32, start_index u32]
00000014  [bucket 1: count u32, start_index u32]
...
00000800  [bucket 254: count u32, start_index u32]

00000804  [entry 0]
          name[32]
          size u32
          offset u32

0000082C  [entry 1]
          name[32]
          size u32
          offset u32

如果以 bgm.pac 这类音频包为例,第一条目录项可抽象为:

text
42 47 4D 30 30 2E 4F 47 47 00 ... 00  [32 bytes]
?? ?? ?? ??                              size
?? ?? ?? ??                              offset

文件名字段解释为:

text
BGM00.OGG\0...

后续根据 offset 跳转到数据区,应该能看到 OGG 文件魔数:

text
4F 67 67 53 = "OggS"

这类“目录项字段”和“目标数据魔数”的对应关系,是验证解析正确的重要证据。

PalFileInfo 结构推测

根据 PalFileCreateEx 命中普通文件和 PAC 文件后的写入行为,可以推测文件信息结构至少包含三个 32-bit 字段。

抽象结构如下:

c
typedef struct PalFileInfo {
    uint32_t offset;
    uint32_t end_offset;
    uint32_t size;
} PalFileInfo;

普通文件命中时:

c
info.offset = 0;
info.size = GetFileSize(h, NULL);
info.end_offset = info.size;

PAC 条目命中时:

c
info.offset = entry.offset;
info.size = entry.size;
info.end_offset = entry.offset + entry.size;
SetFilePointer(h, entry.offset, NULL, FILE_BEGIN);

这个结构的作用不是描述整个 PAC 文件,而是描述“当前逻辑文件”在真实句柄中的可读范围。

进一步逆向 PalFileSetFilePointer、读取封装和 EOF 检查函数,可以继续确认:

text
读取逻辑是否限制在 end_offset 之前
seek 是否以逻辑文件 offset 为基准
是否允许相对当前逻辑文件起点定位

这部分不是解包器的必要条件,但有助于完整理解引擎文件系统。

解包器验收标准

一个正确的 PAC 解包器至少应通过以下验收项。

格式验收

text
magic == "PAC "
entry_count 合理
bucket 范围不越界
entry.offset + entry.size 不超过 PAC 文件大小
entry.offset 不落在目录区内部

样本验收

应能解析不同类型资源包:

text
音频包
图片包
语音包
系统资源包
更新资源包

不能只用 data.pacbgm.pac 单独验证。

文件验收

解出的常见文件应能通过自身魔数检查:

text
.ogg  -> OggS
.bmp  -> BM
.csv  -> 可读文本或表格数据

工具行为验收

工具层面应满足:

text
支持单文件解包
支持目录批量解包
支持指定输出目录
支持不覆盖已有文件
输出 manifest,方便审计 offset / size
遇到坏条目时给出明确错误

实现时最好让命令行和图形界面共用同一套核心解析函数。这样后续修正格式解析时,只需要维护一处逻辑。

私有资源格式后续分析计划

PAC 解包只是第一阶段。若继续深入,可按优先级分析以下格式。

PGD 图像资源

重点观察:

text
文件头魔数
宽度、高度字段
像素格式
调色板
alpha 通道
压缩块或扫描线布局

建议从图片加载函数入手,搜索 .PGD、纹理创建 API、D3D 纹理上传函数。

ANI 动画资源

重点观察:

text
帧数量
帧时间
坐标矩形
引用图片名
循环标志

建议对比多个 .ANI 文件,找固定字段和变化字段。

MIX 混音或语音配置

重点观察:

text
条目数量
音量参数
声道参数
资源编号
关联 OGG 文件名或语音 ID

如果 .MIX 文件很小,优先用结构体猜测和多样本 diff。

DAT / SRC 脚本与文本

重点观察:

text
字符串表
指令表
跳转偏移
文本编码
索引数组

这类文件通常和游戏脚本 VM 或文本系统相关。建议结合 Script.srcText.datFile.dat 的读取函数继续逆向。

最终格式速查

PAC 主头:

c
struct PacHeader {
    char     magic[4];       // "PAC "
    uint32_t reserved;
    uint32_t entry_count;
};

桶表:

c
struct PacBucket {
    uint32_t count;
    uint32_t start_index;
};

PacBucket buckets[255];      // offset = 0x0C

目录项:

c
struct PacEntry {
    char     name[32];
    uint32_t size;
    uint32_t offset;
};

PacEntry entries[entry_count]; // offset = 0x804

数据定位:

c
file_data = pac_base + entry.offset;
file_size = entry.size;

目录项偏移:

c
entry_file_offset = 0x804 + index * 40;

桶范围:

c
bucket_id = (uint8_t)uppercase_name[0];
start = buckets[bucket_id].start_index;
end = start + buckets[bucket_id].count;

查找方式:

c
binary_search(entries[start:end], uppercase_name);

解包方式:

c
for each entry:
    seek(entry.offset)
    read(entry.size)
    write(entry.name)

结语

这次 PAC 逆向的关键不在于发现了复杂加密,而在于从错误的线性目录假设中退出,回到程序真实读取路径,通过 PAL.dllSetFilePointerReadFile 和目录项索引计算还原格式。

对于新手来说,最重要的经验是:十六进制观察负责提出假设,反汇编负责验证假设,脚本负责批量检验假设。

本文版权遵循 CC BY-NC 协议 本站版权政策

(。>︿<。) 已经一滴回复都不剩了哦~