@port139 Blog

基本的にはデジタル・フォレンジックの技術について取り扱っていますが、記載内容には高確率で誤りが含まれる可能性があります。

Windows 11 IME履歴を解き明かす

未知のバイナリをAIと解き明かす:Windows 11 IME解析スクリプト開発記

〜生成AIを「思考のパートナー」として安全に活用する〜

【免責事項】

本記事の構成および内容は、ユーザーと生成AI(Gemini)との対話を通じて作成されました。記述内容には、生成AI特有のハルシネーション(事実とは異なる情報の生成)による誤りが含まれている可能性があります。また、紹介しているスクリプトについても、実行環境やOSのアップデート状況によっては正しく動作しない恐れがあるため、利用の際は必ず検証環境で動作を確認し、自己責任でご使用ください。

※つまり、ほぼ全て生成AIに書いてもらった記事になるので、微妙な点もありますが「へぇ」くらいで読んでいただければ。


1. 導入:Windows 11 IME解析の「壁」

デジタルフォレンジックにおいて、OSの仕様変更は常に大きな課題です。Windows 11では、Microsoft IMEのデータ構造が一新されました。従来の解析手法が通用しなくなったこの「ドキュメントのない未知のバイナリ」に対し、いかにして解析ロジックを構築するか。

今回、私は生成AI(Gemini)を単なるコード生成器としてではなく、**「構造を共に推理する思考のパートナー」**として活用し、データカバレッジ100%の解析ロジックを完成させました。


2. JpnIHDS.dat の役割と所在

解析対象となる JpnIHDS.dat は、Microsoft IMEの予測入力機能における「学習データ」を保持しているバイナリファイルです。

  • 役割: ユーザーが過去に入力・確定した文字列を記録し、次回の入力時に最適な予測候補を表示するために利用されます。フォレンジックの視点では、ブラウザの履歴削除やシークレットモード、あるいは特定のアプリケーション上での入力であっても、このファイルに「入力の証跡」が残る可能性があるため、極めて重要なアーティファクトとなります。

  • ファイルパス: 通常、以下の場所に格納されています。

    C:\Users\<ユーザー名>\AppData\Roaming\Microsoft\InputMethod\Shared\JpnIHDS.dat


3. セキュリティの鉄則:実案件データは絶対に入力しない

生成AIを解析に導入する際、最も重視すべきはデータの機密保持です。

  • 学習リスク: AIに入力した情報がモデルの再学習に使用され、将来的に外部へ漏洩するリスク。

  • 機密保持: 実案件の生証拠データをクラウドAIに送信することは、法的・倫理的に許容されません。

本解析でのアプローチ:

解析プロセスでは、実案件のデータは一切使用していません。自身の環境で作成した**「構造確認用のダミーデータ(検証用データ)」**のみをAIに提示しました。AIに教えるのは「パズル(構造)のルール」であり、「内容(証拠)」ではないという切り分けが、安全な活用の大前提となります。


4. 生成AIとの共同解析プロセス

① パターン認識と仮説の構築

検証用データのヘキサ(16進数)データをAIに提示し、Windows特有のタイムスタンプ(FILETIME)のパターンや、データ長を示すフラグの候補を特定させました。

② 「パース」の前に「検証スクリプト」を書かせる

ここが最大のポイントです。いきなり文字列を抽出するスクリプトを書くのではなく、**「構造の仮説が論理的に正しいかを数学的にチェックする検証スクリプト」をAIに作成させました。レコードのサイズを合算し、次のレコードの開始位置に正確に着地するかをファイル全域で検証。この「カバレッジ(網羅率)」を確認するステップを繰り返すことで、可変長ヘッダー(16バイトと24バイトの混在)という複雑な仕様を特定し、最終的にカバレッジ100%(全領域の論理的説明)**に到達しました。


5. ツール構成と使い方

完成したツールセットは、解析用の win11_msime_parser.py と、構造検証用の win11_msime_analyzer.py の2点です。

解析実行:win11_msime_parser.py

ミリ秒精度のタイムスタンプ出力(UTC/JST並記)と、削除済み履歴(Ghostレコード)の抽出に対応しています。

標準的な実行方法:

Bash
 
python win11_msime_parser.py -i JpnIHDS.dat

6. 出力サンプルの確認

解析に成功すると、いつ、どこで、何が入力されたのかが、ミリ秒精度で可視化されます。

