Преобразование кода и конечные автоматы

Дата публикации 19 янв 2023 | Редактировалось 19 янв 2023
Существует такой интересный объект, как конечный автомат (finite automaton).
Этот автомат обладает внутренними состояниями (это просто такие переменные),
при изменении которых происходят определенные действия. Таким образом, есть набор действий,
каждое из которых определяется текущим состоянием автомата, и/или переходом из одного состояния в другое, и матрица, определяющяя переходы между состояниями.

Рассмотрим простую задачку:
есть строка вида "1,3-7,9-100,105" которую надо преобразовать в бинарный вид.
Рассмотрим реализацию, основанную на технологии конечных автоматов (еще
говорят switch-технология).

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

Код (Text):
  1. int S = 0;
  2.     char*c = "1,3-7,9-100,105";
  3.     for(;;)
  4.     {
  5.       switch(S)
  6.       {
  7.         case 0:
  8.                 if ( isdigit(*c)  ) { S=1; l=*c-'0';      } else
  9.                                     { S=5;                };
  10.                 break;
  11.         case 1:
  12.                 if ( *c == ','    ) { S=0; store(l,l);    } else
  13.                 if ( isdigit(*c)  ) { S=1; l=l*10+*c-'0'; } else
  14.                 if ( *c == '-'    ) { S=2;                } else
  15.                 if ( *c == 0      ) { S=4; store(l,l);    } else
  16.                                     { S=5;                };
  17.                 break;
  18.         case 2:
  19.                 if ( isdigit(*c)  ) { S=3; h=*c-'0';      } else
  20.                                     { S=5;                };
  21.                 break;
  22.         case 3:
  23.                 if ( *c == ','    ) { S=0; store(l,h);    } else
  24.                 if ( isdigit(*c)  ) { S=3; h=h*10+*c-'0'; } else
  25.                 if ( *c == 0      ) { S=4; store(l,h);    } else
  26.                                     { S=5;                };
  27.                 break;
  28.         case 4:
  29.                 exit();
  30.                 break;
  31.         case 5:
  32.                 error();
  33.                 break;
  34.       }
  35.       c++;
  36.     }
Если теперь преобразовать вышеприведенный исходник в более низкоуровневое
представление, при этом избавившись от переменных, то получится вот что:
Код (Text):
  1.  
  2. x:
  3.   x0:
  4.                 if ( isdigit(*c)  ) { l=*c-'0';      c++; goto x1; } else
  5.                                     {                c++; goto x5; };
  6.   x1:
  7.                 if ( *c == ','    ) { store(l,l);    c++; goto x0; } else
  8.                 if ( isdigit(*c)  ) { l=l*10+*c-'0'; c++; goto x1; } else
  9.                 if ( *c == '-'    ) {                     goto x2; } else
  10.                 if ( *c == 0      ) { store(l,l);    c++; goto x4; } else
  11.                                     {                     goto x5; };
  12.   x2:
  13.                 if ( isdigit(*c)  ) { h=*c-'0';      c++; goto x3; } else
  14.                                     {                     goto x5; };
  15.   x3:
  16.                 if ( *c == ','    ) { store(l,h);    c++; goto x0; } else
  17.                 if ( isdigit(*c)  ) { h=h*10+*c-'0'; c++; goto x3; } else
  18.                 if ( *c == 0      ) { store(l,h);    c++; goto x4; } else
  19.                                     {                     goto x5; };
  20.   x4:           exit();
  21.   x5:           error();
  22.         goto x
  23.  
Полученный код, будучи скомпилированным с оптимизацией, крайне труден
для дизассемблирования, и вот по какой причине:
при попытке его восстановления в классические си-конструкции, типа
for, while, if-else, останутся "лишние" goto, и если их будет много,
то понять смысл будет исключительно сложно.
Граф переходов между блоками для подобной программы будет отличаться
от графа для классической программы бОльшим количеством
перекрещивающихся связей.
Для восстановления этого кода в исходный вид,
придется выделить постоянные блоки кода, пронумеровать их, затем заново
ввести переменные-состояния, и только тогда будет возможно пытаться
что-то понять дальше. Но это представляется непростой задачей,
потому что выделение тех же самых блоков кода что и были в исходном
состоянии, после компиляции с оптимизацией становится в некоторых случаях
весьма затруднительным, так как блоки могут делиться на части,
объединяться и перемешиваться.

