Возможная проблема с синхронизацией кэша инструкций в самоизменяющемся коде?

Много связанных вопросов ‹Как синхронизируется кэш инструкций x86? > упомяните x86 должен правильно обрабатывать синхронизацию i-cache в самоизменяющемся коде. Я написал следующий фрагмент кода, который включает и выключает вызов функции из разных потоков, чередующихся с ее выполнением. Я использую операцию сравнения и подкачки в качестве дополнительной защиты, чтобы модификация была атомарной. Но я получаю периодические сбои (SIGSEGV, SIGILL), и анализ дампа ядра заставляет меня подозревать, что процессор пытается выполнить частично обновленные инструкции. Код и анализ приведены ниже. Может быть, я что-то упускаю здесь. Дайте мне знать, если это так.

toggle.c

#include <stdio.h>
#include <inttypes.h>
#include <time.h>
#include <pthread.h>
#include <sys/mman.h>
#include <errno.h>
#include <unistd.h>

int active = 1; // Whether the function is toggled on or off
uint8_t* funcAddr = 0; // Address where function call happens which we need to toggle on/off
uint64_t activeSequence = 0; // Byte sequence for toggling on the function CALL
uint64_t deactiveSequence = 0; // NOP byte sequence for toggling off the function CALL

inline int modify_page_permissions(uint8_t* addr) {

  long page_size = sysconf(_SC_PAGESIZE);
  int code = mprotect((void*)(addr - (((uint64_t)addr)%page_size)), page_size,
    PROT_READ | PROT_WRITE | PROT_EXEC);

  if (code) {
    fprintf(stderr, "mprotect was not successfull! code %d\n", code);
    fprintf(stderr, "errno value is : %d\n", errno);
    return 0;
  }

  // If the 8 bytes we need to modify straddles a page boundary make the next page writable too
  if (page_size - ((uint64_t)addr)%page_size < 8) {
    code = mprotect((void*)(addr-((uint64_t)addr)%page_size+ page_size) , page_size,
      PROT_READ | PROT_WRITE | PROT_EXEC);
    if (code) {
      fprintf(stderr, "mprotect was not successfull! code %d\n", code);
      fprintf(stderr, "errno value is : %d\n", errno);
      return 0;;
    }
  }

  return 1;
}

void* add_call(void* param) {

  struct timespec ts;
  ts.tv_sec = 0;
  ts.tv_nsec = 50000;

  while (1) {
    if (!active) {
      if (activeSequence != 0) {
        int status = modify_page_permissions(funcAddr);
        if (!status) {
          return 0;
        }

        uint8_t* start_addr = funcAddr - 8;

        fprintf(stderr, "Activating foo..\n");
        uint64_t res = __sync_val_compare_and_swap((uint64_t*) start_addr,
                                    *((uint64_t*)start_addr), activeSequence);
        active = 1;
      } else {
        fprintf(stderr, "Active sequence not initialized..\n");
      }
    }

    nanosleep(&ts, NULL);
  }

}

int remove_call(uint8_t* addr) {

  if (active) {
    // Remove gets called first before add so we initialize active and deactive state byte sequences during the first call the remove
    if (deactiveSequence == 0) {
      uint64_t sequence =  *((uint64_t*)(addr-8));
      uint64_t mask = 0x0000000000FFFFFF;
      uint64_t deactive = (uint64_t) (sequence & mask);
      mask = 0x9090909090000000; // We NOP 5 bytes of CALL instruction and leave rest of the 3 bytes as it is

      activeSequence = sequence;
      deactiveSequence = deactive |  mask;
      funcAddr = addr;
    }

    int status = modify_page_permissions(addr);
    if (!status) {
      return -1;
    }

    uint8_t* start_addr = addr - 8;

    fprintf(stderr, "Deactivating foo..\n");
    uint64_t res = __sync_val_compare_and_swap((uint64_t*)start_addr,
                                  *((uint64_t*)start_addr), deactiveSequence);
    active = 0;
    // fprintf(stderr, "Result : %p\n", res);
  }
}

int counter = 0;

void foo(int i) {

  // Use the return address to determine where we need to patch foo CALL instruction (5 bytes)
  uint64_t* addr = (uint64_t*)__builtin_extract_return_addr(__builtin_return_address(0));

  fprintf(stderr, "Foo counter : %d\n", counter++);
  remove_call((uint8_t*)addr);
}

