Обратная отладка виртуальных машин в QEMU

Обратная отладка - это как прокрутить фарш назад. Как запихнуть желток и белок обратно в скорлупу. Как сделать доллар по 6 рублей. В общем, она помогает нам находить источники сбоев в программах, перемещаясь "назад во времени" от места их возникновения.

Часто бывает, что сбой в программе происходит совсем не там, где программист реально допустил ошибку. Из-за этого исправлять ошибки, связанные с испорченной памятью, очень трудно. Обратная отладка (она же реверсивная отладка, она же time travel debugging) позволяет "отматывать" ход выполнения программы назад, чтобы посмотреть, что там делалось раньше. Для отдельных приложений такие функции уже были реализованы в gdb, Mozilla RR, WinDbg. Теперь можно делать то же самое и с виртуальными машинами целиком.

Когда мы отлаживаем виртуальную машину, можно изучать работу драйверов или ядра ОС, либо запускать системы без встроенных в них отладчиков. Для этого QEMU содержит эмулятор gdb сервера. То есть, к нему можно подключиться как к обычному отладочному серверу на другом компьютере, а потом отлаживать что угодно. Правда, есть одно ограничение - определять, какой процесс сейчас выполняется, придется самостоятельно, ни gdb, ни QEMU так не умеют.

Как работает обратная отладка

Конечно же, не существует никакого способа выполнить программу в обратном направлении и вернуться в прошлое. Так же, как и узнать исходное значение eax после xor eax, eax. Поэтому работу программы (или виртуальной машины) приходится записывать. При этом обычно записываются не все-все инструкции, а только входные данные программы. А работа процессора считается детерминированной (да, пока что речь только об одноядерном случае).

После того, как всё записано во время настоящего выполнения программы, можно запускать её воспроизведение с этими же данными. С помощью записанного сценария можно будет сколько угодно раз воспроизводить проявившийся гейзенбаг, не боясь его упустить.

Записываем выполнение

Чтобы записать выполнение работы виртуальной машины, в строке запуска QEMU нужно сделать следующее:

  1. Включить запись сценария опцией -icount

  2. Добавить опции для записи операций с диском

  3. Добавить опции для записи входящих сетевых пакетов

Всё вместе это может выглядеть примерно вот так:

qemu-system-i386 \
     -icount shift=auto,rr=record,rrfile=replay.bin \
     -drive file=disk.qcow2,if=none,snapshot,id=img-direct \
     -drive driver=blkreplay,if=none,image=img-direct,id=img-blkreplay \
     -device ide-hd,drive=img-blkreplay \
     -netdev user,id=net1 -device rtl8139,netdev=net1 \
     -object filter-replay,id=replay,netdev=net1

После завершения работы эмулятора, появится файл replay.bin с записанным сценарием работы. Файл с образом диска изменён не будет, так как у него была указана опция snapshot. Это значит, что следующий запуск виртуальной машины начнётся с того же самого состояния диска, что и этот.

Немного про диски

Несмотря на то, что содержимое дисков не меняется от запуска к запуску благодаря опции snapshot, командная строка всё же содержит загрузку дополнительного драйвера, позволяющего корректно воспроизводить дисковые операции. Это всё из-за того, что QEMU - многопоточная программа. Дисковые операции могут завершаться в произвольный момент (и устраивать гонки). А прерывания должны каждый раз приходить одинаково, раз работа всей системы должна быть в точности воспроизведена. Вот и приходится устраивать шаманство в командной строке.

Запускаем воспроизведение и отладку

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

qemu-system-i386 \
     -icount shift=auto,rr=replay,rrfile=replay.bin \
     -drive file=disk.qcow2,if=none,snapshot,id=img-direct \
     -drive driver=blkreplay,if=none,image=img-direct,id=img-blkreplay \
     -device ide-hd,drive=img-blkreplay \
     -netdev user,id=net1 -device rtl8139,netdev=net1 \
     -object filter-replay,id=replay,netdev=net1 \
     -s