日時 (UTC) 日時 (JST) 状態 入力テキスト オフセット
2026-01-20 07:14:14.123 2026-01-20 16:14:14.123 Active PowerShell (リモート) 0x000020
2026-01-20 07:15:30.456 2026-01-20 16:15:30.456 Active SQLインジェクション 0x00003c
None None Ghost 削除されたキーワード 0x000090

7. まとめ:AI Thought Partnerとしての価値

今回の解析を通じて、生成AIをフォレンジックに活かすための3つのコツを再確認しました。

  1. 実データは隠し、構造のみを伝える: 検証用データを用いてセキュリティを担保する。

  2. 検証スクリプトで「答え合わせ」をする: AIの回答が正しいか、データ全体の整合性で客観的に判断する。

  3. 対話を通じて「推理」を深める: 答えを丸投げするのではなく、論理の矛盾を人間がレビューし、AIと共に解消していく。

AIにはハルシネーションのリスクがありますが、適切な距離感で接し、人間が最終的な検証を行うことで、未知のアーティファクトに対する強力な「解読器」となります。


 


win11_msime_parser.py

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

"""
win11_msime_parser.py
---------------------
Windows 11 Microsoft IMEの予測入力履歴 (JpnIHDS.dat) を解析し、
UTC/JSTの両タイムスタンプと共にCSV出力します。
"""

import struct
import csv
import argparse
import os
from datetime import datetime, timedelta, timezone

def parse_ime_history(input_path, output_path, include_ghost=True):
    utc_tz = timezone.utc
    jst_tz = timezone(timedelta(hours=+9), 'JST')

    if not os.path.exists(input_path):
        print(f"[-] Error: File not found: {input_path}")
        return

    with open(input_path, 'rb') as f:
        data = f.read()

    dat_len = len(data)
    results =
    
    # 0x14にあるポインタを信頼、なければ0x20
    try:
        pos = struct.unpack_from("<I", data, 20)[0]
    except:
        pos = 32

    # 有効なタイムスタンプ範囲
    min_ft = 132222624000000000
    max_ft = 135694368000000000

    while pos < dat_len - 16:
        try:
            timestamp = struct.unpack_from("<Q", data, pos)[0]
            total_size = struct.unpack_from("<H", data, pos + 8)[0]
            header_size = struct.unpack_from("<H", data, pos + 10)[0]
            num_segments = data[pos + 13]

            if total_size == 0 or pos + total_size > dat_len:
                break

            is_valid_time = min_ft <= timestamp <= max_ft
            
            # セグメントからのテキスト抽出
            current_seg_pos = pos + header_size
            record_segments =

            for _ in range(num_segments):
                if current_seg_pos + 2 > pos + total_size: break
                seg_size = struct.unpack_from("<H", data, current_seg_pos)[0]
                if seg_size >= 8:
                    str_bytes = data[current_seg_pos + 8 : current_seg_pos + seg_size]
                    try:
                        decoded = str_bytes.decode('utf-16le', errors='ignore').replace('\x00', '').strip()
                        if decoded: record_segments.append(decoded)
                    except: pass
                current_seg_pos += seg_size

            if record_segments:
                if is_valid_time:
                    dt_utc = datetime(1601, 1, 1, tzinfo=utc_tz) + timedelta(microseconds=timestamp // 10)
                    dt_jst = dt_utc.astimezone(jst_tz)
                    utc_str = dt_utc.strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]
                    jst_str = dt_jst.strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]
                    status = "Active"
                else:
                    utc_str = jst_str = "None/Deleted"
                    status = "Ghost"

                if include_ghost or is_valid_time:
                    results.append([utc_str, jst_str, status, " | ".join(record_segments), header_size, hex(pos), timestamp])

            pos += total_size
        except:
            pos += 1

    header = ["Timestamp(UTC)", "Timestamp(JST)", "Status", "Text", "HeaderSize", "Offset", "RawTimestamp"]
    try:
        with open(output_path, 'w', encoding='utf-8-sig', newline='') as f:
            writer = csv.writer(f)
            writer.writerow(header)
            writer.writerows(results)
        print(f"[+] Successfully parsed {len(results)} records to: {output_path}")
    except Exception as e:
        print(f"[-] Error writing CSV: {e}")

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Win11 MSIME JpnIHDS.dat Parser')
    parser.add_argument('-i', '--input', required=True, help='Input JpnIHDS.dat path')
    parser.add_argument('-o', '--output', help='Output CSV path')
    parser.add_argument('--no-ghost', action='store_false', dest='ghost', help='Exclude ghost records')
    args = parser.parse_args()

    if not args.output:
        args.output = f"ime_parse_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"

    parse_ime_history(args.input, args.output, include_ghost=args.ghost)

 win11_msime_analyzer.py

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