// This thread periodically checks if the method is inactive and if so reactivates it
void spawn_add_call_thread() {
  pthread_t tid;
  pthread_create(&tid, NULL, add_call, (void*)NULL);
}

int main() {

  spawn_add_call_thread();

  int i=0;
  for (i=0; i<1000000; i++) {
    // fprintf(stderr, "i : %d..\n", i);
   foo(i);
  }

  fprintf(stderr, "Final count : %d..\n\n\n", counter);
}

Анализ дампа памяти

Program terminated with signal 4, Illegal instruction.
#0  0x0000000000400a28 in main () at toggle.c:123
(gdb) info frame
 Stack level 0, frame at 0x7fff7c8ee360:
   rip = 0x400a28 in main (toggle.c:123); saved rip 0x310521ed5d
 source language c.
 Arglist at 0x7fff7c8ee350, args:
 Locals at 0x7fff7c8ee350, Previous frame's sp is 0x7fff7c8ee360
 Saved registers:
 rbp at 0x7fff7c8ee350, rip at 0x7fff7c8ee358
(gdb) disas /r 0x400a28,+30
 Dump of assembler code from 0x400a28 to 0x400a46:
  => 0x0000000000400a28 <main+64>:   ff (bad)
     0x0000000000400a29 <main+65>:   ff (bad)
     0x0000000000400a2a <main+66>:   ff eb  ljmpq  *<internal disassembler error>
     0x0000000000400a2c <main+68>:   e7 48  out    %eax,$0x48
 (gdb) disas /r main
  Dump of assembler code for function main:
     0x00000000004009e8 <+0>:    55 push   %rbp
     ...
     0x0000000000400a24 <+60>:   89 c7  mov    %eax,%edi
     0x0000000000400a26 <+62>:   e8 11 ff ff ff callq  0x40093c <foo>
     0x0000000000400a2b <+67>:   eb e7  jmp    0x400a14 <main+44>

Таким образом, как видно, указатель инструкции, по-видимому, расположен внутри адреса внутри инструкции CALL, и процессор, по-видимому, пытается выполнить эту смещенную инструкцию, вызывающую недопустимую ошибку инструкции.


person chamibuddhika    schedule 06.11.2014    source источник
comment
В программировании есть известное предостережение: НИКОГДА не пишите самомодифицирующийся код.   -  person user3629249    schedule 06.11.2014
comment
Да, верно. Считайте, что это скорее исследовательский вопрос. :)   -  person chamibuddhika    schedule 06.11.2014


Ответы (2)


Я думаю, ваша проблема в том, что вы заменили 5-байтовую инструкцию CALL на 5 1-байтовых NOP. Подумайте, что произойдет, когда ваш поток выполнит 3 операции NOP, а затем ваш главный поток решит заменить инструкцию CALL обратно. PC вашего потока будет занимать три байта в середине инструкции CALL и, следовательно, выполнит неожиданную и, вероятно, недопустимую операцию. инструкция.

Что вам нужно сделать, так это поменять местами 5-байтовую инструкцию CALL на 5-байтовую NOP. Вам просто нужно найти многобайтовую инструкцию, которая ничего не делает (например, или создает регистр против самого себя), и если вам нужны дополнительные байты, добавьте некоторые байты префикса, такие как префикс переопределения gs и префикс переопределения размера адреса (оба из что ничего не даст). Используя 5-байтовый NOP, ваш поток гарантированно будет либо в инструкции CALL, либо после инструкции CALL, но никогда внутри нее.

person JS1    schedule 06.11.2014
comment
Я это попробую. Но просто с ума я озадачен, не должна ли вся запись быть атомарной, поскольку я использую операции сравнения и замены, поэтому что-то вроде того, что вы упомянули, не должно быть возможным? - person chamibuddhika; 06.11.2014
comment
@chamibuddhika Вы атомарно обновили восемь байтов. Но декодер инструкций декодирует одну инструкцию за раз, а не восемь байтов за раз. Итак, он декодировал первый байт, выполнил nop, затем декодировал второй байт, выполнил nop, и теперь вы обновили восемь байтов, так что теперь он декодирует третий байт и получает вторую половину инструкции call, которая является мусором. - person Raymond Chen; 06.11.2014
comment
Да, это сработало! Спасибо. Теперь совершенно понятно, как выборка данных блока управления может игнорировать такие изменения даже при операциях с памятью с префиксом блокировки. Я предполагаю, что раньше меня смущало взаимодействие между этими двумя операциями. - person chamibuddhika; 06.11.2014
comment
Приятно слышать, что это сработало. Я подумал об этом еще немного, и если вам нужно позже выгрузить более 5 байтов (по какой-либо причине), вы должны заменить первые несколько байтов инструкцией JMP, которая пропускает все остальные инструкции. Так, например, если вам нужно поменять местами 100 байтов, вы можете поменять местами первые 5 байтов с помощью инструкции JMP, которая пропускает эти 100 байтов. Поскольку вы не можете сделать одну 100-байтовую инструкцию NOP, вам подойдет JMP. Это предполагает, что код не переходит в середину выгружаемого блока. - person JS1; 06.11.2014
comment
Да, это очень хороший момент, который я также понял, думая об этой проблеме. - person chamibuddhika; 06.11.2014

