リモートのClamAVを使ってウィルススキャンする

# 目的

ClamAVはオープンソースのアンチウィルスソフトウェアです。基本的にはローカルのファイルをスキャンするものですが、clamdデーモンを使うことでリモートからファイルを送ってスキャンさせることができます。

# 検証

## clamdの起動

clamdはClamAVのデーモンです。これをまず起動します。今回はさくっとdockerを使用します。

```Dockerfile
FROM centos:8

RUN rpm --import /etc/pki/rpm-gpg/RPM-GPG-KEY-centosofficial && \
    dnf -y install epel-release && \
    rpm --import /etc/pki/rpm-gpg/RPM-GPG-KEY-EPEL-8 && \
    dnf -y install clamav clamav-update clamd && \
    dnf clean all

RUN sed -i -e "s/^LogSyslog /#LogSyslog /" /etc/clamd.d/scan.conf && \
    sed -i -e "s/^#LogVerbose /LogVerbose /" /etc/clamd.d/scan.conf && \
    sed -i -e "s/^#LocalSocket /LocalSocket /" /etc/clamd.d/scan.conf && \
    sed -i -e "s/^#LocalSocketGroup virusgroup/LocalSocketGroup root/" /etc/clamd.d/scan.conf && \
    sed -i -e "s/^#LocalSocketMode /LocalSocketMode /" /etc/clamd.d/scan.conf && \
    sed -i -e "s/^#TCPSocket /TCPSocket /" /etc/clamd.d/scan.conf && \
    sed -i -e "s/^User /#User /" /etc/clamd.d/scan.conf && \
    sed -i -e "s/^#ExitOnOOM /ExitOnOOM /" /etc/clamd.d/scan.conf

RUN chgrp 0 /etc/freshclam.conf /var/lib/clamav /run/clamd.scan && \
    chmod g=u /etc/freshclam.conf /var/lib/clamav /run/clamd.scan

USER 1001

CMD ["sh", "-c", "freshclam && /usr/sbin/clamd --foreground"]

EXPOSE 3310/tcp
```

いくつか要点を解説します。

* CentOS + EPELを使用
* コンテナ向けにいくつかログのパラメータを変更
* LocalSocketはこの用途では本来不要ですが、コンテナの内部でclamdscanを手動実行できるようにするために有効化
* OpenShiftのrestricted SCCでも動くようにユーザーとオーナー・パーミッションを調整
* ウィルスデータベースがないとclamdが起動できないため、初回起動時にfreshclamを実行、以後自動更新

起動します。

```
$ docker build -t clamd . && docker run --rm -p 3310:3310 clamd
...
ClamAV update process started at Thu Sep 17 06:54:50 2020
daily database available for download (remote version: 25930)
Testing database: '/var/lib/clamav/tmp.8e0e5/clamav-c122d78db3a624e132c2d1ee2a7d7de1.tmp-daily.cvd' ...
Database test passed.
daily.cvd updated (version: 25930, sigs: 4317819, f-level: 63, builder: raynman)
main database available for download (remote version: 59)
Testing database: '/var/lib/clamav/tmp.8e0e5/clamav-b32e56e372bc2f792706cd25000103f8.tmp-main.cvd' ...
Database test passed.
main.cvd updated (version: 59, sigs: 4564902, f-level: 60, builder: sigmgr)
bytecode database available for download (remote version: 331)
Testing database: '/var/lib/clamav/tmp.8e0e5/clamav-17a3f21c83d33bac8b13b1d9e449f42a.tmp-bytecode.cvd' ...
Database test passed.
bytecode.cvd updated (version: 331, sigs: 94, f-level: 63, builder: anvilleg)
...
HTML support enabled.
XMLDOCS support enabled.
HWP3 support enabled.
Self checking every 600 seconds.
Listening daemon: PID: 1
MaxQueue set to: 100
```

## clamdとの疎通確認

clamdはTCPソケットを使っていろいろな操作をすることができます。詳細はドキュメントを参照してください。

