Перманентний бан зловмисників за допомогою Fail2Ban + MikroTik

Кілька днів тому я встановив Asterisk, завантажив свою стару конфігурацію з маршрутизацією викликів і мав намір підключитися до місцевого SIP провайдера. Буквально через кілька хвилин після запуску Asterisk'а виявив в логах спроби авторизації на сервері, що не мене анітрохи не здивувало, оскільки така картина спостерігається на будь-якому астериске, дивиться в Інтернет. Було прийнято вольове рішення погратися з коханим микротиком і не менш улюбленим пітоном, і придумати що робити з цими зловмисниками.

Отже, у нас є:
  • Ubuntu Server 14.04 (думаю не принципово, повинно працювати на інших дистрибутивах)
  • Fail2Ban
  • Asterisk (або будь-який інший сервіс, який потрібно захистити від брут форс атак)
  • Роутер MikroTik
  • Руки
  • Бажання винайти велосипед


Прочитавши пару статей (, два) народився наступний концепт:
  1. баним зловмисника на певний час за допомогою Fail2Ban і додаємо запис з його IP адресою в БД MySQL
  2. після певної кількості виданих банів додаємо IP адресу в список заборонених на роутері

А тепер до реалізації рішення.
1. Створюємо БД/таблицю, яка буде містити наступну інформацію — IP адреса, код країни, назва країни, кількість виданих банів, тип атак/сервіс (jail name конфігурації Fail2Ban), остання спроба, перша спроба (з доробком на майбутнє, можливо буду якось ще використовувати ці дані).

Схема
CREATE DATABASE fail2ban CHARACTER SET utf8;

CREATE TABLE `ban_history` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`ip_address` char(15) NOT NULL DEFAULT ",
`country_code` varchar(5) DEFAULT NULL,
`country_name` varchar(30) DEFAULT NULL,
`count` int(11) NOT NULL,
`type` varchar(30) DEFAULT NULL,
`last_attempt` datetime NOT NULL,
`first_attempt` datetime NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;



2. Створюємо скрипт для додавання записів в БД. Скрипт написаний на пітоні і вимагає для своєї роботи такі додаткові модулі — pygeoip і MySQL-python. Обидва модулі легко встановлюються за допомогою пакетного менеджера pip:

pip install pygeoip MySQL-python

Скрипт
#!/usr/bin/env python2
# -*- coding: utf-8 -*-

import os
import urllib
import gzip
import StringIO
import logging
import logging.handlers
import MySQLdb
import MySQLdb.cursors
import ConfigParser
import pygeoip
from datetime import datetime
from import sys exit
from optparse import OptionParser


def main(config, logger, ip_addr, attack_type, GEOIP_DAT):
url = urllib.urlopen('http://geolite.maxmind.com/download/geoip/database/GeoLiteCountry/GeoIP.dat.gz')
url_f = StringIO.StringIO(url.read())
handle = gzip.GzipFile(fileobj=url_f)
with open(GEOIP_DAT, 'w') as out:
for line in handle:
out.write(line)

if config.has_option('general', 'mysql_ip') and config.has_option('general', 'mysql_user') and config.has_option('general', 'mysql_password') and config.has_option('general', 'mysql_db'):
try:
logger.info("Connecting to MySQL host: %s" % config.get('general', 'mysql_ip'))
db = MySQLdb.connect(
host=config.get('general', 'mysql_ip'),
user=config.get('general', 'mysql_user'),
passwd=config.get('general', 'mysql_password'),
db=config.get('general', 'mysql_db'),
cursorclass=MySQLdb.cursors.DictCursor
)

