ClamAV(clamd)を使って、アップロードファイルをサーバーサイドでウィルススキャンするためのアーキ
ClamAV
- マルチプラットフォームでフリーのアンチウィルススイート
- ClamAVにはコマンド版とデーモン版がある。デーモン版はTCPでファイル単位のウィルス検閲に対応している
- clamavのdockerイメージを使ってTCPでウィルススキャンする
clamdホスト構築
clamavの公式イメージによると、
The development of this image will be discontinued. Since 0.104 Cisco provides official docker images for clamav. This image here will be on hold and supported as long as possible.
このイメージの開発は終了します。0.104 以降、Cisco は clamav 用の公式 docker イメージを提供しています。こちらのイメージは保留され、可能な限りサポートされます。
mkodockx/docker-clamav
一応ちゃんと動いてくれるが、現在開発停止していてデータベースの更新中に予期しない切断が発生するため、Ciscoの公式版を使ってくれとのこと。
clamav/clamav | dockerhub
version: '3.2'
services:
app: ...
depends_on:
- clamavd
clamavd:
container_name: clamavd
hostname: clamavd
image: clamav/clamav
ports:
- 3310
ウィルスデータベース
freshclam
ClamAVのウイルステータベースは、/var/lib/clamavディレクトリにmain.cvdおよびdaily.cvdというファイルで保存されている。
これらのデータベースは、freshclamコマンドで更新する。
ClamAVのウイルスデータベースを自動更新するには - @IT
clamav/clamav | dockerhubイメージではfreshclamデーモンが一日一回(--checks=1
)ウィルスデータベースを更新するようにチューニングされている。
freshclamコマンド版を使ってcrontabでスケジュールすることもできるが、まあおまかせのデーモン版で。
ps | grep clamav
PID USER TIME COMMAND
12 clamav 0:00 freshclam --checks=1 --daemon --foreground --stdout --user=clamav
13 clamav 0:22 clamd --foreground
なお、かつて高頻度でダウンロードをかけるクライアントに対策するためfreshclam
によるダウンロードに制限した経緯があるようだ。
Abuse of the download system has forced us to push people towards FreshClam. Unfortunately a handful have ruined it for everyone. (Looking at you, handful of IPs that download the daily.cvd 3x a second)
ダウンロードシステムの乱用により、私たちは人々をFreshClamに追い込むことを余儀なくされました。 残念ながら、一握りがみんなのためにそれを台無しにしました。(あなたを見て、daily.cvdを1秒間に3回ダウンロードする一握りのIP)
Official docker images of clamav
freshclamの設定ファイルを見るとデフォルトは12回(2時間に一回)のようだ。
# Number of database checks per day.
# Default: 12 (every two hours)
#Checks 24
ClamAVによるリアルタイムスキャンの設定(ClamAV 1.0版) | 稲葉サーバーデザイン
clamavdのクライアント実装
PING
コマンドで疎通を確認した後に、INSTREAM
スキャンコマンドを投げる
<?php
class ClamAVClient
{
// clamdホストのデフォルト
const CLAMAVD_HOST = 'clamavd';
const CLAMAVD_PORT = 3310;
private $_clamavd_host;
private $_clamavd_port;
private $_raw_fact;
private $_socket;
public function __construct($host=self::CLAMAVD_HOST, $port=self::CLAMAVD_PORT)
{
$this->_clamavd_host = $host;
$this->_clamavd_port = $port;
}
public function __destruct()
{
$this->closeSocket();
}
/**
* tcpソケットを閉じます
*/
public function closeSocket(){
if( is_resource($this->_socket) ) fclose($this->_socket);
}
/**
* tcpソケットを開きます
* @return resource TCPセッションハンドル
*/
private function openSocket(){
$this->_socket = stream_socket_client("tcp://{$this->_clamavd_host}:{$this->_clamavd_port}", $errno, $errstr, 10);
if (!$this->_socket) {
throw new RuntimeException("ホストの接続に失敗しました。({$errno}){$errstr}");
}
return $this->_socket;
}
/**
* ソケットを取得します
* @return resource TCPセッションハンドル
*/
private function getSocket(){
return $this->_socket ?: $this->openSocket();
}
/**
* clamavdホストにPINGをリクエストします
* @note PINGに対するclamavdの正常応答はPONG
* @return bool true: 正常応答 / false: 異常応答
*/
public function ping(){
$socket = $this->getSocket();
stream_socket_sendto($socket, 'PING');
$this->_raw_fact = stream_socket_recvfrom($socket, 1024);
return $this->fact() === 'PONG';
}
/**
* clamavdのレスポンステキストからファクトを抽出します
* @param string $response_text
* @return string ファクト
*/
public function fact(){
$fact = (strrchr($this->_raw_fact, ":")) ?: ": {$this->_raw_fact}";
return trim(substr($fact, 1));
}
/**
* clamavdにファイルスキャンをリクエストします
*
* @return bool true: ウィルスなし / false: ウイルスあり
*/
public function scan($filepath){
// ファイルが存在するか確認
if ( file_exists($filepath) === false ) {
throw new RuntimeException("ファイルが存在しません。file({$filepath})");
}
// ファイル内容を読み込み
$fileContent = file_get_contents($filepath);
if ($fileContent === false) {
throw new RuntimeException("ファイルの読み込みに失敗しました。file({$filepath})");
}
// PINGセッション
if( ( new ClamAVClient() )->ping() === false ) {
throw new RuntimeException("PINGに失敗しました。");
}
// スキャンセッション
$socket = $this->getSocket();
// ストリームスキャンのハンドシェイク開始 => zINSTREAM\0
$res = stream_socket_sendto($socket, "zINSTREAM\0");
// ファイルを8KBでチャンク送信する
$chunkSize = 8192;
$bytesSent = 0;
while ( $bytesSent < strlen($fileContent) ) {
$chunk = substr($fileContent, $bytesSent, $chunkSize);
$chunkLength = pack('N', strlen($chunk));
$res = stream_socket_sendto($socket, $chunkLength . $chunk);
$bytesSent += $chunkSize;
}
// スキャンのハンドシェイク終了 => ゼロバイトを送信
$res = stream_socket_sendto($socket, pack('N', 0));
// スキャン結果を読み取り => 問題なければ"OK"文字列が入っている
// ウィルス検出 => "stream: Eicar-Signature FOUND"
// ウィルス未検出 => "stream: OK"
$this->_raw_fact = stream_socket_recvfrom($socket, 1024);
if( empty( $this->_raw_fact ) ){
throw new RuntimeException("診断結果が空です。");
}
echo "clamscan result ({$filepath}) : {$this->_raw_fact}";
return $this->fact() === 'OK';
}
}
検閲
テストにはEICAR テストファイルを使用する。
EICAR テストファイルとは?
echo "X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*" > /path/to/virus.txt
try{
$clamav = new ClamAVClient();
$result = $clamav->scan('/path/to/virus.txt');
}catch(RuntimeException $e){
echo "clamavスキャンで例外が発生 {$e->getMessage()}";
}
if( $result === false ){
throw new RuntimeException("clamavスキャンで脅威を検出しました。fact({$clamav->fact()})"); //=> Eicar-Signature FOUND
}