> https://manpages.debian.org/testing/clamav-daemon/clamd.8.en.html

``PING``コマンドを使ってclamdと疎通確認してみます。PINGコマンドは

> PING
Check the server's state. It should reply with "PONG".

という仕様ですので、clamdのTCPポートにPINGと送信すればPONGと返ってくるはずです。

```
$ echo "PING" | nc localhost 3310
PONG
```

うまくいきました。

## INSTREAMコマンドを使ったリモートからのスキャン

clamdの``INSTREAM``コマンドを使うと、リモートからファイルをclamdに送りつけてスキャンをさせることができます。

> INSTREAM
It is mandatory to prefix this command with n or z.
Scan a stream of data. The stream is sent to clamd in chunks, after INSTREAM, on the same socket on which the command was sent. This avoids the overhead of establishing new TCP connections and problems with NAT. The format of the chunk is: '<length><data>' where <length> is the size of the following data in bytes expressed as a 4 byte unsigned integer in network byte order and <data> is the actual chunk. Streaming is terminated by sending a zero-length chunk. Note: do not exceed StreamMaxLength as defined in clamd.conf, otherwise clamd will reply with INSTREAM size limit exceeded and close the connection.

少々わかりづらいので、Javaで実装してみます。

```
	private String scanStream(InputStream fileInputStream, String clamdServer, int clamdPort) throws Exception {
		String scanResult = "";

		try (Socket socket = new Socket(clamdServer, clamdPort)) {
			try (BufferedReader clamdReader = new BufferedReader(new InputStreamReader(socket.getInputStream()))) {
				try (BufferedOutputStream clamdOutputStream = new BufferedOutputStream(socket.getOutputStream())) {
					clamdOutputStream.write("zINSTREAM\0".getBytes());
					clamdOutputStream.flush();

					byte[] buffer = new byte[4096];
					int read = fileInputStream.read(buffer);
					while (read > 0) {
						clamdOutputStream.write(ByteBuffer.allocate(4).putInt(read).array());
						clamdOutputStream.write(buffer, 0, read);

						read = fileInputStream.read(buffer);
					}

					clamdOutputStream.write(new byte[] { 0, 0, 0, 0 });
					clamdOutputStream.flush();

					scanResult = clamdReader.readLine();
				}
			}
		}

		return scanResult;
	}
```

要点は次のとおりです。

* 最初にzINSTREAM\0を送る
* 次にスキャンしたいデータのバイト数を4バイト表現にして送る
* 次にスキャンしたいデータそのものを送る
* 以降、データが尽きるまで繰り返す
* 最後に0を4バイト送って終了

それでは実験してみます。ウィルスは有名なEICARのテストファイルを使います。

> https://www.eicar.org/?page_id=3950

ファイルとしておくと自分のPCのアンチウィルスにひっかかってしまうため、今回はソース内の文字列として扱います。なお、このページをアンチウィルス機能つきのプロクシ経由で表示したり、ページをPDF等で保存するとやはりひっかかるかもしれませんので、データの先頭に``*``を付与しています。

先ほどのメソッドを次のようにコールします。

```
// 先頭の*はダミー
String eicar = "*X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*";

// 先頭の*をスキップ
InputStream fileInputStream = new ByteArrayInputStream(eicar.substring(1).getBytes());

// スキャン
System.out.println(scanStream(fileInputStream, "localhost", 3310));
```

すると次の結果が返ってきます。

```
stream: Win.Test.EICAR_HDB-1 FOUND
```

clamdにも次のようにログ出力されています。

```
instream(172.17.0.1@48926): Win.Test.EICAR_HDB-1 FOUND
```

なお、ウィルスとして検知されなかった場合は

```
stream: OK
```

という文字列がclamdから返ります。clamd側では何も出力されません。

# 使い道

例えばですが、これにRESTの皮を被せてアンチウィルスサーバとし、他のアプリケーションからの要求に応じてウィルススキャンをするといった用途が考えられます。高価な製品を使わなくても無料で実現できるところがポイントになるかと思います。