Теперь рассмотрим более сложный исходник:
Код (Text):
  1.  
  2.     int T[256] =
  3.     {
  4.       4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
  5.       //                      , -      0 1 2 3 4 5 6 7 8 9
  6.       0,0,0,0,0,0,0,0,0,0,0,0,2,3,0,0, 1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,
  7.       0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
  8.       0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
  9.       0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
  10.       0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
  11.       0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
  12.       0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
  13.     };
  14.     for(;;)
  15.     {
  16.       int A;
  17.       switch( S )
  18.       {
  19.         case 0:
  20.                 switch( T[*c] )
  21.                 {
  22.                   case 1:  { S=1; A=1; }; break;
  23.                   default: { S=5; A=2; }; break;
  24.                 }
  25.                 break;
  26.         case 1:
  27.                 switch( T[*c] )
  28.                 {
  29.                   case 2:  { S=0; A=2; }; break;
  30.                   case 1:  { S=1; A=3; }; break;
  31.                   case 3:  { S=2;      }; break;
  32.                   case 4:  { S=4; A=2; }; break;
  33.                   case 0:  { S=5;      }; break;
  34.                 }
  35.                 break;
  36.         case 2:
  37.                 switch( T[*c] )
  38.                 {
  39.                   case 1:  { S=3; A=4; }; break;
  40.                   default: { S=5;      }; break;
  41.                 }
  42.                 break;
  43.         case 3:
  44.                 switch( T[*c] )
  45.                 {
  46.                   case 2:  { S=0; A=5; }; break;
  47.                   case 1:  { S=3; A=6; }; break;
  48.                   case 4:  { S=4; A=5; }; break;
  49.                   default: { S=5;      }; break;
  50.                 }
  51.                 break;
  52.         case 4:
  53.                 A = 7;
  54.                 break;
  55.         case 5:
  56.                 A = 8;
  57.                 break;
  58.       }
  59.       switch( A )
  60.       {
  61.         case 1: { l=*c-'0';      c++; }; break;
  62.         case 2: { store(l,l);    c++; }; break;
  63.         case 3: { l=l*10+*c-'0'; c++; }; break;
  64.         case 4: { h=*c-'0';      c++; }; break;
  65.         case 5: { store(l,h);    c++; }; break;
  66.         case 6: { h=h*10+*c-'0'; c++; }; break;
  67.         case 7: { exit();             }; break;
  68.         case 8: { error();            }; break;
  69.       }
  70.     }
  71.  
