Распуши пингвина! Разбираем способы фаззинга ядра Linux

Содержание статьи

  • Что такое фаззинг
  • Простой способ
  • Запускаем ядро
  • Разбираемся со вводами
  • [Не] автоматизируем
  • Готово
  • Способ получше
  • Собираем покрытие
  • Ловим баги
  • Автоматизируем
  • Все вместе
  • Навороченные идеи
  • Вытаскиваем код в юзерспейс
  • Фаззим внешние интерфейсы
  • За пределами API-aware-фаззинга
  • Структурируем внешние вводы
  • Помимо KCOV
  • Собираем релевантное покрытие
  • За пределами сбора покрытия кода
  • Собираем корпус вводов
  • Ловим больше багов
  • Итоги и советы
  • Выводы

Пос­ледние пять лет я ищу уяз­вимос­ти в ядре Linux с помощью фаз­зинга. За это вре­мя я сде­лал три боль­ших про­екта: фаз­зил сетевую под­систе­му со сто­роны сис­темных вызовов (и написал нес­коль­ко экс­пло­итов для най­ден­ных багов), затем фаз­зил ту же сеть с внеш­ней сто­роны и, наконец, фаз­зил под­систе­му USB со сто­роны устрой­ств.

info

Статья написа­на редак­цией «Хакера» по мотивам док­лада «Фаз­зинг ядра Linux» Ан­дрея Конова­лова при учас­тии док­ладчи­ка и изло­жена от пер­вого лица с его раз­решения.

Ког­да я говорю об ата­ках на USB, мно­гие сра­зу вспо­мина­ют Evil HID — одну из атак типа BadUSB. Это ког­да под­клю­чаемое устрой­ство выг­лядит безобид­но, как флеш­ка, а на самом деле ока­зыва­ется кла­виату­рой, которая авто­мати­чес­ки откры­вает кон­соль и дела­ет что‑нибудь нехоро­шее.

В рам­ках моей работы по фаз­зингу такие ата­ки меня не инте­ресо­вали. Я искал в пер­вую оче­редь пов­режде­ния памяти ядра. В слу­чае ата­ки через USB сце­нарий похож на BadUSB: мы под­клю­чаем спе­циаль­ное USB-устрой­ство и оно начина­ет делать нехоро­шие вещи. Но оно не набира­ет коман­ды, при­киды­ваясь кла­виату­рой, а экс­плу­ати­рует уяз­вимость в драй­вере и получа­ет исполне­ние кода внут­ри ядра.

За годы работы над фаз­зингом ядра у меня ско­пилась кол­лекция ссы­лок и нарабо­ток. Я их упо­рядо­чил и прев­ратил в док­лад. Сей­час я рас­ска­жу, какие есть спо­собы фаз­зить ядро, и дам советы начина­ющим иссле­дова­телям, которые решат занять­ся этой темой.

 

Что такое фаззинг

Фаз­зинг — это спо­соб искать ошиб­ки в прог­раммах.

Как он работа­ет? Мы генери­руем слу­чай­ные дан­ные, переда­ем их на вход прог­рамме и про­веря­ем, не сло­малась ли она. Если не сло­малась — генери­руем новый ввод. Если сло­малась — прек­расно, мы наш­ли баг. Пред­полага­ется, что прог­рамма не дол­жна падать от неожи­дан­ного вво­да, она дол­жна этот ввод кор­рек­тно обра­баты­вать.

Кон­крет­ный при­мер: мы берем XML-пар­сер и скар­мли­ваем ему слу­чай­но сге­нери­рован­ные XML-фай­лы. Если он упал — мы наш­ли баг в пар­сере.

Фаз­зеры мож­но делать для любой шту­ки, которая обра­баты­вает вход­ные дан­ные. Это может быть при­ложе­ние или биб­лиоте­ка в прос­транс­тве поль­зовате­ля — юзер­спей­се. Это может быть ядро, может быть про­шив­ка, а может быть даже железо.