cursor = db.cursor()
logger.debug("Connected")
except MySQLdb.Error, e:
logger.error("Error %d: %s" % (e.args[0], e.args[1]))
exit(2)
else:
query = """select * from ban_history where ip_address='%s' and type='%s'""" % (ip_addr, attack_type)
result = run_query(cursor, query, logger)
result = cursor.fetchall()
now = datetime.now()
gi = pygeoip.GeoIP(GEOIP_DAT, flags=pygeoip.const.MEMORY_CACHE)
country_code = gi.country_code_by_addr(ip_addr)
country_name = gi.country_name_by_addr(ip_addr)
if len(result) > 0:
logger.info("Updating blacklist DB record for IP-address %s" % ip_addr)
result = result[0]
count = result['count'] + 1
query = """update ban_history set count=%s, last_attempt='%s', country_code='%s', country_name='%s' where id=%s""" % (count, now, country_code, country_name, result['id'])
result = run_query(cursor, query, logger)
db.commit()
else:
logger.info("Adding IP-address %s into blacklist DB" % ip_addr)
count = 1
query = """insert into ban_history (ip_address, country_code, country_name, count, type, last_attempt, first_attempt) values('%s', '%s', '%s', %s '%s', '%s', '%s')""" % (ip_addr, country_code, country_name, count, attack_type, now, now)
result = run_query(cursor, query, logger)
db.commit()

else:
logger.error("Configuration incomplete")
exit(3)


def run_query(cursor, query, logger):
try:
logger.debug("Running query \'%s\'" % query)
cursor.execute(query)
except MySQLdb.Error, e:
logger.error("Error %d: %s" % (e.args[0], e.args[1]))
exit(2)
else:
return True


if __name__ == '__main__':
try:
ROOT_PATH = os.path.dirname(os.path.realpath(__file__))
GEOIP_DAT = os.path.join(ROOT_PATH, 'GeoIP.dat')
parser = OptionParser(usage="usage: %prog [-c <configuration_file>] [-v] --ip IP-ADDRESS --type TYPE")
parser.add_option("-v", "--verbose",
action="store_true",
default=False,
dest="verbose",
help="Verbose output")
parser.add_option("-c", "--config",
action="store",
default=False,
dest="cfg_file",
help="Full path to configuration file")
parser.add_option("--ip",
action="store",
default=False,
dest="ip_addr",
help="Attacker IP address")
parser.add_option("--type",
action="store",
default=False,
dest="attack_type",
help="Type of attack (service)")

(options, args) = parser.parse_args()
verbose = options.verbose

ip_addr = options.ip_addr
attack_type = options.attack_type

# Reading configuration file
cfg_file = options.cfg_file
if not cfg_file:
cfg_file = os.path.join(ROOT_PATH, 'blacklist_db.cfg')
config = ConfigParser.RawConfigParser()
config.read(cfg_file)

# Logging
if config.get('general', 'log_file'):
LOGFILE = config.get('general', 'log_file')
else:
LOGFILE = '/tmp/blacklist_db.log'

FORMAT = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s:% (message)s', datefmt='%Y-%m-%d %H:%M:%S')
try:
rotatetime = logging.handlers.TimedRotatingFileHandler(LOGFILE, when="midnight", interval=1, backupCount=14)
except IOError, e:
print "ERROR %s: Can not open log file %s" % (e[0], e[1])
exit(1)
except Exception, e:
print "Can not configure logger - %s" % e
exit(1)

formatter = logging.Formatter('%(asctime)s: %(message)s','%y-%m-%d %H:%M:%S')

rotatetime.setFormatter(FORMAT)
logger = logging.getLogger('BLACKLIST-DB')
logger.addHandler(rotatetime)

if детального:
lvl = logging.DEBUG
console = logging.StreamHandler()
formatter = logging.Formatter('%(asctime)s: %(message)s','%y-%m-%d %H:%M:%S')
console.setFormatter(formatter)
logger.addHandler(console)
else:
lvl = logging.INFO

logger.setLevel(lvl)

if ip_addr and attack_type:
main(config, logger, ip_addr, attack_type, GEOIP_DAT)
else:
logger.error("IP address and attack type are needed but not specified")
exit(1)

except (KeyboardInterrupt):
logger.info("CTRL-C... exit")
exit(0)

except (SystemExit):
logger.info("Exit")
exit(0)




Дані для підключення до БД скрипт бере з конфігураційного файлу за промовчанням, який намагається знайти в тій же директорії, так само можна задати шлях за допомогою ключа "-c".

Приклад кофигурационного файлу[general]
log_file = /var/log/blacklist_db.log
mysql_ip = localhost
mysql_user = db_user
mysql_password = db_pass
mysql_db = fail2ban


Ключовий момент — скрипт виконується разом з додаванням правил iptables, тому я відредагував наступні файли:
/etc/fail2ban/action.d/iptables-allports.conf
# Вихідний варіант
actionban = iptables-I fail2ban<name> 1-s <ip> -j <blocktype>

