Vadim Goncharov (nuclight) wrote,
Vadim Goncharov
nuclight

Category:

Torrent: альтернативный способ детектирования

Со времени событий вокруг uTP прошло более 4 месяцев, разработчики uTorrent на месте не стояли, в теме на nag.ru тоже накатали уже более сорока страниц. Придумал и я принципиально новый способ ловить соединения торрентов, но обо всём по порядку.

Вести с полей


До разработчиков дошла весть, что протокол они реализовали не по собственной же спецификации, и в апдейтах uTorrent, а также выпущенной в мае опенсорсной реализации uTP поддерживаются уже оба формата. Как и ожидалось, со сменой формата пошла такая же волна, и сигнатуры пришлось усложнять. Найти их можно всё в той же теме на Наге (первые 20 страниц можно пропустить). Обсуждали и (опять) сторону кооперации - тут ведь такой момент, рубить пришлось и тем провайдерам, которые вполне лояльно относились к торрентам. Причем эффект это дало положительный и для пользователей: на tcp у них качает быстрей. Кто-то ругался в том же духе, что и я в предыдущем посте по теме:
Вы не поверите, но разработчики uTP стремились именно снизить overhead за счет того, что часть функций TCP уже реализована в протоколе BitTorrent (вычисление контрольных сумм, повторная передача частей с плохой суммой). Но они столкнулись с проблемой congestion control, которую до этого люди много лет решали для TCP, что и привело к увеличению нагрузки на сеть. Я вообще удивляюсь, что они не пригласили для разработки uTP ни одного ученого, специализирующегося на congestion control. Это очередное проявление инженерной самонадеянности, что дескать можно обойтись без научного подхода, которая привела к созданию таких уродов, как архитектура х86 и язык PHP.

Однако, как оказалось, разработчики не то что ученых не пригласили - они даже тестировали не в реальных условиях. Создавался uTP для медленных ADSL-линков, ну и ладно, а Ethernet проверяют пусть пользователи. Именно так: админы с Нага подняли тему на форуме разработчиков: http://forum.utorrent.com/viewtopic.php?id=76254 - и получили вот такой ответ:
- Also, if you mentioned laboratory testings - do you check how uTP willl load softrouter comparing to TCP?
- No, we have not run any such tests. To a very large degree, we rely on reports from our users, including you

Экспериментаторы на всея Интернете, тьфу. Впрочем, на определенный контакт они всё же пошли:
Извините за реанимацию темы. Тут Арвид Норберг (BitTorrent Inc) ищет добровольцев, чтобы потраблшутить этот косяк с мелкими пакетами.
Разыскивается: ISP, у которого оборудование просело от uTP, готовый немного поэкспериментировать на своих пользователях.

Однако, коллективный разум nag.ru выдвинул идею, по типу уже имевшей место быть кооперации трекеров с провайдерами насчет retracker.local:
На форуме utorrent пробегала правильная идея насчет выключения uTP путём внесения определенной записи в ДНС, т.е. чтобы этот вопрос решался конкретным сетевым администратором.

