🌊

HSP: DPM ファイルと暗号化の仕様 (非公式)

2023/01/05に公開
1

この文書は、プログラミング言語 Hot Soup Processor (HSP) で使用されるアーカイブファイル形式である DPM ファイルの仕様について記述したものです。OpenHSP の実装とリバースエンジニアリングの結果をもとに、暗号化を含むすべての仕様を記述しています。HSP の暗号化を解除したい人に捧げます。

DPM ファイルは、HSP の実行ランタイム自身とその拡張ライブラリである hspcmp.dll によって読み書きが可能です。この文書の目的は、それらの実装に依存せずに DPM ファイルを扱えるようにすることにあります。

HSP 3.7 から採用された DPM2 形式の詳細については説明しません。

DPM ファイルの機能概要

HSP では、実行ファイルを作成する際に、スクリプトから読み込む画像や音声などのファイルを1つのファイルや実行ファイル (.exe) に埋め込むことが可能です。このとき、複数のファイルを1つにまとめるためのファイル形式が DPM です。HSP 製の実行ファイルの末尾には DPM ファイルが埋め込まれています。

DPM は単純なアーカイブ形式ですが、アップデートのたびに少しずつ改良が加えられています。暗号化アルゴリズムは何度か改訂されていますが、HSP 3.7 で導入された DPM2 形式に取って代わられるまでは、データ形式に互換性があります。

更新日時 バージョン 更新内容
2001/10/17 2.55 ファイルの暗号化に対応(暗号化キーは固定)
2003/02/01 2.6 暗号化キーを可変にするように変更
2011/07/27 3.3 RC1 暗号化アルゴリズムの微調整
2022/06/02 3.7 beta2 新しいファイル形式 (DPM2) を採用

前述のように、DPM ファイルは実行ファイルに埋め込むことができますが、埋め込まずに単体の DPM ファイルを作成することも可能です。実行ファイルに埋め込んだ場合とそうでない場合では、以下のような違いがあります。

  • 独立型の DPM ファイル
    • 暗号化キーはスクリプトから指定する

      chdpm "dpmname",p1

      "dpmname" : DPMファイル名
      p1(-1) : 暗号化キー指定

  • 埋め込み型の DPM ファイル
    • 暗号化キーは、実行ファイル内の実行オプション領域(HSPHED)に埋め込まれている
    • 実行オプション領域には、DPM のチェックサムも格納されており、データの改変が検出されると起動しない仕組みになっている
    • 埋め込まれた DPM ファイルを単独で抜き出しても、chdpm 命令で復号することはできない仕組みになっている

DPM ファイルを展開できるツール

HSP でプログラムを自作する場合、HSP の標準命令を使用してファイルを読み込んだり、HSP スクリプトエディタや hspcmp.dll を通じて DPM ファイルを作成したりすることが可能です。

既存の DPM の暗号化を解除してファイルを抽出したい場合、以下のツールを使用できます。

  • spihsp: ごちゃ(私)が開発した Susie プラグイン。別途 AtoB Converter などのホストツールが別途必要で、単独で実行可能なツールに比べると準備が煩雑です。暗号化キーの指定が必要な DPM ファイルの展開には対応していません(2022 年現在)
  • HSP-Decompiler: HSP の逆コンパイラ。既知平文攻撃によって start.ax の復号には対応しています。他のファイルに興味がない場合は手軽に使えます

DPM ファイルの構造

ファイルの詳細について説明します。以下のことを前提としています。

  • 数値はすべてリトルエンディアンです(少なくとも Windows では。プラットフォーム間の可搬性は考慮されていないかもしれません)
  • 文字エンコーディングはプラットフォームに依存します。Windows では Shift-JIS に近い CP932 となります

全体的な構造

項目 サイズ 説明
DPM ヘッダー 16 bytes 全体データサイズやファイル数などの情報
ディレクトリエントリ 32 bytes × エントリ数 ファイルごとの情報
ネームイメージ 可変 未使用。常に0バイト。長いファイル名の格納に使用されるはずでした
実データ 可変 ファイルデータ