# Змінений варіант
actionban = iptables-I fail2ban<name> 1-s <ip> -j <blocktype>
/opt/blacklist_db/blacklist_db.py -v --ip <ip> --type <name>


/etc/fail2ban/action.d/iptables-multiport.conf
# Вихідний варіант
actionban = iptables-I fail2ban<name> 1-s <ip> -j <blocktype>

# Змінений варіант
actionban = iptables-I fail2ban<name> 1-s <ip> -j <blocktype>
/opt/blacklist_db/blacklist_db.py -v --ip <ip> --type <name>


/etc/fail2ban/action.d/iptables-new.conf(не впевнений для чого використовується це дія, вніс зміни для вірності)
# Вихідний варіант
actionban = iptables-I fail2ban<name> 1-s <ip> -j <blocktype>

# Змінений варіант
actionban = iptables-I fail2ban<name> 1-s <ip> -j <blocktype>
/opt/blacklist_db/blacklist_db.py -v --ip <ip> --type <name>


Таким чином, після додавання відповідних правил iptables виповнюється наш скрипт і додає, або оновлює дані в БД.

3. Створюємо скрипт для генерування блэклистов, які згодом будуть імпортовані в наш микротик. Скрипт використовує той же конфігураційний файл для отримання налаштувань, необхідних для підключення до БД і так само шукає його у своїй кореневій директорії, знову ж таки можна задати шлях за допомогою ключа "-c". На виході створюється скрипт/список адрес для імпорту в микротик, знову ж таки в тій же самій директорії, можна вказати альтернативний шлях за допомогою ключа "-o". У блэклист потрапляють IP адреси отримали бан 10 і більше разів (if ip['count'] >= 10).

Скрипт
#!/usr/bin/env python2
# -*- coding: utf-8 -*-

import os
import logging
import logging.handlers
import MySQLdb
import MySQLdb.cursors
import ConfigParser
from import sys exit
from optparse import OptionParser


def main(config, logger, output):
if config.has_option('general', 'mysql_ip') and config.has_option('general', 'mysql_user') and config.has_option('general', 'mysql_password') and config.has_option('general', 'mysql_db'):
try:
logger.info("Connecting to MySQL host: %s" % config.get('general', 'mysql_ip'))
db = MySQLdb.connect(
host=config.get('general', 'mysql_ip'),
user=config.get('general', 'mysql_user'),
passwd=config.get('general', 'mysql_password'),
db=config.get('general', 'mysql_db'),
cursorclass=MySQLdb.cursors.DictCursor
)

cursor = db.cursor()
logger.debug("Connected")
except MySQLdb.Error, e:
logger.error("Error %d: %s" % (e.args[0], e.args[1]))
exit(2)
else:
contents = ['/ip firewall address-list']
logger.info('Fetching adresses from the blacklist DB')
query = """select * from ban_history"""
result = run_query(cursor, query, logger)
result = cursor.fetchall()
for ip in result:
if ip['count'] >= 10:
list_name = '%s_BLC' % ip['type'].upper()
logger.info('Adding IP %s into \'%s\' list' % (ip['ip_address'], list_name))
list_line = 'add address=%s list=%s comment=BLACKLIST' % (ip['ip_address'], list_name)
contents.append(list_line)

if len(contents) > 1:
logger.info('Generating mikrotik rsc script...')
script_file = open(output, 'w')
for item in contents:
script_file.write("%s\r\n" % item)

script_file.close()

logger.info('Done')

else:
logger.error("Configuration incomplete")
exit(3)


def run_query(cursor, query, logger):
try:
logger.debug("Running query \'%s\'" % query)
cursor.execute(query)
except MySQLdb.Error, e:
logger.error("Error %d: %s" % (e.args[0], e.args[1]))
exit(2)
else:
return True


if __name__ == '__main__':
try:
ROOT_PATH = os.path.dirname(os.path.realpath(__file__))
parser = OptionParser(usage="usage: %prog [-c <configuration_file>] [-v] [-o <output_file_path>]")
parser.add_option("-v", "--verbose",
action="store_true",
default=False,
dest="verbose",
help="Verbose output")
parser.add_option("-c", "--config",
action="store",
default=False,
dest="cfg_file",
help="Full path to configuration file")
parser.add_option("-o",
action="store",
default=False,
dest="output",
help="Full path for the generated script file")

