2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

プリザンターの添付ファイルにウイルススキャン機能を追加してみる

2
Posted at

はじめに

プリザンターにはフォーム機能(Form.Enabled)があり、これを有効にすると認証なしで外部のユーザーからデータを受け付けられるようになります。便利な反面、不特定多数のユーザーから添付ファイルが送信されるケースが出てくるため、セキュリティ面の考慮が必要になります。

現状のプリザンターでは、添付ファイルに対して拡張子のブラックリストチェックが行われていますが、拡張子を偽装されると防げません。例えば、.exe はブロックされますが、マルウェアが .docx.pdf に偽装されてアップロードされるリスクには対応できません。

そこで、ClamAV というオープンソースのアンチウイルスエンジンを使って、アップロード時にウイルススキャンを行う仕組みを考えてみます。

今回紹介する方法は本体コードへの改変が必要になります。そのため、実際に本記事に記載されている方法を試すには、Visual StudioとVisual Studio Codeなどのビルドして実行するための環境が必要です。ビルド環境については、公式レポジトリのドキュメントを参照してください。

バージョン 1.5.1.0 を対象にしています

現状の添付ファイルバリデーション

まず、プリザンターが現在どのようなファイルチェックを行っているか確認してみましょう。

バリデーションの全体像

バリデーション メソッド 内容
件数制限 OverLimitQuantity() カラムごとの LimitQuantity(デフォルト 30)
個別サイズ制限 OverLimitSize() カラムの LimitSize MB
合計サイズ制限 OverTotalLimitSize() TotalLimitSize
テナントストレージ制限 OverTenantStorageSize() ContractSettings.StorageSize (GB)
ファイル名検証 IsValidFileName() null 文字、../\: を禁止
拡張子ブロックリスト IsAllowedExtension() 除外リストに該当する拡張子を拒否
画像フォーマット検証 OnUploadingSiteImage() ImageSharp で画像フォーマット検証
MD5 ハッシュ検証 ValidateFileHash() アップロード後のファイル整合性チェック

サイズ・件数・ファイル名と一通りチェックされていますが、ファイルの中身(バイナリ)に対する検査は行われていません

さらに、ファイル名検証と拡張子ブロックリストはフォーム経由のアップロードでのみ適用されます。フォーム機能は不特定多数のユーザーからファイルを受け付けることを想定しているため、最低限の防御として拡張子チェックが組み込まれているわけです。

拡張子ブロックリストの実装

このチェックを担う BinaryValidators.OnValidatingFormUpload() を見てみましょう。

Implem.Pleasanter/Models/Binaries/BinaryValidators.cs
public static Error.Types OnValidatingFormUpload(
    Context context,
    string[] uuids,
    string[] fileUuid,
    string[] fileNames)
{
    if (!context.IsForm)
    {
        return Error.Types.None; // フォーム以外は検証スキップ
    }
    // ... UUID バリデーション省略 ...
    if (context.PostedFiles != null)
    {
        foreach (var file in context.PostedFiles)
        {
            if (!IsValidFileName(file.FileName) || !IsAllowedExtension(file.FileName))
            {
                return Error.Types.InvalidRequest;
            }
        }
    }
    return Error.Types.None;
}

冒頭の if (!context.IsForm) return Error.Types.None; がポイントです。フォーム経由でないリクエスト(認証済みユーザーや API)では、この検証はまるごとスキップされます。つまり、拡張子チェックはフォーム機能というオープンな入口に対する最低限の防御として位置づけられています。

呼び出し先の IsAllowedExtension() は以下の実装です。

Implem.Pleasanter/Models/Binaries/BinaryValidators.cs
public static bool IsAllowedExtension(string fileName)
{
    if (string.IsNullOrEmpty(fileName))
    {
        return false;
    }
    var firstDotIndex = fileName.IndexOf('.');
    if (firstDotIndex < 0)
    {
        return true;
    }
    var parts = fileName[firstDotIndex..].Split('.');
    foreach (var part in parts)
    {
        if (string.IsNullOrEmpty(part))
        {
            continue;
        }
        var extension = "." + part;
        if (Parameters.Form.AttachmentExcludedExtensions.Contains(extension))
        {
            return false;
        }
    }
    return true;
}

二重拡張子(malware.txt.exe のようなケース)にも対応しており、ドットで分割して全パーツをチェックしています。

除外対象の拡張子は Form.json で定義されています。ファイル名に Form とあるように、フォーム機能向けの設定です。

