マルウェア (malicious software) はウイルス・ワーム・ランサムウェア・スパイウェア等の総称。 データ分析の現場では「ログから既知マルウェア感染を検出」「未知の挙動を異常検知で発見」する応用が一般的。 SSDSE 系の公開データには含まれないが、 公開セキュリティログ(VirusShare 等)と組み合わせて分類器を学習するのが典型。
「分類 → 解析手法 → 検出技術」 がマルウェア対策ワークフローの核。
🍰 まずはやさしく
悪いソフトをまとめた呼び名です。
セキュリティの分析に使います。
スマホやPCを守るために必要です。
重要なポイントを短くまとめます。
ウイルス、 ワーム、 トロイの木馬の総称
🍰 まずはやさしく
悪いソフトを見分ける学習ページです。
特徴から正体を当てる練習をします。
部活の連絡などで使うPCを守ります。
実際に手を動かして分類を学びます。
このページは「セキュリティ」グループ内の「マルウェア(Malware)」項目です。 ウイルス・ワーム・トロイの木馬等の悪意ソフトウェアを「特徴量から確率で検知する」という分類問題として、 実際に手を動かしながら学べます。
関連: サイバーセキュリティ 分類
🍰 まずはやさしく
忍者のように隠れて動くソフトです。
攻撃者が得をするために使います。
PDFを開いた瞬間に動き出します。
データから正体を見抜く方法を学びます。
マルウェアは「意図せず実行されたとたん、 攻撃者の利益のために動き出すプログラム」。 一般のアプリと違って、 (1) 感染経路(メール添付・USB・OS 脆弱性)、 (2) 常駐手段(レジストリ書き換え・サービス登録)、 (3) ペイロード(情報窃取・暗号化身代金・bot 化)の 3 段構成を持つ。 例えばランサムウェアは「PDF を開く → バックグラウンドで全ファイルを AES 暗号化 → 復号鍵と引き換えにビットコイン要求」の流れで、 マシン側から見れば「通常のファイル I/O が爆発的に増える」というシグナル。
統計分析の道具としての マルウェア検知 は、 PE ファイルの 2,381 次元の特徴量(インポート関数・セクション数・エントロピー等)から「良性 / 悪性」を分類する 2 クラス分類問題。 EMBER-2018 のような公開データセットで LightGBM が AUC ≈ 0.99 を出す一方、 packer 変化やゼロデイで concept drift が起きる点が他の分類問題と決定的に違う。
マルウェアを直感で掴むには「体重計の見えない指紋」のメタファが効く。 普通のソフトはディスクに置いてあるだけだが、 マルウェアは 起動時のシステムコール列 ・ ネットワーク先 IP ・ レジストリ書き込みパターン という見えない指紋を残す。 静的解析(実行前にバイナリを読む)と動的解析(実行して挙動を観察)の 2 軸でこの指紋を捕まえるのが現代の検知技術。
検知は 3 層で捉えると整理しやすい。 (1) シグネチャ検知(既知検体のハッシュや YARA ルールとの一致)は高速だが未知株に無力、 (2) 挙動検知(実行時の API 呼び出し列・ファイル I/O・レジストリ操作の異常)はゼロデイにも反応するが誤検知が増える、 (3) ML 検知(静的・動的特徴量から悪性確率を学習)は両者の中間で、 EMBER 等のベンチマークで高い AUC を出す。 実運用では単一手法に頼らず、 この 3 層を組み合わせた多層防御が前提になる。
🍰 まずはやさしく
ウイルスなどの総称(まとめ)です。
ネットの攻撃を防ぐために使います。
メールやUSBからの侵入を防ぎます。
種類や検知のルールを整理して学びます。
ウイルス、 ワーム、 トロイの木馬の総称
英語名 Malware。
マルウェア検知を理解・運用するときは、 次のような前提を意識してください:
検知器は特徴量 x から悪性である確率 p = P(悪性 | x) を出し、閾値 t と比べて「p ≥ t なら悪性と判定」する。見逃し 1 件の損失を CFN、誤警報 1 件の損失を CFP とすると、あるファイルを
$$\text{悪性と判定したときの期待損失} = (1-p)\,C_{FP}, \qquad \text{良性と判定したときの期待損失} = p\,C_{FN}$$なので、悪性と判定する方が得なのは (1 − p) CFP ≤ p CFN、つまり
$$p \;\ge\; t^{*} = \frac{C_{FP}}{C_{FP} + C_{FN}}$$のときになる。見逃しが誤警報の 10 倍痛いなら t* = 1 ÷ 11 ≈ 0.091 で、既定の 0.5 よりずっと低い。ただしこれは p が「本当の確率」になっている(較正されている)ときの話で、🧮 の実験のようにランダムフォレストの投票割合をそのまま使うと、最適な閾値は式からずれる(実験では 0.05)。また p 自体が、評価データと実運用で悪性の割合が違えば変わるので、閾値は実運用に近いデータで決め直す。
「マルウェア」の定式化:
$$P(\text{malware} \mid \mathbf{x}) = \sigma\!\left(\sum_{j=1}^{d} w_j x_j + b\right)$$
PE ファイルから抽出した d 次元静的特徴 $\mathbf{x}$ に対し、 ロジスティック回帰で悪性確率を出す。
| 記号 | 読み方 | 意味 | 例 |
|---|---|---|---|
| $\mathbf{x}$ | エックス(ベクトル) | PE ヘッダ・API 呼び出し・エントロピー等の特徴ベクトル | EMBER で 2,381 次元 |
| $x_j$ | エックス・ジェイ | j 番目の特徴値 | section_entropy = 7.8 |
| $w_j$ | ダブリュー・ジェイ | 特徴 j の重み。 学習で決まる | エントロピー高 → $w_j > 0$ |
| $b$ | ビー | バイアス項。 基準オフセット | $b = -2.3$ |
| $\sigma$ | シグマ(シグモイド) | 線形和を確率 [0,1] へ写像 | $\sigma(0)=0.5$, $\sigma(2)=0.88$ |
| $P(\text{mal} \mid \mathbf{x})$ | ピー・マル・ギブン・エックス | 悪性確率(出力) | 0.92 → 高確度で悪性 |
| 閾値 $\tau$ | タウ | 判定境界。 FN/FP の trade-off | 既定 0.5、 厳しめ 0.3 |
| $d$ | ディー | 特徴次元数 | 静的 2,381 + 動的 N-gram 数万 |
読み下し:「ある PE ファイルの特徴ベクトル $\mathbf{x}$ を入力すると、 各特徴を重み $w_j$ で線形結合し、 シグモイド $\sigma$ で 0〜1 の確率に押し込み、 これが閾値 $\tau$ を超えたら悪性 (malware) と判定する」と読む。 EMBER の 2,381 次元では PE ヘッダ (DOS/COFF/Optional)、 セクションエントロピー、 import 関数名のハッシュ等が $\mathbf{x}$ の中身。
静的解析の特徴量としてよく使うのが、ファイルやセクションのバイトのシャノンエントロピー H = −Σ pi log2 pi で、pi は 256 種類のバイト値それぞれの出現割合。最大は 256 種類が均等に出る 8 ビット/バイト。手で確かめると、4 バイト「A A B C」なら p(A)=0.5、p(B)=p(C)=0.25 なので H = −(0.5×log20.5 + 2×0.25×log20.25) = 0.5 + 1.0 = 1.5 ビット/バイト。パッカーで圧縮・暗号化された本体はどのバイト値もほぼ均等に現れるので 8 に近づき、これが「エントロピーが 7 を超えるセクションは疑え」という経験則の理由になる。
🎯 このコードでやること:実在のファイルとして SSDSE-B-2026.csv の先頭 20,000 バイトを使い、そのまま・zlib で圧縮・乱数バイト列・0x00 の繰り返しの 4 つのエントロピーを求める。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | import math, zlib import numpy as np from collections import Counter def entropy(b: bytes) -> float: """バイト列のシャノンエントロピー H = −Σ p_i log2 p_i(ビット/バイト、最大 8)""" n = len(b) return sum(-c / n * math.log2(c / n) for c in Counter(b).values()) # (a) ふつうのデータ:SSDSE-B-2026.csv の先頭 20,000 バイト(数字・カンマ・cp932 の日本語) a = open('data/raw/SSDSE-B-2026.csv', 'rb').read()[:20000] # (b) 同じバイト列を zlib で圧縮(パッカーが本体を圧縮して隠すのに近い) b = zlib.compress(a, 9) # (c) 乱数バイト列(暗号化されたデータに近い)。seed 固定 c = np.random.default_rng(0).integers(0, 256, size=20000, dtype=np.uint8).tobytes() # (d) 0x00 の繰り返し(ゼロ埋めされた領域) d = bytes(20000) for name, x in [('(a) CSV そのまま', a), ('(b) CSV を zlib 圧縮', b), ('(c) 乱数バイト列', c), ('(d) 0x00 の繰り返し', d)]: print(f'{name:16} {len(x):6d} バイト 種類 {len(set(x)):3d} エントロピー = {entropy(x):.3f} ビット/バイト') |
💬 CSV そのままは 161 種類のバイト値しか使わず 4.227 ビット/バイトだが、同じ内容を zlib で圧縮すると 256 種類すべてが現れ 7.977 と、乱数(7.992)とほとんど区別がつかない。0x00 だけの領域は 0。エントロピーが高いこと自体は「圧縮または暗号化されている」ことしか示さず、正規のインストーラや画像・動画も高くなるので、単独では悪性の証拠にならない。他の特徴(インポート関数の少なさ、セクション名など)と組み合わせて使う。
マルウェア検知は「PE ファイルや実行ログから抽出した特徴量 → 悪性 / 良性ラベル」の分類問題として定式化できる。 ここでは検知の中核となる評価指標(検知率・誤検知率)を、 AV エンジンの試行結果(合成データ)で手を動かして体感する。 人口統計のような社会統計はセキュリティ検知の題材にはならないため、 本ページではマルウェア検知固有のデータ構造で計算する。
※具体的な製品の検知率や処理件数は、 各社の公開資料で条件(対象データ・期間・評価方法)を確かめてから引用すること。 条件の違う数値どうしは比べられない。
| 手法 | 入力 | 代表アルゴリズム | 特徴 |
|---|---|---|---|
| シグネチャ検知 | ハッシュ/正規表現 | 既知株のみ | 速いがゼロデイ無力 |
| ヒューリスティック | ルールベース | 亜種に弱い | 誤検知多め |
| 静的 ML | PE 構造 + GBDT | 実行不要 | パッカに弱い |
| 動的 ML | Sandbox 実行ログ + RNN | 評価コスト高 | パッカ突破可 |
| 深層学習 (CNN) | バイナリを画像化 → ResNet | 大量の学習データが要る | 解釈が難 |
| YARA + ML | ハイブリッド | プロのチューニング | 高精度・高運用負荷 |
合成データで AV エンジンの検知精度を計算する。
| 区分 | 件数 |
|---|---|
| 正常→正常 (TN) | 9,900 |
| 正常→マル (FP) | 20 |
| マル→マル (TP) | 80 |
| マル→正常 (FN) | 10 |
1 2 3 4 5 6 7 | tp, fn, fp, tn = 80, 10, 20, 9900 rec = tp/(tp+fn) fpr = fp/(fp+tn) acc = (tp+tn)/(tp+fn+fp+tn) print(f"Recall: {rec:.3f}") print(f"FPR: {fpr:.4f}") print(f"Accuracy: {acc:.4f}") |
💬 手計算 (Step 2) と Python 出力が完全一致。
上の試行では 10,010 件中マルウェアが 90 件(0.9%)で、 警報 100 件(TP 80 + FP 20)のうち本物は 80 件、 つまり適合率は 0.80 だった。 検知率 0.889 と誤検知率 0.0020 は検知器の性質なので固定し、 検査対象に占めるマルウェアの割合 π だけを変えると、 適合率 = 検知率 × π ÷ (検知率 × π + 誤検知率 × (1 − π)) は大きく動く。

