每次512字节:重建IBM i保存文件校验和
IBM i 保存文件保留两个容器校验和。这项逆向工程从反编译的读取器复现了两种算法,通过只改动依赖于变更的字节来修复被修改的文件,并厘清了这些校验和能保护什么、不能保护什么。
中文
复制

每次 512 字节:重建 IBM i 保存文件的校验和
引言
当 IBM i 保存文件内部的字节被修改时,究竟需要重新计算哪些内容?
IBM i 保存文件通常称为 SAVF,它使用两种容器一致性校验和机制。每个 512 字节的有效载荷都带有一个 16 字节的尾部,而逻辑流还在其最后一个有效载荷中携带一个 16 字节的全局校验和。我想把这两种机制都理解到足以复现它们、修改一个 SAVF,并且只修复那些依赖于我所做更改的字节。
为了完成重建,我使用了一个由 IBM 生成的小型 SAVF,以及公开分发的 jSAVF 读取器,并反编译了它的 JAR。我用 Python 重新实现了这两种算法,并复现了原始文件中存储的每一个校验和。第二个文件包含三个被修改的有效载荷字节和一个过期的全局校验和。修复该容器只改动了全局校验和字段和最后一条记录的尾部。我还在 IBM i 授权内部代码(LIC)安装介质中找到了全局算法的表和相邻常量。
最后,我利用重建出的格式,从一个包含两个对象的 SAVF 中移除了一个完整的对象。一份记录尾部有效但全局校验和过期的副本在导入时被拒绝。在我重新生成全局校验和和最终尾部之后,IBM i 接受了这个更短的 SAVF,DSPSAVF 只显示保留下来的那个对象,并且该未改动的对象成功恢复。
这些值为 SAVF 容器提供了内部一致性检查,但它们是确定性的、无密钥的,并且可以重新计算。它们不能验证来源,也不能阻止某人修改文件并重新计算其校验和。IBM i 有单独的机制用于 SAVF 签名以及验证容器内存储的对象。在完成逆向工作之后,我会回到这一边界问题。
构建一个我可以修改的 SAVF
我想要一个内容由我控制的小型 SAVF,所以我创建了一个,其中包含一个名为 USERB1/CALLAES 的一次性程序。我禁用了数据压缩,因为固定、未压缩的有效载荷更容易逐字节比较:
CRTSAVF FILE(USERB1/SAVE1)
SAVOBJ OBJ(CALLAES) LIB(USERB1) OBJTYPE(*PGM) DEV(*SAVF) SAVF(USERB1/SAVE1) DTACPR(*NO)
CRTSAVF 创建 save-file 对象。SAVOBJ 把程序保存进去,DTACPR(*NO) 让数据保持不压缩。它不会关闭任何一层校验和。
我用 SCP 直接从 /QSYS.LIB 把 SAVF 拷了出来:
scp userb1@ibmi.example:/QSYS.LIB/USERB1.LIB/SAVE1.FILE .
/QSYS.LIB/USERB1.LIB/SAVE1.FILE 路径是 SAVF 对象在 Integrated File System 中的视图。这样传过来的是一个二进制文件,可以逐字节检查。
得到的 SAVE1.FILE 长 84,480 字节。
SAVF 长什么样
外层记录格式不用我从头摸索。2001 年 1 月的一篇 MI400 帖子 把 RISC SAVF 描述为 528 字节一条记录:512 字节数据、4 字节二进制记录号、12 字节校验和。2007 年 midrange.com 上的一次讨论 也提到每条 528 字节记录中都带校验和信息。
这个布局和我的测试文件完全对得上:
84,480 bytes = 160 * 528
每条物理记录是 512 字节负载加 16 字节尾部:
physical file: | 512-byte payload | 16-byte trailer | 512-byte payload | ...
logical stream: | 512 payload bytes | 512 payload bytes
去掉尾部,剩下 81,920 字节的逻辑流:
81,920 bytes = 160 * 512
尾部一旦去掉,逻辑 SAVF 流中的偏移就和原始文件中的对应偏移对不上了。所以我要用两套坐标:
- 物理偏移指向传输过来的文件,包含尾部;
- 逻辑偏移指向拼接后的 512 字节负载。
换算关系是:
def logical_to_physical(offset: int) -> int:
return (offset // 512) * 528 + (offset % 512)
实验后面会出现一个改过的副本,名为 SAVE2.FILE。它相对 SAVE1.FILE 有三处负载差异,正好说明为什么必须区分这两套坐标:
| 逻辑偏移 | 记录索引 | 负载内偏移 | 物理偏移 |
|---|---|---|---|
0x6D50 | 54 | 0x150 | 0x70B0 |
0x6D52 | 54 | 0x152 | 0x70B2 |
0x6D53 | 54 | 0x153 | 0x70B3 |
这里的 index 54 是从 0 开始的记录索引。尾部存的是从 1 开始的序号,所以同一个单位是第 55 号记录。十六进制编辑器和 cmp 用物理偏移;SAVF 内部的结构用逻辑偏移。
IBM 也记录了外层的一部分。CRTSAVF 文档 说,取出的 SAVF 记录带有顺序校验和信息。IBM i 在插入记录时会校验,改动过或顺序不对的记录会被拒绝。那份文档讲的是逐条插入时的校验,不是后面要讨论的全局校验和。
我没有为损坏的记录尾部单独跑一次实时阴性对照。记录级校验有 IBM 的文档记录;本文后面的受控阴性实验隔离的是第二个机制,即全局校验和。
其他公开工作覆盖的范围已经超出了外层布局。Try-AS/400 SAVF 页面 在 2026 年 8 月 9 日的初版修订(作为修订 1781 保存在其页面历史中)记录了逐记录折叠、时间戳 XOR、全局叠加,以及在逻辑 0x40 处的备份。它还记录了头部长度和记录计数字段、条目标记、名称、目录引用、存储长度与总长度,以及下面移除实验用到的 QSYS 目录对象计数和描述符布局。该页面警告称,部分内容由 AI 生成或修改,尚未经过核对,因此我将其作为在先的公开文档予以注明,并用自己的测试样本和 jSAVF 独立验证这些字段。
本文关注的是完整的全局状态机顺序、备份非零时的测试、与之匹配的 LIC 数据、确定性重建与修复、公开测试向量,以及受控的 IBM i 行为。
重建逐记录校验和
我没有去猜测标准的校验和家族,而是先找现成的 SAVF 读取器。jSAVF 已经实现了该格式,其作者注明信息来自 archive.midrange.com 社区,尤其是 IBM 的两个专有校验和算法。我反编译了分发的 JAR,并以其中的 ChunkChecker 逻辑作为逐记录算法的起点。
每个 16 字节尾部的前四个字节是从 1 开始的记录序号。其余 12 字节是变换后的校验和值。这些值在 SAVE1.FILE 中很容易看到:
index 0 / record #1: 00000001 8f40b6a68e88a98ea91e2d60
index 1 / record #2: 00000002 af2012d24b4b7f2000004000
index 159 / record #160: 000000a0 afc501282b18f73c5c308e69
反编译代码在构建每个记录校验和时还会用到 SAVF 的前八个字节。在 SAVE1.FILE 中,这些字节是:
af2012d24b4b7f20
jSAVF 把这个值当作 SAVF 时间戳,在归约轮次之后将其混入校验和。
反编译代码中的主要操作是带移位的字节数组加法。jSAVF 称之为 chkAdd;下面的 Python 重建使用名称 chk_add。目标比源多一个字节:
def chk_add(source: bytearray, destination: bytearray) -> None:
shift_carry = 0
add_carry = 0
for i in range(len(source) - 1, -1, -1):
value = source[i]
shifted = ((value << 1) | shift_carry) & 0xff
shift_carry = value >> 7
total = shifted + destination[i + 1] + add_carry
add_carry = total >> 8
destination[i + 1] = total & 0xff
destination[0] = (
destination[0] + add_carry + shift_carry
) & 0xff
源和目标都是大端序大整数——字节数组就是一个大数的各位数字,最高有效字节在前。chk_add 把源大整数翻倍(左移一位)后加到目标上,目标比源宽一个字节,用来容纳溢出。从源最高字节溢出的那一位,加上加法产生的最终进位,都落在 destination[0] 里。
算法从四字节序列加上 512 字节负载开始,分两趟处理这 516 字节:
| 趟 | 输入 | 缓冲区长度 | 输出 |
|---|---|---|---|
| 1 | 516 字节 | 61 到 68 | 68 字节 |
| 2 | 68 字节 | 5 到 12 | 12 字节 |
每趟填满八个缓冲区,然后执行七次级联的 chk_add 运算。不寻常的地方在于最后两个输入切片的顺序。第一趟按 68, 67 顺序填充,第二趟用 12, 11。累加器链仍然按缓冲区索引 0 到 7 走:
def reduce_once(data: bytes, input_sizes: list[int]) -> bytes:
parts = []
offset = 0
for size in input_sizes:
parts.append(bytearray(data[offset:offset + size]))
offset += size
assert offset == len(data)
# Earlier input fills buffer 7; the final input fills buffer 6.
accumulators = parts[0:6] + [parts[7], parts[6]]
for i in range(7):
chk_add(accumulators[i], accumulators[i + 1])
return bytes(accumulators[7])
phase1_input = sequence.to_bytes(4, "big") + payload
phase1 = reduce_once(phase1_input, [61, 62, 63, 64, 65, 66, 68, 67])
phase2 = reduce_once(phase1, [ 5, 6, 7, 8, 9, 10, 12, 11])
最后一步把前八个输出字节与 SAVF 时间戳异或,并在前面加上序列:
result = bytearray(phase2)
for i in range(8):
result[i] ^= timestamp[i]
record_checksum = sequence.to_bytes(4, "big") + bytes(result)
记录 #2 给出了一个紧凑的测试向量。它的 512 字节负载全为零。序列为 2、时间戳为 af2012d24b4b7f20 时,计算结果为:
00000002 af2012d24b4b7f2000004000
这正是 IBM 生成的文件中存储的尾部数据。对全部 160 条记录跑同样的计算,也能复现每一个存储的尾部。
零负载适合检查序列处理和时间戳异或,但检测不出那个不寻常的切片顺序。正确顺序和普通的升序填充在零负载下结果相同。因此我加了一个带模式的记录向量:
payload = bytes(range(256)) * 2
sequence = 2
timestamp = bytes.fromhex("af2012d24b4b7f20")
正确的 68,67 和 12,11 填充顺序产生:
000000028e43fa214379304d4081df91
把最后两个缓冲区改成普通升序填充则产生:
0000000272b71b3df71e6c4d4081df3f
我用 jSAVF 1.83 编译出的 ChunkChecker 直接验证了正确结果,也用下面提到的公开 Python 实现验证过。这个向量能抓住顺序错误,同时不暴露测试 SAVF 中的任何字节。
求全局校验和
修好记录尾还不够。jSAVF 的 GlobalChecker 还给出了第二个 16 字节值,它是针对完整的逻辑流计算出来的。
这个字段位于最后 16 个逻辑字节中。在物理上,它就是最后一条记录 payload 的最后 16 个字节,紧挨着它的记录尾之前:
| 496 ordinary payload bytes | 16-byte global checksum | 16-byte record trailer |
^ file_size - 32 ^ file_size - 16
对于我这个 84,480 字节的文件,全局字段从 0x149E0(84,448)开始,最后一条记录尾从 0x149F0(84,464)开始。
这就产生了一个自引用问题:全局校验和占用的字节本来会是它自己输入的一部分。在反编译代码中,GlobalChecker 通过从第一个 payload 的逻辑偏移 0x40 处读取原始 16 字节来解决这个问题。为了计算全局值,我把这份备份恢复到最后的字段上,处理重建后的逻辑流,然后再把校验和写回最后一个 payload。
全局例程使用一张 2,048 字节的查找表。jSAVF 把它以 net/anerty/os400/savf/check/sandbox.ser 的形式存放在 JAR 里。该文件是一个序列化的 Java 字节数组,长 2,075 字节,实际的表从偏移 27 开始:
ac ed 00 05 75 72 ... 00 00 08 00 <2,048-byte payload>
^ stream magic ^ length = 0x800
表的哈希是:
2,048-byte table SHA-256:
93f81fd81523a2521cf17b234edb05155998bc7e46d88b966b4bd9eef64c0764
表包含八个 256 字节的块。每个块都是 00 到 ff 的一个排列,因此每个字节值在整张表中出现八次。
我检查了当前 jSAVF 1.83 发行版,用这些命令提取了同一个资源:
curl -fL -A 'Mozilla/5.0' -O https://www.anerty.net/dl/jsavf-dist-1.83-jars-only-linux.tar.xz
tar -xJf jsavf-dist-1.83-jars-only-linux.tar.xz
unzip -p jsavf/jSAVF-1.83.jar net/anerty/os400/savf/check/sandbox.ser > sandbox.ser
全局状态以两个八字节值和两个计数器开始:
s0 = bytearray([0x55] * 8)
s8 = bytearray([0xaa] * 8)
c0 = 1
c8 = 121
每个 512 字节的 payload 按 32 个 16 字节的块处理。前八个字节更新 s0,后八个更新 s8。每一半都被当作一个 8 字节的环,而不是一个扁平列表,所以每个字节都有右邻,字节 7 会绕回到字节 0:
b0 → b1 → b2 → b3
↑ ↓
b7 ← b6 ← b5 ← b4
从 b7 开始顺时针读取,每一对相邻字节喂入一次查表:(b7,b0) → state[0],(b0,b1) → state[1],…… (b6,b7) → state[7]。该例程通过在开头前置最后一个字节的副本构造这个环,得到一个九字节窗口,然后把每一对相邻字节折叠成一个 11 位的表索引:
window = bytes([half[7]]) + half
for k in range(8):
index = ((window[k] << 8) | window[k + 1]) & 0x7ff
state[k] ^= table[index]
表 XOR 之后,八字节状态作为一个无符号大端 64 位值循环左移 29 位。
当 s0 计数器达到 251 或 s8 计数器达到 257 时,对应状态会按位打乱。s8 阈值事件还会在两个状态之间交换第 4 到第 7 个字节。
下面的伪代码包含复现校验和所需的全部状态与顺序。physical 是完整的 SAVF 镜像,table 是 2,048 字节的查找表。该函数从每条 528 字节记录中剥去 16 字节的物理尾部,从而得到有序的 512 字节载荷块:
MASK64 = (1 << 64) - 1
def rotate_left_64_be(state: bytearray, count: int = 29) -> None:
value = int.from_bytes(state, "big", signed=False)
value = ((value << count) | (value >> (64 - count))) & MASK64
state[:] = value.to_bytes(8, "big")
def build_spread_tables():
spread1 = [0] * 256
for byte in range(256):
value = 0
for bit in range(7, -1, -1):
value = (value << 2) | ((byte >> bit) & 1)
spread1[byte] = value
spread2 = [(value << 1) & 0xffff for value in spread1]
return spread1, spread2
def shuffle(state, spread1, spread2):
output = bytearray(8)
for pair in range(4):
value = spread2[state[pair]] | spread1[state[pair + 4]]
output[pair * 2:pair * 2 + 2] = value.to_bytes(2, "big")
state[:] = output
def mix_half(half, state, counter, threshold,
table, spread1, spread2):
window = bytes([half[7]]) + bytes(half)
for k in range(8):
index = ((window[k] << 8) | window[k + 1]) & 0x7ff
state[k] ^= table[index]
rotate_left_64_be(state, 29)
threshold_event = (counter == threshold)
if threshold_event:
counter = 0
shuffle(state, spread1, spread2)
# The caller performs the cross-state exchange, if required,
# before incrementing this counter.
return counter, threshold_event
def compute_global_checksum(physical: bytes, table: bytes) -> bytes:
assert len(physical) > 0 and len(physical) % 528 == 0
assert len(table) == 2048
records = [
physical[i * 528:i * 528 + 512]
for i in range(len(physical) // 528)
]
spread1, spread2 = build_spread_tables()
s0 = bytearray([0x55] * 8)
s8 = bytearray([0xaa] * 8)
c0, c8 = 1, 121
global_backup = records[0][0x40:0x50]
for record_index, record in enumerate(records):
payload = bytearray(record)
if record_index == len(records) - 1:
payload[496:512] = global_backup
for offset in range(0, 512, 16):
block = payload[offset:offset + 16]
c0, _ = mix_half(
block[0:8], s0, c0, 251, table, spread1, spread2
)
c0 += 1
c8, s8_event = mix_half(
block[8:16], s8, c8, 257, table, spread1, spread2
)
if s8_event:
old_s0_tail = bytes(s0[4:8])
s0[4:8] = s8[4:8]
s8[4:8] = old_s0_tail
c8 += 1
return bytes(s0) + bytes(s8)
build_spread_tables 实现了一个经典的位交错技巧:spread1[byte] 把 byte 的 8 个位分散开,使每个位落在 16 位值的每隔一个位的位置上,中间的位置留零;spread2 是同一张表左移一位的结果,因此它的位正好落在 spread1 留空的位置上:
bit position: 15 14 13 12 11 10 09 08 07 06 05 04 03 02 01 00
spread1(byte): 0 b7 0 b6 0 b5 0 b4 0 b3 0 b2 0 b1 0 b0
spread2(byte): b7 0 b6 0 b5 0 b4 0 b3 0 b2 0 b1 0 b0 0
shuffle 随后把状态两半中各取一个字节——spread2[state[pair]] 取自前半,spread1[state[pair + 4]] 取自后半——通过将它们的分散值做 OR 运算合并,把两个字节的位两两交错成一个 16 位输出。对全部四对字节都这样做,就把状态的两个四字节半部分逐位交错在一起。
顺序很重要。算法先应用表 XOR,再把结果循环左移 29 位。然后它检查计数器。如果计数器已达到上限,算法就将其重置并打乱各位。对于 s8,它还会在两个运行值之间交换四个字节。直到这时它才递增计数器。改变这个顺序就会改变结果。
一个合成的全局校验和向量
下面的向量无需私有 SAVF 即可测试全局算法。它包含九条物理记录。从零开始计数的记录 r 中的载荷字节 i 为 (r * 17 + i * 29 + 3) & 0xff。逻辑位置 0x40 处的备份为 f0f1...feff,最终覆盖层初始包含 a0a1...aeaf,而每个 16 字节物理尾部都为零,因为尾部不是全局校验和的输入。
九条记录包含 288 个块,因此这个向量会同时触及两个打乱阈值以及由 s8 触发的跨状态交换:
records = []
for record_index in range(9):
payload = bytearray(
(record_index * 17 + offset * 29 + 3) & 0xff
for offset in range(512)
)
records.append(payload)
records[0][0x40:0x50] = bytes(range(0xf0, 0x100))
records[-1][496:512] = bytes(range(0xa0, 0xb0))
physical = bytearray()
for payload in records:
physical.extend(payload)
physical.extend(bytes(16))
expected = bytes.fromhex("dbf1671ed17396a03f60bd52ddf749db")
calculated = compute_global_checksum(bytes(physical), table)
assert calculated == expected
print(f"calculated global: {calculated.hex()}")
输出为:
calculated global: dbf1671ed17396a03f60bd52ddf749db
查找表包含上面显示的 SHA-256。我用 jSAVF 1.83 编译出的 GlobalChecker 以及独立的 Python 重建分别核对了这个期望值。
为什么最后一条记录必须最后修复
写入全局校验和会改变最后一条记录的 payload,从而使该记录的 trailer 失效。对于普通的 payload 修改,依赖关系是:
changed payload bytes
+-> affected record trailer(s)
`-> global checksum
`-> final record trailer
全局计算只读取 512 字节的 payload,不读取物理记录的 trailer。因此,受影响的 trailer 和全局值是 payload 变更的两个并行结果。最后一条 trailer 依赖于新写入的全局值,必须最后计算。
安全的修复顺序是:
- 重新计算各记录的 trailer;
- 将 header 备份恢复到最后一个字段之后,计算全局值;
- 在
file_size - 32处写入全局值; - 再次重新计算最后一条记录的 trailer。
有一种特殊情况。全局字段在物理上替换了真正的最后 16 个逻辑字节。如果这些字节必须改变,替换数据应放在逻辑 0x40 处的备份中。这会改变记录 #1 的 payload 和 trailer、全局校验和,最后是最后一条记录的 trailer。
验证备份假设
主实验中使用的 SAVF 在逻辑 0x40 处的备份是全零。这意味着,即使某个实现把最后一个字段替换为零,对这些文件仍会得到正确结果。因此我寻找了备份非零的 SAVF,并找到了两个有用的例子:
| 文件 | 逻辑 0x40 处的备份 | 存储的全局值 | 补零计算 | 考虑备份的计算 |
|---|---|---|---|---|
20260830_QSYS_QTOCSSTBL_uncompressed.savf | 40404040404040404040404040404040 | d1d0d5c608257c3a75f62b1d3b509364 | 11101506c8e5bcfab536ebddfb9053a4 | 完全匹配 |
QTOFRULZ.FILE | d3404040404000000000000000000000 | 59311393322093c8527e36d903e5c4a4 | 99f1d393395a1308527e36d903e5c4a4 | 完全匹配 |
这两个文件的每条记录 trailer 也都有效。把被覆盖的字段替换为零会得到表中所示的错误值。恢复 16 字节的 header 备份则能精确重现每个存储的全局校验和。对这两个文件而言,这证实了备份是全局校验和输入的一部分。
在 IBM LIC 中找到相同的常量
jSAVF 展示了全局校验和的计算方式。我还想确认那张不寻常的表和那些常量是否同样出现在 IBM 的 LIC 中。
我在一个 IBM i 7.5 RS_750-J Licensed Machine Code 安装镜像中搜索了完整的 2,048 字节表。它在安装镜像中出现一次,位于偏移 0x42FDE8A0。在紧邻该表之前的 64 字节内,我找到了重建算法所用的两个状态种子、两个计数器种子以及两个阈值:
0x42FDE860: 55555555 55555555 00000000 00000000 initial s0
0x42FDE870: 00000001 000000fb 00000000 00000000 c0 = 1, threshold = 251
0x42FDE880: aaaaaaaa aaaaaaaa 00000000 00000000 initial s8
0x42FDE890: 00000079 00000101 00000000 00000000 c8 = 121, threshold = 257
0x42FDE8A0: 90aca3f7 e3e620be 686e0935 ea4efd6c table begins
同一张表也出现在 QFILEMCD 中,这是一个提取出来的 LIC 代码与数据镜像。它从文件偏移 0x2503E8A0 开始。
如果你能接触到授权的 IBM LIC 安装介质以及提取出的 QFILEMCD,搜索这张表本身并不复杂:
from pathlib import Path
import hashlib
import mmap
EXPECTED_TABLE_SHA256 = (
"93f81fd81523a2521cf17b234edb05155998bc7e46d88b966b4bd9eef64c0764"
)
table_resource = Path("sandbox.ser").read_bytes()
assert len(table_resource) >= 27 + 2048
table = table_resource[27:27 + 2048]
# Pin the table extracted from the jSAVF 1.83 build used for this article.
assert hashlib.sha256(table).hexdigest() == EXPECTED_TABLE_SHA256
with open("QFILEMCD", "rb") as f, \
mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as image:
offsets, start = [], 0
while (found := image.find(table, start)) != -1:
offsets.append(found)
start = found + 1
print([hex(offset) for offset in offsets])
在我分析的 QFILEMCD 镜像上,输出为:
['0x2503e8a0']
这样就能在上文给出的文件偏移处定位到该表的一份完整副本。
找到这些常量,并不能确定读取它们的 LIC 例程,也无法证明 IBM 的控制流与 jSAVF 一致。它同样不能说明生成我这个 SAVF 的机器运行的就是这个 LIC 版本。搜索结果表明的是:IBM 的安装介质中包含同一张表,以及与之匹配的状态种子、计数器种子和阈值。
修改与修复 SAVF
我把支持备份的实现放在一个名为 savf_checksum_demo.py 的独立 Python 脚本中。它的公开接口有四个操作:修复校验和字段、列出已保存对象、从兼容的供体 SAVF 复制对象、删除对象。添加和删除操作会自动修复所有相关的校验和字段。下文的复现部分使用与最终实验相同的对象删除路径,且不需要私有文件。
对照原始 SAVF 进行验证
我在未经改动的 IBM 生成文件上运行了重建。验证输出为:
record trailers: all 160 OK
stored global: 5513f83d38b944fe9814e1dc8d0b04fe
calculated global: 5513f83d38b944fe9814e1dc8d0b04fe
global checksum: OK
这个 Python 实现复现了全部 160 个记录尾部以及存储的全局值 5513f83d38b944fe9814e1dc8d0b04fe。这就把记录编号、两轮归约、时间戳 XOR、表解析、字段位置和全局状态转换都与 IBM i 写入的值做了对照检查。
分析一个记录有效、全局值过期的文件
SAVE2.FILE 给了我一个有用的中间状态。与 SAVE1.FILE 相比,记录 #55(索引 54)中有三个程序负载字节变成了 0x41:
| 逻辑偏移 | 物理偏移 | 变化 |
|---|---|---|
0x6D50 | 0x70B0 | fb → 41 |
0x6D52 | 0x70B2 | ff → 41 |
0x6D53 | 0x70B3 | 30 → 41 |
记录 #55 的尾部有五个字节随负载一起变化。更新后的尾部与这些字节吻合,全部 160 个记录尾部均有效。全局校验和与最后一条记录的尾部仍与 SAVE1.FILE 逐字节相同,因此存储的全局值已经过期。
record trailers: all 160 OK
stored global: 5513f83d38b944fe9814e1dc8d0b04fe
calculated global: 5013b87d388154fb9835f5dc8d0980ff
global checksum: STALE
与 SAVE1.FILE 相比,这个中间文件有八处不同:三个负载字节,以及记录 #55 尾部的五个字节。我把这三处修改应用到 SAVE1.FILE 上,只重新计算记录 #55 的尾部,就复现了这个状态;结果与 SAVE2.FILE 逐字节一致。
预测修复的影响范围
依赖模型预测,修复 SAVE2.FILE 只会改动两处:
- 内嵌的全局字段;
- 最后一条记录的尾部,因为全局字段属于它的负载。
被改动的程序字节和记录 #55 的尾部应当保持不变。
我用最终工具修复了一份副本:
$ python3 savf_checksum_demo.py fix SAVE2.FILE SAVE2-repaired.FILE
checksum repaired: SAVE2-repaired.FILE
单独跑了一遍校验,160 个记录尾部全部有效,存储的全局值与计算出的全局值都等于 5013b87d388154fb9835f5dc8d0980ff。
cmp 在 SAVE2.FILE 与修复后的输出之间恰好找出 21 个发生变化的字节位置:
physical 0x149E0..0x149EF global field: 11 positions differ
physical 0x149F0..0x149FF final record trailer: 10 positions differ
elsewhere: 0 differences
只有 11 个和 10 个位置不同,是因为有些字节在新旧 16 字节值中恰好相等。完整对比如下:
| 对比 | 负载字节 | 尾部字节 | 全局字段字节 | 位置总数 |
|---|---|---|---|---|
SAVE1.FILE → SAVE2.FILE | 3 | 记录 #55 中 5 个 | 0 | 8 |
SAVE2.FILE → 修复后的输出 | 0 | 记录 #160 中 10 个 | 11 | 21 |
SAVE1.FILE → 修复后的输出 | 3 | 两条尾部共 15 个 | 11 | 29 |
这是一次一致性检查,而非独立验证,因为预测和生成修复用的是同一套实现。不过,输出只改动了预期的那 21 个字节位置,别处没有任何变化。
编辑 SAVF 容器
三字节的 SAVE1/SAVE2 比较确定了校验和的依赖顺序,但编辑已保存程序内部的字节还会引出一些与对象校验无关的问题。因此,最后的测试改为改动 SAVF 结构:从一个包含两个对象的容器中删掉一个完整对象,保留对象的已保存段数据不动。
我创建了 TESTA 和 TESTB,作为同一程序的一次性副本,并将它们以不压缩的方式一起保存。得到的 SAVF 有 288 条物理记录。经备份恢复后的逻辑流包含三个顺序项。这里,一项指的是一段连续的字节区域,带有自己的头部和存储长度:
QSRDSSPC.1 logical 0x1000..0x3fff length 0x3000 srds 0x0
TESTA logical 0x4000..0x13fff length 0x10000 srds 0x70
TESTB logical 0x14000..0x23fff length 0x10000 srds 0x107
在这段输出中,srds 是 QSYS 目录描述符偏移量,编码在 +0x24 处项字段的低 24 位中(目录项本身为零)。描述符就是目录中一个已保存对象的条目。
这些偏移量来自三种不同的结构,各自相对于自身的起点,而不是相对于整个文件:
logical stream
├─ file header (+0x34 header length, +0x08/+0x38 record count)
├─ QSRDSSPC.1 item 0x1000..0x3fff
│ ├─ item header (+0x04 name, +0x24 catalogue-descriptor link)
│ └─ catalogue (+0x2A object count)
│ ├─ descriptor[0] @ +0x70 (151 bytes — TESTA)
│ └─ descriptor[1] @ +0x107 (151 bytes — TESTB)
├─ TESTA item 0x4000..0x13fff (item header +0x24 → 0x70)
└─ TESTB item 0x14000..0x23fff (item header +0x24 → 0x107)
明白了这一点:QSRDSSPC.1 是承载对象目录的保存/恢复数据空间项。解析器在遍历逻辑流之前先恢复全局覆盖层。0x34 处的四字节大端头部长度字段为 0x1000,因此第一项从逻辑位置 0x1000 开始。实现也支持文档中记载的零值,即表示 512 字节的头部。它检查 0xFFFFFFFF 处的项标记,读取项偏移 0x04 处的 EBCDIC 名称,并根据 0x48 处的四字节大端字段计算每个未压缩项的存储长度:
(big_endian_u32(item + 0x48) + 1) * 512
该项还把自己的总长度以 512 字节为单位存放在 0xCC 处的四字节大端字段中。2001 年的 MI400 描述把它称为到下一个描述符之间的 528 字节记录数。对于连续的非末项,两种描述是一致的:同一个计数同时描述了这两种视角。乘以 512 得到该项的逻辑长度,而同样数量的 528 字节物理记录则给出原始文件中到下一个描述符的距离。末项即使后面没有描述符,也仍然带有自己的总长度。
在这个测试样本中,QSRDSSPC.1 把 0x17 存放在 0x48,把 0x18 存放在 0xCC,两者推导出的长度都是 0x3000。TESTA 和 TESTB 分别把 0x7F 存放在 0x48、把 0x80 存放在 0xCC,两者都推导出 0x10000。这些值与实际条目跨度一致。这个 QSYS 布局里没有单独的绝对下一项指针:外层解析器按计算出的条目长度向前推进,而对象目录是一个以计数分隔、固定步长的描述符数组。jSAVF 单独处理下一项,是为了应对 IFS 目录树,而这里并不存在这种结构。
删除 TESTA 不需要改动 0xCC:QSRDSSPC.1 保留了它的 0x3000 跨度,保留下来的 TESTB 条目位置前移,但跨度仍是 0x10000。编辑器把头部偏移 0x08 处的四字节大端记录数,以及 0x38 处对应的八字节大端计数,从 288 改成了 160;头部长度字段保持为 0x1000。
目录占据逻辑位置 0x3000..0x3FFF。目录偏移 0x2A 处的四字节大端对象计数为二。对于这个目标版本格式,两个 151 字节的对象描述符分别从目录偏移 0x70 和 0x107 开始。每个已保存对象条目在条目偏移 0x24 处有一个四字节大端字段。jSAVF 用高字节表示 IFS 标志,低 24 位作为 QSYS 目录描述符偏移;这里的 QSYS 值是 0x70 和 0x107。
因此,删除 TESTA 不只是删掉它的字节。编辑器做了这些事:
- 删除
0x4000..0x13FFF处完整的TESTA条目; - 把目录对象计数从二改为一;
- 把
TESTB描述符从0x107移到0x70,并清空不再使用的槽位; - 把
TESTB的条目头描述符链接从0x107改为0x70; - 更新文件头中的两个记录计数字段;
- 把新的最后 16 个逻辑字节复制到逻辑位置
0x40的备份区,重建物理记录,并重新计算每个记录尾部; - 计算新的全局校验和,写入最终载荷,并重新计算最终尾部。
151 字节的 TESTB 目录描述符原样从 0x107 复制到 0x70。它在条目偏移 0x44 处的头部占用为 0x2F8;其已保存对象区段从第一个 4096 字节边界开始,即条目相对偏移 0x1000。对齐基线和编辑后的 TESTB 条目后,这前 0x1000 字节描述符/头部区域中唯一的差异是相对偏移 0x26 和 0x27,位于四字节目录引用字段内。从条目偏移 0x1000 到条目末尾的每一个字节都没有变化。这次编辑产生了以下结构变化:
| 属性 | 基线 | 修改后 |
|---|---|---|
TESTA 项 | 0x4000..0x13FFF | 缺失 |
TESTB 项 | 0x14000..0x23FFF | 0x4000..0x13FFF |
| 逻辑大小 | 0x24000(147,456 字节) | 0x14000(81,920 字节) |
| 物理记录数 | 288 | 160 |
| 物理大小 | 152,064 字节 | 84,480 字节 |
| 存储的全局校验和 | 791ea5ec90f35d20829dc5561098c130 | bfd84cce59a6e7993e85c8a0e1a4e9cf |
对于这个测试样本,新的最终逻辑字节和备份都是零。但实现仍然把实际的新尾部复制进备份,而不是假定它为零。
导入之前,修复后的结果满足以下不变量:
SAVF differs from baseline: YES
logical structure differs: YES
TESTA present in baseline: YES
TESTA present in edited: NO
TESTB present in baseline: YES
TESTB present in edited: YES
TESTB saved-object section payload changed: NO
TESTB item-header descriptor link: 0x107 -> 0x70
record trailers: all 160 OK
stored global == calculated global: YES
我还生成了一个负对照,做了同样的结构修改并重建了记录尾部,但保留了旧的全局值。它的 160 个尾部全部通过校验;存储的全局值为 791ea5ec90f35d20829dc5561098c130,而计算结果为 bfd84cce59a6e7993e85c8a0e1a4e9cf。陈旧副本和修复副本的备份还原逻辑流完全相同。它们相差的 28 个物理字节位置仅限于 0x149E0..0x149EF 处的全局字段和 0x149F0..0x149FF 处的最终记录尾部。
在 IBM i 上测试修改后的 SAVF
我通过 IBM i 7.5 上相同的流文件和 CPYFRMSTMF 路径传输了基线、修改后陈旧、修改后修复这三个文件。基线和修改后的变体使用了不同的目标 SAVF,每次还原都使用全新的目标库。
| 用例 | 记录校验 | 全局 | CPYFRMSTMF | DSPSAVF | 还原 TESTB |
|---|---|---|---|---|---|
| 基线 | 288 有效 | 有效 | CPCA081 | TESTA、TESTB | CPC3703 |
| 修改后 + 陈旧 | 160 有效 | 陈旧 | CPDA088,然后 CPFA095 | 未到达 | 未到达 |
| 修改后 + 修复 | 160 有效 | 有效 | CPCA081 | 仅 TESTB | CPC3703 |
陈旧导入停止于:
CPDA088: Error writing to object.
CPFA095: Stream file not copied.
修复导入完成于:
CPCA081: Stream file copied to object.
DSPSAVF 报告了 160 条记录、一个已保存对象,并且只列出了 TESTB。将该保留对象还原到空库中产生:
CPC3703: 1 objects restored from SVC29 to RMRF29.
实际受控运行使用 SVC29 作为源库,RMRF29 作为全新的还原库;下面的复现命令使用通用示例名称。
这就是校验和修复器的实际结果。SAVF 在结构上已经不同,TESTA 缺失,而 TESTB 的已保存对象区段数据没有变化。在重新生成依赖的记录校验和字段与全局校验和字段之后,IBM i 接受并解释了修改后的容器,保留的对象仍然可用。
这个编辑器并不打算逐字节复现原生 SAVOBJ 操作生成的输出;实验要验证的是,经过结构性编辑和校验和修复的容器能否被 IBM i 正确接受和解释。
复现实验
不需要那些私有的研究用 SAVF。这些实验所用的具体实现见 Silent Signal SAVF IBM i 仓库,要求 Python 3.9 或更高版本,提供四个命令:
fix重新计算每条记录的尾部、全局校验和以及最终尾部;list校验记录尾部并列出 QSYS 目录;add从兼容的供体 SAVF 中复制一个完整对象并修复输出;remove删除一个完整对象并修复输出。
编辑器只支持这里测试的未压缩 QSYS 布局,这是有意为之。遇到压缩项、不兼容的目录、有歧义的名称、未知链接、目录空间不足等情况,它一律拒绝,而不是去猜。
先从公开的 jSAVF 发行包中提取查找表资源:
curl -fL -A 'Mozilla/5.0' -O https://www.anerty.net/dl/jsavf-dist-1.83-jars-only-linux.tar.xz
tar -xJf jsavf-dist-1.83-jars-only-linux.tar.xz
unzip -p jsavf/jSAVF-1.83.jar net/anerty/os400/savf/check/sandbox.ser > sandbox.ser
所有会改动校验和的命令,在使用提取出的表之前,都会用本文前面公布的 SHA-256 对它做校验。
1. 创建并导出一个双对象基线
把 USERB1 换成一个可丢弃的测试库,把 TESTPGM 换成一个可复制、可丢弃的程序。下面的 SSH 账号和主机只是示例。
CRTDUPOBJ OBJ(TESTPGM) FROMLIB(USERB1) OBJTYPE(*PGM) TOLIB(USERB1) NEWOBJ(TESTA)
CRTDUPOBJ OBJ(TESTPGM) FROMLIB(USERB1) OBJTYPE(*PGM) TOLIB(USERB1) NEWOBJ(TESTB)
CRTSAVF FILE(USERB1/BASELINE)
SAVOBJ OBJ(TESTA TESTB) LIB(USERB1) OBJTYPE(*PGM) DEV(*SAVF) SAVF(USERB1/BASELINE) DTACPR(*NO)
CPYTOSTMF FROMMBR('/QSYS.LIB/USERB1.LIB/BASELINE.FILE') TOSTMF('/tmp/baseline.FILE') STMFOPT(*REPLACE)
scp userb1@ibmi.example:/tmp/baseline.FILE baseline.FILE
列出基线中的对象,然后执行一次校验和修复,输出到单独的文件,再与原始文件比较:
$ python3 savf_checksum_demo.py list baseline.FILE
TESTA
TESTB
$ python3 savf_checksum_demo.py fix baseline.FILE baseline-checked.FILE
checksum repaired: baseline-checked.FILE
$ cmp -s baseline.FILE baseline-checked.FILE && echo "baseline checksums already valid"
baseline checksums already valid
fix 总是重新计算完整的校验和状态。因此,字节完全一致就说明原始文件里已经包含了计算出的记录尾部和全局值。记录条数和校验和取值取决于所保存的对象和 IBM i 版本。
2. 删除一个对象并修复容器
remove 写出一个新的 SAVF,不改动基线,并修复输出中所有依赖校验和的地方:
$ python3 savf_checksum_demo.py remove baseline.FILE edited-repaired.FILE TESTA
removed object: TESTA
objects: TESTB
record count: 288 -> 160
检查生成的目录,并证明再跑一遍校验和不会产生任何改动:
$ python3 savf_checksum_demo.py list edited-repaired.FILE
TESTB
$ python3 savf_checksum_demo.py fix edited-repaired.FILE edited-checked.FILE
checksum repaired: edited-checked.FILE
$ cmp -s edited-repaired.FILE edited-checked.FILE && echo "edited SAVF checksums valid"
edited SAVF checksums valid
$ wc -c baseline.FILE edited-repaired.FILE
152064 baseline.FILE
84480 edited-repaired.FILE
236544 total
修复后的文件并不是基线的还原副本:它少了 128 条记录,不包含 TESTA 项,并且仍然含有未改动的 TESTB 段载荷。
已发布的编辑器有意只输出经过校验和修复的结果。上一节描述的“已编辑但过期”阴性对照是在验证过程中生成的:保留基线的全局值,同时重建被编辑文件的记录尾部。它用于单独隔离全局校验和的强制执行逻辑,但故意构造无效 SAVF 并不属于公开命令接口的一部分。
3. 导入并检查编辑后的 SAVF
通过同一路径上传基线和编辑后的物理文件:
scp baseline.FILE userb1@ibmi.example:/tmp/baseline.FILE
scp edited-repaired.FILE userb1@ibmi.example:/tmp/edited-repaired.FILE
分别创建 SAVF,并用 CPYFRMSTMF 导入这两个文件:
CRTSAVF FILE(USERB1/BASEIN)
CRTSAVF FILE(USERB1/EDITFIX)
CPYFRMSTMF FROMSTMF('/tmp/baseline.FILE') TOMBR('/QSYS.LIB/USERB1.LIB/BASEIN.FILE') MBROPT(*REPLACE)
CPYFRMSTMF FROMSTMF('/tmp/edited-repaired.FILE') TOMBR('/QSYS.LIB/USERB1.LIB/EDITFIX.FILE') MBROPT(*REPLACE)
在本文使用的 IBM i 7.5 系统上,两次导入都以 CPCA081 完成。前面提到的单独准备的过期对照则先报 CPDA088,随后报 CPFA095,以失败告终。
显示修复后的 SAVF,并将其保留的对象还原到一个新库中:
DSPSAVF FILE(USERB1/EDITFIX) OUTPUT(*PRINT)
CRTLIB LIB(EDITTEST) TYPE(*TEST)
RSTOBJ OBJ(TESTB) SAVLIB(USERB1) DEV(*SAVF) OBJTYPE(*PGM) SAVF(USERB1/EDITFIX) RSTLIB(EDITTEST)
在受控运行中,DSPSAVF 列出了 TESTB,没有列出 TESTA;RSTOBJ 以 CPC3703 完成。
4. 其他工具操作
同一版本可以从兼容的供体 SAVF 中复制单个对象,也可以在不改变已保存对象数据的前提下修复校验和字段:
python3 savf_checksum_demo.py add target.FILE donor.FILE output.FILE OBJECT
python3 savf_checksum_demo.py fix input.FILE fixed.FILE
add 要求存在兼容的未压缩目录、唯一的供体对象名、未使用的描述符槽位,以及足够的空闲目录空间。仓库 README 说明了受支持的布局,以及命令在哪些情况下会停止而不是猜测。
安全边界
IBM 的记录校验和全局校验能发现不一致或未修复的 SAVF 数据,但这些值是可确定的且未加密钥。一旦算法公开,采用这种布局的 SAVF 就可以在平台之外完成校验、修复和结构化编辑,无需借助 IBM i 重新生成容器校验和。这对格式工具、取证分析、互操作以及受损 SAVF 的受控修复都很有用;它并不能取代 IBM i 独立的签名和已保存对象验证机制。
IBM 在文档中单独描述了 SAVF 的数字签名层。程序校验、对象签名、转换以及恢复策略检查属于已保存对象和恢复流程,与 SAVF 容器校验和相互独立,容器修复不会改动它们。最后一个实验通过删除一个完整条目、同时保持保留对象的已保存区段数据不变,避开了这一歧义。
结论
逆向分析需要从两个视角看同一个文件:用于记录尾部的 528 字节物理记录,以及用于逻辑流的拼接后的 512 字节载荷。逐记录算法把序列号和载荷经过移位字节数组加法逐步归约,并混入 SAVF 时间戳。全局算法使用一张 2,048 字节的替代表、两个轮转状态、由计数器触发的洗牌,以及一份头部备份,来处理它在末尾载荷上的叠加。
该实现复现了 IBM 生成的原始 SAVF 中所有存储的校验和。合成向量对两种算法都做了验证,两个非零备份文件确认了实际备份字节在这些样例中的作用,而 IBM LIC 介质中出现了同一张表和相近的常量。SAVE1/SAVE2 的对比还重建出「记录有效、全局值过期」的状态,清楚显示出载荷变更后究竟哪些校验和字段必须更新。
最后一个 IBM i 对照实验测试了对容器的实际编辑。我从一个包含两个对象、未压缩的 SAVF 中删除了完整的 TESTA 条目,保留 TESTB 的已保存对象区段数据,重建物理记录流,并重新生成记录校验和与全局校验和字段。在全局值仍为过期状态时,IBM i 拒绝了这份经过结构编辑的副本。它接受了修复后的副本,DSPSAVF 只显示 TESTB,且 TESTB 恢复成功。
这就为所测试的 IBM i 7.5 未压缩 QSYS 布局确立了校验和结论:只要重新计算依赖的校验和字段,SAVF 容器就可以被结构性编辑并重新在内部变为有效。这并不意味着支持所有历史版本或压缩版本的 SAVF,也不改变 IBM i 各自独立的签名与已保存对象校验机制。