Как динамическая перекомпиляция обрабатывает проверки указателей команд в программной виртуализации?
(Этот вопрос не предназначен конкретно для VirtualBox или x86 как такового, но, поскольку они являются лучшими примерами, которые мне известны, я буду ссылаться на них и спрашивать, как VBox справляется с некоторыми сценариями. Если вы знаете, из других решений, которые не используются VBox, не забудьте упомянуть их.)
Я читал, как VirtualBox выполняет виртуализацию программного обеспечения, и я не понимаю следующее.
Перед выполнением кода кольца 0 CSAM [Code Scanning and Analysis Manager] рекурсивно сканирует его, чтобы обнаружить проблемные инструкции. Затем PATM [Patch Manager] выполняет исправление на месте, то есть заменяет инструкцию переходом в память гипервизора, где встроенный генератор кода поместил более подходящую реализацию. На самом деле, это очень сложная задача, так как есть много странных ситуаций, которые нужно обнаружить и правильно обработать. Таким образом, с его нынешней сложностью можно утверждать, что PATM является передовым компилятором на месте.
Рассмотрим следующую примерную последовательность команд в коде ring-0:
call foo
foo:
mov EAX, 1234
mov EDX, [ESP]
cmp EDX, EAX
jne bar
call do_something_special_if_return_address_was_1234
bar:
...
Вызываемый здесь проверяет, является ли обратный адрес вызывающему 1234
и если это так, он делает что-то особенное. Очевидно, что исправление изменит адрес возврата, поэтому мы должны иметь возможность его обработать.
Документация VirtualBox гласит, что он обнаруживает "проблемные" инструкции и исправляет их на месте, но я не совсем понимаю, как это может работать, по двум причинам:
Кажется, что любая инструкция, которая выставляет указатель инструкции, является "проблематичной", из которых
call
вероятно, самый распространенный (и чрезвычайно). Означает ли это, что VirtualBox должен анализировать и, возможно, исправлять каждыйcall
инструкция это видит в кольце 0? Разве это не снижает производительность? Как они справляются с этим с высокой производительностью? (Случаи, которые они упоминают в своей документации, довольно неясны, поэтому я запутался, почему они не упомянули такую общую инструкцию, если она происходит. И если это не проблема, я не понимаю, почему.)Если поток команд оказывается измененным (например, динамически загружать / выгружать модули ядра), VirtualBox должен динамически обнаруживать это и собирать мусор в недоступных перекомпилированных инструкциях. В противном случае это приведет к утечке памяти. Но это означает, что каждый
mov
инструкция (иpush
инструкции и все остальное, что записывает в память) теперь нужно будет анализировать и потенциально исправлять, возможно, многократно, поскольку это может быть изменение кода, который был исправлен. Казалось бы, это фактически выродило бы весь код гостевого кольца 0 в почти полную программную эмуляцию (поскольку цель перемещения неизвестна во время перекомпиляции), что привело бы к резкому росту стоимости виртуализации, и все же это не то впечатление, которое я получаю от читать документацию. Разве это не проблема? Как это эффективно обрабатывается?
Обратите внимание, что я не спрашиваю об аппаратной виртуализации, такой как Intel VT или AMD-V, и мне не интересно читать о них. Я хорошо знаю, что они вообще избегают этих проблем, но мой вопрос касается чисто виртуализации программного обеспечения.
1 ответ
По крайней мере для QEMU
кажется, что ответ таков: даже в переведенном коде есть отдельный эмулируемый "стек", который настроен на те же значения, что и код, который будет иметься при естественном запуске, и этот "стек" - тот, который читается эмулируемый код, который видит те же значения, как если бы он был запущен изначально.
Это означает, что эмулированный код не может быть переведен для использования call
, ret
или любые другие инструкции по использованию стека напрямую, так как они не будут использовать эмулируемый стек. Следовательно, эти вызовы заменяются переходами к различным битам кода thunk, что делает правильные вещи в терминах вызова эквивалентного переведенного кода.
Детали для QEMU
Кажется, что ОП (разумное) предположение состоит в том, что call
а также ret
инструкции будут появляться в переведенном двоичном файле, а стек будет отражать адреса динамически переведенного кода. На самом деле (в QEMU) происходит то, что call
а также ret
инструкции удаляются и заменяются потоком управления, который не использует стек, а значения в стеке имеют те же значения, что и в собственном коде.
То есть ментальная модель OP заключается в том, что результат преобразования кода в некоторой степени похож на нативный код с некоторыми исправлениями и модификациями. По крайней мере, в случае QEMU, это не совсем так - каждый базовый блок в значительной степени транслируется с помощью Tiny Code Generator (TCG), сначала в промежуточное представление, а затем в целевую архитектуру (даже если исходные и конечные арки являются такой же, как в моем случае). Эта колода имеет хороший обзор многих технических деталей, включая обзор TCG, как показано ниже.
Результирующий код обычно не похож на входной код и обычно увеличивается в размере примерно в 3 раза. Регистры часто используются довольно редко, и вы часто видите последовательные избыточные последовательности. Особенно уместным в этом вопросе является то, что по существу все инструкции потока управления совершенно разные, поэтому ret
а также call
инструкции в нативном коде почти никогда не переводятся на простой call
или же ret
в переведенном коде.
Пример: во-первых, немного кода C с return_address()
вызов, который просто возвращает адрес возврата, и main()
который печатает эту функцию:
#include <stdlib.h>
#include <stdio.h>
__attribute__ ((noinline)) void* return_address() {
// stuff here?
return __builtin_return_address(0);
}
int main(int argc, char **argv) {
void *a = return_address();
printf("%p\n", a);
}
noinline
здесь важно, так как иначе gcc
просто может просто встроить функцию и жестко закодировать адрес непосредственно в сборку, не делая call
или доступ к стеку вообще!
С gcc -g -O1 -march=native
это компилируется в:
0000000000400546 <return_address>:
400546: 48 8b 04 24 mov rax,QWORD PTR [rsp]
40054a: c3 ret
000000000040054b <main>:
40054b: 48 83 ec 08 sub rsp,0x8
40054f: b8 00 00 00 00 mov eax,0x0
400554: e8 ed ff ff ff call 400546 <return_address>
400559: 48 89 c2 mov rdx,rax
40055c: be 04 06 40 00 mov esi,0x400604
400561: bf 01 00 00 00 mov edi,0x1
400566: b8 00 00 00 00 mov eax,0x0
40056b: e8 c0 fe ff ff call 400430 <__printf_chk@plt>
400570: b8 00 00 00 00 mov eax,0x0
400575: 48 83 c4 08 add rsp,0x8
400579: c3 ret
Обратите внимание, что return_address()
возвращается [rsp]
так же, как пример ОП. main()
функция вставляет rdx
, где printf
прочитает это от.
Мы ожидаем, что обратный адрес звонящего return_address
быть инструкцией после звонка, 0x400559
:
400554: e8 ed ff ff ff call 400546 <return_address>
400559: 48 89 c2 mov rdx,rax
... и действительно, это то, что мы видим, когда запускаем его изначально:
person@host:~/dev/test-c$ ./qemu-test
0x400559
Давайте попробуем это в QEMU:
person@host:~/dev/test-c$ qemu-x86_64 ./qemu-test
0x400559
Оно работает! Обратите внимание, что QEMU по умолчанию переводит весь код и помещает его далеко от обычного исходного местоположения (как мы скоро увидим), поэтому нам не нужны никакие специальные инструкции для запуска перевода.
Как это работает под одеялом? Мы можем использовать -d in_asm,out_asm
вариант с QEMU, чтобы увидеть, что он делает из этого кода.
Во-первых, код, ведущий к вызову (IN
часть является родным кодом, а OUT
это то, что QEMU переводит это - извините за синтаксис AT&T, я не могу понять, как изменить это в QEMU):
IN: main
0x000000000040054b: sub $0x8,%rsp
0x000000000040054f: mov $0x0,%eax
0x0000000000400554: callq 0x400546
OUT: [size=123]
0x557c9cf33a40: mov -0x8(%r14),%ebp
0x557c9cf33a44: test %ebp,%ebp
0x557c9cf33a46: jne 0x557c9cf33aac
0x557c9cf33a4c: mov 0x20(%r14),%rbp
0x557c9cf33a50: sub $0x8,%rbp
0x557c9cf33a54: mov %rbp,0x20(%r14)
0x557c9cf33a58: mov $0x8,%ebx
0x557c9cf33a5d: mov %rbx,0x98(%r14)
0x557c9cf33a64: mov %rbp,0x90(%r14)
0x557c9cf33a6b: xor %ebx,%ebx
0x557c9cf33a6d: mov %rbx,(%r14)
0x557c9cf33a70: sub $0x8,%rbp
0x557c9cf33a74: mov $0x400559,%ebx
0x557c9cf33a79: mov %rbx,0x0(%rbp)
0x557c9cf33a7d: mov %rbp,0x20(%r14)
0x557c9cf33a81: mov $0x11,%ebp
0x557c9cf33a86: mov %ebp,0xa8(%r14)
0x557c9cf33a8d: jmpq 0x557c9cf33a92
0x557c9cf33a92: movq $0x400546,0x80(%r14)
0x557c9cf33a9d: mov $0x7f177ad8a690,%rax
0x557c9cf33aa7: jmpq 0x557c9cef8196
0x557c9cf33aac: mov $0x7f177ad8a693,%rax
0x557c9cf33ab6: jmpq 0x557c9cef8196
Ключевая часть здесь:
0x557c9cf33a74: mov $0x400559,%ebx
0x557c9cf33a79: mov %rbx,0x0(%rbp)
Вы можете видеть, что он на самом деле вручную помещает адрес возврата из нативного кода в "стек" (который, как правило, доступен с помощью rbp
). После этого обратите внимание, что нет call
инструкция к return_address
, Скорее имеем:
0x557c9cf33a92: movq $0x400546,0x80(%r14)
0x557c9cf33a9d: mov $0x7f177ad8a690,%rax
0x557c9cf33aa7: jmpq 0x557c9cef8196
В большей части кода r14
кажется указателем на некоторую внутреннюю структуру данных QEMU (т.е. не используется для хранения значений из эмулируемой программы). Вышеуказанные пушки 0x400546
(который является адресом return_address
функция в нативном коде) в поле структуры, на которую указывает r14
палочки 0x7f177ad8a690
в rax
и прыгает на 0x557c9cef8196
, Этот последний адрес встречается повсюду в сгенерированном коде (но его определение нет) и, похоже, является своего рода внутренним диспетчерским или групповым методом. Предположительно, он использует либо собственный адрес, либо, что более вероятно, загадочное значение, указанное в rax
отправить в переведенный return_address
метод, который выглядит так:
----------------
IN: return_address
0x0000000000400546: mov (%rsp),%rax
0x000000000040054a: retq
OUT: [size=64]
0x55c131ef9ad0: mov -0x8(%r14),%ebp
0x55c131ef9ad4: test %ebp,%ebp
0x55c131ef9ad6: jne 0x55c131ef9b01
0x55c131ef9adc: mov 0x20(%r14),%rbp
0x55c131ef9ae0: mov 0x0(%rbp),%rbx
0x55c131ef9ae4: mov %rbx,(%r14)
0x55c131ef9ae7: mov 0x0(%rbp),%rbx
0x55c131ef9aeb: add $0x8,%rbp
0x55c131ef9aef: mov %rbp,0x20(%r14)
0x55c131ef9af3: mov %rbx,0x80(%r14)
0x55c131ef9afa: xor %eax,%eax
0x55c131ef9afc: jmpq 0x55c131ebe196
0x55c131ef9b01: mov $0x7f9ba51f7713,%rax
0x55c131ef9b0b: jmpq 0x55c131ebe196
Первый фрагмент кода, кажется, устанавливает пользовательский "стек" в ebp
(получая это от r14 + 0x20
, который, вероятно, является эмулированной структурой состояния машины) и в конечном итоге читает из "стека" (строки mov 0x0(%rbp),%rbx
) и хранит их в указанном r14
(mov %rbx,0x80(%r14)
).
Наконец-то доходит до jmpq 0x55c131ebe196
, который переводит в рутину эпилога QEMU:
0x55c131ebe196: add $0x488,%rsp
0x55c131ebe19d: pop %r15
0x55c131ebe19f: pop %r14
0x55c131ebe1a1: pop %r13
0x55c131ebe1a3: pop %r12
0x55c131ebe1a5: pop %rbx
0x55c131ebe1a6: pop %rbp
0x55c131ebe1a7: retq
Обратите внимание, что я использую слово "стек" в кавычках выше. Это связано с тем, что этот "стек" является эмуляцией стека, видимой эмулируемой программой, а не истинным стеком, на который указывает rsp
, Истинный стек, на который указывает rsp
контролируется QEMU для реализации эмулируемого потока управления, и эмулируемый код не имеет к нему прямого доступа.
Некоторые вещи могут измениться
Выше мы видим, что содержимое "стека", видимое эмулированным процессом, одинаково в QEMU, но детали стека меняются. Например, адрес стека при эмуляции выглядит иначе, чем изначально (т. Е. Значение rsp
а не материал, на который указывает [rsp]
).
Эта функция:
__attribute__ ((noinline)) void* return_address() {
return __builtin_frame_address(0);
}
обычно возвращает адреса как 0x7fffad33c100
но возвращает адреса как 0x40007ffd00
под QEMU. Это не должно быть проблемой, потому что ни одна действительная программа не должна зависеть от точного абсолютного значения адреса стека. Мало того, что он вообще не определен и непредсказуем, но в последних операционных системах он действительно разработан, чтобы быть полностью непредсказуемым из-за стека ASLR (Linux и Windows оба реализуют это). Программа выше возвращает новый адрес каждый раз, когда я запускаю его изначально (но тот же адрес в QEMU).
Самоизменяющийся код
Вы также упомянули о проблеме изменения потока команд и привели пример загрузки модуля ядра. Во-первых, по крайней мере для QEMU, код переводится только "по требованию". Функции, которые могут быть вызваны, но не выполняются в определенном порядке, никогда не переводятся (вы можете попробовать это с функцией, которая вызывается условно в зависимости от argc
). Таким образом, в общем случае загрузка нового кода в ядро или в процесс в эмуляции пользовательского режима обрабатывается тем же механизмом: код будет просто переведен при первом вызове.
Если код на самом деле является самоизменяющимся (то есть процесс записывает в свой собственный код), то что-то должно быть сделано, поскольку без помощи QEMU продолжит использовать старый перевод. Таким образом, чтобы обнаружить самоизменяющийся код без ущерба для каждой записи в память, нативный код живет только на страницах с разрешениями R+X. Следствием этого является то, что запись вызывает ошибку GP, которую QEMU обрабатывает, отмечая, что код сам изменился, делает недействительным перевод и так далее. Множество деталей можно найти в этой теме и в других местах.
Это разумный механизм, и я ожидаю, что другие виртуальные машины с трансляцией кода сделают нечто подобное.
Обратите внимание, что в случае самоизменяющегося кода проблема "сборки мусора" проста: эмулятор информируется о событии SMC, как описано выше, и, поскольку на этом этапе он должен выполнить повторный перевод, он отбрасывает старый перевод,