Однако разработчики на это предложение ответитили совершенно недвусмысленным отказом. Более того, затем были комментарии уже вполне научного характера - народ разобрался в их намерениях и стал предлагать более совершенные алгоритмы, по анализу выходит, что им, в принципе, даже и не обязательно уменьшать размер пакета для этого. Добровольцы пошли дальше и посмотрели на то, что вместо общепринятого термина latency они вводят свой производный delay (дельта между минимальным latency за последние 2 минуты), это-то ладно, но дальше они привязывают его к совершенно произвольной константе в 100 миллисекунд, без учета реального положения дел в сети. Им был предложен алгоритм (даже на псевдокоде расписали!), который вычисляет нужное значение по статистике за последние 2 минуты - что заодно позволит избавиться и от пилообразных графиков загрузки канала, колебаний вокруг нужного размера. Только куда там, с тех пор прошло более 3 недель, а в теме до сих пор тишина...
А вот как откомментировал открытые исходники libutp в той же теме на nag.ru товарищ с ником Ivan_83:
Но пока:
if (flags == ST_RESET && (conn->conn_id_send == id || conn->conn_id_recv == id))
можно попробовать тупо слать PacketFormatV1 с connid 0 до 65535 и type= ST_RESET; на адрес/порт жертвы.
по идее это оторвёт все коннекты на uTP, только адрес/порт узнать сначало нужно.
Можно написать в фаере заворот (зеркалирование) всего UDP на отдельную машину на один порт, которая будет проверять пакет uTP или нет, если да то слать пачку/несколько ресетов юзеру, можно даже заспуфить IP отправителя (если работать на машине-фаере его можно получить из pf/ipfw). Если даже будет ложный детек-маловеротно что ресет пакеты uTP другие проги воспримут/пострадают.
PS: просмотр чужого кода почти всегда отбивает желание этим пользоваться :(
PPS: чукча не читатель, чукча писатель: повторили косяк с дыркой в DNS, а ведь в изначальном пакете connid был 32 бита, хотя бы, и доп поля не проверяются при разрыве (и вообще не понятно проверяются ли - код бегло пролистал).
PPPS: UDP для торрента хорошо, реализация та ещё - бррр - уже костыли торчат v, v1 и по мелочам.

После чего он реализовал демона, который вешается на divert-сокет и отправляет концам uTP-соединения пакет сброса, т.е. позволяет прервать соединение и в середине (сигнатуры ловили только пакет установления, что означало медленное падение трафика с начала резки). Код он выложил тут: http://www.rozhuk.org.ru/forum/index.php?topic=175
За ним последовал Max Irgiznov, который взялся переделать код его демона в netgraph-ноду, дабы в режиме ядра было производительнее; выложил её в двух вариантах: первый генерирует ответы, второй просто дропает пакеты. Впрочем, код сырой, работает в ядре, так что пользуйтесь на свой страх и риск - демон на divert хоть и работает в userland, как natd, но для UDP-трафика нагрузки, на самом деле, невелики.

О пользе универсальных интерфейсов


Тут надо немножко сказать про этот самый механизм divert(4), который позволяет такие трюки. Давным-давно, в 90-х, когда возникла задача NAT, типичный способ её решения был - прям тут же в ядре реализовать непосредственное решение этой задачи. Это вообще распространенный подход: не особенно задумываясь, взять первое подходящее. Во FreeBSD решили несколько иначе: задачу увидели в чуть более общем виде, как изменение проходящих пакетов, и реализовали соответственно - raw-сокеты были уже известны, ввести аналогичный тип сокетов под эту задачу было несложно. Если задача универсализируется путем довольно простых добавлений - именно так и стоит делать. Хотя это и могло казаться ненужным, ибо долгое время единственным используемым потребителем divert-сокетов был natd - другие пользователи таки появились. Работает divert так: в адресе сокета процессу возвращается идентификатор (номер правила ipfw), на котором пакет был "завернут" (diverted) в userland. Процесс обычно отправляет пакет обратно в ядро с тем же самым адресом, и путь по правилам ipfw продолжаается с прерванного места (см. схему), но может и изменить его произвольным образом, тогда далее пакет пойдет с другого правила. То есть, это способ для приложения послужить аналогом набора правил "skipto" для ipfw.
Именно так, помимо вышеупомянутого демона для uTP, работает, например, ipfw-classifyd: анализирует пакеты на предмет сложных протоколов, в ipfw возвращается только номер правила результата - какой протокол получился, что с ним делать. Преимущество обработки пакетов отдельным демоном - выше надежность системы (баги с падениями бывали актуальны даже для natd, ядро же упало бы в таких случаях в панику), проще программировать, доступно куда больше возможностей. Цена - более низкая производительность на переключениях контекста, но актуально это не для всех задач. Ну, допустим, анализируемого трафика не так много. Или же с ним производятся достаточно сложные действия, начиная с проверки регулярных выражений - здесь затраты на переключение контекста станут несущественными сравнительно с основной работой.

Рыба гниёт с головы


Эта старая поговорка применима не только к незадачливым разработчикам uTorrent, хотя именно их идиотизм и вызвал к жизни послужившие основой сего поста исследования. Здесь мы пойдем дальше - к голове BitTorent-сети.
Какова, собственно, задача шейпинга/блокировки у провайдера? Найти соединения интересующего протокола, то есть пары IP:port (с обеих концов), что с ними делать дальше, известно. Именно задачу поиска пытаются решить фильтры по сигнатурам непосредственно в потоке данных, и именно им усложняет работу шифрование. Казалось бы, неразрешимо, разработчики клиентов всегда будут опережать провайдеров в этой борьбе.
Однако откуда эту информацию берут сами клиенты? Они откуда-то знают, куда соединяться, никакой анализ сигнатур всего проходящего трафика им для этого не требуется. Получают они её с трекера (в случае торрентов). Именно это и есть та "голова" сети, её слабое место. Анализ трафика на сигнатуры кажется естественным, потому что это еще один применяемый к очередному пакету тест (вроде "это порт 80?") непосредственно на том оборудовании, которое тут же применит и действие. Но можно ведь и загрузить правила откуда-то извне, без таких дорогостоящих проверок, если знать списки - кто куда собирается соединиться. Такой анализ, разумеется, сложнее и затратнее - но, с другой стороны, управляющего трафика, от трекера, куда меньше трафика данных, так что в сумме выйдет дешевле.
Предположим, схема такая - мы знаем адреса трекеров, мы отправляем их HTTP-трафик на машину с FreeBSD для анализа. Скрипт на ней выдает правила (ACL) уже непосредственно для тех железок, через которые идут данные. Поскольку трафика трекеров сравнительно не так много, с этим справится скрипт на Perl, висящий на divert-сокете. Тут-то он, divert, и пригождается - в ядро Perl не засунешь.
Здесь надо немного рассказать о том, как торренты работают. Клиент запрашивает у трекера список пиров для каждой раздачи - это адреса и порты других клиентов (пиров), куда он может коннектиться. Трекер отвечает ему в стандартном для торрентов bencoded-формате, внутри HTTP-ответа:
15:04:11.819658 IP bt.http > bmw.4378: P 1018458854:1018459110(256) ack 4282925248 win 8760
        0x0000:  4500 0128 5fca 4000 3706 835d 5faa 8b0d  E..(_.@.7..]_...                 
        0x0010:  c3d0 b120 0050 111a 3cb4 72e6 ff48 40c0  .....P..<.r..H@.                 
        0x0020:  5018 2238 c338 0000 4854 5450 2f31 2e31  P."8.8..HTTP/1.1                 
        0x0030:  2032 3030 204f 4b0d 0a53 6572 7665 723a  .200.OK..Server:                 
        0x0040:  206e 6769 6e78 2f30 2e37 2e36 320d 0a44  .nginx/0.7.62..D                 
        0x0050:  6174 653a 2046 7269 2c20 3230 204e 6f76  ate:.Fri,.20.Nov                 
        0x0060:  2032 3030 3920 3039 3a30 333a 3031 2047  .2009.09:03:01.G                 
        0x0070:  4d54 0d0a 436f 6e74 656e 742d 5479 7065  MT..Content-Type                 
        0x0080:  3a20 7465 7874 2f68 746d 6c3b 2063 6861  :.text/html;.cha                 
        0x0090:  7273 6574 3d77 696e 646f 7773 2d31 3235  rset=windows-125                 
        0x00a0:  310d 0a54 7261 6e73 6665 722d 456e 636f  1..Transfer-Enco                 
        0x00b0:  6469 6e67 3a20 6368 756e 6b65 640d 0a43  ding:.chunked..C                 
        0x00c0:  6f6e 6e65 6374 696f 6e3a 2063 6c6f 7365  onnection:.close                 
        0x00d0:  0d0a 0d0a 3439 0d0a 6438 3a69 6e74 6572  ....49..d8:inter                 
        0x00e0:  7661 6c69 3333 3837 6531 323a 6d69 6e20  vali3387e12:min.                 
        0x00f0:  696e 7465 7276 616c 6933 3338 3765 353a  intervali3387e5:                 
        0x0100:  7065 6572 7332 343a c3d0 b120 f7d3 4e8c  peers24:......N.                 
        0x0110:  07c8 ee8e 4df5 a473 9ec7 4e8c 00b2 5fbe  ....M..s..N..._.                 
        0x0120:  650d 0a30 0d0a 0d0a                      e..0....                         

Формат описан в BEP-003, я не буду на нём подробно останавливаться - достаточно выделенных цветом частей. Внутри словаря (dictionary) с валидным ответом обязательно присутствует ключ interval (в секундах) - через какое время клиенту следует повторно запросить список. В общем-то очевидно, что файрвол может использовать его как время истечения срока действия списка - клиенты могли выключиться, например. А вот с ключом peers всё несколько сложнее. По спецификации ему соответствует список словарей (ассоциативных массивов) с данными клиентов. Но этот формат занимает много места, и сейчас используют упакованный, "компактный" формат: просто строка, из кусков по 6 байт, 4 байта на IP и 2 на порт, для каждого адреса. Трекер обычно ограничивает количество пиров, отдаваемых одному клиенту, так что список должен влезать в один пакет (поэтому пересборку tcp-потока с анализом последующих пакетов я делать не стал).
Скрипт тестировался именно на этой "компактной" форме отдаче (такие тут локальные трекеры под рукой), хотя поддержка спецификации тоже присутствует, просто мне не на чем было проверить.
Примечание: один и тот же порт используется как для TCP, так и для UDP (uTP). Поэтому, например, имея такой список, можно просто ограничить срабатывание на UDP, целиком зарезав uTP, и совсем не тронув TCP.

Итак, вот сам скрипт, всё необходимое реализовано вручную, никаких дополнительных модулей сверх штатной поставки Perl 5 он не требует:
#!/usr/bin/perl -w
#
# A BitTorrent HTTP Tracker Response IP/Port pairs catcher.
#
# (c) Vadim Goncharov (nuclight), 2010.
# Covered by standard 3-clause BSD license.
#
# Uses FreeBSD's divert(4) sockets and prints IP/port pairs catched from
# BitTorrent tracker's responses to clients (may be used to shape/firewall).
# Requires no additional Perl modules. Output format is one line per record,
# four fields space-separated:
#
#   host port client_ip expire_time
#
# where host and port are those of remote listening Torrent, client_ip is IP
# of Torrent client getting response from tracker (probably on your local
# network) and expire time is UNIX timestamp until this tuple is still valid.

use strict;
use POSIX;
use IO::Socket;
use IO::Select;

use constant IP_MAXPACKET    => 65535;  # Maximum IP Packet size
use constant IP_VERSION_IPv4 => 4;      # IP version 4
use constant IP_PROTO_TCP    => 6;      # Transmission Control Protocol

my $div_host = '127.0.0.1'; # actually ignored by divert(4), could be any
my $div_port = 6881;
my $sanitize = 0;           # output or not corrected IPs

# setup the divert socket
my $divsock = IO::Socket::INET->new(LocalHost => $div_host,
                                    LocalPort => $div_port,
                                    Type => IO::Socket::SOCK_RAW,
                                    Proto => 'divert')
    or die "Can't create divert socket: $!\n";

# initialize the select object
my $select = new IO::Select($divsock);

sub ip2str($)
{
    my $ip = shift;
    return join('.', unpack("C4", pack("N", $ip)));
}

sub output
{
    my ($ip, $port, $src, $dst, $endtime) = @_;
    my $safeip;

    if (($safeip = $ip) =~ s/[^-A-Za-z0-9_.]//g) {
        print STDERR "INSECURE FROM $src: $ip $port $dst $endtime\n";
    }

    print "$safeip $port $dst $endtime\n" if ($safeip eq $ip) || $sanitize;
}

sub process_tcp_data
{
    my ($pkt, $src, $dst) = @_;

    if ($pkt =~ m/^HTTP\/1.*\r\n\r\n.*d8:intervali(\d+)e/gs) {
        my $interval = $1;
        my $endtime = time + $interval;

        # packed peer list case
        if ($pkt =~ m/\G.*5:peers(\d+):/gs) {
            my $binpeerlen = $1;
            my $start = pos $pkt;

            # check for incorrect/truncated packets
            if ($start + $binpeerlen > length($pkt)) {
                $binpeerlen = length($pkt) - $start;
                $binpeerlen -= $binpeerlen % 6;
            }

            # do actual work - extract IP/port pairs from binary string
            map {
                my ($ip, $port) = unpack("Nn", $_);
                output(ip2str $ip, $port, $src, $dst, $endtime);
            } unpack("(a6)*", substr($pkt, $start, $binpeerlen));
        }

        # bencoded peer list case (BEP-003)
        if ($pkt =~ m/\G.*5:peersl/gs) {
            while ($pkt =~ m/\Gd2:ip(\d+):/gs) {
                my $ip = substr($pkt, pos $pkt, $1);
                pos $pkt += $1;
                if ($pkt =~ m/\G.*?4:porti(\d+)ee/gs) {
                    output($ip, $1, $src, $dst, $endtime);
                }
            }
        }
        # there've been seen 'interval' without 'peers' in the wild,
        # do nothing in this case (e.g. warning from tracker to client)
    }
}

# main loop
while (1) {
    my ($data, $fwtag, $sock);

    # check when one can read
    foreach $sock ($select->can_read) {
        if ($sock == $divsock) {
            # fetch the packet
            $fwtag = recv($sock, $data, IP_MAXPACKET, 0)
                or die "Unable to read packet: $!\n";

            # check that is IPv4 and TCP
            my $hlen = ord($data);   # ver and header length
            if ((($hlen & 0xf0) >> 4) == IP_VERSION_IPv4) {
                $hlen = ($hlen & 0x0f) * 4;
                my ($proto, $src, $dst, $tcphlen) = (unpack("x9Cx2NNx" . ($hlen-20) . "x12Ca*", $data))[0..3];
                if ($proto == IP_PROTO_TCP) {
                    $tcphlen = ($tcphlen & 0xf0) >> 2;

                    # finally get only TCP payload part
                    if (length($data) > $hlen + $tcphlen) {
                        $data = unpack("x" . ($hlen+$tcphlen) . "a*", $data);
                        process_tcp_data($data, ip2str $src, ip2str $dst);
                    }
                }
            }
        }
    }
}

И попробуйте-ка напишите аналог на PHP! :-p

Nota bene: На самом деле скрипт выполняет пассивный анализ данных без модификации пакетов, поэтому используйте правило tee, а не divert - оно передает в сокет копии пакетов, поскольку скрипт ничего не пишет в ядро обратно (оригиналы бы просто дропались, если бы не tee).

Говорят, что для ряда версий линуксового ядра есть патчи, реализующие divert(4). Так что скрипт портабелен и реализован как часть конструктора - он просто выводит на stdout найденные пары и время их "протухания" в формате Unix. Этот вывод можно пайпом отправить во что-то, что будет преобразовывать в правила конкретного файрвола, будь он на этой же машине или же ACL для удаленной Cisco. На самом деле скрипт выводит так же адрес локального клиента (который делал запрос к трекеру) на случай, если он вдруг понадобится для конкретных правил. Следует иметь в виду, что трекер, среди отданных пар адресов, часто может отдавать адреса и Ваших же клиентов (они ведь наверняка участвуют в раздаче). Поэтому учитывайте направления.
Пример строки вывода такой:
1.2.3.4 5678 192.168.199.123 1278453649
Здесь можно ожидать коннектов от Вашего клиента 192.168.199.123 к удаленному 1.2.3.4:5678 в течение промежутка времени до 02:00 07.07.2010 Москвы. Разумеется, к этому адресу и порту скорее всего будут коннектиться и другие клиенты в локальной сети.

Эксперимент со скриптом на живой сети из ~70 активных торрент-пользователей (100Mbit аплинка в полку, ~11kpps), начатый в районе полуночи выходного дня, за час вывел около 20 тысяч уникальных хостов. Интервал трекера составлял примерно 50 минут, через это время первые записи начали протухать, и сначала число записей еще немного росло, потом начало медленно снижаться (наступала ночь).

Вариант скриптов обвязки для FreeBSD


На FreeBSD нет средства, позволяющего эффективно матчить именно пары ip:port, выбор скуден: или линейный список правил, или только адреса в таблице. Однако с большой долей уверенности мы можем предположить, что в torrent-обмене из внешней сети участвуют только машины конечных клиентов, не сервера, а потому мы можем шейпить их целиком, не опасаясь, что какие-то более приоритетные сервисы на тех машинах задавим. Значит, нам достаточно будет вносить только первое поле из четырех в выводе скрипта в саму таблицу, а четвертое, где unixtime, тоже вносить в таблицу аргументом, но не для использования в файрволе, а для скрипта, который будет чистить старые записи.
Итак, добавляем правило в ipfw для отправки на анализ:
ipfw add 6008 tee 6881 tcp from tracker.torrents.site.ru 80 to any
Допустим, правила шейпера будут использовать таблицу 68. Пусть скрипты добавляют хосты в неё:
$ cat torr_ipfw_tbl_add.sh
#!/bin/sh

torrtbl=68
fwt="/sbin/ipfw table"  # DON'T use "-q": will not return error on add

while read ip port dstip endtime; do
        if ! $fwt $torrtbl add $ip $endtime 2>/dev/null; then
                $fwt $torrtbl delete $ip
                $fwt $torrtbl add $ip $endtime
        fi
done

Как можно заметить, этот скрипт проверяет, была ли ошибка добавления, и если да, то удаляет старую запись - у неё скорее всего время жизни меньше, чем у новой.
Добавляем в крон скрипт для очистки старых записей. В принципе, этот однострочник можно вписать в кронтаб напрямую, без отдельного файла скрипта:
$ cat torr_ipfw_tbl_expire.sh
#!/bin/sh

torrtbl=68
fw="/sbin/ipfw"
$awk="/usr/bin/awk"

$fw table $torrtbl list | $fw -p $awk '{ if ($2 < '`date +%s`') {print "table '$torrtbl' delete " $1}}' /dev/stdin

И, наконец, запускаем всё это дело вертеться в бэкграунде, с сохранением возможных ошибок в файл:
(./torrent_tracker_catch.pl | ./torr_ipfw_tbl_add.sh) >error.log 2>&1 &
Вуаля, теперь ipfw table 68 list | wc -l в любой момент времени можно спросить, сколько их там, несчастных в таблице.

Проблемы, итоги


Наблюдение показало, что сам перловый скрипт практически не ест CPU. А вот приведенные выше скрипты добавления для FreeBSD в отдельные моменты форкались эдак на 10-15% загрузки процессора подопытного роутера на P4. Оптимальнее, конечно, был бы вариант скрипта-демона, самостоятельно следящего за таблицами и не дергающего процесс ipfw на каждую строчку, но это уже не вписывается в объем поста и оставляется домашним заданием, ибо ничего сложного для нормального админа в этом нет.

Проблемным местом является определение того, какие же именно пакеты отдавать в скрипт: очевидно, что весь HTTP - это чересчур много. Надо знать адреса трекеров, но не все они могут быть заранне известны. Выходом может служить детектирование в ядре сигнатуры "\nd8:intervali" (см. выше пример дампа) и отправка в divert-сокет только подпавших под неё пакетов.
К сожалению, во FreeBSD в настоящее время нет штатных средств для поиска подстроки в пакете. Если этот аспект производительности составляет проблему, можно соорудить костыль из 4 программ для ng_bpf для поиска четырех байтов "\nd8:i" последовательным перебором; так можно покрыть около 1000 первых байт пакета.

Однако я надеюсь, что это не понадобится - ни в ближайшее время, ни вообще. В общем-то, сей пост есть превентивный шаг: если разработчики не пойдут на сотрудничество с провайдерами - выше был пример инициативы с DNS, отказали, ну не идиотизм ли. Держателям трекеров (хотя на Западе, кстати, некоторые трекеры сами банят тех, кто с uTP) сейчас ответить нечего - попытка ввести шифрование приведет к сильному возрастанию на них нагрузки, которая и так не маленькая (это ж не оконечный клиент). Там тоже, кстати, идиотизм реализации - трекером обычно служит скрипт на PHP в составе процесса Апача, overhead по использованию ресурсов и требованию к железу дикий. Такое впечатление по количеству идиотизмов, что в их среде много где с головы гниёт... впрочем, об этом сказано уже достаточно.
Tags: freebsd, ipfw, l7, админское, идиоты, сети
Subscribe

  • Post a new comment

    Error

    Anonymous comments are disabled in this journal

    default userpic

    Your reply will be screened

    Your IP address will be recorded 

  • 28 comments