Ког­да мы начина­ем работать над фаз­зером для оче­ред­ной прог­раммы, нам нуж­но разоб­рать­ся со сле­дующи­ми воп­росами:

  • Как прог­рамму запус­кать? В слу­чае при­ложе­ния в юзер­спей­се — запус­тить бинар­ник. А вот запус­тить ядро или час­ти про­шив­ки так прос­то не вый­дет.
  • Что слу­жит вход­ными дан­ными? Для XML-пар­сера вход­ные дан­ные — XML-фай­лы. А, нап­ример, бра­узер и обра­баты­вает HTML, и исполня­ет JavaScript.
  • Как вход­ные дан­ные прог­рамме переда­вать? В прос­тей­шем слу­чае дан­ные переда­ются на стан­дар­тный ввод или в виде фай­ла. Но прог­раммы могут получать дан­ные и через дру­гие каналы. Нап­ример, про­шив­ка может получать их от физичес­ких устрой­ств.
  • Как генери­ровать вво­ды? «Вво­дом» будем называть набор дан­ных, передан­ный прог­рамме на вход. В качес­тве вво­да мож­но соз­давать мас­сивы ран­домных бай­тов, а мож­но делать что‑нибудь более умное.
  • Как опре­делять факт ошиб­ки? Если прог­рамма упа­ла — это баг. Но сущес­тву­ют ошиб­ки, которые не при­водят к падению. При­мер: утеч­ка информа­ции. Такие ошиб­ки тоже хочет­ся находить.
  • Как авто­мати­зиро­вать про­цесс? Мож­но запус­кать прог­рамму с новыми вво­дами вруч­ную и смот­реть, не упа­ла ли она. А мож­но написать скрипт, который будет делать это авто­мати­чес­ки.
  • Се­год­ня мы говорим о ядре Linux, так что в каж­дом из воп­росов мы можем мыс­ленно заменить сло­во «прог­рамма» на «ядро Linux». А теперь давай поп­робу­ем най­ти отве­ты.

     

    Простой способ

    Для начала при­дума­ем отве­ты поп­роще и раз­работа­ем пер­вую вер­сию нашего фаз­зера.

     

    Запускаем ядро

    Нач­нем с того, как ядро запус­кать. Здесь есть два спо­соба: исполь­зовать железо (компь­юте­ры, телефо­ны или одноплат­ники) или исполь­зовать вир­туаль­ные машины (нап­ример, QEMU). У каж­дого свои плю­сы и минусы.

    Ког­да запус­каешь ядро на железе, то получа­ешь сис­тему в том виде, в котором она работа­ет в реаль­нос­ти. Нап­ример, там дос­тупны и работа­ют драй­веры устрой­ств. В вир­туал­ке дос­тупны толь­ко те фичи, которые она под­держи­вает.

    С дру­гой сто­роны, железом гораз­до слож­нее управлять: раз­ливать ядра, перезаг­ружать в слу­чае падения, собирать логи. Вир­туал­ка в этом пла­не иде­аль­на.

    Еще один плюс вир­туаль­ных машин — мас­шта­биру­емость. Что­бы фаз­зить на боль­шем количес­тве железок, их надо купить, что может быть дорого или логис­тичес­ки слож­но. Для мас­шта­биро­вания фаз­зинга в вир­туал­ках дос­таточ­но взять машину помощ­нее и запус­тить их сколь­ко нуж­но.

    Учи­тывая осо­бен­ности каж­дого из спо­собов, вир­туал­ки выг­лядят как луч­ший вари­ант. Но давай для начала отве­тим на осталь­ные воп­росы. Гля­дишь, мы при­дума­ем спо­соб фаз­зить, который не при­вязан к спо­собу запус­ка ядра.

     

    Разбираемся со вводами

    Что явля­ется вход­ными дан­ными для ядра? Ядро обра­баты­вает сис­темные вызовы — сис­колы (syscall). Как передать их в ядро? Давай напишем прог­рамму, которая дела­ет пос­ледова­тель­ность вызовов, ском­пилиру­ем ее в бинарь и запус­тим. Всё: ядро будет интер­пре­тиро­вать наши вызовы.

    Те­перь раз­берем­ся с тем, какие дан­ные переда­вать в сис­колы в качес­тве аргу­мен­тов и в каком поряд­ке сис­колы вызывать.

    Са­мый прос­той спо­соб генери­ровать дан­ные — брать слу­чай­ные бай­ты. Этот спо­соб работа­ет пло­хо: обыч­но прог­раммы, вклю­чая то же ядро, ожи­дают дан­ные в более‑менее кор­рек­тном виде. Если передать им сов­сем мусор, даже эле­мен­тарные про­вер­ки на кор­рек­тность не прой­дут, и прог­рамма отка­жет­ся обра­баты­вать ввод даль­ше.

    Спо­соб луч­ше: генери­ровать дан­ные на осно­ве грам­матики. На при­мере XML-пар­сера: мы можем за­ложить в грам­матику зна­ние о том, что XML-файл сос­тоит из XML-тегов. Таким обра­зом мы обой­дем эле­мен­тарные про­вер­ки и про­ник­нем глуб­же внутрь кода пар­сера.

    Од­нако для ядра такой под­ход надо адап­тировать: ядро при­нима­ет пос­ледова­тель­ность сис­колов с аргу­мен­тами, а это не прос­то мас­сив бай­тов, даже сге­нери­рован­ных по опре­делен­ной грам­матике.

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

    int fd = open("/dev/something", …);ioctl(fd, SOME_IOCTL, &{0x10, ...});close(fd);

    Це­ликом эта прог­рамма — типич­ный ввод, который обра­баты­вает ядро. То есть вво­ды для ядра пред­став­ляют собой пос­ледова­тель­нос­ти сис­колов. При­чем их аргу­мен­ты струк­туриро­ваны, а их резуль­тат может переда­вать­ся от одно­го сис­кола к дру­гому.

    Это все похоже на API некой биб­лиоте­ки — его вызовы при­нима­ют струк­туриро­ван­ные аргу­мен­ты и воз­вра­щают резуль­таты, которые могут переда­вать­ся в сле­дующие вызовы.

    По­луча­ется, что, ког­да мы фаз­зим сис­колы, мы фаз­зим API, который пре­дос­тавля­ет ядро. Я такой под­ход называю API-aware-фаз­зинг.

    В слу­чае ядра Linux, к сожале­нию, точ­ного опи­сания всех воз­можных сис­колов и их аргу­мен­тов нет. Есть нес­коль­ко попыток сге­нери­ровать эти опи­сания авто­мати­чес­ки, но ни одна из них не выг­лядит удов­летво­ритель­ной. Поэто­му единс­твен­ный спо­соб — это написать опи­сания руками.

    Так и сде­лаем: выберем нес­коль­ко сис­колов и раз­работа­ем алго­ритм генери­рова­ния их пос­ледова­тель­нос­тей. Нап­ример, заложим в него, что в ioctl дол­жен переда­вать­ся резуль­тат open и струк­тура пра­виль­ного типа со слу­чай­ными полями.

     

    [Не] автоматизируем

    С авто­мати­заци­ей пока не будем замора­чивать­ся: наш фаз­зер в цик­ле будет генери­ровать вво­ды и переда­вать их ядру. А мы будем вруч­ную монито­рить лог ядра на пред­мет оши­бок типа kernel panic.

     

    Готово

    Всё! Мы отве­тили на все воп­росы и раз­работа­ли прос­той спо­соб фаз­зинга ядра.

    Как запус­кать ядро? В QEMU или на реаль­ном железе
    Что будет вход­ными дан­ными? Сис­темные вызовы
    Как вход­ные дан­ные переда­вать ядру? Че­рез запуск исполня­емо­го фай­ла
    Как генери­ровать вво­ды? На осно­ве API ядра
    Как опре­делять наличие багов? По kernel panic
    Как авто­мати­зиро­вать? while (true) syscall(…)

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

    Ход рас­сужде­ний был прос­тым, но сам под­ход работа­ет прек­расно. Если спе­циалис­та по фаз­зингу ядра Linux спро­сить: «Какой фаз­зер работа­ет опи­сан­ным спо­собом?», то он сра­зу ска­жет: Trinity! Да, фаз­зер с таким алго­рит­мом работы уже сущес­тву­ет. Одно из его пре­иму­ществ — он лег­ко перено­симый. Закинул бинарь в сис­тему, запус­тил — и все, ты уже ищешь баги в ядре.

     

    Способ получше

    Фаз­зер Trinity сде­лали дав­но, и с тех пор мысль в области фаз­зинга ушла даль­ше. Давай поп­робу­ем улуч­шить при­думан­ный спо­соб, исполь­зовав более сов­ремен­ные идеи.

     

    Собираем покрытие

    Идея пер­вая: для генера­ции вво­дов исполь­зовать под­ход coverage-guided — на осно­ве сбор­ки пок­рытия кода.

    Как он работа­ет? Помимо генери­рова­ния слу­чай­ных вво­дов с нуля, мы под­держи­ваем набор ранее сге­нери­рован­ных «инте­рес­ных» вво­дов — кор­пус. И иног­да, вмес­то слу­чай­ного вво­да, мы берем один ввод из кор­пуса и его слег­ка модифи­циру­ем. Пос­ле чего мы исполня­ем прог­рамму с новым вво­дом и про­веря­ем, инте­ресен ли он. А инте­ресен ввод в том слу­чае, если он поз­воля­ет пок­рыть учас­ток кода, который ни один из пре­дыду­щих исполнен­ных вво­дов не пок­рыва­ет. Если новый ввод поз­волил прой­ти даль­ше вглубь прог­раммы, то мы добав­ляем его в кор­пус. Таким обра­зом, мы пос­тепен­но про­ника­ем все глуб­же и глуб­же, а в кор­пусе собира­ются все более и более инте­рес­ные прог­раммы.

    Этот под­ход исполь­зует­ся в двух основных инс­тру­мен­тах для фаз­зинга при­ложе­ний в юзер­спей­се: AFL и libFuzzer.

    Источник: xakep.ru

    Ответить

    Ваш адрес email не будет опубликован. Обязательные поля помечены *