💬 実運用ではマルウェアはごく一部なので、 検知率の高さより誤検知率の低さが警報の信頼性を決める。 評価用データでマルウェアの割合を高めにしてあると適合率は実運用より良く見えるので、 報告するときは評価データでの割合も書き添える。
⚠️ のファミリ単位の実験(架空データ)で、訓練に無いファミリに対して出した悪性確率を使い、見逃し(FN)1 件の損失を誤警報(FP)1 件の 10 倍と仮定したときの期待コスト 10 × FN + 1 × FP を、閾値ごとに手で計算する。
| 閾値 | TP | FN | FP | TN | 期待コスト = 10 × FN + 1 × FP |
|---|---|---|---|---|---|
| 0.1 | 1,091 | 109 | 963 | 2,037 | 10 × 109 + 963 = 2,053 |
| 0.2 | 1,001 | 199 | 492 | 2,508 | 10 × 199 + 492 = 2,482 |
| 0.3 | 855 | 345 | 281 | 2,719 | 10 × 345 + 281 = 3,731 |
| 0.5 | 622 | 578 | 90 | 2,910 | 10 × 578 + 90 = 5,870 |
| 0.7 | 314 | 886 | 15 | 2,985 | 10 × 886 + 15 = 8,875 |
既定の閾値 0.5 は誤警報が 90 件と少ないが、見逃しが 578 件あり、期待コストは 5,870。閾値を 0.1 に下げると誤警報は 963 件に増えるが、見逃しが 109 件まで減るので、コストは 2,053 と 3 分の 1 近くになる。下のコードで同じ表を再現し、0.01 刻みで最小の閾値を探す。
🎯 このコードでやること:上の表を Python で再現し、閾値を 0.01 刻みで動かして期待コストが最小になる閾値を探す。確率が正しく較正されているときの理論上の目安 C_FP ÷ (C_FP + C_FN) とも比べる。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | import numpy as np from sklearn.ensemble import RandomForestClassifier from sklearn.model_selection import GroupKFold, cross_val_predict # mw_family.py と同じ架空(合成)データ(60 ファミリ × 20 亜種 + 良性 3,000 件) rng = np.random.default_rng(0) n_fam, per_fam, n_benign, d = 60, 20, 3000, 8 centers = rng.normal(0.8, 1.0, size=(n_fam, d)) X_mal = np.vstack([c + rng.normal(0, 0.35, size=(per_fam, d)) for c in centers]) X = np.vstack([X_mal, rng.normal(0, 1.0, size=(n_benign, d))]) y = np.r_[np.ones(n_fam * per_fam), np.zeros(n_benign)] groups = np.r_[np.repeat(np.arange(n_fam), per_fam), n_fam + np.arange(n_benign)] # 未知ファミリに対する悪性確率(ファミリ単位の 5 分割で、テスト側だけを予測) p = cross_val_predict(RandomForestClassifier(n_estimators=300, random_state=0, n_jobs=-1), X, y, cv=GroupKFold(5), groups=groups, method='predict_proba')[:, 1] C_FN, C_FP = 10, 1 # 見逃し 1 件は誤警報 10 件分の損失、と仮定 print(' 閾値 TP FN FP TN 期待コスト = 10×FN + 1×FP') for t in [0.1, 0.2, 0.3, 0.5, 0.7]: pred = p >= t tp = int(((pred == 1) & (y == 1)).sum()); fn = int(((pred == 0) & (y == 1)).sum()) fp = int(((pred == 1) & (y == 0)).sum()); tn = int(((pred == 0) & (y == 0)).sum()) print(f' {t:.1f} {tp:5d} {fn:5d} {fp:5d} {tn:5d} {C_FN * fn + C_FP * fp:6d}') ts = np.linspace(0.01, 0.99, 99) cost = [C_FN * ((p < t) & (y == 1)).sum() + C_FP * ((p >= t) & (y == 0)).sum() for t in ts] print(f'0.01 刻みで探した期待コスト最小の閾値 = {ts[int(np.argmin(cost))]:.2f}(コスト {min(cost)})') print(f'理論上の目安 C_FP / (C_FP + C_FN) = {C_FP / (C_FP + C_FN):.3f}') |
💬 手計算の表と同じ TP・FN・FP・TN とコスト(0.1 で 2,053、0.5 で 5,870)が出た。0.01 刻みで探すと最小は閾値 0.05(コスト 1,918)で、理論上の目安 1 ÷ 11 ≈ 0.091 より低い。ランダムフォレストの確率は木の投票割合で、較正されていないため式どおりにはならない。閾値は 0.5 に固定せず、コストの比と実データでの件数から決め、評価データの悪性の割合(ここでは 29%)が実運用(ごく一部)と違うことも考えて、実運用に近い割合で決め直す。
マルウェア検知は本質的に 2 クラス分類問題(良性 / 悪性)です。 ここでは架空のファイル/プロセスを 2 次元の特徴空間 に散布して、 2 つの検知戦略を切り替えながら「検出率」と「誤検知(良性を悪性と誤る)」のトレードオフを体感します。 これは防御・検知教育のための可視化であり、 攻撃手法は一切扱いません。
※ データはすべて架空(合成)です。 実在の検体・攻撃手順とは無関係で、 シード固定(seed=20260614)で決定的に生成しています。 71 点=良性 41・悪性 30(うち未知の亜種 8 は既知シグネチャの外側に配置)。
| 指標 | 値 | 意味 |
|---|---|---|
| 検出率 / 再現率 (Recall) | – | 悪性のうち捕まえた割合 TP/(TP+FN) |
| 適合率 (Precision) | – | 悪性判定のうち本当に悪性 TP/(TP+FP) |
| 誤検知率 (FPR) | – | 良性を悪性と誤る割合 FP/(FP+TN) |
| 未知の亜種 検出 | – | 未知 8 点のうち検出できた数 |
| 混同行列 (TP/FP/FN/TN) | – | 検出の内訳 |
シグネチャ検知=「指名手配写真との一致」。 既に知られた顔(既知ファミリの領域)にピタリ一致した点だけを捕まえます。 誤認逮捕(誤検知)はほぼゼロですが、 写真に無い顔=未知の亜種は素通り。 上のグラフでシグネチャ方式に切り替えると、 オレンジの未知の亜種 8 点は 1 つも枠が付かないことが確認できます(検出 0/8)。 いっぽう 異常検知=「挙動の不審さ」。 良性の普段の振る舞い(中心付近の塊)から離れた点を怪しむので、 未知の亜種も距離で捕まえられます。 ただし「重い正規ソフト」のような紛らわしい良性まで巻き込んで誤検知が発生します。
現実の検知は 2 方式の二択ではなく多層防御です。 (1) 機械学習型検知:多数の特徴量から悪性確率を学習し、 シグネチャと異常検知の中間の柔軟さを得る(本ページ上部の 定義・Python 実装を参照)。 (2) サンドボックス:隔離環境で実際の挙動を観測し、 静的特徴では見えない振る舞いを異常検知に供給。 (3) 多層防御:シグネチャ(高速・低誤検知)で既知を捌き、 異常検知/ML で未知を拾い、 人による最終確認でトレードオフの穴を埋める。 分類の枠組みとしては 分類、 未知検出の枠組みとしては 外れ値検出が土台になります。
🎮 のデモの「シグネチャ vs 異常検知」を、学習する検知器どうしで数値にする。教師あり学習は「訓練で見た悪性に似ているか」を学ぶので、既知のファミリと違う方向に外れた新ファミリは良性側に入ってしまう。良性だけで「普段の姿」を学ぶ異常検知は、方向を問わず外れたものを拾うが、その代わり良性の外れ値も拾う。
🎯 このコードでやること:架空の特徴量 8 次元で、既知の悪性ファミリ(平均 +2.5)と良性で学習したランダムフォレストと、良性だけで学習した IsolationForest(上位 1% を異常とする)を、訓練に無い新ファミリ(平均 −2.5)と新しい良性 3,000 件に当てる。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | import numpy as np from sklearn.ensemble import RandomForestClassifier, IsolationForest # 架空(合成)データ:既知の悪性ファミリは「+方向」に、未知の新ファミリは「−方向」に外れている rng = np.random.default_rng(3) d = 8 benign_tr = rng.normal(0, 1, size=(3000, d)) known_mal = rng.normal(2.5, 0.6, size=(600, d)) # 訓練に使える既知ファミリ benign_te = rng.normal(0, 1, size=(3000, d)) new_mal = rng.normal(-2.5, 0.6, size=(100, d)) # 訓練に無い新ファミリ(別の方向に異常) X_tr = np.vstack([benign_tr, known_mal]); y_tr = np.r_[np.zeros(3000), np.ones(600)] rf = RandomForestClassifier(n_estimators=300, random_state=0, n_jobs=-1).fit(X_tr, y_tr) iso = IsolationForest(n_estimators=300, contamination=0.01, random_state=0).fit(benign_tr) # 良性だけで学習 for name, flag in [('教師あり(RF, 閾値 0.5)', lambda X: rf.predict_proba(X)[:, 1] >= 0.5), ('異常検知(IsolationForest)', lambda X: iso.predict(X) == -1)]: print(f'{name:22} 新ファミリの検知率 = {flag(new_mal).mean():.2f} ' f'良性の誤検知率 = {flag(benign_te).mean():.3f}({int(flag(benign_te).sum())} / 3000 件)') |
💬 教師ありのランダムフォレストは良性の誤検知が 0 件だが、新ファミリを 1 件も検知できない(検知率 0.00)。良性だけで学習した IsolationForest は新ファミリを 100 件すべて拾う(1.00)代わりに、良性 3,000 件のうち 34 件(1.1%)を誤検知する。これは contamination=0.01 で「良性の上位 1% を異常」と決めた設定どおりの誤検知率で、実運用で良性が 1 日 100 万件あれば約 1 万件の誤警報になる。既知の脅威は教師あり、未知は異常検知で拾い、異常検知の警報は人やサンドボックスで確かめる、という多層防御の分担はこの性質の違いから来ている。
マルウェア検知では、 まず特徴量を抽出し、 分類器で悪性確率を推定して閾値で判定する。 静的解析(PE ヘッダ・エントロピー・import 関数)と動的解析(API 呼び出し列・通信ログ)を組み合わせ、 LightGBM などの勾配ブースティングで学習するのが実務の標準パターンである。
🎯 このコードでやること:SSDSE-B-2026.csv の先頭 20,000 バイトを「既知の検体」に見立ててその SHA-256 をシグネチャ DB に登録し、最後の 1 ビットだけ反転したもの・末尾に 16 バイト足しただけのものが DB に一致するかを調べる。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | import hashlib # 実在のファイルとして SSDSE-B-2026.csv の先頭 20,000 バイトを「既知の検体」に見立てる orig = open('data/raw/SSDSE-B-2026.csv', 'rb').read()[:20000] variant = bytearray(orig) variant[-1] ^= 0x01 # 最後の 1 バイトの最下位 1 ビットだけを反転した「亜種」 variant = bytes(variant) appended = orig + b'\x00' * 16 # 末尾に 0x00 を 16 バイト足しただけの「亜種」 known = {hashlib.sha256(orig).hexdigest()} # シグネチャ DB(既知検体のハッシュ) for name, x in [('元のファイル', orig), ('1 ビット反転', variant), ('末尾に 16 バイト追加', appended)]: h = hashlib.sha256(x).hexdigest() diff_bits = bin(int(h, 16) ^ int(hashlib.sha256(orig).hexdigest(), 16)).count('1') same = sum(p == q for p, q in zip(x, orig)) print(f'{name:10} 一致バイト {same:5d}/{len(orig)} SHA-256 先頭 16 桁 {h[:16]} ' f'元と異なるビット {diff_bits:3d}/256 シグネチャ一致: {h in known}') |
💬 中身が 20,000 バイト中 19,999 バイト一致している亜種でも、SHA-256 は 256 ビット中 114 ビットが変わり、シグネチャ DB には一致しない。末尾に 16 バイト足しただけの亜種も 149 ビットが変わる。ハッシュは「1 ビットでも違えば全く別の値になる」ように作られているので、既知の検体そのものには確実に当たる一方、1 バイト書き換えた亜種には無力になる(⚠️ の「シグネチャ検知のみに依存」)。そこで、似たファイルほど近い値になるファジーハッシュや、YARA のように特徴的な部分文字列を照合するルール、特徴量から学習する機械学習で亜種を拾う。
| 年 | 名前 | 何が起きたか |
|---|---|---|
| 1971 | Creeper | ARPANET 上を移動してメッセージを表示する実験的な自己複製プログラム。 これを消す Reaper も作られた。 |
| 1986 | Brain | IBM PC 互換機のフロッピーディスクのブートセクタに感染するウイルス。 PC 向けの初期の例としてよく挙げられる。 |
| 1988 | Morris ワーム | UNIX の脆弱性を突いてインターネット上で自己増殖し、 多数の計算機を停止させた。 CERT/CC 設立のきっかけになった。 |
| 1999-2000 | Melissa・ILOVEYOU | メールの添付ファイルとアドレス帳を使って大規模に拡散。 メール経由の感染が主流になる。 |
| 2001-2003 | Code Red・SQL Slammer・Blaster | サーバや Windows の脆弱性を突くワームが短時間で世界中に広がる。 パッチ適用の遅れが被害を広げた。 |
| 2010 | Stuxnet | 制御システム(PLC)を標的にしたマルウェアとして知られ、 物理的な設備への攻撃が現実の脅威になった。 |
| 2013- | ランサムウェアの本格化 | CryptoLocker など、 ファイルを暗号化して身代金を要求する手口が広がる。 |
| 2016 | Mirai | ルーターや監視カメラなどの IoT 機器を初期パスワードで乗っ取り、 大規模な DDoS 攻撃に使った。 |
| 2017 | WannaCry・NotPetya | SMB の脆弱性(EternalBlue)を使って自己増殖するランサムウェア・破壊型マルウェアが世界的な被害を出した。 |
| 2019- | Emotet など | メールで広がるマルウェアが日本でも繰り返し流行し、 JPCERT/CC などが注意喚起を出した。 |
感染経路は「フロッピーディスク → メール添付 → ネットワーク上の脆弱性 → IoT 機器」と移り、 目的も「いたずら・実験」から「金銭(ランサムウェア)」「設備の破壊」へと変わってきた。 検知の側も、 既知の検体と一致するかを見るシグネチャ方式から、 未知の検体を振る舞いや特徴量で見分ける機械学習・異常検知へ広がっている。
🎯 やること:precision-recall トレードオフ
📥 入力:合成スコア列(synthetic)
1 2 3 4 5 6 7 8 9 10 11 | import numpy as np scores = np.array([0.1,0.3,0.5,0.7,0.9]) labels = np.array([0,0,1,1,1]) for th in [0.3,0.5,0.7]: pred = (scores>=th).astype(int) tp = ((pred==1) & (labels==1)).sum() fp = ((pred==1) & (labels==0)).sum() fn = ((pred==0) & (labels==1)).sum() prec = tp/(tp+fp) rec = tp/(tp+fn) print(f'th={th} TP={tp} FP={fp} FN={fn} precision={prec:.2f} recall={rec:.2f}') |
📤 実行結果:
💬 解釈:閾値 0.3 では良性のスコア 0.3 まで拾って FP=1(precision 0.75)、0.7 に上げると FP は 0 になる代わりにスコア 0.5 の悪性を見逃して recall が 0.67 に落ちる。この 5 件では 0.5 が両方 1.00 だが、実データでは良性と悪性のスコアが重なるので、見逃し(FN)を恐れるマルウェア検知では低めの閾値が好まれる。
マルウェア (Malware) はウイルス、 ワーム、 トロイの木馬、 ランサムウェアなど、 悪意を持って設計されたソフトウェアの総称です。 検知は「特徴量 → ラベル (悪性 / 良性)」の分類問題として定式化され、 統計学・機械学習の応用先として急速に発展しています。 ここでは、 マルウェア検知の理論的基盤、 特徴量設計、 検知精度の評価、 そして実務での落とし穴を整理します。
検知器は確率モデル $P(\text{悪性} \mid \mathbf{x}) = \sigma(w^\top \mathbf{x} + b)$ で記述され、 ロジスティック回帰や深層学習でこの確率を推定します。 特徴ベクトル $\mathbf{x}$ には PE ヘッダ情報・セクションエントロピー・import 関数・API 呼び出し列などを用います。 学習の実務では、 混同行列から検知率 (recall)・誤検知率 (FPR)・PR-AUC を求め、 不均衡データ・閾値選択・偽陽性 / 偽陰性のトレードオフを体感するのが基本です。
マルウェア検知の精度は、 特徴量設計に大きく左右されます。 静的解析特徴 (PE ヘッダ情報, 関数呼び出しグラフ, 文字列) と動的解析特徴 (システムコール列, ネットワーク通信) を組み合わせると検知率が大幅に上がります。 高次元の特徴空間を扱うため、 「特徴量選択 → モデルフィット → 評価」のワークフローを丁寧に回すことが重要です。
実環境では悪性サンプルが全体の 1% 未満ということが普通で、 この強い不均衡が分類精度を大きく歪めます。 対処法は (1) SMOTE などのオーバーサンプリング (2) クラス重み付け (class_weight='balanced') (3) 評価指標を accuracy から F1, AUC, PR-AUC に変更、 などが定番です。
マルウェア検知で最も致命的なのは「悪性を良性と判定する」偽陰性です。 そのため recall (再現率) を重視し、 偽陽性を多少増やしてでも見逃しを減らす運用が標準的です。 混同行列を出力してみると、 「precision と recall のトレードオフ」「閾値を下げると recall は上がるが precision は下がる」関係を視覚的に確認できます。
攻撃者は検知をかわすために「難読化」「コードパッキング」「環境チェックで解析環境では実行しない」など多様な技を使います。 攻撃者と防御者のゲームを意識した分析設計が重要です。 敵対的機械学習 (adversarial ML) は、 マルウェア検知における重要研究テーマです。
未知のマルウェア (ゼロデイ) は教師あり学習で対応が難しいため、 異常検知 (autoencoder, isolation forest) が補完手段として使われます。 正常なプロセス群からマハラノビス距離で外れ値を検出する手法は、 未知マルウェア検知の入門として教育的価値が高いです。 実マルウェア検知でも、 「正常な挙動と乖離するプロセスをアラート」する考え方が同じです。
マルウェアは日々新しいバリアントが現れるため、 検知器も継続的に再学習する必要があります。 データ分布が時間とともに変わる「データドリフト」により、 過去のモデルが現在のデータに当てはまらなくなる現象が起きます。 MLOps の枠組みでモデル性能を監視し、 必要に応じて再学習する仕組みは、 マルウェア検知でも統計分析でも共通の課題です。
VirusShare, MalwareBazaar, EMBER, Microsoft Malware Classification Challenge などが代表的データセットです。 教育目的で実マルウェア検体を扱うのは法的に注意が必要なため、 まずは EMBER のような「抽出済み特徴量」の公開データセットで「特徴量と分類問題」のフレームを学ぶのが安全です。 そのあと実検体解析へ段階的に進むと無理がありません。
| シナリオ | 具体例 | 注意点 |
|---|---|---|
| 教師あり検知 | PE 静的特徴からロジスティック回帰で悪性確率を推定 | 不均衡データの扱いが鍵 |
| 異常検知 | 正常プロセスのプロファイルからマハラノビス距離で外れ値検出 | 閾値選択が難しい |
| 深層学習検知 | 数百次元の特徴量から CNN/RNN で検知 | 解釈性と訓練データ量がトレードオフ |
| ルールベース | 既知ハッシュ / YARA など固定ルールで分類 | 未知変種に弱い |
| アンサンブル | 複数モデルを組み合わせて投票 | 計算コストが上がるが頑健 |
Q1. accuracy だけでマルウェア検知を評価できますか?
A1. できません。 不均衡データでは「全部良性と予測」しても 99% accuracy になります。 F1, AUC, PR-AUC を併用するのが標準です。
Q2. 閾値はどう決めますか?
A2. ROC 曲線上で recall を優先するなら左寄り (閾値を下げる)、 precision を優先するなら右寄り (閾値を上げる)。 業務要件で決めるのが原則です。
Q3. 不均衡データへの SMOTE の弱点は?
A3. 高次元では合成サンプルが意味を持ちにくく、 過学習しやすいです。 マルウェアでは静的解析の特徴量が高次元になりがちなので、 慎重な評価が必要です。
Q4. 異常検知と分類検知のどちらが優れていますか?
A4. 既知のマルウェアには分類検知、 未知のマルウェア (ゼロデイ) には異常検知が向きます。 実運用では両者を併用するのが標準です。
Q5. 実マルウェアを扱わずに検知の勉強はできますか?
A5. EMBER や SOREL-20M のような「抽出済み特徴量」の公開データセットを使えば、 実検体を直接扱わずに「不均衡データの分類」「閾値選択」「混同行列の読み方」など検知の基礎原理を安全に練習できます。
マルウェア検知は「特徴量 → ラベル」の分類問題として定式化でき、 統計学・機械学習・データサイエンスの応用として中核的な位置を占めます。 検知の実践では、 不均衡データの扱い、 閾値選択、 混同行列の読み方、 異常検知の発想などがコアスキルになります。 実際のマルウェア検知では、 特徴量設計、 ゼロデイへの対応、 敵対的環境での運用、 MLOps による継続的再学習など、 多くの追加要件がありますが、 基礎は本ページの内容で押さえられます。
マルウェア対策を説明する時は、 種類、侵入経路、検知方法、隔離、復旧、再発防止を分けます。 単に危険性を述べるだけでなく、 ログ、バックアップ、権限管理、更新管理を具体策として結びつけます。
マルウェアは 1 つのファミリから少しずつ違う亜種が大量に作られる。行ごとにシャッフルして分割すると、テストに入った亜種の「兄弟」が訓練にいるので、未知のファミリに対する実力より高いスコアが出る。SSDSE にはマルウェアのデータが無いので、ファミリ構造を持つ架空(合成)の特徴量で分け方だけを変えて比べる。
🎯 このコードでやること:架空の静的特徴量 8 次元(悪性 60 ファミリ × 20 亜種 = 1,200 件、良性 3,000 件、seed 固定)にランダムフォレストを当て、行をシャッフルした 5 分割と、ファミリ単位の 5 分割(GroupKFold)の AUC を比べる。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | import numpy as np from sklearn.ensemble import RandomForestClassifier from sklearn.model_selection import StratifiedKFold, GroupKFold, cross_val_score # 架空(合成)の静的特徴量:実在の検体とは無関係。seed 固定で決定的に生成 rng = np.random.default_rng(0) n_fam, per_fam, n_benign, d = 60, 20, 3000, 8 centers = rng.normal(0.8, 1.0, size=(n_fam, d)) # ファミリごとの特徴の中心 X_mal = np.vstack([c + rng.normal(0, 0.35, size=(per_fam, d)) for c in centers]) fam = np.repeat(np.arange(n_fam), per_fam) # 亜種がどのファミリか X_ben = rng.normal(0, 1.0, size=(n_benign, d)) X = np.vstack([X_mal, X_ben]) y = np.r_[np.ones(len(X_mal)), np.zeros(n_benign)] groups = np.r_[fam, n_fam + np.arange(n_benign)] # 良性は 1 件ずつ別グループ print('悪性', int(y.sum()), '件(', n_fam, 'ファミリ × ', per_fam, '亜種) / 良性', n_benign, '件') rf = RandomForestClassifier(n_estimators=300, random_state=0, n_jobs=-1) auc_row = cross_val_score(rf, X, y, cv=StratifiedKFold(5, shuffle=True, random_state=0), scoring='roc_auc') auc_fam = cross_val_score(rf, X, y, cv=GroupKFold(5), groups=groups, scoring='roc_auc') print(f'行をシャッフルして 5 分割(同じファミリが訓練とテストに分かれる) AUC = {auc_row.mean():.3f}') print(f'ファミリ単位で 5 分割(テストのファミリは訓練に無い) AUC = {auc_fam.mean():.3f}') |
💬 行をシャッフルすると AUC 0.981、ファミリ単位に分けると 0.908 で、差の 0.073 は「同じファミリの兄弟を訓練で見ていた」ことによる水増しである。本番で問題になるのは訓練に無いファミリなので、報告すべきは 0.908 の側になる。実データでも、ハッシュが違うだけの亜種や同じパッカーで包んだ検体は、ファミリ名・類似度で束ねてから分割する(🧩 の「ファミリ単位のリーク」)。
攻撃者は検知を逃れるように手口を変えるので、過去の検体で作った検知器は未来の検体で成績が落ちる。架空(合成)のデータで、毎月新しいファミリが現れ、月が進むほど特徴が良性に似せられていく状況を作り、学習したままのモデルと毎月再学習するモデルを比べる。
🎯 このコードでやること:1〜12 月の架空データ(毎月 悪性 10 ファミリ × 20 亜種 = 200 件と良性 500 件)で、1〜6 月だけで学習したモデルと、前月までの全データで毎月学習し直すモデルの、7〜12 月の AUC と検知率(閾値 0.5)を並べる。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | import numpy as np from sklearn.ensemble import RandomForestClassifier from sklearn.metrics import roc_auc_score, recall_score # 架空(合成)データ:毎月新しいファミリが現れ、月が進むほど特徴が良性に似せられていく rng = np.random.default_rng(1) d, months = 8, 12 rows = [] for m in range(1, months + 1): shift = 1.2 - 0.08 * m # 悪性の中心が月ごとに良性(0)へ近づく for _ in range(10): # 毎月 10 ファミリ × 20 亜種 c = rng.normal(shift, 0.6, size=d) rows += [(m, 1, c + rng.normal(0, 0.35, size=d)) for _ in range(20)] rows += [(m, 0, rng.normal(0, 1.0, size=d)) for _ in range(500)] month = np.array([r[0] for r in rows]); y = np.array([r[1] for r in rows]); X = np.array([r[2] for r in rows]) from sklearn.model_selection import StratifiedKFold, cross_val_score rf = RandomForestClassifier(n_estimators=300, random_state=0, n_jobs=-1) in_cv = cross_val_score(rf, X[month <= 6], y[month <= 6], cv=StratifiedKFold(5, shuffle=True, random_state=0), scoring='roc_auc') print(f'1〜6 月の中で行をシャッフルした 5 分割 AUC = {in_cv.mean():.3f}(学習期間の中だけの評価)') fixed = rf.fit(X[month <= 6], y[month <= 6]) print('月 | 1〜6 月で学習したまま | 前月までの全データで毎月再学習') for m in range(7, months + 1): te = month == m p0 = fixed.predict_proba(X[te])[:, 1] upd = RandomForestClassifier(n_estimators=300, random_state=0, n_jobs=-1).fit(X[month < m], y[month < m]) p1 = upd.predict_proba(X[te])[:, 1] print(f'{m:2d} | AUC {roc_auc_score(y[te], p0):.3f} 検知率 {recall_score(y[te], p0 >= 0.5):.2f} ' f'| AUC {roc_auc_score(y[te], p1):.3f} 検知率 {recall_score(y[te], p1 >= 0.5):.2f}') |
💬 学習期間の中で行をシャッフルした評価では AUC 0.992 と完璧に近いのに、学習したままのモデルは 7 月の 0.938 から 12 月の 0.601 まで下がり、検知率は 0.52 から 0.04 になる。毎月再学習すると 12 月でも AUC 0.791・検知率 0.17 を保つが、それでも 7 月より低い。攻撃側が良性に寄せてくる限り、再学習は劣化を遅らせるだけで止めはしないので、動的解析や人の判断を組み合わせる。評価は必ず「過去で学習して未来でテスト」の時間分割で行い、学習期間内のシャッフル評価(0.992)を性能として報告しない。
🎯 このコードでやること:良性と悪性のスコア分布を固定した架空の検知器を、悪性の割合が 30%・1%・0.1% の検査対象に当て、ROC-AUC と PR-AUC(平均適合率)を比べる。
1 2 3 4 5 6 7 8 9 10 11 12 13 | import numpy as np from sklearn.metrics import roc_auc_score, average_precision_score # 架空(合成)の検知スコア:同じ検知器(悪性と良性のスコア分布は固定)を、悪性の割合だけ変えた検査対象に当てる rng = np.random.default_rng(0) n_benign = 200_000 s_benign = rng.normal(0.0, 1.0, n_benign) # 良性のスコア for rate in [0.30, 0.01, 0.001]: n_mal = int(round(n_benign * rate / (1 - rate))) s_mal = rng.normal(2.5, 1.0, n_mal) # 悪性のスコア(良性より 2.5 高い) s = np.r_[s_mal, s_benign]; y = np.r_[np.ones(n_mal), np.zeros(n_benign)] print(f'悪性の割合 {rate:6.1%}(悪性 {n_mal:6d} 件) ROC-AUC = {roc_auc_score(y, s):.3f} ' f'PR-AUC(平均適合率)= {average_precision_score(y, s):.3f}') |
💬 同じ検知器なので ROC-AUC は 0.961〜0.967 とほとんど変わらないが、PR-AUC は 30% で 0.924、1% で 0.478、0.1% で 0.180 まで下がる。ROC 曲線の横軸の誤検知率は「良性のうち何割を誤ったか」で、良性の件数が何倍になっても変わらないのに対し、適合率は誤警報の件数そのもので決まるからである(🧮 の基準率の節と同じ仕組み)。評価用に悪性を多めに集めたデータで ROC-AUC 0.96 と報告しても、実運用で警報をどれだけ信じてよいかは分からないので、実運用に近い割合での PR-AUC か適合率を併せて示す。
本セクションは既存の「直感/落とし穴/発展」を壊さず補う追記です。 上の 5 つの落とし穴(シグネチャ依存・クラス不均衡・concept drift・敵対的攻撃・人的要因)とは重複しない別角度だけを簡潔にまとめます。 数値例はすべて架空(合成)の説明用で、 実在の検体・データセットの実測値ではありません(マルウェアは SSDSE には含まれないため、 公的統計の捏造は行いません)。
上部の「直感」は検知の仕組み(3 層防御)を説明しました。 ここでは別の視点としてコストの非対称性を足します。 マルウェア検知の目的は正解率を上げることではなく、 被害の期待コストを最小化することです。 見逃し(FN)は 1 件で「全社ランサム暗号化・情報流出」という桁違いの損失を生む一方、 誤検知(FP)は「アナリストが数分かけて無害と確認する」コストで済むことが多い。 両者のコストが非対称だからこそ、 実務ではわざと誤検知寄りに閾値を倒すのが合理的です。 メタファは空港の手荷物検査で、 爆発物の見逃しコストが甚大なので、 多少の手作業検査(誤検知)を許容して検出側に倒します。 この「期待コスト最小化」の発想は 分類の閾値選択そのもので、 適合率と再現率のどちらを優先するかをビジネス上のコストで決めるという一段深い理解につながります。
既存の 5 件は主に「モデルと攻撃者」の話でした。 以下は主に「評価とデータの作り方」で足をすくわれる、 見落とされがちな別系統の罠です。
マルウェアを中心に、 ウイルス・ワーム・トロイ・ランサムウェア・スパイウェアの分類と、 検知 (signature / behavior / ML)・防御 (EDR / WAF)・対応 (SOC / IR) の三層を整理した概念マップ。
マルウェアの分類 (ウイルス / ワーム / ランサムウェア / RAT) は、 増殖方式・ペイロード・C2 通信の有無で区分される。 検出は MITRE ATT&CK のテクニック ID に対応付けて報告する。
マルウェア対策は単一の検知器では完結しない。 上流のエンドポイント監視 (EDR) と通信ログ、 並列のシグネチャマッチ + 振る舞い分析 + ML 分類器、 下流のインシデント対応 (SOC) を多層で組み合わせる多層防御が前提となる。
実データではマルウェア検体特徴量 (API call 列・PE ヘッダ・エントロピー) を入力に、 ML で benign/malicious 分類して accuracy/recall を測り、 検知後は隔離 → 解析 → 再発防止策まで連動させるのが業界標準。
🌳 「マルウェア」の概念ツリー
├── 上位概念
│ ├── 情報セキュリティ
│ │ ├── 機密性 (Confidentiality)
│ │ ├── 完全性 (Integrity)
│ │ └── 可用性 (Availability)
│ └── サイバー攻撃 (cyber-attack)
│ ├── 受動的攻撃 (盗聴・トラフィック解析)
│ ├── 能動的攻撃 (改ざん・なりすまし)
│ └── ソーシャルエンジニアリング (フィッシング)
├── 並列概念(同レベル攻撃手法)
│ ├── DDoS (分散型サービス妨害)
│ ├── ゼロデイ攻撃 (パッチ未公開脆弱性)
│ └── サプライチェーン攻撃
├── マルウェア 本体(種別)
│ ├── 自己複製型
│ │ ├── ウイルス (宿主必要)
│ │ ├── ワーム (単独で増殖)
│ │ └── ボット (C2 サーバ制御)
│ ├── 隠蔽型
│ │ ├── トロイの木馬 (有用ソフト偽装)
│ │ ├── ルートキット (OS 深層に常駐)
│ │ └── ファイルレス (メモリ常駐)
│ └── 経済目的型
│ ├── ランサムウェア (身代金要求)
│ ├── スパイウェア / キーロガー
│ └── クリプトジャッキング (採掘乗っ取り)
└── 下位/発展概念(防御・解析)
├── アンチウイルス / EDR / XDR
├── サンドボックス動的解析 (Cuckoo)
├── YARA ルール / シグネチャ DB
├── ML ベース検知 (n-gram + Random Forest)
└── MITRE ATT&CK フレームワーク
マルウェア対策の手法選択は、 (1) 既知/未知の判別、 (2) 検出と防御の段階、 (3) リソース制約、 で判断する。 シグネチャ・ヒューリスティック・ML 分類・サンドボックス・EDR を多層で配置するのが基本。
例えば製造業の社内ネットワークなら、 (1) 端末は EDR + AV、 (2) サーバはアプリ許可リスト + ファイル整合性監視 (Tripwire / OSSEC)、 (3) 境界は次世代ファイアウォール + サンドボックス、 を多層配置する。 検知ログは SIEM (Splunk / Sentinel) に集約し、 ML で異常スコアリングして SOC が対応する流れが標準。