Implem.Pleasanter/App_Data/Parameters/Form.json
{
    "Enabled": false,
    "AttachmentExcludedExtensions": [
        ".exe", ".dll", ".com", ".scr", ".pif",
        ".msi", ".msp", ".bat", ".cmd", ".ps1",
        ".vbs", ".vbe", ".js", ".jse", ".wsf", ".wsh",
        ".aspx", ".asp", ".php", ".php3", ".php4", ".php5", ".phtml",
        ".jsp", ".jspx", ".cfm", ".cfc",
        ".hta", ".htaccess", ".htpasswd", ".config", ".cer",
        ".sh", ".bash", ".csh", ".ksh",
        ".pl", ".py", ".rb", ".jar", ".war"
    ]
}

42 種類の実行可能ファイル拡張子がブロックされます。不特定多数からアップロードされることを想定して、スクリプトや実行ファイルが直接送り込まれないようにする最低限の防御が備わっているということです。

拡張子チェックだけでは不十分な理由

とはいえ、拡張子ベースのチェックには限界があります。

  • マルウェアが .docx.pdf.zip など許可された拡張子に偽装できる
  • マクロ付き Office ファイル(.xlsm.xlsx にリネーム等)を検出できない
  • ゼロデイ攻撃のペイロードが正規のファイル形式に埋め込まれるケースに対応できない
  • そもそもフォーム経由以外のアップロードには拡張子チェック自体が適用されない

つまり、ファイルの中身を実際にスキャンする仕組みが必要ということです。

ウイルススキャン手法の比較

無償かつクロスプラットフォームで使えるウイルススキャン手法を調べてみました。

