[Astro] #136 File Inspector — ブラウザ完結型バイナリ解析・メタデータ抽出・拡張子偽装検知ツール
はじめに
ローカル環境でファイルをサーバーへ送信することなく、ブラウザ完結でバイナリ解析・メタデータ抽出・拡張子偽装検知を行う「File Inspector & Checker」を実装しました。
前作のAudio Converter(#135)と同様にサーバーサイド処理を行わず、クライアントサイドのJavaScriptとWeb APIのみで完結するアーキテクチャを採用しています[cite: 8, 9]。
本記事では、マジックバイトによるファイル種別検知と拡張子不一致警告、Web Crypto APIおよびSparkMD5を用いた高速ハッシュ計算、ExifReaderによるメタデータ解析、16進数ヘキサダンプ表示、さらにPNG/JPEGのEOF外隠しデータ検出の実装詳細を解説します。
スクリーンショット
動画(GIF)
1. 全体アーキテクチャ
[ Astroページ (file-inspector.astro) ]
│
├── 左サイドバー (Panel Left)
│ ├─ ファイルドロップゾーン / クリップボード貼り付け
│ ├─ ステータスバッジ & ファイル基本情報
│ ├─ 解析オプション(ハッシュ / EXIF / ヘキサ)
│ └─ アクション(JSONレポート出力 / クリア)
│
└── 右メインエリア (Main Area)
├─ 拡張子不一致・隠しデータ警告バンナー
├─ バイナリヘッダー & ハッシュ結果カード
├─ EXIF・プロパティ表示カード
├─ EOF外隠しデータ検知アラート
├─ ヘキサダンプビューア (先頭512バイト)
└─ インラインファイルプレビュー
パイプラインの基本動作:
- ドロップされたファイルから
file.slice()で部分読み込みを行い、全体をメモリに乗せずに各種検証を開始します。 - 外部ライブラリ(ExifReader / SparkMD5)は処理開始時に CDN から動的ロード(Lazy Load)し、初期読み込み負荷を低減します。
- ファイルヘッダーのバイナリシグネチャ(マジックバイト)を判定し、宣言拡張子との整合性を検証します。
- Web Crypto API による SHA-1 / SHA-256 および SparkMD5 による MD5 の算出を並列実行します。
- PNG の
IENDチャンクや JPEG のEOIマーカーを解析し、画像の終端以降に隠された余剰データ(EOF外データ)を走査します。
2. マジックバイト判定と拡張子偽装チェック
ファイルの拡張子(.png や .jpg など)は容易に書き換え可能なため、ファイル先頭のバイナリシグネチャ(マジックバイト)を検証して真の形式を判定します。
バイナリヘッダーの抽出と判別
ファイル全体を読み込まず、先頭32バイトのみを ArrayBuffer として取得して16進数文字列に変換します。
var bufferHeader = await file.slice(0, 32).arrayBuffer();
var uint8Header = new Uint8Array(bufferHeader);
var hexHeader = Array.from(uint8Header)
.map(function(b) { return b.toString(16).padStart(2, '0').toUpperCase(); })
.join('');
定義したシグネチャテーブル SIGNATURES と照合します。RIFF ヘッダー(52494646)を持つファイルに関しては、後続のオフセット位置を調べることで WAV と WEBP を正確に識別します。
var SIGNATURES = [
{ hex: '89504E47', ext: 'png', mime: 'image/png' },
{ hex: 'FFD8FF', ext: 'jpg', mime: 'image/jpeg' },
{ hex: '47494638', ext: 'gif', mime: 'image/gif' },
{ hex: '25504446', ext: 'pdf', mime: 'application/pdf' },
{ hex: '504B0304', ext: 'zip', mime: 'application/zip' },
{ hex: '377ABCAF271C', ext: '7z', mime: 'application/x-7z-compressed' },
{ hex: '52494646', ext: 'wav', mime: 'audio/wav' },
{ hex: '1A45DFA3', ext: 'webm', mime: 'video/webm' },
{ hex: '0000001866747970', ext: 'mp4', mime: 'video/mp4' }
];
function detectFileType(hex, ext, fileType) {
for (var i = 0; i < SIGNATURES.length; i++) {
var sig = SIGNATURES[i];
if (hex.startsWith(sig.hex)) {
if (sig.hex === '52494646') {
if (hex.slice(16, 24) === '57454250') return { ext: 'webp', mime: 'image/webp' };
return { ext: 'wav', mime: 'audio/wav' };
}
return sig;
}
}
if (fileType) {
var sub = fileType.split('/')[1];
return { ext: sub || ext, mime: fileType };
}
return { ext: ext || 'unknown', mime: 'application/octet-stream' };
}
拡張子不一致の検出 (Mismatch Verification)
宣言された拡張子(ext)とマジックバイトから検出された形式(detected.ext)を比較します。.jpeg と .jpg や、内部構造が ZIP である .docx / .xlsx などの同等エイリアスは正常判定(MATCHED)と扱います。不一致を検出した場合はアラートバンナーを表示します。
function checkMatch(declaredExt, detectedExt) {
if (!declaredExt || !detectedExt) return true;
if (declaredExt === detectedExt) return true;
if ((declaredExt === 'jpeg' && detectedExt === 'jpg') || (declaredExt === 'jpg' && detectedExt === 'jpeg')) return true;
if (declaredExt === 'tif' && detectedExt === 'tiff') return true;
if ((declaredExt === 'docx' || declaredExt === 'xlsx' || declaredExt === 'pptx') && detectedExt === 'zip') return true;
return false;
}
3. 遅延ロードとマルチハッシュ・メタデータ解析
初期表示速度を落とさないよう、EXIF解析ライブラリやMD5計算ライブラリは解析実行時に CDN から読み込みます。
ライブラリのオンデマンド読込
async function loadLibraries() {
if (!window.ExifReader) {
await new Promise(function(resolve) {
var s = document.createElement('script');
s.src = 'https://cdn.jsdelivr.net/npm/[email protected]/dist/exif-reader.js';
s.onload = resolve;
s.onerror = resolve;
document.head.appendChild(s);
});
}
if (!window.SparkMD5) {
await new Promise(function(resolve) {
var s = document.createElement('script');
s.src = 'https://cdn.jsdelivr.net/npm/[email protected]/spark-md5.min.js';
s.onload = resolve;
s.onerror = resolve;
document.head.appendChild(s);
});
}
}
Web Crypto API と SparkMD5 による指紋生成
SHA-1 と SHA-256 にはブラウザ内蔵の crypto.subtle.digest を利用し、MD5 には SparkMD5 を利用してハッシュ値を並列計算します。
async function calculateHashes(file) {
var buffer = await file.arrayBuffer();
// Web Crypto API (SHA-1 / SHA-256)
try {
var sha1Buf = await crypto.subtle.digest('SHA-1', buffer);
document.getElementById('hash-sha1').value = bufToHex(sha1Buf);
var sha256Buf = await crypto.subtle.digest('SHA-256', buffer);
document.getElementById('hash-sha256').value = bufToHex(sha256Buf);
} catch (e) {
document.getElementById('hash-sha1').value = 'Error';
document.getElementById('hash-sha256').value = 'Error';
}
// SparkMD5 (MD5)
if (window.SparkMD5) {
try {
var spark = new SparkMD5.ArrayBuffer();
spark.append(buffer);
document.getElementById('hash-md5').value = spark.end();
} catch (e) {
document.getElementById('hash-md5').value = 'Error';
}
}
}
4. PNG / JPEG の EOF 外隠しデータ検出
画像ファイルの末尾に別のバイナリや ZIP ファイル等を結合するステガノグラフィ手法を検出するため、規格上の終端(EOF: End of File)を超えてデータが存在するかどうかを検査します。
PNG の IEND チャンク検知
PNG 画像は構造上、必ず IEND チャンク(ASCII表記 49 45 4E 44)とその直後の 4 バイト CRC(計 8 バイト)で終了します。ファイル末尾 1024 バイトを抽出し、IEND の位置から正常な終端位置を計算して実際のファイルサイズと比較します。
async function detectHiddenData(file, detectedExt) {
var fileSize = file.size;
if (detectedExt === 'png') {
var tailBuf = await file.slice(Math.max(0, fileSize - 1024)).arrayBuffer();
var bytes = new Uint8Array(tailBuf);
var iendHex = '49454E44';
var hexStr = Array.from(bytes).map(b => b.toString(16).padStart(2, '0').toUpperCase()).join('');
var idx = hexStr.lastIndexOf(iendHex);
if (idx !== -1) {
var eofPos = (fileSize - tailBuf.byteLength) + (idx / 2) + 8;
var extraBytes = fileSize - eofPos;
if (extraBytes > 0) {
return {
detected: true,
extraBytes: extraBytes,
message: 'PNG (IEND) 終端以降に ' + formatBytes(extraBytes) + ' の余剰データを検出'
};
}
}
}
// ...
}
JPEG の EOI マーカー検知
JPEG 画像は末尾の EOI (End of Image) マーカー FF D9 で終了します。末尾バイトから逆方向に FF D9 を特定し、その直後に未知のデータが付加されているかを判定します。
if (detectedExt === 'jpg' || detectedExt === 'jpeg') {
var tailBuf = await file.slice(Math.max(0, fileSize - 1024)).arrayBuffer();
var bytes = new Uint8Array(tailBuf);
for (var i = bytes.length - 2; i >= 0; i--) {
if (bytes[i] === 0xFF && bytes[i + 1] === 0xD9) {
var eofPos = (fileSize - tailBuf.byteLength) + i + 2;
var extraBytes = fileSize - eofPos;
if (extraBytes > 0) {
return {
detected: true,
extraBytes: extraBytes,
message: 'JPEG (EOI) 終端以降に ' + formatBytes(extraBytes) + ' の余剰データを検出'
};
}
break;
}
}
}
return { detected: false };
5. ヘキサダンプビューアと利便性機能
バイナリデータの構造を確認できるよう、先頭512バイトのヘキサダンプを表示する generateHexDump 関数を組み込んでいます。
ヘキサダンプ生成処理
アドレス(オフセット)、16バイトごとのHex表示、および印字可能ASCII文字(ASCII 32〜126 以外は .)を出力形式として成形します。
function generateHexDump(arrayBuffer) {
var bytes = new Uint8Array(arrayBuffer);
var lines = [];
for (var i = 0; i < bytes.length; i += 16) {
var chunk = bytes.subarray(i, i + 16);
var address = i.toString(16).padStart(8, '0').toUpperCase();
var hexArr = [];
for (var j = 0; j < 16; j++) {
if (j < chunk.length) {
hexArr.push(chunk[j].toString(16).padStart(2, '0').toUpperCase());
} else {
hexArr.push(' ');
}
}
var ascii = '';
for (var k = 0; k < chunk.length; k++) {
var c = chunk[k];
ascii += (c >= 32 && c <= 126) ? String.fromCharCode(c) : '.';
}
var hexStr = hexArr.slice(0, 8).join(' ') + ' ' + hexArr.slice(8, 16).join(' ');
lines.push(address + ' | ' + hexStr + ' | ' + ascii);
}
return lines.join('\n');
}
クリップボードペーストとJSONコピー
- Paste (Ctrl+V) サポート: ブラウザ上で
pasteイベントをハンドリングし、クリップボード上の画像をダイレクトにインスペクト可能です。 - JSON Report コピー: 検証結果、ファイルサイズ、マジックバイト、計算された各ハッシュ値をオブジェクト化し、ワンクリックで JSON 形式としてコピーできます。
6. まとめ
- マジックバイト自動検証: 拡張子に惑わされずバイナリヘッダーから真のファイル形式を検知し、偽装を判別。
- クライアントサイドセキュリティ: サーバー通信を行わず、ファイルデータを外部へ送信せずにローカルブラウザ内で全処理を完了。
- EOF隠しデータ検知: PNGの
IENDや JPEGのEOIマーカー以降の追記データを解析し、埋め込みファイルを露出。 - ヘキサダンプ & JSONレポート: アドレス付きヘキサダンプ表示とレポートのクリップボード出力により解析を効率化。
Web Crypto API や Slice API などのブラウザ標準機能を活用することで、依存ライブラリを最小限に抑えた高速なバイナリ解析ツールを構築できました。