Как видим, последний пример написан без команд сравнения и условных
переходов.
В таком виде программа представляет собой цикл из трех частей:
в первой части анализируются внешние данные (здесь это строка символов),
и преобразуются в некоторые внутренние переменные (здесь это показано
неявно через массив T[]), затем вычисляются переходы по таблицам между
состояниями (здесь это первый switch), и одновременно на каждом таком
переходе выбирается из таблиц номер действия (здесь это опять же первый
switch и переменная A), и в третьей части цикла выполняется некоторое
реальное действие (здесь это второй switch).
И вот что интересно: от программы как таковой, мы теперь перешли к
номерам блоков команд, и последовательность этих номеров и является
самой сутью исходной программы.
Рассмотрим теперь такой вопрос: как генерируется номер следующего действия.
Он генерируется в зависимости от текущих состояний конечного автомата.
В случае, если таких переменных-состояний мало,
можно обойтись таблицами, в которых указано, какие следующие состояния
и действия соответствуют текущим состояниям.
Подобные таблицы могут быть преобразованы в функцию.
Требования к такой функции просты: на вход ей подается
число, в котором закодированы текущие состояния, а на выходе
получается число, в котором закодированы следующие состояния и номер
действия.
Таким образом работа программы будет заключаться в итерации
такого цикла:
1. Вызвать функцию, передав в нее текущие состояния,
затем из результата извлечь новые значения состояний и
номер действия.
2. Выполнить действие, определяемое полученным номером.
Рассмотрим следующий вариант.
Состояниями являются значения регистров EAX..EDI, а номером
действия является численное значение байтов, определяющих
некую инструкцию, дополненную либо NOP'ами либо командой RETN.
Тогда ВСЯ наша программа будет являться... функцией.
Изначально, на вход этой функции подается, к примеру,
EAX = 2, ECX = 3, остальные = 0, команда NOP.
Тогда число будет, например, таким:
00000000...00000003000000020000C390
<--EDI->...<--ECX-><--EAX-><--cmd->
На выходе функция дает результат:
00000000...00000003000000020000C341
<--EDI->...<--ECX-><--EAX-><--cmd->
что соответствует команде inc ecx,
и тем же самым состояниям-значениям регистров.
Тогда мы выполняем полученную команду, и меняется состояние ecx,
и мы подаем в функцию такое число:
00000000...00000004000000020000С341
<--EDI->...<--ECX-><--EAX-><--cmd->
и так далее.
К числу, понятно, надо еще добавить уникальный номер
каждой инструкции в программе, потому что сами опкоды
могут повторяться.
Интересно то, что при помощи такой функции, можно закодировать
бОльшую часть ассемблерных команд -- в частности, все условные и
безусловные переходы, а также все команды, изменяющие значения
регистров известным образом. Остаются только команды работы
с памятью, прочими устройствами и команды вызова api-функций.
Возникает вопрос: как построить такую функцию.
И здесь есть огромное количество вариантов, от простых таблиц,
и до нейронных сетей.
В вышеприведенном примере, конечно, ничего не получится,
потому что регистров много, принимать они могут много разных
значений, и поэтому функция будет такой большой, что
не поместится в компьютере. А жаль.
Поэтому рассмотрим упрощенный вариант.
Аргументом функции будет номер состояния.
Результатом будет число размером в WORD.
Старшим байтом результата будет первый байт опкода.
Младшим байтом будет номер состояния,
а его младшим битом -- значение флага ZF.
Поэтому появится возможность убрать из программы
команды вида JZ/JNZ, и закодировать их
посредством переходов между состояниями.
Сама программа будет криптовать текстовую строчку.
Исходный код программы:
ДЕЙСТВИЯ: СОСТОЯНИЯ И ПЕРЕХОДЫ:
Код (Text):
  1.  
  2.                                       NZ          ZR
  3. xormsg:                            -1 --> 00
  4.          xor     edx, edx          00 --> 02   01 --> 02
  5. cycle1:  lea     esi, msg          02 --> 04   03 --> 04
  6.          mov     ecx, msg_size     04 --> 06   05 --> 06
  7. cycle2:  xor     [esi], dh         06 --> 08   07 --> 08
  8.          sub     dh, dl            08 --> 10   09 --> 10
  9.          inc     esi               10 --> 12   11 --> 12
  10.          dec     ecx               12 --> 06   13 --> 14
  11.          jnz     cycle2
  12.          inc     dl                14 --> 16   15 --> 16
  13.          cmp     dl, 'Z'           16 --> 02   17 --> 18
  14.          jne     cycle1
  15.          retn                      18
  16.     Функция, заданная таблицей:
  17.        Аргумент   Значение
  18.         f( -1 ) = 0x3300
  19.         f(0x00) = 0xBE02
  20.         f(0x01) = 0xBE02
  21.         f(0x02) = 0xB904
  22.         f(0x03) = 0xB904
  23.         f(0x04) = 0x3006
  24.         f(0x05) = 0x3006
  25.         f(0x06) = 0x2A08
  26.         f(0x07) = 0x2A08
  27.         f(0x08) = 0x460A
  28.         f(0x09) = 0x460A
  29.         f(0x0A) = 0x490C
  30.         f(0x0B) = 0x490C
  31.         f(0x0C) = 0x3006
  32.         f(0x0D) = 0xFE0E
  33.         f(0x0E) = 0x8010
  34.         f(0x0F) = 0x8010
  35.         f(0x10) = 0xBE02
  36.         f(0x11) = 0x0012
  37.  