"""
Windows 11 MSIME Structure Analyzer (JpnIHDS.dat)
------------------------------------------------
このスクリプトは、JpnIHDS.datのバイナリ構造が定義された仮説通りであるかを検証します。
データカバレッジとチェーンの整合性を確認し、構造の変化や未知のフラグを特定するために使用します。
"""

import struct
import os
import argparse
from datetime import datetime, timedelta, timezone

def verify_structure(input_path):
    if not os.path.exists(input_path):
        print(f"[-] Error: File not found: {input_path}")
        return

    with open(input_path, 'rb') as f:
        data = f.read()

    dat_len = len(data)
    jst = timezone(timedelta(hours=+9), 'JST')
    min_ft = 132222624000000000  # 2020/01/01
    max_ft = 135694368000000000  # 2030/12/31

    # ファイルヘッダー(0x14)から最初のレコード位置を取得
    first_ptr = struct.unpack_from("<I", data, 20)[0]
    
    print(f"[*] Analyzing structure: {input_path}")
    print(f"[*] File Size: {dat_len} bytes")
    print(f"[*] First Record Offset: {hex(first_ptr)}")
    print("-" * 110)
    print(f"{'Offset':<10} | {'Status':<10} | {'Timestamp (JST)':<20} | {'Structure (H + Segs = Total)'}")
    print("-" * 110)

    pos = first_ptr
    total_records = 0
    errors = 0
    accounted_bytes = first_ptr

    while pos < dat_len - 16:
        curr = pos
        try:
            timestamp = struct.unpack_from("<Q", data, pos)[0]
            total_size = struct.unpack_from("<H", data, pos + 8)[0]
            header_size = struct.unpack_from("<H", data, pos + 10)[0]
            num_segments = data[pos + 13]

            if total_size == 0:
                print(f"{hex(curr):<10} | FATAL      | Total size is zero. Chain broken.")
                break

            # セグメントの整合性チェック
            seg_sum = 0
            seg_ptr = pos + header_size
            seg_list = []
            for _ in range(num_segments):
                if seg_ptr + 2 > dat_len: break
                s_size = struct.unpack_from("<H", data, seg_ptr)[0]
                seg_list.append(s_size)
                seg_sum += s_size
                seg_ptr += s_size

            # ロジックの正当性判定
            is_valid_time = min_ft <= timestamp <= max_ft
            size_match = (header_size + seg_sum == total_size)
            
            # 次のポインタの妥当性
            next_pos = pos + total_size
            chain_ok = False
            if next_pos <= dat_len:
                if next_pos == dat_len:
                    chain_ok = True
                else:
                    next_val = struct.unpack_from("<Q", data, next_pos)[0]
                    if min_ft <= next_val <= max_ft or next_val == 0: # 0はGhost/Emptyの可能性
                        chain_ok = True

            status = "PASS" if (size_match and chain_ok) else "FAIL"
            if status == "FAIL": errors += 1

            dt_str = "N/A"
            if is_valid_time:
                dt = datetime(1601, 1, 1, tzinfo=timezone.utc) + timedelta(microseconds=timestamp // 10)
                dt_str = dt.astimezone(jst).strftime('%Y-%m-%d %H:%M:%S')

            seg_str = "+".join(map(str, seg_list)) if seg_list else "0"
            print(f"{hex(curr):<10} | {status:<10} | {dt_str:<20} | {header_size} + ({seg_str}) = {total_size}")

            if status == "FAIL":
                print(f"    [!] Error Detail: Header({header_size}) + SegsSum({seg_sum}) = {header_size+seg_sum} (Expected: {total_size})")
                print(f"    [!] Hex: {data[curr:curr+16].hex(' ')}")

            pos += total_size
            accounted_bytes += total_size
            total_records += 1

        except Exception as e:
            print(f"{hex(pos):<10} | EXCEPTION  | {e}")
            break

    print("-" * 110)
    coverage = (accounted_bytes / dat_len) * 100
    print(f"[Summary] Total Records: {total_records}, Errors: {errors}, Coverage: {coverage:.2f}%")

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Analyze logical consistency of JpnIHDS.dat')
    parser.add_argument('-i', '--input', required=True, help='Path to JpnIHDS.dat')
    args = parser.parse_args()
    verify_structure(args.input)