毎回512バイト:IBM i保存ファイルのチェックサムを再構築
IBM i の保存ファイルは 2 つのコンテナチェックサムを保持する。このリバースエンジニアリングは逆コンパイルされたリーダーから両アルゴリズムを再現し、変更依存バイトのみを書き換えて改変ファイルを修復し、これらのチェックサムが保護できるものとできないものを明らかにする。
日本語
コピー

512 バイトずつ:IBM i 保存ファイルのチェックサムを再構築する
はじめに
IBM i 保存ファイル内部のバイトを書き換えたとき、再計算が必要になるのはどこか。
IBM i の保存ファイル、通称 SAVF は、コンテナの整合性を保つためのチェックサム機構を 2 つ使っている。512 バイトの各ペイロードには 16 バイトのトレーラーが付き、さらに論理ストリームは最後のペイロードに 16 バイトのグローバルチェックサムを載せている。この 2 つを再現し、SAVF を改変し、自分の変更に依存するバイトだけを直せる程度には理解したい。
再構築には、IBM が生成した小さな SAVF と、公開されている jSAVF リーダーを使い、その JAR を逆コンパイルした。両方のアルゴリズムを Python で再実装し、元のファイルに格納されているチェックサムをすべて再現できた。2 つ目のファイルには、改変された 3 バイトのペイロードと、古くなったグローバルチェックサムが入っている。コンテナを修復したところ、変更されたのはグローバルチェックサムのフィールドと最後のレコードのトレーラーだけだった。グローバルアルゴリズムのテーブルと隣接する定数は、IBM i のライセンス内部コード(LIC)インストールメディアにも見つかった。
最後に、再構築したフォーマットを使い、2 つのオブジェクトを含む SAVF からオブジェクトを 1 つ丸ごと取り除いた。レコードのトレーラーは有効だがグローバルチェックサムが古いままのコピーは、インポート時に拒否された。グローバルチェックサムと最終トレーラーを再生成すると、IBM i は短くなった SAVF を受け入れ、DSPSAVF には残したオブジェクトだけが表示され、その未改変のオブジェクトは正常に復元された。
これらの値は SAVF コンテナの内部整合性チェックを与えるが、決定的で鍵を持たず、再計算できる。出所を検証するものではなく、ファイルを改変してチェックサムを計算し直す者を止めるものでもない。SAVF の署名と、コンテナ内に格納されたオブジェクトの検証には、IBM i が別の仕組みを用意している。この境界の話は、リバース作業を終えてから戻る。
自分で改変できる SAVF を作る
中身を自分で制御できる小さな SAVF が欲しかったので、USERB1/CALLAES という使い捨てのプログラムを 1 つ含むものを作った。固定長で非圧縮のペイロードのほうがバイト単位で比較しやすいので、データ圧縮は無効にした:
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 のパスは、Integrated File System から見た SAVF オブジェクトのビューだ。こうして転送されるのはバイナリファイルで、バイト単位で検査できる。
出来上がった 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 ストリームのオフセットは元ファイルの対応するオフセットと一致しなくなる。そこで 2 つの座標系を使う:
- 物理オフセットは、トレーラーを含む転送後のファイルを指す;
- 論理オフセットは、連結された 512 バイトのペイロードを指す。
換算はこうなる:
def logical_to_physical(offset: int) -> int:
return (offset // 512) * 528 + (offset % 512)
この先の実験に、SAVE2.FILE という改変版のコピーが登場する。SAVE1.FILE に対してペイロードの差分が 3 か所あり、2 つの座標系を区別しなければならない理由をちょうどよく示してくれる:
| 論理オフセット | レコード索引 | ペイロード内オフセット | 物理オフセット |
|---|---|---|---|
0x6D50 | 54 | 0x150 | 0x70B0 |
0x6D52 | 54 | 0x152 | 0x70B2 |
0x6D53 | 54 | 0x153 | 0x70B3 |
ここで index 54 は 0 から始まるレコード索引だ。トレーラーに格納されるのは 1 から始まる番号なので、同じ単位が第 55 号レコードになる。16 進エディタと cmp は物理オフセットを使い、SAVF 内部の構造は論理オフセットを使う。
IBM も外側の一部を文書化している。CRTSAVF のドキュメントによれば、取り出した SAVF レコードには順序チェックサム情報が付く。IBM i はレコードを挿入するときに検証し、改変されたレコードや順序が違うレコードは拒否される。この文書が扱うのは 1 レコードずつ挿入するときの検証であり、これから見るグローバルチェックサムではない。
壊れたレコードトレーラーについて、ライブのネガティブコントロールを別途走らせてはいない。レコードレベルのチェックは IBM が文書化している。この記事の後半で行う統制されたネガティブ実験が切り分けるのは、2 つ目の機構、つまりグローバルチェックサムのほうだ。
他の公開作業は外側のレイアウトを超えた範囲までカバーしている。Try-AS/400 の SAVF ページは、2026 年 8 月 9 日の初版(リビジョン 1781 としてページ履歴に保存されている)で、レコード単位の折り畳み、タイムスタンプの XOR、グローバルな重ね合わせ、論理 0x40 にあるバックアップを記録している。ヘッダ長とレコード数フィールド、エントリマーカー、名前、ディレクトリ参照、格納長と総長、そして後述の削除実験で使う QSYS ディレクトリのオブジェクト数と記述子のレイアウトも記している。同ページは、内容の一部が AI によって生成または改変されており未検証だと警告している。そこで先例となる公開文書として言及するにとどめ、これらのフィールドは自分のテストサンプルと jSAVF で独立に検証した。
本稿が扱うのは、グローバル状態機械の順序全体、バックアップが非ゼロのときのテスト、それに対応する LIC データ、決定論的な再構築と修復、公開テストベクタ、そして制御された IBM i の挙動である。
レコード単位のチェックサムを再構築する
標準的なチェックサム系を当て推量するのではなく、まず既存の SAVF リーダーを探した。jSAVF はすでにこの形式を実装しており、作者は情報源を archive.midrange.com コミュニティ、とりわけ IBM の2つの独自チェックサムアルゴリズムだと記している。配布されている JAR を逆コンパイルし、その中の ChunkChecker ロジックをレコード単位アルゴリズムの出発点にした。
各16バイトの末尾の最初の4バイトは、1から始まるレコード通し番号である。残りの12バイトは変換後のチェックサム値だ。これらの値は SAVE1.FILE で簡単に確認できる:
index 0 / record #1: 00000001 8f40b6a68e88a98ea91e2d60
index 1 / record #2: 00000002 af2012d24b4b7f2000004000
index 159 / record #160: 000000a0 afc501282b18f73c5c308e69
逆コンパイルされたコードは、各レコードのチェックサムを組み立てる際に SAVF の先頭8バイトも使う。SAVE1.FILE では、これらのバイトは次のとおり:
af2012d24b4b7f20
jSAVF はこの値を SAVF のタイムスタンプとして扱い、還元ラウンドの後にチェックサムへ混ぜ込む。
逆コンパイルされたコードの主要な操作は、シフトを伴うバイト配列の加算である。jSAVF はこれを chkAdd と呼ぶ。以下の Python による再構築では chk_add という名前を使う。ターゲットはソースより1バイト大きい:
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 はソースの大きな整数を倍にして(1ビット左シフト)ターゲットに加える。ターゲットはソースより1バイト広く、オーバーフローを受け止める。ソースの最上位バイトからあふれたビットと、加算で生じた最終桁上がりの両方が destination[0] に収まる。
アルゴリズムは4バイトのシーケンスと512バイトのペイロードから始まり、この516バイトを2パスで処理する:
| パス | 入力 | バッファ長 | 出力 |
|---|---|---|---|
| 1 | 516 バイト | 61 から 68 | 68 バイト |
| 2 | 68 バイト | 5 から 12 | 12 バイト |
各パスは8つのバッファを埋めてから、7段の連鎖した chk_add 演算を実行する。変わっているのは最後の2つの入力スライスの順序だ。第1パスは 68, 67 の順で埋め、第2パスは 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])
最後のステップで、先頭8バイトの出力を SAVF タイムスタンプと XOR し、先頭にシーケンスを付ける:
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レコードすべてに対して実行しても、格納された末尾がすべて再現できる。
ゼロのペイロードはシーケンス処理とタイムスタンプの XOR を確認するには向いているが、あの変わったスライス順序は検出できない。正しい順序も、ごく普通の昇順の埋め方も、ゼロのペイロードでは同じ結果になる。そこでパターン付きのレコードベクタを追加した:
payload = bytes(range(256)) * 2
sequence = 2
timestamp = bytes.fromhex("af2012d24b4b7f20")
正しい 68,67 と 12,11 の埋め方では次が得られる:
000000028e43fa214379304d4081df91
最後の2つのバッファを普通の昇順の埋め方に変えると次が得られる:
0000000272b71b3df71e6c4d4081df3f
正しい結果は、jSAVF 1.83 でコンパイルした ChunkChecker で直接検証し、後述する公開 Python 実装でも検証した。このベクタは順序の誤りを捉えつつ、テスト用 SAVF のバイトを一切明かさない。
グローバルチェックサムを求める
レコード末尾を直すだけでは足りない。jSAVF の GlobalChecker は、完全な論理ストリームに対して計算される2つ目の16バイト値も与える。
このフィールドは最後の16論理バイトの中にある。物理的には、最後のレコードのペイロードの最後の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 は最初のペイロードの論理オフセット 0x40 から生の16バイトを読むことでこれを解決している。グローバル値を計算するために、私はこのバックアップを最後のフィールドに復元し、再構築した論理ストリームを処理してから、チェックサムを最後のペイロードに書き戻した。
グローバルルーチンは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
テーブルは8つの256バイトブロックからなる。各ブロックは 00 から ff の順列なので、各バイト値はテーブル全体に8回現れる。
現行の 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
グローバル状態は2つの8バイト値と2つのカウンタから始まる:
s0 = bytearray([0x55] * 8)
s8 = bytearray([0xaa] * 8)
c0 = 1
c8 = 121
各512バイトのペイロードは32個の16バイトブロックとして処理される。先頭8バイトが s0 を更新し、後半8バイトが s8 を更新する。どちらの半分もフラットなリストではなく8バイトの環として扱われるので、どのバイトにも右隣があり、バイト7はバイト0へと折り返す:
b0 → b1 → b2 → b3
↑ ↓
b7 ← b6 ← b5 ← b4
b7 から時計回りに読むと、隣り合うバイトの各ペアが1回のテーブル参照に渡される: (b7,b0) → state[0]、(b0,b1) → state[1]、…… (b6,b7) → state[7]。このルーチンは先頭に最後のバイトのコピーを前置してこの環を構成し、9バイトの窓を得てから、隣り合うバイトの各ペアを11ビットのテーブル添字へと畳み込む:
window = bytes([half[7]]) + half
for k in range(8):
index = ((window[k] << 8) | window[k + 1]) & 0x7ff
state[k] ^= table[index]
表 XOR の後、8 バイトの状態は符号なしビッグエンディアン 64 ビット値として 29 ビット循環左シフトされる。
s0 カウンタが 251 に、または s8 カウンタが 257 に達すると、対応する状態がビット単位で撹拌される。s8 のしきい値イベントではさらに、2 つの状態の間で第 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 ビット値の 1 ビットおきの位置に置き、間の位置にはゼロを残す。spread2 は同じ表を 1 ビット左シフトしたもので、そのビットは 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 が状態の各半分から 1 バイトずつ取る。spread2[state[pair]] は前半から、spread1[state[pair + 4]] は後半から取り、それぞれの分散値を OR で統合して 2 バイトのビットを交互に 16 ビット出力へ織り込む。これを 4 組すべてのバイトに対して行うことで、状態の 4 バイトずつ 2 つの半分がビット単位で交互に組み合わされる。
順序が重要である。アルゴリズムはまず表 XOR を適用し、その結果を 29 ビット循環左シフトする。次にカウンタを確認する。カウンタが上限に達していれば、リセットしてビットを撹拌する。s8 の場合はさらに、2 つの実行値の間で 4 バイトを交換する。カウンタをインクリメントするのはここからである。この順序を変えれば結果も変わる。
合成グローバルチェックサムベクター
以下のベクターは、非公開の SAVF なしでグローバルアルゴリズムをテストできる。9 つの物理レコードを含む。ゼロから数えてレコード r のペイロードバイト i は (r * 17 + i * 29 + 3) & 0xff である。論理位置 0x40 のバックアップは f0f1...feff、最終オーバーレイは当初 a0a1...aeaf を含み、各 16 バイトの物理トレーラーはすべてゼロである。トレーラーはグローバルチェックサムの入力ではないからだ。
9 つのレコードは 288 ブロックを含むため、このベクターは 2 つの撹拌しきい値と、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 変更から並行して生じる 2 つの結果である。最後の trailer は新しく書き込まれたグローバル値に依存するため、最後に計算しなければならない。
安全な修復順序は次のとおり:
- 各レコードの trailer を再計算する;
- header バックアップを最後のフィールドの後ろに戻し、グローバル値を計算する;
file_size - 32にグローバル値を書き込む;- 最後のレコードの trailer をもう一度再計算する。
特殊なケースが 1 つある。グローバルフィールドは物理的に、本当の最後の 16 論理バイトを置き換える。これらのバイトを変更する必要がある場合、置換データは論理 0x40 のバックアップに置く。これによりレコード #1 の payload と trailer、グローバルチェックサム、そして最後に最後のレコードの trailer が変わる。
バックアップ仮説の検証
主実験で使った SAVF では、論理 0x40 のバックアップがすべてゼロだった。つまり、最後のフィールドをゼロで置き換える実装であっても、これらのファイルでは正しい結果が得られる。そこでバックアップが非ゼロの SAVF を探し、有用な例を 2 つ見つけた:
| ファイル | 論理 0x40 のバックアップ | 格納されたグローバル値 | ゼロ埋め計算 | バックアップを考慮した計算 |
|---|---|---|---|---|
20260830_QSYS_QTOCSSTBL_uncompressed.savf | 40404040404040404040404040404040 | d1d0d5c608257c3a75f62b1d3b509364 | 11101506c8e5bcfab536ebddfb9053a4 | 完全一致 |
QTOFRULZ.FILE | d3404040404000000000000000000000 | 59311393322093c8527e36d903e5c4a4 | 99f1d393395a1308527e36d903e5c4a4 | 完全一致 |
この 2 つのファイルは、各レコードの trailer もすべて有効だった。上書きされたフィールドをゼロで置き換えると、表に示した誤った値になる。16 バイトの header バックアップを復元すると、格納された各グローバルチェックサムが正確に再現される。この 2 つのファイルについては、バックアップがグローバルチェックサム入力の一部であることが確認できた。
IBM LIC に同じ定数を見つける
jSAVF はグローバルチェックサムの計算方法を示している。あの異例の表と定数が IBM の LIC にも同じく現れるかどうかも確かめたかった。
IBM i 7.5 RS_750-J Licensed Machine Code のインストールイメージで、完全な 2,048 バイト表を検索した。インストールイメージ内では 1 回だけ、オフセット 0x42FDE8A0 に現れる。表の直前 64 バイト以内に、再構築アルゴリズムが使う 2 つの状態シード、2 つのカウンタシード、2 つのしきい値を見つけた:
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']
これで、先に示したファイルオフセットに、この表の完全なコピーが 1 つあると特定できる。
これらの定数が見つかったからといって、それを読む LIC ルーチンが特定できるわけでも、IBM の制御フローが jSAVF と一致すると証明できるわけでもない。まして、私の SAVF を生成したマシンがこの LIC バージョンで動いていたことの証拠にもならない。検索結果が示すのは、IBM のインストールメディアに同じ表が含まれ、状態シード、カウンタシード、しきい値も一致する、ということだけだ。
SAVF の変更と修復
バックアップを支える実装は savf_checksum_demo.py という独立した Python スクリプトに置いた。公開インターフェースは 4 つの操作からなる。チェックサムフィールドの修復、保存済みオブジェクトの一覧表示、互換性のあるドナー SAVF からのオブジェクト複製、オブジェクトの削除。追加と削除は関連するチェックサムフィールドをすべて自動で修復する。以下の再現では、最終実験と同じオブジェクト削除経路を使い、非公開ファイルは必要としない。
元の SAVF との照合
手を加えていない IBM 生成ファイルに対して再構築を実行した。検証出力はこうだ。
record trailers: all 160 OK
stored global: 5513f83d38b944fe9814e1dc8d0b04fe
calculated global: 5513f83d38b944fe9814e1dc8d0b04fe
global checksum: OK
この Python 実装は、160 個のレコードトレーラーすべてと、格納されたグローバル値 5513f83d38b944fe9814e1dc8d0b04fe を再現した。これでレコード番号、2 ラウンドの縮約、タイムスタンプ XOR、表の解析、フィールド位置、グローバル状態の変換を、IBM i が書き込んだ値と突き合わせて確認できた。
レコードは有効だがグローバル値が古いファイルを解析する
SAVE2.FILE は有用な中間状態を与えてくれた。SAVE1.FILE と比べると、レコード #55(インデックス 54)でプログラムペイロードの 3 バイトが 0x41 に変わっている。
| 論理オフセット | 物理オフセット | 変化 |
|---|---|---|
0x6D50 | 0x70B0 | fb → 41 |
0x6D52 | 0x70B2 | ff → 41 |
0x6D53 | 0x70B3 | 30 → 41 |
レコード #55 のトレーラーでは 5 バイトがペイロードとともに変化した。更新後のトレーラーはこれらのバイトと一致し、160 個のレコードトレーラーはすべて有効だ。グローバルチェックサムと最終レコードのトレーラーは SAVE1.FILE とバイト単位で同一のままなので、格納されたグローバル値はすでに古い。
record trailers: all 160 OK
stored global: 5513f83d38b944fe9814e1dc8d0b04fe
calculated global: 5013b87d388154fb9835f5dc8d0980ff
global checksum: STALE
SAVE1.FILE と比べると、この中間ファイルには 8 か所の違いがある。3 つのペイロードバイトと、レコード #55 のトレーラーの 5 バイトだ。この 3 か所の変更を SAVE1.FILE に適用し、レコード #55 のトレーラーだけを再計算すると、この状態を再現できた。結果は SAVE2.FILE とバイト単位で一致した。
修復の影響範囲を予測する
依存モデルが予測するのは、SAVE2.FILE の修復で変わるのが次の 2 か所だけということだ。
- 埋め込まれたグローバルフィールド
- 最終レコードのトレーラー。グローバルフィールドはそのペイロードに属するため
変更されたプログラムバイトとレコード #55 のトレーラーはそのままのはずだ。
最終ツールでコピーを 1 つ修復した。
$ 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 | 2 つのトレーラーで計 15 個 | 11 | 29 |
予測と修復の生成に同じ実装を使っている以上、これは独立した検証ではなく整合性チェックだ。とはいえ、出力が変えたのは予測どおりの 21 バイト位置だけで、ほかはどこも変わっていない。
SAVF コンテナを編集する
3 バイトの SAVE1/SAVE2 比較でチェックサムの依存順序は確定したが、保存済みプログラム内部のバイトを編集すると、オブジェクトのチェックサムとは無関係な問題も出てくる。そこで最後のテストは SAVF 構造の変更に切り替えた。2 つのオブジェクトを含むコンテナからオブジェクトを 1 つ丸ごと削除し、残すオブジェクトの保存済みセグメントデータには手を触れない。
TESTA と TESTB を同じプログラムの使い捨てコピーとして作り、非圧縮で一緒に保存した。得られた SAVF は 288 個の物理レコードを持つ。バックアップから復元した論理ストリームには 3 つの順序項目が含まれる。ここで項目とは、独自のヘッダーと格納長を持つ連続したバイト領域を指す。
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 ビットにエンコードされている(ディレクトリ項目自体はゼロ)。記述子とは、ディレクトリ内の保存済みオブジェクト 1 つのエントリのことだ。
これらのオフセットは 3 つの異なる構造に由来し、それぞれファイル全体ではなく自身の起点を基準にしている。
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 の 4 バイトビッグエンディアンのヘッダー長フィールドは 0x1000 なので、最初の項目は論理位置 0x1000 から始まる。実装は、512 バイトのヘッダーを意味する文書化されたゼロ値にも対応している。0xFFFFFFFF の項目タグを確認し、項目オフセット 0x04 の EBCDIC 名を読み、0x48 の 4 バイトビッグエンディアンフィールドから各非圧縮項目の格納長を計算する。
(big_endian_u32(item + 0x48) + 1) * 512
この項目は自身の全長を 512 バイト単位で 0xCC の 4 バイトビッグエンディアンフィールドに格納する。2001 年の MI400 の記述では、これを次の記述子までの 528 バイトレコード数と呼んでいる。連続する非最終項目では両者の記述は一致する。同じカウントがこの 2 つの見方を同時に表しているからだ。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 の 4 バイトビッグエンディアンレコード数と、0x38 の対応する 8 バイトビッグエンディアンカウントを 288 から 160 に変更した。ヘッダ長フィールドは 0x1000 のままである。
ディレクトリは論理位置 0x3000..0x3FFF を占める。ディレクトリオフセット 0x2A の 4 バイトビッグエンディアンオブジェクト数は 2 である。このターゲットバージョン形式では、2 つの 151 バイトオブジェクト記述子がそれぞれディレクトリオフセット 0x70 と 0x107 から始まる。保存された各オブジェクトエントリは、エントリオフセット 0x24 に 4 バイトビッグエンディアンフィールドを持つ。jSAVF は上位バイトを IFS フラグに、下位 24 ビットを QSYS ディレクトリ記述子オフセットに使う。ここでの QSYS 値は 0x70 と 0x107 である。
つまり TESTA の削除は、そのバイトを消すだけでは済まない。エディタは次のことを行った。
0x4000..0x13FFFの完全なTESTAエントリを削除する。- ディレクトリオブジェクト数を 2 から 1 に変更する。
TESTB記述子を0x107から0x70へ移動し、使わなくなったスロットをクリアする。TESTBのエントリヘッダ記述子リンクを0x107から0x70に変更する。- ファイルヘッダの 2 つのレコード数フィールドを更新する。
- 新しい最後の 16 論理バイトを論理位置
0x40のバックアップ領域にコピーし、物理レコードを再構築し、各レコード末尾を再計算する。 - 新しいグローバルチェックサムを計算し、最終ペイロードに書き込み、最終末尾を再計算する。
151 バイトの TESTB ディレクトリ記述子は 0x107 から 0x70 へそのままコピーされる。エントリオフセット 0x44 のヘッダ占有量は 0x2F8 であり、保存オブジェクト区画は最初の 4096 バイト境界、すなわちエントリ相対オフセット 0x1000 から始まる。ベースラインと編集後の TESTB エントリを揃えると、この先頭 0x1000 バイトの記述子/ヘッダ領域で唯一異なるのは、4 バイトディレクトリ参照フィールド内の相対オフセット 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 パスを使って、ベースライン、変更後陳腐化、変更後修復の 3 ファイルを転送した。ベースラインと変更後の変種には別々のターゲット 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 件のレコード、保存済みオブジェクト 1 件を報告し、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 以上が必要で、4 つのコマンドを提供する:
fixは各レコードのトレーラ、グローバルチェックサム、最終トレーラを再計算する;listはレコードトレーラを検証し、QSYS ディレクトリを列挙する;addは互換性のあるドナー SAVF から完全なオブジェクトを 1 つコピーし、出力を修復する;removeは完全なオブジェクトを 1 つ削除し、出力を修復する。
エディタが対応するのは、ここでテストした非圧縮の 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. 2 オブジェクトのベースラインを作成してエクスポートする
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
ベースライン内のオブジェクトを列挙し、次にチェックサム修復を 1 回実行して別のファイルに出力し、元のファイルと比較する:
$ 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. オブジェクトを 1 つ削除してコンテナを修復する
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 でこの 2 つのファイルをインポートする:
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 コンテナのチェックサムとは独立しており、コンテナ修復がそれらを変更することはない。最後の実験は、完全なエントリを 1 つ削除しつつ保持オブジェクトの保存済み区画データを変えないことで、この曖昧さを回避している。
結論
逆解析では、同じファイルを二つの視点から見る必要がある。記録尾部の 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 が独自に持つ署名と保存済みオブジェクトの検証機構を変えるものでもない。