Теперь сгенерируем функцию.
Пусть это будет полином. Хоть и тривиально, но интересно.
f(X) = SUMi( C*X^i ), i=0..18

Здесь X -- аргумент функции, который, как мы решили, есть текущее
состояние, а в возвращаемом функцией числе будет закодировано
следующее состояние и первый байт инструкции.
Нахуячим простенькую програмку по подсчету коэффициентов полинома: см. (1)
Теперь у нас есть все, что необходимо для реализации функции.
Вот как она будет выглядеть:
Код (Text):
  1.  
  2.   float calc(float X)
  3.   {
  4.     float y = 0;
  5.     for(int j=0; j<N; j++)
  6.       y += pow(X,j) * C[j];
  7.     return y;
  8.   }
  9.    Или же, в более приятном виде:
  10. N                       equ     19
  11. go_next_state:          pushf  ; IN/OUT: EBX=current/next state
  12.                         pusha
  13.                         finit
  14.                         fild    dword ptr [esp+4*4]     ; pusha.ebx
  15.                         push    N
  16.                         pop     ecx
  17.                         lea     edx, C_table
  18.                         fldz            ; st(1)
  19.                         fld1            ; st(0)
  20. __c1:                   fld     st(0)
  21.                         fld     tbyte ptr [edx]
  22.                         fmulp
  23.                         faddp   st(2),st(0)
  24.                         fmul    st(0),st(2)
  25.                         add     edx, 10
  26.                         loop    __c1
  27.                         fistp   dword ptr [esp+4*4]     ; pusha.ebx
  28.                         fistp   dword ptr [esp+4*4]     ; pusha.ebx
  29.                         popa
  30.                         popf
  31.                         retn
  32. C_table                 label   tbyte
  33.   dt      4.864199999999999986e+04   ; C[ 0]
  34.   dt      1.052028658321440037e+06   ; C[ 1]
  35.   dt     -2.226893544084362929e+06   ; C[ 2]
  36.   dt      1.054917653361728822e+06   ; C[ 3]
  37.   dt      1.030581898921544049e+06   ; C[ 4]
  38.   dt     -1.641889337850409245e+06   ; C[ 5]
  39.   dt      1.056816179457771135e+06   ; C[ 6]
  40.   dt     -4.226269487577827341e+05   ; C[ 7]
  41.   dt      1.179126264336835917e+05   ; C[ 8]
  42.   dt     -2.418954943314347526e+04   ; C[ 9]
  43.   dt      3.749247680199179089e+03   ; C[10]
  44.   dt     -4.446691826539333110e+02   ; C[11]
  45.   dt      4.044639078866551414e+01   ; C[12]
  46.   dt     -2.801020107772053989e+00   ; C[13]
  47.   dt      1.450610807381378830e-01   ; C[14]
  48.   dt     -5.436999378694686739e-03   ; C[15]
  49.   dt      1.391774067561251339e-04   ; C[16]
  50.   dt     -2.174850123595773034e-06   ; C[17]
  51.   dt      1.563443629925163634e-08   ; C[18]
  52.  