DPM ヘッダー

項目 サイズ 説明
シグネチャ 4 bytes ASCII "DPMX" (44 50 4D 58)
実データ開始ポインタ 4 bytes 実データ領域の開始位置。DPM ファイル先頭からのオフセット
ディレクトリエントリ数 4 bytes 格納されているファイル数
ネームイメージのサイズ 4 bytes 常に 0

全体のファイルサイズは記録されませんが、ディレクトリエントリのファイルサイズを合計することで計算できます。

ディレクトリエントリ

項目 サイズ 説明
ファイル名 16 bytes ファイル名。必ず 0x00 で終端します
名前格納ポインタ 4 bytes 未使用。常に -1 (FF FF FF FF)。ネームイメージ内のオフセットが格納されるはずでした
暗号化キー 4 bytes 暗号化なしの場合は 0。HSP 2.55 では、暗号化されたファイルには常に 0x11223344 が格納され、値によって暗号化内容が変化することはありません
ファイル格納ポインタ 4 bytes ファイルデータの開始位置。実データ領域の先頭からのオフセット
ファイルサイズ 4 bytes ファイルの長さ(バイト数)

実データ領域

ファイルデータが格納されます。

暗号化されたデータの復号には別途暗号化キーが必要です(暗号化キーが固定されている HSP 2.55 を除く)。

実行ファイルへの埋め込みと実行オプションの構造

実行ファイルに埋め込まれる場合、DPM ファイルは HSP ランタイムの末尾に連結されます。

