Защищаемся от вирусов: KAV + sendmail
 
Исходный код: scanmail-0.2-nix.tar.gz (2003-03-22 17:24:25/2522/181)

Вот, давеча, пришел наконец-то и к нам пакет KAV for Linux Sendmail. Решил я защитить почтовый сервак. Но это оказалось не так-то просто. В общем, вариант защиты, предлагаемый Лабораторией Касперского меня не устроил. Я побродил по Интернету, нашел пару рецептов, но все они показались мне довольно сложными. Стоп, сказал я себе! Наверняка есть очень простой способ, нужно только хорошенько подумать.

Всего две кружки чая и пара страниц документации на sendmail – и действительно, решение нашлось. Прежде всего, нужно четко определить – что нам нужно. Постолько-поскольку мы не мать Тереза и благотворительностью не занимаемся, защищать нам нужно только тех пользователей, почта которых складывается на нашем сервере, то есть локальных. Про остальных можно забыть – пусть их свои админы чистят.

Так вот, по отношению к почтовым системам, есть такое понятие Mail Delivery Agent – агент доставки почты. Нам даже не нужно углубляться в детали, достаточно того, что это отдельная программа, которая, в конечном итоге, складывает письма в почтовый ящик пользователя. Вызывает эту программы Mail Transfer Agent – программа, которая определяет - какой MDA использовать для маршрутизации конкретного сообщения. В нашем случае MTA – это sendmail. Если хорошенько подумать, то можно прийти к выводу, что подменив MDA локальной доставки, можно легко манипулировать сообщениями. А после обработки, с такой же легкостью вызвать прежний локальный MDA. А для наших целей большего и не надо. Итак, решение найдено - необходимо разработать промежуточный MDA, который будет являться посредником между MTA и MDA локальной доставки.

Теперь по поводу проверки на вирусы. В конце-концов, почтовое сообщение это простой текстовый файл – мы можем сохранить его во временном файле. А проверять будем с помощью KAV. Да, да - того самого, на который было раскручено начальство. В составе пакета KAV поставляется очень полезная фича - kavdaemon. Это тот же самый сканер, но предназначен он как раз для интеграции Антивируса Касперского с другим программным обеспечением. kavdaemon - это демон, который висит в памяти и прослушивает сокет на предмет запросов. Запросы, естессно, на сканирование. А почему бы нам не использовать kavscanner? Дело в том, что процесс загрузки антивирусных баз довольно медленная штука, и если мы будем использовать kavscanner, то каждое письмо будет очень долго обрабатываться. А демон загружает базы всего один раз. Для того, чтобы использовать демона мы не станем работать с сокетами – к чему лишние сложности. Повторный запуск демона превращает его в клиента уже запущенного демона. А указать на цель мы можем и из командной строки. Ну вот, как бы и вырисовывается схема нашей программы. А на чем мы ее реализуем? Конечно же – на любимом Perl.

Мы решили, что подсунем sendmail-у новый MDA. Для локального доставщика это изменения всего двух строк. Какие это строки я скажу после того, как мы напишем программу. Теперь, детально о том, что должна делать наша программа. Так как sendmail это дело не шуточное, наша программа должны быть достаточно защищенной. Из нашей программы будут целых два запуска внешних программ: kavdaemon и стандартный MDA локальной доставки. Проще всего, конечно open(). Таким образом мы сможем прочитать вывод от KAVdaemon и переслать стандартному MDA тело сообщения. Тело сообщения передается MDA на стандартный ввод STDIN. Однако, функции open с одним параметром использует командный интерпретатор для разбора командной строки.

open(KAV,"/opt/AVP/kavdaemon –o{/var/tmp/message} |"); open(PROC, "| /usr/bin/procmail ".join(" ",@ARGV)); 
Честно говоря, это не самое лучшее решение. Правда есть еще system. Но и system тоже использует командный интерпретатор при вызове с одним аргументом. Кроме того, при использовании system дочерний процесс использует потоки родительского. Дело в том, что наша программа не должна ничего выдавать в STDOUT. Иначе, MTA подумает что MDA не удалось доставить сообщение, и может запихать письмо в очередь, или вообще отослать его обратно. Помимо всего прочего, наша программа должна не только передать полученное собщение на вход локального доставщика, но еще и вернуть результаты его (локального MDA) работы: код возврата и то, что он выдаст в STDOUT.