Поскольку первый опкод закодирован в старшем байте возвращаемого функцией
числа, в собственно программе он будет отсутствовать.
Вот программа, полученная в результате всех этих извращений:
Код (Text):
  1.  
  2.                         .data
  3. msg                     db      'ой, пошли все копибара, гражданини!',0
  4. msg_size                equ     $-msg
  5. start:
  6.                         call    xormsg
  7.                         push    0
  8.                         push    offset msg
  9.                         push    offset msg
  10.                         push    0
  11.                         callW   MessageBoxA
  12.                         call    xormsg
  13.                         push    0
  14.                         push    offset msg
  15.                         push    offset msg
  16.                         push    0
  17.                         callW   MessageBoxA
  18.                         push    -1
  19.                         callW   ExitProcess
  20. xormsg:
  21.                         xor     ebx, ebx    ; начальное состояние = -1
  22.                         dec     ebx
  23.                         jmp     begin
  24. cycle:                  pushf               ; копируем ZF в младший бит EBX
  25.                         push    eax         ;
  26.                         lahf                ;
  27.                         shr     ah, 6 ; ZF  ;
  28.                         and     ah, 1       ;
  29.                         and     bl, 0FEh    ;
  30.                         or      bl, ah      ;
  31.                         pop     eax         ;
  32.                         popf                ;
  33. begin:
  34.                         call    go_next_state ; вычисляем следующее состояние
  35.                         cmp     bl, 18      ; последнее состояние --> выход
  36.                         jae     quit
  37.                         push    eax ecx     ; берем первый байт инструкции из
  38.                         mov     cl, bh      ; результата функции, и патчим себя
  39.                         mov     bh, 0
  40.                         mov     eax, jtab[ebx*4]
  41.                         mov     [eax], cl
  42.                         pop     ecx eax
  43.                         jmp     jtab[ebx*4] ; переходим на действие
  44. quit:                   retn
  45. jtab                    label   dword       ; таблица указателей на действия
  46.                                             ; index = номер действия =
  47.                         dd      s00,s01     ;       = номер состояния
  48.                         dd      s02,s03
  49.                         dd      s04,s05
  50.                         dd      s06,s07
  51.                         dd      s08,s09
  52.                         dd      s10,s11
  53.                         dd      s12,s13
  54.                         dd      s14,s15
  55.                         dd      s16,s17
  56. s00:
  57. s01:                  ; xor     edx, edx
  58.                         db      ?,0D2h
  59.                         jmp     cycle
  60. s02:
  61. s03:                  ; lea     esi, msg
  62.                         db      ?
  63.                         dd      offset msg
  64.                         jmp     cycle
  65. s04:
  66. s05:                  ; mov     ecx, msg_size
  67.                         db      ?
  68.                         dd      msg_size
  69.                         jmp     cycle
  70. s06:
  71. s07:                  ; xor     [esi], dh
  72.                         db      ?,36h
  73.                         jmp     cycle
  74. s08:
  75. s09:                  ; sub     dh, dl
  76.                         db      ?,0F2h
  77.                         jmp     cycle
  78. s10:
  79. s11:                  ; inc     esi
  80.                         db      ?
  81.                         jmp     cycle
  82. s12:
  83. s13:                  ; dec     ecx
  84.                         db      ?
  85.                         jmp     cycle
  86. s14:
  87. s15:                  ; inc     dl
  88.                         db      ?,0C2h
  89.                         jmp     cycle
  90. s16:
  91. s17:                  ; cmp     dl, 'Z'
  92.                         db      ?,0FAh,'Z'
  93.                         jmp     cycle
  94.  
Теперь, чтобы пронять логику работы программы, надо взять функцию,
и получить все ее значения для всех возможных состояний,
а затем составить таблицу переходов между состояниями,
и заменить их на jz/jnz.
После этого, если программа не была спроектирована как конечный автомат,
так, как это показано в начале статьи, то можно будет реверсировать
ее в классические си-конструкции типа if, for, while.
На этом перейдем к теории.
Самое интересное заключается в том, что все показанные здесь
преобразования кода возможно осуществлять автоматически,
то есть конвертировать части обычных бинарных программ или сорцов
в конечные автоматы и/или заменять порядок выполнения команд
функцией.
Предположим, что в качестве переменной части состояния автомата
был бы использован не флаг ZF, а, скажем, значение регистра AL.
Тогда для получения всей информации, закодированной в функции,
на каждом шаге пришлось бы анализировать 256 вариантов.
А если бы это был регистр AX, то при проверке первых
двух байт файла на MZ, из текущего состояния было бы 65534 перехода
на одну ветвь исполнения, и 2 перехода на другую ветвь.
При еще больших размерах состояний, перебор всех состояний на
каждом шаге стал бы еще более трудным, и тогда часть программы,
отвечающая за изменение того же exe файла была бы пропущена,
то есть не была бы экстрактирована из функции.
В идеале такая функция должна представлять собой черный ящик,
в него подают текущее состояние - он возвращает следующее,
а получить значение из середины последовательности крайне сложно.
В общем, достаточно уже сказано, и на этом я пойду выпью пивка,
а вы сидите и ломайте головы над всем этим бредом - авось пригодится
где-нибудь...

