Подмена сетевых вызовов в большом проекте на примере postgres

Тема фаззинга сетевой подсистемы, по крайней мере в рамках опенсорсного сообщетсва, крайне мало разработана. Существует инструмент afl-net, который работает для относительно не сложных случаев, с потенциально не очень большим количеством внутренних состояний системы. При этом у него есть ряд своих ограничений, там никак не рассматривается возможность дозированного поступления данных или внезапного разрыва соединения, и т.п. Для случая нашей системы т.е. postgres, я решили попробовать опереться на собственные силы: подменить сетевые системные вызовы на свои собственные и передавать данные пришедшие из фаззера так как мне будет нужно.

Преуведомление: Для полного понимания этой заметки рекомендуется ознакомится с работой сетевой подсистемы, по книге У. Ричард Стивенс, Unix Профессиональное программирование, глава 16, а так же с методикой подмены системных вызовов. Конкретной статьи не подскажу, но посоветую разобраться как это сделано в preeny, в модуле setstdin. Там логика логика работы прозрачная, и будет просто увидеть как работает подмена. Кроме того по-любому будет полезно погонять вашу систему с strace’ом, чтобы в живую увидеть в какой последовательности сетевые вызовы вызываются.

Текущий статус исследования: Удалось успешно подсунуть постгресу вместо сетевого пакета содержимое файла, без фактического создания сетевого соединения, при этом так, что postgres этого не заметил. К фаззеру пока по ряду причин эта система не подключена.

Результаты предыдущих исследований: У нас уже был прототип фаззинга работы сетевой подсистемы запатченный в систему “на живую”. Куча кода была закомментирована, какие-то магические блобы подставлялись вместо реальных сетевых данных. У этого патча была достаточно большая поверхность и перенос его на следующую версию продукта всякий раз требовал внимания квалифицированного специалиста. Поэтому появилась идея поменять подход, не менять постгрес совсем (или менять минимально) но поменять сами сетевые вызовы, чтобы они транслировали данные пришедшие из фаззера в сетевую подсистему напрямую.

Первое что нам тут понадобилось, это убрать многопроцесность postgres’а с сохранением работы сетевой подсистемы. Фаззинг системы из многих процессов, если и возможен, то лучше все равно этого не делать ;-). Этого удалось добиться малой кровью: если в месте где происходит штатный fork postaster’а в бэкенд оставить только child-ветку и убрать пару проверок, то мы получим одинокий зомби-backend принявший сетевое соединение который завершит работу при дисконекте. Для нужд фаззинга – сойдет.

От подмены сетевых вызовов средствами preeny я отказался. Во-первых один из студентов сообщал что пробовал и у него не получилось, но я правда не стал разбираться почему. Во-вторых меня не устроила гибкость предоставляемая preeny, там сетевой сокет заменяется на STDIN, и у меня как у разработчика нет никаких средств повлиять на происходящее (записать дамп, подавать данные побайтно и т.п.) а я бы в будущем хотел бы. В-третьих postgres осуществляет дополнительное колдунство с сокетом, устанавливая всякие блокировки и т.п. Все равно это колдунство надо будет отключать, переопределяя доп. системные вызовы.

Выяснилось так же, что системные вызовы вполне можно подменять не только через LD_PRELOAD как это делалось в случае с preeny, но и прямо внутри бинарника:

int (*original_bind)(int sockfd, const struct sockaddr *addr,
                socklen_t addrlen);