Кроме того, в этой командной строке появился ключ -s, позволяющий подключиться к виртуальной машине отладчиком. Для этого запускаем gdb, где вводим команду target remote 127.0.0.1:1234 (или же, сокращённо, tar rem :1234).

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

В тот момент, когда мы подключились отладчиком к виртуальной машине, в ней был создан полный снимок состояния. Теперь можно быстро вернуться туда, просто загрузив снимок. Но это ещё не всё! Этот же снимок может автоматически загружаться для выполнения команд "шаг назад" и "продолжить назад".

Перемещаемся назад во времени

Чтобы пройти на один шаг вперёд, нужно выполнить команду stepi (или же просто si). Команда для шага назад называется reverse-stepi (или rsi). Работает это так. Вы даёте отладчику команду, он передаёт её эмулятору, эмулятор загружает более ранний снимок системы, а потом воспроизводит её работу до момента, предшествующего текущему шагу. Кстати, из этого следует, что за самый первый снимок зайти назад нельзя.

Если идти назад по одной инструкции, то до нужного места можно никогда не дойти. К счастью, есть команда reverse-continue (или rc). Правда, её не получится запустить и смотреть, как в песочных часах песок идёт вверх. Она предназначена для другого - искать, какая из точек останова (breakpoint, watchpoint) сработала бы последней, если бы программа выполнялась "нормально". Для этого опять используются снимки машины:

  1. Загружается предшествующий снимок

  2. Работа машины воспроизводится до текущего момента. При этом запоминаются те точки останова, которые бы могли сработать

  3. Снова загружается предшествующий снимок

  4. Если сработавших точек останова не было, эмулятор переходит к п.1 с более ранним снимком, либо останавливается, если снимки кончились

  5. Снова воспроизводится работа машины, но уже до той точки, что срабатывала последней

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

  1. Ставим watchpoint на ячейку памяти, где хранится испорченный указатель

  2. Выполняем reverse continue

  3. Profit! Пристально смотрим на код, который нам этот указатель перезаписал

Конкретный пример

Рассмотрим драйвер, предназначенный для обрушивания системы (выходит, что это не отладка, ведь драйвер уже налажен и систему обрушивает успешно). Обработчик запросов в нём копирует случайное число байт, что, конечно же, частенько приводит к kernel panic. В этом состоянии никакой отладчик в системе уже не работает.

long usbInfoIoctl(struct file *f, unsigned int cmd, unsigned long arg)
{
  int i;
  struct urb *urb;
  char *buf, result[24];
  struct usb_device *device;
  ...
  /* Receive data from USB */
  transmit_bulk_package(&urb, device, &buf, 36,
      usb_rcvbulkpipe(device, 0x82), 0x00000201);
  /* Some result processing */
  memcpy(result, buf, rand());
  for (i = 0; i < 24; i++)
    printk(KERN_INFO "result[%i] = %c\n", i, result[i]);
  ...
  return 0;
}

Конечно, ядро нам немножко помогает при падении, выдавая стек вызовов. И если мы достаточно умны, можно посмотреть в код, и исправить ошибку. Но этого хватает не всегда.

[938.289683] Kernel panic - not syncing; stack-protector:
Kernel stack is corrupted in: c89e93d0 
[938.289741]
[938.293354] Pid: 2768, comm: test Tainted: G 0 3.2.0-4-686-pae
#1 Debian 3.2.65-l+deb7ul 
[938.293853] Call Trace:
[938.296274]  [<c12c0c2a>] ? panic+0x4d/0xl41
[938.298932]  [<c1038576>] ? __stack_chk_fail+Oxd/Oxd
[938.301428]  [<c89e93d0>] ? usbInfoIoctl+0x217/0x21d [usb_info]
[938.301782]  [<c89e93d0>] ? usbInfoIoctl+0x217/0x21d [usb_info]
[938.302003]  [<c10291ff>] ? kmap_atomic_prot+0x2f/OxeO
[938.302583]  [<c10d9857>] ? do_vfs.ioctl+0x459/0x48f
[938.302784]  [<c12c85a7>] ? do_page_fault+0x342/0x35e
[938.302972]  [<c12c8594>] ? do_page_fault+0x32f/0x35e
[938.303173]  [<c10cIf85>] ? kmem_cache_free+0xle/0x4a
[938.303445]  [<c10cd5e7>] ? do_sys_open+0xc3/0xcd
[938.303642]  [<c10d98d1>] ? sys.ioct1+0x44/0x67
[938.303829]  [<c12c9edf>] ? sysenter_do_call+0x!2/0xl2

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