Итак, нам нужно обеспечить два безопасных вызова внешних программ, с одной из которых необходимо взаимодействовать через STDIN, а с другой через STDOUT. При этом, нельзя допускать вывода в STDOUT. Если использовать open() с несколькими параметрами, то командный интерпретатор вызван не будет, так как параметры итак уже разложены по-отдельности. Однако, в этом случае мы не сможем переопределить потоки. Да, пожалуй, попали в переделку. Не волнуйтесь, мы же виртуозы :) да к тому же и пишем под UNIX, где работать с потоками и процессами не просто а очень просто.

Для решения проблемы безопасного и быстрого запуска внешней программы нам придется разработать специальную процедуру, которая позволит как передавать что-то на вход, так и получать вывод из программы. Схема проста: сначала что-то передаем программе на вход, а затем считываем результат ее работы и получаем код завершения.

Давайте разберем код нижеследующей функции:

sub HiddenExec{ my (@args) = @_; return undef if $#args < 0 || !-X $args[0]; my ($input,$pid) = (pop(@args)); pipe(IN,OUT); return undef unless defined($pid = open(CHILD,"|-")); if ($pid != 0){ 	close(OUT); 	print CHILD $input if defined($input); 	close(CHILD); 	my ($out,$exc) = ("",0); 	$out .= $_ while (<IN>); 	close(IN); 	$exc = $1 if $out =~ s/exc:(\d+)$//; 	waitpid($pid,0); 	my ($sig,$cod) = ($exc & 127,$exc >> 8); 	return $cod,$out,$sig; }else{ 	close(IN); 	open(STDOUT,">&=OUT"); 	close(OUT); 	my $exc = system {$args[0]} @args; 	print "exc:$exc"; 	exit; } } 
Для успешной работы, функция должна получить хотя бы один аргумент. Этот аргумент (первый по счету) будет интерпретироваться как полное имя файла запускаемой программы. Второй оператор тела функции выполняет проверку двух условий: наличие хотя бы одного параметра и доступность программы на выполнение реальным пользователем (то есть того, от имени которого работает наша программа). Далее объявляются две переменные: $input и $pid. Переменная $input инициализируется значением последнего элемента списка аргументов вызова функции. Заметьте, что одновременно список освобождается от последнего аргумента. Все что остается в массиве, будет использовано в качестве аргументов запуска программы (в том числе, первый параметр - полное имя файла программы).

Следующая строка связывает два дескриптора IN и OUT в канал, для последующего обмена данными с дочерним потоком. Так как операции записи-чтения мы будем выполнять строго последовательно, мы не заботимся о блокировке.

Далее, функция пытается породить дочерний процесс. Напомню, что вызов open с указанными аргументами неявно использует fork и так же возвращает идентификатор порожденного процесса. После этого заметна уже привычная схема работы двух процесов:

	if ($pid != 0){ # Родительский процесс 	}else{ # Дочерний процесс 	} 
Сначала рассмотрим что делает дочерний процесс. При ветвлении, потомку достаются все открытые дескрипторы родителя. По этому, мы спокойно можем использовать созданный ранее канал для обратной связи с родителем. Мы закрываем ненужный потомку дескриптор канала, предназначенный для чтения - его будет использовать родитель. Следующий оператор выполняет перенаправление потока STDOUT в дескриптор OUT созданного канала. В этом случае, мы не можем использовать подмену с помощью функции select - такой вариант не сработает в паре с system. Я рекомендую всегда использовать вариант open(STDOUT,">&=HANDLE"), так как это работает во многих случаях. После этого сразу закрываем дескриптор OUT, так как теперь его дублирует STDOUT.

Ну и наконец, после перенаправления стандартного потока вывода функция вызывает system. Обратите внимание на аргументацию вызова. Такая запись гарантированно защищает нас от обращения к командному интерпретатору для разбора строки запуска: ведь все аргументы и так уже передаются в виде списка. В качестве результата работы функции system мы получаем гибридный код, на основании которого можем рассчитать код завершения запускаемой программы, а так же сигнал, который привел к завершению работы программы.

Теперь посмотрим на работу родителя. Первым делом, родительский процесс закрывает ненужный ему дескриптор OUT. Как мы знаем, это одна сторона созданного ранее канала, предназначенная для записи. Но нам он не нужен, по причине, что мы уже переопределили поток ввода для дочернего процесса. Для родительской стороны это дескриптор CHILD, а для потомка - STDIN. Этого мы добились с помощью вызова open(CHILD,"|-"). Иначе говоря, все, что родитель запишет в CHILD, будет приниматься потомком через STDIN. Как мы знаем, при вызове функции system порождаемый процесс наследует потоки ввода-вывода от родительского (родитель здесь - процесс, который выполнил вызов system) процессу. Соответственно, в программу, запущенную с помощью вызова system, будет поступать все то, что будет записано в дескриптор CHILD родителем (верхнего уровня). Можно считать, что половина задачи выполнена - мы успешно (и не затрагивая существующие потоки ввода-вывода) передали информацию на вход программы. При этом мы использовали наиболее безопасный (рекомендуемый) способ запуска внешних программ.

Снова вернемся к коду порожденного процесса, а точнее к оператору, в котором выполняется перенаправление стандартного потока вывода. Этот оператор играет роль обратного связующего звена. Теперь, все то, что будет выдаваться в STDOUT порожденного процесса, может быть поймано на другом конце канала. А дескриптором для чтения у нас обладает родитель. Соответственно, он без проблем прочитает и то, что выдал дочерний процесс, и то, что попалов STDOUT в результате работы функции system.

Я думаю дальнейший код родительской части понять нетрудно. После выдачи данных в вызываемую программу, родитель сразу закрывает дескриптор, через который ведется запись в порожденный процесс. закрытие дескриптора приводит к тому, что порожденный процесс узнает о завершении ввода. После этого, программа, запущенная в работу с помощью system, должна начать обрабатывать полученные данные. Если вы намереваетесь использовать эту функцию для вызова каких либо программ, рекомендую подстраховаться от проблем, связанных с буфферизацией ввода. Например с помощью IO::Handle:

use IO::Handle; pipe(IN,OUT); OUT->autoflush(1); 
Или то же самое, но без подключения IO::Handle:
	select((select(OUT),$| = 1)[0]); 
После того, как передача данных от родителя к потомку завершена, родительский процесс начинает читать данные, приходящие от порожденного процесса, в том числе и от программы, запущенной с посредством функции system. Последним элементом является передача родителю кода завершения вызываемой программы. В порожденном потоке это значение помечается префиксом "exc:" и передается родителю. Родитель культурно дожидается завершения работы потомка (waitpid), после чего рассчитывает код завершения и сигнал - причину завершения, очищает полученный вывод от кода возврата и все это в виде массива из трех элементов возвращает в вызывающий код. Естественно, что для запуска интерактивных программ этот рецепт не подойдет.

Идем дальше. Итак, наша программа запущена, на вводе тело сообщения и куча непонятных аргументов в @ARGV. Ну и черт с ними, нам вовсе необязательно изучать чем аргументирует наш вызов MTA. Точно такие же аргументы мы передадим на вход стандартного локального MDA – пусть сам с ними разбирается. Далее, создадим временный файл в который выгрузим содержимое STDIN – то есть тело сообщения. После выгрузки, с помощью нашей новой функции HiddenExec() запустим kavdaemon и ткнем его носом в наш временный файл. Результат обработки покажет – есть ли в теле сообщения вирусы. О кодах возврата поговорим немного позже. Дальше, если результат проверки позволяет, отсылаем письмо адресату, то есть вызываем стандартный MDA локальной доставки и даем ему скушать наше сообщение. В противном случае, просто убиваем сообщение. Хотя, в принципе если вы коллекционер, то можно и оставить.

Да, кстати. Прежде чем браться за программу, нам еще нужно разобраться с кодами, которые возвращает MDA. Ниже приведены коды ошибок (sysexits.h из сырцов sendmail):

#define EX_OK			0 /* successful termination */ #define EX__BASE		64 /* base value for error messages */ #define EX_USAGE		64 /* command line usage error */ #define EX_DATAERR		65 /* data format error */ #define EX_NOINPUT		66 /* cannot open input */ #define EX_NOUSER		67 /* addressee unknown */ #define EX_NOHOST		68 /* host name unknown */ #define EX_UNAVAILABLE	69 /* service unavailable */ #define EX_SOFTWARE		70 /* internal software error */ #define EX_OSERR		71 /* system error (e.g., can't fork) */ #define EX_OSFILE		72 /* critical OS file missing */ #define EX_CANTCREAT	73 /* can't create (user) output file */ #define EX_IOERR		74 /* input/output error */ #define EX_TEMPFAIL		75 /* temp failure; user is invited to retry */ #define EX_PROTOCOL		76 /* remote error in protocol */ #define EX_NOPERM		77 /* permission denied */ #define EX_CONFIG		78 /* configuration error */ #define EX__MAX			78 /* maximum listed value */ 
Мы будем использовать эти коды по ходу работы программы, подбирая наиболее подходящие.

Определимся с переменными:

#!/usr/bin/perl -w use strict my %err = ( 0=>'No viruses were found', 1=>'Virus scan was not complete', 2=>'Found corrupted or changed virus', 3=>'Suspicious objects were found', 4=>'Known viruses were detected', 5=>'All viruses disinfected', 6=>'All viruses deleted', 7=>'File AvpDaemon is corrupted', 8=>'Corrupted objects were found'); my $proc = '/usr/bin/procmail';	# Путь к MDA локальной доставки my $temp = '/var/tmp/'.time;		# Имя временного файла my $daem = '/opt/AVP/kavdaemon';	# Путь к демону my $logf = '/var/tmp/scanmail.log';	# Путь к лог-файлу my $from = 'KEEPER@edemnv.ru';	# От него будут отсылаться предупреждения my $exst = 0;				# Статус возврата my $outm = '';				# Для хранения вывода my $sign = 0;				# Сигнал завершения (не используется) my $body = '';				# Для хранения тела сообщения my $size = 0;				# Объем сообщения в байтах my $t_sz = '';				# Текстовое представление объема my $info = scalar(localtime)."\n";	# Ход работы my $sndr = undef;				# Адрес отправителя my $retp = undef;				# Обратный адрес my $subj = undef;				# Тема сообщения my $mlto = undef;				# Адресат 
Далее идет уже непосредственно код обработки сообщений:
my ($head,@line,$l); $body .= $l while ($l = <STDIN>); $size = length($body); $t_size = $size.'b'; $t_sz = int($size / 1024).'Kb' if $size >= 1024; $info .= "Size: $t_sz\n"; unless (defined($head = GetMsgHead())){ 	$info .= "Fatal: message header was not detected!\n"; 	Log($info); 	exit(65); } 
На этом участке программа считывает сообщение, которое передает нам sendmail MTA на стандартный ввод. После этого, мы пытаемся получить заголовок сообщения. Как известстно, заголовок почтового сообщения отделается от тела двойным переносом строки. Функция GetMsgHead() возвращает заголовок или неопределенное значение, в случае если найти заголовок не удалось. Это явный признак 'неправильного' сообщения, поэтому мы выходим с кодом возврата 65.

Далее нам нужно найти в заголовках адрес отправителя, обратный адрес, тему и адресата:

@line = split("\n",$head); foreach (@line){ 	$sndr = $1 if /^from: (.+)/i; 	$retp = $1 if /^return-path: (.+)/i; 	$subj = $1 if /^subject: (.+)/i; 	$mlto = $1 if /^to: (.+)/i; } $info .= "SND:"(defined($sndr) ? $sndr : "N/A"."\n"; $info .= "RET:"(defined($retp) ? $retp : "N/A"."\n"; $info .= "SBJ:"(defined($subj) ? $subj : "N/A"."\n"; $info .= "RCP:"(defined($mlto) ? $mlto : "N/A"."\n"; 
Мы не должны проверять почту, которая приходит с адреса $from, так как этим отправителем наша программа и является. Чтобы избежать лишних проверок добавляем следующий код:
if ($sndr eq $from){ 	($exst,$outm,$sign) = HiddenExec($proc,@ARGV,$body); 
То есть, если письмо от нас самих, то мы его пропускаем без всяких проверок. Внимание! убедитесь, что выбранный вами адрес $from не используется другими программами или пользователями.

Далее, обработка сообщения, пришедшего не от нас:

}else{ 	unless (SaveMsg()){ 		$info .= "Fatal: Tempopary file $temp creation failure\n"; 		Log($info); 		exit(75); 	} 
Здесь мы пытаемся сохрать сообщение во временный файл. В случае неудачи, выходим с соответствующим кодом.

Если нам удалось сохранить сообщение, тогда выполняем попытку запустить демон kavdaemon на предмет сканирования только что сохраненного файла.

	($exst,$outm,$sign) = HiddenExec($daem,"-o{$temp}",undef); 
Если в результате сканирования сообщение претерпело изменения (например, все вирусы были удалены), тогда необходимо его перечитать:
	if ($exst == 5 || $exst = 6){ unless (ReadMsg()){ 			$info .= "Fatal: Failure at reading the changed message!\n"; 			Log($info); 			exit(71); 		} 	} 
Если перечитать файл по каким-то причинам не удалось, то мы записываем об этом в лог-файл. Заметьте, что при этом временный файл, в который мы выгрузили сообщение, не удаляется. В последствии можно просмотреть текст сообщения и направить его вручную.

Мы отправляем сообщение в трех случаях:

  • когда в сообщении вирусы не были обнаружены ($exst == 0);
  • все вирусы были вылечены ($exst == 5);
  • все вирусы были удалены ($exst == 6).

Во всех остальных случаях мы возвращаем сообщение отправителю.

	if ($exst != 5 && $exst != 6 && $exst != 0){ 		$exst = 65; 		$outm = $info; 	}else{ 		($exst,$outm,$sign) = HiddenExec($prog,@ARGV,$body); 	} 	unlink($temp); 	print $outm; 	Log($info); } exit($exst); 
Далее следует удаление временного файла и вывод того, что вернул нам MDA локальной доставки. Далее, запись в лог-файл, и выход с результатом отправки.

В вышеописанном коде есть вызовы нескольких неизвестных нам процедур. Итак, GetMsgHead():

sub GetMsgHead{ 	return $1 if $body =~ /^(.+?)\n\n/s; 	return undef; } 
Как видно - ничего сложного. Так как переменная $body объявлена в области действия модуля, мы не тратим время на передачу параметров. Процедура возвращает неопределенное значение, если найти заголовок не удалось. Далее, SaveMsg(), ReadMsg() и Log(). Код этих функций настолько тривиален, что я не буду их описывать, а просто приведу код:
sub SaveMsg{ return undef unless open(TMP,"> $temp"); return undef unless flock(TMP,2); print TMP $body; flock(TMP,8); close(TMP); return 1; } sub ReadMsg{ return undef unless open(TMP,$temp); return undef unless flock(TMP,2); read(TMP,$body,-s $temp); flock(TMP,8); close(TMP); return 1; } sub Log{ return unless open(LOG,">> $logf"); return unless flock(LOG,2); print LOG $_[0],"End: ",scalar(localtime),"\n\n"; flock(LOG,8); close(LOG); } 
Теперь о sendmail.cf. Все очень просто. Сохраняете скрипт например в /usr/bin/scanmail.pl. Далее, открываем sendmail.cf и ищем подстроку "Mlocal". Там сейчас должно быть что-то вроде:
Mlocal,	P=/usr/bin/procmail, ...куча других аргументов… 		A=/usr/bin/procmail –Y –a $h –d $u 
Я к тому, что нужно только лишь поменять вот эти два пути на путь к нашему скрипту, т.е. для примера на " P=/use/bin/scanmail.pl " и " A=/usr/bin/scanmail.pl –Y –a $h –d $u". Для пущей надежности установите на scanmail.pl бит постоянства. Ну вот, вроде и все. Перезапускаем sendmail и проверяем. Создаем файл virus.com в котором записываем следующую кракозябру:
X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H* 
Аттачим файл к письму, адресованному локальному юзеру. В общем, все должно получиться. Во время отладки смотрите в почтовые логи – помогает. Если вас вдруг уволят с работы, просто меняем строки в sendmail.cf на то, что было раньше. После вашего ухода фирма разоряется на борьбе с вирусами, а мы получаем моральное удовлетворение. Шутю, конечно.
 
Автор: Whirlwind
 
Оригинал статьи: http://woweb.ru/publ/58-1-0-421