int bind(int sockfd, const struct sockaddr *addr,
                socklen_t addrlen)
{
    int res = original_bind(sockfd, addr, addrlen);
    if (addr->sa_family == AF_INET)
    {
        binded_ip4_sock = sockfd;
    }
    return res;
}
int main()
{
original_bind = dlsym(RTLD_NEXT, "bind");
...

Последовательность вызовов при установке сетевого соединения в целом такова: bind, select, accept и дальше recv или send в произвольных комбинациях. Гугл и вышеупомянутый Стивенс про них знают все. Спросите.

Примерно в такой последовательности мы их и будем подменять в нашем частном случае:

  • Подменяем bind, в любом случае зовем оригинальную функцию, но если прибиндились к IPv4 сокету, то запоминаем его номер. (см. пример выше). У нас ровно одно IPv4 соединение создается в системе, можем никакие другие параметры не проверять.
  • Следующим управление попадает в подмененный select. Мы в нем можем подождать пока вся процедура старта системы не завершиться, а потом сделать вид что мы “приняли” соединение.

И тут надо сделать отступление: если упрощать сетевые сокеты бывают двух видов. Первый это сокет который был при-bind’жен к кокнретному IP и порту, тогда сокет уже открыт но соединение еще не установлено. Второй возникает когда в открытый сокет, постучался удаленный пользователь. Тогда создается полноценное соединение, и этому соединению присваивается свой уникальный сокет. Этой операцией заимается системный вызов select. Он получает в качестве параметров список binded-сокетов, и возвращает список вновь созданных сокетов, если было установлено соединение с внешним клиентов.

В нашем случае никакого запроса извне на самом деле нет. И создать новый сокет вручную у нас нет никакой возможности. Если вернуть какой-то фейковый номер, то с ним не смогут работать другие вызовы сетевого стека, нам придется переопределять их все. Это много.

Поэтому мы поступим хитрее. Мы в качестве результата работы select вернем номер того самого binded-сокета который нам в select передали. С таким сокетом нельзя будет делать recv и send, но нам этого не надо, мы все равно этим вызовы подменим. Зато другие вызовы пытающиеся получить информацию о сокете будут счастливы от того что им передали реально существующий номер. Поэтому select будет выглядеть примерно так

int select(int nfds, fd_set *readfds, fd_set *writefds,
                  fd_set *exceptfds, struct timeval *timeout)
{
  if (readfds && FD_ISSET(binded_ip4_sock, readfds))
  {
    if (система еще не запустилась)
    {
       sleep(1);
       return 0;
    }
    FD_ZERO(readfds);
    FD_SET(binded_ip4_sock,readfds);
    /*Обнулите еще и writefds и exceptfds если они определены */
   return 1;
  }
  return original_select(nfds, readfds, writefds,
                  exceptfds, timeout)
}
  • После того как select у нас вернет какой-то дескриптор socket’а этот дескриптор попадет в вызов accept. В нашем случае у нас ровно одно сетевое соединение устанавливается, можем считать что accept всегда наш. Нюанс accept ’а в том, что он должен заполнить стурктуру struct sockaddr *addr с сетевой информацией remote-хоста. А раз мы подменяем собой настоящий accept то заполнить эту структуру чем-нибудь валидным должны мы сами:
if (addr)
{
    struct sockaddr_in fake_raddr;
    if (*addrlen > sizeof(struct sockaddr_in))
            *addrlen = sizeof(struct sockaddr_in);
    fake_raddr.sin_family = AF_INET;
    fake_raddr.sin_port = 7777;
    inet_pton(AF_INET, "127.0.0.1", &fake_raddr.sin_addr);

    memcpy(addr,&fake_raddr,*addrlen);
}

И всё. Теперь осталось

  • переопределить recv и send, чтобы для нашего подставного сокета они делали то что нам надо.
ssize_t recv(int sockfd, void *buf, size_t len, int flags)
{
    if (net_socket_fd == binded_ip4_sock)
    {
        /* Заполняйте buf так как хотите */
        return /*размер*/;
    }
    return original_recv(sockfd, buf, len, flags);
}

ssize_t send(int sockfd, const void *buf, size_t len, int flags)
{
    if (net_socket_fd == binded_ip4_sock)
    {
        /* Просто все игнорируем */
        return len;
    }
    return original_send(sockfd, buf, len, flags);
}

Пользуясь вышеописанной методикой я успешно прошел в postgres’е процедуру инициализации соединения без фактического установления сетевого соединения, записав в буфер внутри recv бинарное содержимое ранее сдампленного в файл пакета.

А дальше уже дело техники…