Приложение (1) - программа для подсчета коэффициентов полинома.
Для понимания рекомендуется знание си и
самую малость линейной алгебры.
Код (Text):
  1.  
  2.   #include <windows.h>
  3.   #include <stdio.h>
  4.   #include <stdlib.h>
  5.   #include <assert.h>
  6.   #include <io.h>
  7.   #include <math.h>
  8.   #pragma hdrstop
  9.   #define float    long double
  10.   int N = 19;
  11.   float X[100] = {-1,0,2,4,6, 8,10,12,14,16,1,3,5,7, 9,11,13,15,17 };
  12.   float Y[100] = {
  13.     0+0x3300,
  14.     2+0xBE00,
  15.     4+0xB900,
  16.     6+0x3000,
  17.     8+0x2A00,
  18.    10+0x4600,
  19.    12+0x4900,
  20.     6+0x3000,
  21.    16+0x8000,
  22.     2+0xBE00,
  23.     2+0xBE00,
  24.     4+0xB900,
  25.     6+0x3000,
  26.     8+0x2A00,
  27.    10+0x4600,
  28.    12+0x4900,
  29.    14+0xFE00,
  30.    16+0x8000,
  31.    18+0x0000 };
  32.   float C[100];
  33.   float calc(float X)
  34.   {
  35.     float y = 0;
  36.     for(int j=0; j<N; j++)
  37.       y += pow(X,j) * C[j];
  38.     return y;
  39.   }
  40.   void init()
  41.   {
  42.     float* Z = new float[ N*(N+1) ];
  43.     assert(Z);
  44.   #define Zyx(y,x)       Z[y*(N+1)+x]
  45.     for(int y=0; y<N; y++)
  46.     {
  47.       for(int x=0; x<N; x++)
  48.         Zyx(y,x) = pow(X[y], x);
  49.       Zyx(y,N) = Y[y];
  50.     }
  51.     for(int n=0; n<N-1; n++)
  52.     for(int y=n+1; y<N; y++)
  53.     {
  54.       float t = Zyx(y,n) / Zyx(n,n);
  55.       for(int x=0; x<=N; x++)
  56.         Zyx(y,x) -= Zyx(n,x) * t;
  57.     }
  58.     for(int n=N-1; n>=0; n--)
  59.     {
  60.       float s = 0;
  61.       for(int t=n+1; t<=N-1; t++)
  62.         s += Zyx(n,t) * C[t];
  63.       C[n] = (Zyx(n,N) - s) / Zyx(n,n);
  64.     }
  65.     delete Z;
  66.     for(int i=0; i<N; i++)
  67.       assert(abs(calc(X[i]) - Y[i]) < 0.0001);
  68.   }
  69.   void main()
  70.   {
  71.     init();
  72.     for(int n=0; n<N; n++)
  73.       printf("f(%5.2Lf) (%02X) = %5.2Lf (%04X)\n", X[n],
  74.       (int)ceil(X[n]), Y[n], (int)ceil(Y[n]));
  75.     printf("f(X) = SUMi( C[i]*X^i ), i=0..%i\n", N-1);
  76.     for(int n=0; n<N; n++)
  77.       printf("dt %30.19Le  ; C[%2i]\n", C[n], n);
  78.   }
  79.  

2 1.295
Application

Application
Active Member

Регистрация:
15 окт 2022
Публикаций:
1

Комментарии


      1. x0rum 13 фев 2023
        лайк (EDI != ESI) ^ (EBP | BL)
      2. HESH 19 янв 2023
        Немного теории для читателей - Wiki
        Детерминированные или недетерминированные конечные автоматы, со стеком или без, прекрасно заменяются единой вируальной машиной и благодаря этому получается гораздо меньше кода и въехать в логику работы конкретного движка становится гораздо проще.. При этом функционал прекрасно наследуется обёртками.