В 80x86 большинство вызовов используют относительное смещение, а не абсолютный адрес. По сути, это «вызвать код по адресу здесь + ‹ смещение , а не «вызвать код по адресу ‹ > >".

Для 64-битного кода смещение может составлять 8 или 32 бита. Он никогда не бывает 64-битным.

Например, для 2-байтовой инструкции «вызов с 8-битным смещением» вы бы удалили 6 байтов перед инструкцией вызова, сам код операции call и операнд инструкции (смещение).

В другом примере для 5-байтовой инструкции «вызов с 32-битным смещением» вы должны удалить 3 байта перед инструкцией вызова, сам код операции call и операнд инструкции (смещение).

Тем не мение...

Это не единственный способ позвонить. Например, можно вызвать с помощью указателя на функцию, где адрес вызываемого кода вообще не находится в инструкции (но может быть в регистре или быть переменной в памяти). Существует также оптимизация под названием «оптимизация хвостового вызова», при которой call, за которой следует ret, заменяется на jmp (вероятно, с некоторыми дополнительными изменениями стека для передачи параметров, очистки локальных переменных вызывающей стороны и т. д.).

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

person Brendan    schedule 06.11.2014
comment
Да, это правда, что не рекомендуется заниматься такого рода хакерством, и для этого многое зависит от того, как вызов выполняется для общего случая. Но предполагая, что этот конкретный вызов является экземпляром вызова из 5 байтов (что имеет место для этого конкретного сценария), моя идея состояла в том, чтобы проверить влияние самоизменяющегося кода на i-cache. Так что мой вопрос по этому поводу носит в основном исследовательский характер, учитывая, что мы можем найти фиксированную последовательность вызовов в заданной точке. Кстати, я думаю, что здесь я осторожен, чтобы не перебить остальные 3 байта во время операции CAS, чтобы они остались нетронутыми. - person chamibuddhika; 06.11.2014
comment
@chamibuddhika: Чтобы сделать это правильно (для плохого определения права), вам нужно загрузить уже существующие байты в mask, чтобы вы изменяли только 5 байтов и оставляли остальные 3 байта без изменений. Также нужно правильно рассчитать смещение (адрес_новой_функции - адрес_возврата). Наконец, CAS глючит (если что-то еще изменит его в неподходящее время, сравнение может завершиться ошибкой, что приведет к отсутствию обмена). - person Brendan; 06.11.2014
comment
Примечание. Чтобы прояснить ошибку CAS; вы ожидаете атомарную загрузку, а затем сравнение и обмен, но на самом деле вы получаете загрузку, за которой следует атомарное сравнение и обмен. Нагрузка не является атомарной по отношению к CAS. - person Brendan; 06.11.2014
comment
Итак, дело в том, что я заранее заполняю последовательность CALL foo из исходной немодифицированной программы, а также делаю вычисление маски NOP только один раз и повторно использую их в последующих переключениях. Итак, я предположил, что конкретные 8 байтов могут когда-либо принимать только одно из этих двух значений из-за атомарности операции CAS по отношению к записи. Я немного не понимаю, что вы имели в виду под нагрузкой здесь. Если это была загрузка старого значения случая, например: CAS (ptr, old_val, new_val), я думаю, это правда. Но даже в этом случае он должен просто выйти из строя, но не оставить никакой наполовину записанной последовательности байтов. - person chamibuddhika; 06.11.2014
comment
Не хватило места в предыдущем комментарии. Интересно, что здесь записанная последовательность байтов кажется правильной, включая всю правильную последовательность CALL. Просто кажется, что указатель инструкции увеличен таким образом, что он улавливает последние три байта инструкции CALL. (ff ff ff правильной последовательности e8 11 ff ff ff) - person chamibuddhika; 06.11.2014