(options, args) = parser.parse_args()
verbose = options.verbose
output = options.output

if not output:
output = os.path.join(ROOT_PATH, 'blacklists.rsc')

# Reading configuration file
cfg_file = options.cfg_file
if not cfg_file:
cfg_file = os.path.join(ROOT_PATH, 'blacklist_db.cfg')
config = ConfigParser.RawConfigParser()
config.read(cfg_file)

# Logging
if config.get('general', 'log_file'):
LOGFILE = config.get('general', 'log_file')
else:
LOGFILE = '/tmp/blacklist_db.log'

FORMAT = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s:% (message)s', datefmt='%Y-%m-%d %H:%M:%S')
try:
rotatetime = logging.handlers.TimedRotatingFileHandler(LOGFILE, when="midnight", interval=1, backupCount=14)
except IOError, e:
print "ERROR %s: Can not open log file %s" % (e[0], e[1])
exit(1)
except Exception, e:
print "Can not configure logger - %s" % e
exit(1)

formatter = logging.Formatter('%(asctime)s: %(message)s','%y-%m-%d %H:%M:%S')

rotatetime.setFormatter(FORMAT)
logger = logging.getLogger('BLACKLIST-DB')
logger.addHandler(rotatetime)

if детального:
lvl = logging.DEBUG
console = logging.StreamHandler()
formatter = logging.Formatter('%(asctime)s: %(message)s','%y-%m-%d %H:%M:%S')
console.setFormatter(formatter)
logger.addHandler(console)
else:
lvl = logging.INFO

logger.setLevel(lvl)

main(config, logger, output)

except (KeyboardInterrupt):
logger.info("CTRL-C... exit")
exit(0)

except (SystemExit):
logger.info("Exit")
exit(0)




Цей скрипт виконується за допомогою крона, я виставив періодичність запуску в 15 хвилин.
*/15 * * * * /шлях/до/скрипту > /dev/null 2 > &1


4. Імпорт отриманого списку в наш роутер.

Дана частина практично повністю «вкрадена» другої статті.

Раз на годину файл завантажується з сервера по протоколу HTTP за допомогою наступного скрипта (нижче скрипт і правило планувальника для микротика):
# Скрипт для скачування блэклиста, замініть example.com на доменне ім'я або IP-адреса Вашого сервера
/system script add name="Download_blacklists" source={
/tool fetch url="http://example.com/blacklists.rsc" mode=http;
:log info "Downloaded blacklists.rsc";
}

# Правило планувальника для його виконання
/system scheduler add comment="Download blacklists" interval=1h name="DownloadBlackLists" on-event=Download_blacklists start-date=jan/01/1970 start-time=01:05:00


Скрипт для імпорту блэклиста:
# Скрипт
/system script add name="Update_blacklists" source={
/ip firewall address-list remove [/ip firewall address-list find comment="BLACKLIST"];
/import file-name=blacklists.rsc;
:log info "For old blacklists and add new";
}

# Правило планувальника
/system scheduler add comment="Update BlackList" interval=1h name="InstallBlackLists" on-event=Update_blacklists start-date=jan/01/1970 start-time=01:15:00


Для використання цього списку створюються забороняють правила і поміщаються перед дозволеними (т. к. правила виконуються по порядку), в даному прикладі створені 2 правила, для SSH з'єднань і SIP:
/ip firewall filter
add action=reject chain=forward comment="SIP: Reject Blacklisted IP addresses" dst-port=5060-5061 in-interface=ID-Net protocol=udp src-address-list=ASTERISK_BLC
add action=reject chain=forward comment="SSH: Reject Blacklisted IP addresses" dst-port=22 in-interface=ID-Net protocol=tcp src-address-list=SSH_BLC


Де ID-Net ім'я мого зовнішнього інтерфейсу.

Цей «велосипед» ні на що не претендує і був зібраний «на коліні» за пару-трійку годин.
Сподіваюся на конструктивну критику хабровчан і пропозиції з можливим поліпшенням.

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.