cat /sys/module/usb_info/sections/.name

После этого добьёмся, чтобы система упала (для этого мы использовали специально созданную программу, обращающуюся к драйверу). Теперь питание эмулятора можно отключить. Так как у нас есть полностью записанный ход выполнения эмулятора, можно отлаживать любой кусок этого сценария сколько угодно раз.

Загружаем в gdb символьную информацию о драйвере с помощью команды add-symbol-file и адреса, который выдала команда cat.

Наличие в выводе kernel panic вызова _stackchk_fail намекает нам на то, что содержимое стека было испорчено. Попробуем посмотреть, был ли перезаписан адрес возврата из функции. Для этого остановимся на последней её строке и поставим watchpoint на ту ячейку, где адрес возврата хранится.

После этого выполним команду reverse-continue. Вуаля! Мы остановились на строке, которая затирает адрес возврата и не даёт нам нормально выйти из функции:

memcpy(result, buf, rand());

Вот полный лог того, что происходило в отладчике:

(gdb) break 375
Breakpoint 1 at 0xc89e93ba: file 
    /home/user/kernelModule/usb_info.c, line 375.
(gdb) continue
Continuing.

Breakpoint 1, usbInfoIoctl (f=0xc5309f0c, cmd=3349205032,
    arg=3310012224) at /home/user/kernelModule/usb_info.c:375
375        return 0;
(gdb) info frame 
Stack level 0, frame at 0xc5309f34:
 eip = 0xc89e93ba in usbInfoIoctl (/home/user/kernelModule/
    usb_info.c:271); saved eip 0xc10d9857
 called by frame at 0xc5567bf0
 source language c.
 Arglist at 0xc5309eec, args: f=0xc4f09b80, cmd=2147791873,
    arg=3216111224
 Locals at 0xc5309eec, Previous frame's sp is 0xc5309f34
 Saved registers:
  esi at 0xc5309f28, edi at 0xc5309f2c, eip at 0xc5309f30
(gdb) watch  *0xc5309f30
(gdb) reverse-continue 
Continuing.

Program received signal SIGTRAP, Trace/breakpoint trap.
0xc89e939e in usbInfoIoctl (f=0xc5309f0c, cmd=3349205032,
    arg=3310012224) at /home/user/kernelModule/usb_info.c:371
371        memcpy(result, buf, rand());

Ещё немного про снимки состояния машины

Если сценарий, который вы отлаживаете, достаточно длительный, можно насоздавать дополнительных снимков состояния виртуальной машины. Можно делать это прямо из gdb с помощью команды monitor savevm <name>. Загрузить ранее созданные снимки можно командой monitor loadvm <name>. Все эти снимки будут автоматически использоваться при выполнении команд обратной отладки.

Если создавать снимки машины так, чтобы они записывались в образ диска, то можно их будет использовать при повторных запусках воспроизведения работы. Чтобы это сделать, нужно убрать у диска опцию snapshot, и включить создание снимка системы при старте записи/воспроизведения. Подробности есть в документации к QEMU.

PS Официально обратная отладка появится в QEMU 5.2, разработка которой пока что не окончена. Но уже сейчас можно собрать эмулятор из исходников самостоятельно, чтобы попользоваться этими функциями.

Реклама
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее

Комментарии 5

    +1
    Даже не представлял, что такое возможно. Спасибо за новые знания!
      0

      Про эмуляторы ретроприставок с перемоткой назад тоже не знали?

        0
        Нет конечно.
      0

      А если проблема в использовании неинициализированной памяти, то легко ли это обнаружить? Ведь перематывая назад выяснится, что мусорное число было до снимка. Или там обычный "baadfood"?

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

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

      Самое читаемое