HSP 実行ファイルには、DPM ファイルのオフセットや暗号化キーなどを知るための小さな実行オプション領域が組み込まれています。実行ファイルをコンパイルする前には、以下のように意味のない文字列が設定されています (hsp3win.cpp#L39) が、

48 53 50 48 45 44 7E 7E 00 5F 31 5F 5F 5F 5F 5F  HSPHED~~._1_____
5F 5F 5F 5F 32 5F 5F 5F 5F 5F 5F 5F 5F 5F 33 5F  ____2_________3_
5F 5F 5F 5F 5F                                   _____

コンパイル後は、たとえば以下のようなデータが書き込まれます。

00 00 00 00 00 00 00 00 00 32 38 39 32 38 30 00  .........289280. 
00 00 00 78 80 02 79 E0 01 64 00 00 73 1B 2E 6B  ...x..y..d..s..k 
CD 9E AA 81 5F                                   ...._

便宜上、この実行オプション領域を HSPHED と呼ぶことにします。

HSPHED の構造

実行オプション領域は、次のような構造になっています。

項目 サイズ 説明
シグネチャ 9 bytes 通常は常に 0。コンパイル前は ASCII "HSPHED~~\0"
DPM 格納ポインタ 8 bytes DPM ファイルの埋め込み位置を表す文字列。実行ファイル先頭からのオフセット - 0x10000 を10進数テキストとして記録します。必ず 0x00 で終端します
実行ファイルのタイプ 1 byte 実行ファイルのタイプ (type)。通常: 0x00, フルスクリーン: 'f' (0x66), スクリーンセーバー: 's' (0x73)
実行ファイルのタイプの終端? 1 byte 常に 0
シグネチャ: x 1 byte 常に 'x' (0x78)
初期ウィンドウXサイズ 2 bytes 初期ウィンドウXサイズ (xsize)
シグネチャ: y 1 byte 常に 'y' (0x79)
初期ウィンドウYサイズ 2 bytes 初期ウィンドウYサイズ (ysize)
シグネチャ: d 1 byte 常に 'd' (0x64)
初期ウィンドウ非表示SW 2 bytes 初期ウィンドウ非表示SW (hide)。表示: 0, 非表示: 1
シグネチャ: s 1 byte 常に 's' (0x73) (HSP 2.6 以降)
DPM チェックサム 2 bytes DPM チェックサム (HSP 2.6 以降)
シグネチャ: k 1 byte 常に 'k' (0x6b) (HSP 2.6 以降)
DPM 暗号化キー 4 bytes DPM 暗号化キー (HSP 2.6 以降)。chdpm 命令で指定する暗号化キーに相当しますが、チェックサム検証の有無に応じて暗号化キーが微妙に異なる方法で取り扱われるため、chdpm 命令でこの値を指定してもデータは解読できません

実行ファイル中の HSPHED を確実に検索する方法は用意されていませんが、x、y、d の文字が必ずある位置に現れること、HSPHED が示す位置に DPM ファイルのヘッダーが存在することを利用して、ファイル全体をスキャンすることでほぼ確実に発見できます。また、32ビット実行ファイルでは、通常、HSPHED の開始位置は4の倍数にアライメントされているはずです。

リソースエディターや UPX のような一般のツールで実行ファイルを編集する際には、HSPHED に記録された DPM オフセットの整合性を崩さないように注意が必要です。

DPM チェックサムの計算

前述の通り、HSP 2.6 以降では、DPM のチェックサム値が一致しないと実行ファイルが起動しません。

チェックサムの計算処理は OpenHSP で実装が公開されています (dpmread.cpp#L288)。その名のとおり、DPM の先頭からファイル終端 (EOF) までのチェックサムを計算しますが、HSPHED の暗号化キーの一部を足し合わせます。以下は、同等の実装を表す単独のソースコードです。

/**
 * DPM のチェックサムを計算します。
 * @param dpm DPM ファイルデータ
 * @param dpmsize DPM ファイルサイズ
 * @param deckey 暗号化キー
 * @return チェックサム
 */
int calc_sum(char *dpm, int dpmsize, int deckey)
{
	const int sumseed = ((deckey >> 24) & 0xff) / 7;

	int sum = 0;
	for (int i = 0; i < dpmsize; i++) {
		sum += dpm[i] + sumseed;
		// Tip: sumseed * dpmsize はループ外で計算しても良い
	}
	sum &= 0xffff;
	return sum;
}

余談ですが、HSP が最初に実行するオブジェクトファイルである "start.ax" のファイル名も、簡単なスクランブルとチェックサムによって保護されています。

データ暗号化の詳細と復号処理

暗号化アルゴリズムは何度か改訂されていて、HSP 3.3 以降、HSP 2 6 以降、HSP 2.55 で微妙な違いがあります。

どのバージョンでも、差分符号化後に減算と XOR によってデータが暗号化されます。減算や XOR に使う数値は1ファイル内で変化しないため、実質的にビット演算部は単一換字式暗号とみなすことができます。乱数を用いないため、暗号化によるデータ圧縮効率の低下は少ないです。この暗号は、暗号化ループの中で2つの8ビット暗号化キーしか使用しないため、既知の平文を用いた総当たり攻撃に対して脆弱です。

OpenHSP では、DPM ファイルを読み込むための実装が公開されていますが、暗号化に関わる処理だけが削除されています。本書では、リバースエンジニアリングの結果をもとに、隠されている暗号化処理を復元して紹介します。

復号処理

復号処理は dpm_fread 関数に含まれます (dpmread.cpp#L186)。HSP 3.3 以降では、減算演算子と XOR 演算子が入れ替わっています。

HSP 2.6 以降の復号処理
// HSP 2.6 以降
static int dpm_fenc, dpm_enc1, dpm_enc2;
static int seed1, seed2;
static unsigned char dpm_lastchr = 0;

int dpm_fread( void *mem, int size, FILE *stream )
{
	int a;
	int len;
	unsigned char *p;
	unsigned char ch;
	int seed1x,seed2x;

	if ( memf_flag ) {							// メモリストリーム時
		len = size;
		if (( memfile.cur + size ) >= memfile.size ) len = memfile.size - memfile.cur;
		if ( len>0 ) {
			memcpy( mem, memfile.pt + memfile.cur, len );
			memfile.cur += len;
		}
		return len;
	}
	else {
		len = (int)fread( mem, 1, size, stream );

		// 復号処理 ここから
		if ( dpm_fenc != 0 && len > 0 ) {
			seed1x = (seed1 + dpm_enc1) & 0xff;
			seed2x = (seed2 + dpm_enc2) & 0xff;
			ch = dpm_lastchr;
			for (int i = 0; i < len; i++) {
#if vercode >= 0x3300 // HSP 3.3~3.6
				ch += ((mem[i] ^ (unsigned char)seed1x) - (unsigned char)seed2x);
#elif vercode >= 0x2600 // HSP 2.6~3.2
				ch += ((mem[i] - (unsigned char)seed1x) ^ (unsigned char)seed2x);
#endif
				mem[i] = ch;
			}
			dpm_lastchr = ch;
		}
		// 復号処理 ここまで
	}
	return len;
}

HSP 2.55 の復号処理は、固定の暗号化キーが使用されることを除いて HSP 2.6 と同等です。

HSP 2.55 の復号処理
// HSP 2.55
// 暗号化キーは実際には dpm_ini 関数内、DPM ヘッダーの読み込み処理の直後で初期化されます。
static int dpm_fenc;
static int seed1 = 0x55;
static int seed2 = 1;
static unsigned char dpm_lastchr = 0;

int dpm_fread( void *mem, int size, FILE *stream )
{
	int len;
	unsigned char ch;

	len = (int)fread( mem, 1, size, stream );
	if ( dpm_fenc != 0 && len > 0 ) {
		ch = dpm_lastchr;
		for (int i = 0; i < len; i++) {
			ch += (mem[i] - (unsigned char)seed2) ^ (unsigned char)seed1;
			mem[i] = ch;
		}
		dpm_lastchr = ch;
	}
	return len;
}

暗号化キーの計算

HSP 2.6 以降では、復号に使用する暗号化キーは、DPM 全体の暗号化キー(HSPHED に格納されている値 または chdpm 命令で指定した値)と、 DPM のディレクトリエントリに記録されているファイル固有の暗号化キーを足し合わせて計算する必要があります。

結論としては、各関数の暗号化キーに関する処理をまとめると、以下のようになります。HSP 3.3 以降では、計算に使用する定数の値が変更されています。

暗号化キーの計算方法を表す疑似コード
int deckey;             // DPM ファイル全体の暗号化キー
int dpm_fenc;           // ファイル別の暗号化キー (0 は暗号化なし)
int sum;                // チェックサムの値
int sumsize;            // DPM の実データ領域のサイズ(ただし、チェックサムを検証しない単独型 DPM では常に0)
int seed1x, seed2x;     // 復号処理で用いる暗号化キー

// DPM ファイル全体の暗号化キーを変形する
if (deckey == -1) {
	seed1 = 0xaa;
	seed2 = 0x55;
}
else {
	const unsigned char deckey1 = (unsigned char)(deckey & 0xff);
	const unsigned char deckey2 = (unsigned char)((deckey >> 8) & 0xff);
	const unsigned char deckey3 = (unsigned char)((deckey >> 16) & 0xff);
	const unsigned char deckey4 = (unsigned char)((deckey >> 24) & 0xff);

	// 実行ファイル埋め込み型 DPM と単独型 DPM で sumsize の値が異なることに注意する
	seed1 = ((deckey1 * deckey3 / 3) ^ sumsize) & 0xff;
	seed2 = ((deckey2 * deckey4 / 5) ^ sumsize ^ 0xaa) & 0xff;
}

// ファイル別の暗号化キーを変形する
const unsigned char dpm_fenc1 = dpm_fenc & 0xff;
const unsigned char dpm_fenc2 = (dpm_fenc >> 8) & 0xff;
const unsigned char dpm_fenc3 = (dpm_fenc >> 16) & 0xff;
const unsigned char dpm_fenc4 = (dpm_fenc >> 24) & 0xff;

#if vercode >= 0x3300 // HSP 3.3~3.6
	dpm_enc1 = (dpm_fenc1 + 0x5a) ^ dpm_fenc3;
	dpm_enc2 = (dpm_fenc2 + 0xa5) ^ dpm_fenc4;
#elif vercode >= 0x2600 // HSP 2.6~3.2
	dpm_enc1 = (dpm_fenc1 + 0x55) ^ dpm_fenc3;
	dpm_enc2 = (dpm_fenc2 + 0xaa) ^ dpm_fenc4;
#end

// それぞれのキーを足し合わせる
seed1x = (seed1 + dpm_enc1) & 0xff;
seed2x = (seed2 + dpm_enc2) & 0xff;

元の処理は dpm_ini 関数 (dpmread.cpp#L310) と dpmchk 関数 (dpmread.cpp#L118) にあります。

元の実装に近いソースコード
int dpm_ini( char *fname, long dpmofs, int chksum, int deckey )
{
	// 前半は省略

	//		DPMのチェックサムを検証する
	//
	sum = 0; sumsize = 0;
	sumseed = ((deckey>>24)&0xff)/7;
	if ( chksum != -1 ) {
		fp=fopen( dpmfile,"rb" );
		if (dpmofs>0) fseek( fp, dpmofs, SEEK_SET );
		while(1) {
			a1=fgetc(fp);if (a1<0) break;
			sum+=a1+sumseed;sumsize++;
		}
		fclose(fp);
		sum &= 0xffff;				// lower 16bit sum
		if ( chksum != sum ) return -2;
		sumsize -= hedsize;
	}

	// DPM 全体の暗号化キーを変形する ここから
	if ( deckey == -1 ) {
		seed1 = 0xaa;
		seed2 = 0x55;
	}
	else {
		const unsigned char deckey1 = (unsigned char)(deckey & 0xff);
		const unsigned char deckey2 = (unsigned char)((deckey >> 8) & 0xff);
		const unsigned char deckey3 = (unsigned char)((deckey >> 16) & 0xff);
		const unsigned char deckey4 = (unsigned char)((deckey >> 24) & 0xff);

		//seed1 = (((deckey1 * deckey3) & 0xff) / 3) ^ sumsize) & 0xff;
		//seed2 = (((deckey2 * deckey4) & 0xff) / 5) ^ sumsize ^ 0xffffffaa) & 0xff;
		seed1 = ((deckey1 * deckey3 / 3) ^ sumsize) & 0xff;
		seed2 = ((deckey2 * deckey4 / 5) ^ sumsize ^ 0xaa) & 0xff;
	}
	// DPM 全体の暗号化キーを変形する ここまで

	//		DPMモードにする
	//
	dpm_flag = 1;
	strcpy(dpm_file,dpmfile);
	return 0;
}
static int dpmchk( char *fname )
{
	// 前半は省略

	// ファイル別暗号化キーの変形 ここから
	if ( dpm_fenc != 0 ) {
#if vercode >= 0x3300 // HSP 3.3~3.6
		dpm_enc1 = (buf[d_fenc] + 0x5a) ^ buf[d_fenc+2];
		dpm_enc2 = (buf[d_fenc+1] + 0xa5) ^ buf[d_fenc+3];
#elif vercode >= 0x2600 // HSP 2.6~3.2
		dpm_enc1 = (buf[d_fenc] + 0x55) ^ buf[d_fenc+2];
		dpm_enc2 = (buf[d_fenc+1] + 0xaa) ^ buf[d_fenc+3];
#end
	}
	// ファイル別暗号化キーの変形 ここまで

	dpm_lastchr = 0;
	dpm_opmode = 1;
	return 0;
}

Discussion

QCFiumQCFium

参考になる記事をどうもありがとうございます。
「HSP 2.6 以降の復号処理」のHSP 2.6~3.2の部分のコード

ch += ((mem[i] - (unsigned char)seed1x) ^ (unsigned char)seed2x);

は、seed1xとseed2xが逆ではないでしょうか ?