手法 クロスプラットフォーム 無償 パフォーマンス 検出精度
ClamAV(clamd デーモン) 対応 対応 高速 実用的
ClamAV(clamscan コマンド) 対応 対応 低速 実用的
Windows Defender(AMSI) 非対応 対応 高速 高い
Windows Defender(MpCmdRun.exe 非対応 対応 普通 高い
YARA ルールスキャン 対応 対応 高速 ルール依存
VirusTotal API 対応 制限あり 低速 非常に高い

Windows 環境限定であれば AMSI(Windows Defender 連携)が追加ソフト不要で手軽ですが、Linux でも動かすことを考えると ClamAV が最も現実的な選択肢 です。

ClamAV を使ったスキャン構成

ClamAV とは

ClamAV はオープンソース(GPL v2)のアンチウイルスエンジンです。clamd というデーモンプロセスを常駐させ、TCP ソケット経由でファイルデータを送信してマルウェア判定を受け取ります。

項目 内容
ライセンス GPL v2(無償)
対応 OS Windows / Linux / macOS
.NET ライブラリ nClam(MIT ライセンス)
スキャン方式 ストリームスキャン(TCP 接続)
定義更新 freshclam コマンドで自動更新
Docker clamav/clamav 公式イメージあり

アーキテクチャ

スキャンの全体像をフローで見てみましょう。

既存のバリデーション(サイズ・拡張子)を通過した後、ファイル保存の前後にスキャンを挿入するイメージです。

スキャンモードの設計

すべてのアップロードをスキャンすると負荷がかかるため、3 つのモードを設けます。

ScanMode 対象 用途
All 全アップロード経路 不特定多数がアクセスする環境
FormOnly フォーム経由のみ 外部フォームあり・内部ユーザーは信頼
Disabled なし 従来動作(デフォルト)

各コントローラーとの対応を見てみましょう。

アップロード経路 All FormOnly Disabled
BinariesController(認証ユーザー) スキャン - -
FormBinariesController(フォーム) スキャン スキャン -
Api/BinariesController(API) スキャン - -

フォーム機能で外部からファイルを受け付けるけど、社内ユーザーのアップロードまでスキャンする必要がない、というケースでは FormOnly が便利です。

実装のポイント

パラメータ設定ファイル

プリザンターの既存のパラメータパターンに合わせて、VirusScan.json で設定を管理します。

Implem.Pleasanter/App_Data/Parameters/VirusScan.json
{
    "ScanMode": "Disabled",
    "Provider": "ClamAV",
    "ClamAvHost": "localhost",
    "ClamAvPort": 3310,
    "TimeoutMs": 30000,
    "MaxFileSizeMB": 100,
    "RejectOnScanError": false,
    "ScanImages": true
}
パラメータ 説明
ScanMode All / FormOnly / Disabled のいずれか
Provider スキャンエンジン(現時点では ClamAV のみ)
ClamAvHost clamd デーモンのホスト名
ClamAvPort clamd デーモンのポート番号
TimeoutMs スキャンタイムアウト(ミリ秒)
MaxFileSizeMB スキャン対象の最大ファイルサイズ(超過時はスキップ)
RejectOnScanError true: スキャンエラー時にアップロード拒否 / false: 許可
ScanImages 画像アップロードもスキャン対象にするか

デフォルトが Disabled なので、VirusScan.json を配置しなければ従来どおりの動作になります。

スキャンサービスのインターフェース

DI(依存性注入)で切り替えられるようにインターフェースを定義します。

Implem.Pleasanter/Libraries/Security/IVirusScanService.cs
public interface IVirusScanService
{
    Task<VirusScanResult> ScanAsync(byte[] data, string fileName);
}
Implem.Pleasanter/Libraries/Security/VirusScanResult.cs
public class VirusScanResult
{
    public bool IsClean { get; set; }
    public bool IsError { get; set; }
    public string VirusName { get; set; }
    public string ErrorMessage { get; set; }

    public static VirusScanResult Clean()
        => new VirusScanResult { IsClean = true };

    public static VirusScanResult Infected(string virusName)
        => new VirusScanResult { IsClean = false, VirusName = virusName };

    public static VirusScanResult Error(string message)
        => new VirusScanResult { IsClean = false, IsError = true, ErrorMessage = message };
}

ClamAV 実装

nClam ライブラリを使った ClamAV 連携の実装です。

Implem.Pleasanter/Libraries/Security/ClamAvVirusScanService.cs
using nClam;

public class ClamAvVirusScanService : IVirusScanService
{
    private readonly ClamClient _client;
    private readonly int _maxFileSizeBytes;

    public ClamAvVirusScanService()
    {
        var host = Parameters.VirusScan?.ClamAvHost ?? "localhost";
        var port = Parameters.VirusScan?.ClamAvPort ?? 3310;
        _maxFileSizeBytes = (Parameters.VirusScan?.MaxFileSizeMB ?? 100) * 1024 * 1024;
        _client = new ClamClient(host, port)
        {
            MaxStreamSize = _maxFileSizeBytes
        };
    }

    public async Task<VirusScanResult> ScanAsync(byte[] data, string fileName)
    {
        if (data == null || data.Length == 0)
            return VirusScanResult.Clean();

        if (data.Length > _maxFileSizeBytes)
            return VirusScanResult.Clean(); // サイズ超過はスキップ

        try
        {
            var result = await _client.SendAndScanFileAsync(data);
            return result.Result switch
            {
                ClamScanResults.Clean => VirusScanResult.Clean(),
                ClamScanResults.VirusDetected => VirusScanResult.Infected(
                    result.InfectedFiles?.FirstOrDefault()?.VirusName ?? "Unknown"),
                _ => VirusScanResult.Error(
                    $"ClamAV scan returned: {result.Result}")
            };
        }
        catch (Exception ex)
        {
            return VirusScanResult.Error($"ClamAV connection error: {ex.Message}");
        }
    }
}

ウイルスが検出されれば Infectedclamd に接続できなければ Error を返します。Error の場合は RejectOnScanError の設定に応じてアップロードを許可するか拒否するかを制御します。

無効時のスタブ実装

スキャンが無効な場合は常に「クリーン」を返す実装を使います。

Implem.Pleasanter/Libraries/Security/NullVirusScanService.cs
public class NullVirusScanService : IVirusScanService
{
    public Task<VirusScanResult> ScanAsync(byte[] data, string fileName)
    {
        return Task.FromResult(VirusScanResult.Clean());
    }
}

アップロードフローへの統合

BinaryUtilities.UploadFile メソッドのフローにスキャンを挿入する想定です。

スキャン要否の判定ロジックはこのようになります。

public enum VirusScanMode
{
    All = 0,
    FormOnly = 1,
    Disabled = 2
}

private static bool ShouldScan(bool isFormUpload)
{
    var mode = Parameters.VirusScan?.ScanMode ?? VirusScanMode.Disabled;
    return mode switch
    {
        VirusScanMode.All => true,
        VirusScanMode.FormOnly => isFormUpload,
        _ => false
    };
}

BinariesController からの呼び出しは isFormUpload = falseFormBinariesController からは isFormUpload = true を渡すことで、ScanMode に応じた制御が可能です。

DI 登録

Startup.csConfigureServices でスキャンモードに応じたサービスを登録します。

Implem.Pleasanter/Startup.cs
// ウイルススキャンサービス登録
if (Parameters.VirusScan?.IsEnabled == true)
{
    services.AddSingleton<IVirusScanService, ClamAvVirusScanService>();
}
else
{
    services.AddSingleton<IVirusScanService, NullVirusScanService>();
}

ParametersStartup のコンストラクタ内の Initializer.Initialize() でロード済みなので、この時点でアクセスできます。

ClamAV のセットアップ

Docker(推奨)

最も手軽なのは Docker コンテナでの起動です。

docker run -d --name clamav -p 3310:3310 clamav/clamav:latest

これだけで clamd デーモンが起動し、ウイルス定義も自動でダウンロードされます。

Linux(パッケージ)

# Ubuntu/Debian
sudo apt-get install clamav clamav-daemon
sudo freshclam          # ウイルス定義更新
sudo systemctl start clamav-daemon

Windows

# winget でインストール
winget install ClamAV.ClamAV

# ウイルス定義更新
freshclam.exe

# デーモン起動
clamd.exe

clamd.conf の推奨設定

clamd.conf
# TCP ソケットでリッスン
TCPSocket 3310
TCPAddr 127.0.0.1

# ストリームスキャンの最大サイズ
StreamMaxLength 100M

# 圧縮爆弾対策
MaxScanSize 100M
MaxFileSize 25M
MaxRecursion 16
MaxFiles 10000

チャンクアップロード時の考慮

プリザンターは Content-Range ヘッダーによるチャンクアップロードに対応しています。途中のチャンクだけではウイルスパターンを検出できないため、最終チャンクが到着してファイル全体が揃った時点でスキャン する必要があります。

// 完了判定
var isComplete = contentRange == null
    || (contentRange.To + 1 == contentRange.Length);

if (isComplete && ShouldScan(isFormUpload))
{
    // ファイル全体をスキャン
}

セキュリティ上の注意点

ウイルススキャン機能を追加する際に気をつけたいポイントをまとめます。

リスク 対策
clamd との通信の盗聴 clamdlocalhost または専用ネットワーク内に配置する
大量アップロードによる DoS MaxFileSizeMB でサイズ制限、clamd の接続数上限を設定
圧縮爆弾(zip bomb) clamdMaxScanSize / MaxRecursion を設定
ウイルス定義の陳腐化 freshclam の自動更新を有効化し、更新状況を監視
スキャンタイムアウトの悪用 本番環境では RejectOnScanError: true を推奨
誤った安心感 スキャンは多層防御の一要素であることを意識する

特に RejectOnScanError の設定は重要です。false にすると clamd が停止していてもアップロードが通ってしまうため、本番環境では true にしておくのが安全です。

エラー時の動作まとめ

状況 RejectOnScanError: true RejectOnScanError: false
ウイルス検出 アップロード拒否 アップロード拒否
clamd 接続失敗 アップロード拒否 アップロード許可(ログ記録)
タイムアウト アップロード拒否 アップロード許可(ログ記録)
ファイルサイズ超過 スキャンスキップ(許可) スキャンスキップ(許可)

ウイルスが検出された場合は設定に関わらず必ず拒否されます。RejectOnScanError はあくまで「スキャン自体がエラーになった場合」の動作を制御するものです。

テスト方法

ウイルス検出のテストには EICAR テストファイル を使います。これはアンチウイルスソフトの動作確認用に標準化されたテストファイルで、実際のマルウェアではありません。

X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*

この文字列をテキストファイルとして保存し、アップロードすると、ClamAV が「Eicar-Signature」として検出します。.docx などにリネームしても検出されるため、拡張子偽装への耐性も確認できます。

まとめ

プリザンターのフォーム機能で外部からファイルを受け付けるケースでは、拡張子チェックだけでは不十分です。ClamAV を使ったウイルススキャン機能の追加を検討してみました。

  • 現状のプリザンターには拡張子ベースのチェックのみでウイルススキャン機構は存在しない
  • ClamAV + nClam の組み合わせがクロスプラットフォーム・無償・高パフォーマンスで最も現実的
  • ScanModeAll / FormOnly / Disabled)で環境に応じた柔軟な制御が可能
  • デフォルト Disabled のため、設定しなければ従来の動作に影響しない
  • Docker で clamav/clamav を起動するだけで手軽にスキャン環境を構築できる
  • スキャンは多層防御の一要素であり、拡張子チェック・サイズ制限と組み合わせて使うことが大切
2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?