Тексты

  Тексты:

Пхова 2004
ICFPC 2007
ICFPC 2008
Sapka 2009
ICFPC 2009
ICFPC 2010
Защита от взлома

  Переводы:

Природа Будды
Махамудра



Если обнаружите в тексте ошибку, выделите ее мышкой и нажмите Ctrl-Enter.

Пишем защиту от взлома

  1. Постановка задачи
  2. Я занимаюсь разработкой и продажей программ по схеме try before you buy, когда программу может скачать и установить любой желающий, но полная функциональность (по времени работы или по возможностям) доступна лишь тем, кто купил лицензию и ввел правильный код. Взломщик может модифицировать программу таким образом, чтобы вся функциональность была доступна сразу и бесплатно. Соответственно, задача защиты от взлома состоит в том, чтобы сделать такую модификацию настолько сложной, чтобы взлом требовал неоправданно много усилий и взломщик отказался от этой затеи. Задачи полностью исключить взлом не стоит, т.к. это вряд ли вообще возможно.

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

  3. Зачем писать свою защиту
  4. На рынке существует большое количество готовых решений для защиты программ от взлома (протекторов). Зачем же понадобилось изобретать свой велосипед? По нескольким причинам:
    1. Чем популярнее протектор, чем в большем количестве софта он используется, тем больше опытных взломщиков пытаются его взломать, тем больше шанс успешного взлома. И действительно, для многих популярных протекторов уже есть готовые автоматические "анпротекторы" или хотя бы подробные описания того, как такую защиту снимать.
    2. Наиболее надежные протекторы (вроде StarForce) используют такие приемы работы с аппаратурой или операционной системой, которые причиняют массу неудобств легальным пользователям софта: то купленная программа не работает, то из-за нее не работают другие программы, то еще какие-нибудь проблемы возникают. Но даже такие протекторы все равно нередко оказываются взломаны или обойдены.
    3. Многие протекторы стоят ощутимых денег, которые, учитывая вышеназванные моменты, могут не окупиться.
    4. Ну и главное: написание своей защиты - это просто интересно и весело!

  5. Основная идея
  6. Взлом обычной программы состоит из двух основных действий: 1) найти, что нужно изменить, чтобы программа работала как зарегистрированная, и 2) внести эти изменения, не нарушив работоспособности программы. Соответственно, защита от взлома должна затруднять изучение логики частей, отвечающих за регистрацию (остальную логику пусть видят, в ней ничего интересного для взломщика нет), и противодействовать изменению программы. Основной инструмент взломщика - дизассемблер/отладчик, позволяющий перевести машинные коды (или инструкции MSIL в случае .NET) в более-менее читаемую программу, а также прервать ее выполнение в нужном месте или по нужному условию и увидеть, что в этот момент проиcходит. Защищаться можно по-разному: запутывать свой код, заменять прямые ссылки и переходы на косвенные, определять наличие отладчика и т.п. Все это требует больших усилий (или продвинутых инструментов) от программиста, но не останавливает взломщиков. Мы же пойдем другим путем - путем создания своей виртуальной машины с собственным набором команд. Всю логику, которую нужно спрятать от анализа взломщиком, мы скомпилируем в байткод нашей виртуальной машины, который будет исполнен простой и универсальной процедурой. Взломщик может дизассемблировать код исполнителя ВМ, но тот не прольет свет на логику регистрации, т.к. она будет сосредоточена в байткоде, для которого дизассемблера уже не существует. Чтобы проанализировать нужную логику, взломщику как минимум придется реализовать свой дизассемблер, а потом разбираться в тонне незнакомого кода, большая часть которого будет вводящим в заблуждение мусором, автогенеренным нашим компилятором. Собственная ВМ обладает рядом дополнительных плюсов:
    • Это гораздо проще динамического расшифровывания и запуска настоящего машинного кода, т.к. ВМ может быть очень проста.
    • Нет проблем со штуками вроде Data Execution Prevention.
    • Антивирусы не считают такой код подозрительным (частая проблема с промышленными протекторами).
    • Мы имеем полный контроль над компиляцией и полную свободу фантазии по части обфускации получающегося байткода.
    Основная проблема с данным подходом - взломщик может атаковать ту часть программы, которая взаимодействует с нашей ВМ. В простейшем случае - вообще убрать обращение к ней и тем самым обойти весь механизм регистрации. Чтобы этого не происходило, необходимо часть важной для функционирования программы логики также поместить в ВМ.

    Пример:
    Один из моих продуктов - плагин для Adobe After Effects и Premiere Pro, работающий с видео. При запуске плагина виртуальная машина исполняет код, который проверяет правильность ключа и целостность плагина, и в зависимости от результата расшифровывает либо одну последовательность команд ВМ, либо другую. Расшифрованная последовательность команд исполняется во время работы плагина при обработке каждого кадра. В процессе работы плагин портит некоторые свои структуры, а расшифрованный код их восстанавливает. Т.е. если убрать вызов расшифрованного кода, плагин перестанет быть работоспособным. Если плагин не зарегистрирован, то расшифрованный код кроме того рисует на видео полосы, тем самым позволяя оценить результат работы, но не позволяя его использовать на практике, побуждая заинтересованного пользователя к покупке. "Правильный" код, не рисующий полосы, расшифровывается с помощью ключа, извлекаемого из полученной при покупке лицензионной информации, т.е. ключ для расшифровки в самом коде нигде не содержится и, не имея купленной лицензии, расшифровать правильный код нельзя.

  7. Виртуальная машина
  8. Большинство (если не всех) виртуальных машин можно разделить на два типа: стековые и регистровые. В стековой ВМ большинство операций берут аргументы на стеке и туда же кладут результат операции. Такие машины очень удобны тем, что компилятор для них написать существенно проще. Но они имеют один минус: скорость работы получается примерно вдвое хуже, чем у регистровой, т.к. приходится выполнять больше действий. Регистровые ВМ могут оперировать значениями из памяти напрямую, сохраняя промежуточные значения в массиве как бы регистров. Компилировать в такие ВМ чуть сложнее, но все равно довольно просто, как мы увидим дальше. Поскольку скорость работы ВМ для нас важна (надо уметь быстро посчитать контрольную сумму файла), а свой JIT писать сложно, будем использовать регистровую ВМ. Набор команд ВМ будет зависеть от того, какие алгоритмы мы хотим перенести в ВМ. Для проверки регистрационных ключей и целостности программы нам понадобятся арифметические операции над целыми числами, а также возможность читать и писать байты и целые из памяти/массивов. Для управления понадобятся операции сравнения и условных переходов. Арифметические операции будут двухадресными - у каждой такой команды два операнда: source и destination. Этот выбор сделан довольно произвольно, никто не мешает сделать и трехадресные команды. Байткод будет представлять из себя последовательность целых чисел. Каждое такое число будет либо командой, либо операндом, либо мусором - если там, где исполнитель ВМ ожидает команду, находится незнакомое значение, оно игнорируется и ВМ переходит к следующему элементу байткода. Так можно разбавить реальный код отвлекающим мусором. Поскольку каждый операнд - целое число, введем модификаторы команд, которые будут описывать смысл операндов. В случае нативного кода, операнд-источник может быть либо просто числом, либо номером регистра, содержащим значение, либо номером регистра, содержащим адрес в памяти (указатель). В случае .NET указателей у нас нет, поэтому такой операнд может быть либо просто числом, либо индексом в массиве, при этом номер массива может быть задан модификатором команды или отдельным операндом (при этом один из массивов можно рассматривать как набор регистров). Операнд-назначение может быть всем тем же, за исключением числа - мы можем писать результат операции в регистр или память, но не в константу.

    Описать нашу ВМ можно следующим образом. Определяем команды и модификаторы:

    //modifiers: R is number of register, V is value, P is number of register that has a pointer
    #define RR 0x000
    #define RP 0x100
    #define RV 0x200
    #define PR 0x400
    #define PP 0x500
    #define PV 0x600
    
    //commands:
    #define ADD  1  //a1, a2 : a1 += a2
    #define SUB  2  //a1, a2 : a1 -= a2
    #define MUL  3  //a1, a2 : a1 *= a2
    #define DIV  4  //a1, a2 : a1 /= a2
    #define MOD  5  //a1, a2 : a1 %= a2
    #define XOR  6  //a1, a2 : a1 ^= a2
    #define MOV  7  //a1, a2 : a1 = a2
    #define MOVB 8  //a1, a2 : a1 = a2
    MOV и MOVB буду отличаться размером копируемого значения - int для MOV и byte для MOVB.

    Для управления выполнением у ВМ будет индекс текущей команды ip и флаг, который будет выставляться операциями сравнения и использоваться операцией условного перехода:

    #define LE   9  //a1, a2 : flag = a1 < a2 
    #define EQ   10 //a1, a2 : flag = a1 == a2 
    #define RET  0x10000 //r : return r
    #define JZ   0x20000 //v : if !flag then ip += v
    #define JMP  0x30000 //v : ip += v 
    
    Теперь, чтобы вставить в нашу программу байткод, достаточно написать что-то вроде:
    int bytecode1[] = {
    MOV|RV, 1, 2,  // записать в регистр N1 число 2
    MUL|RR, 1, 1,  // умножить регистр N1 на себя
    RET, 1         // вернуть значение регистра N1 в качестве результата выполнения
    };
    В случае С/С++ байткод можно вынести в отдельный файл и писать просто
    int bytecode2[] = {
    #include "bytecode2.r"
    };
    Как видите, нам даже не нужна программа-ассемблер, ее функции исполнит компилятор C++ или C#.

    Исполнитель ВМ будет выглядеть очень просто:

    int VM::Run(int *code, int codelen)
    {
      int ip=0;
      while(ip>=0 && ip<codelen) {
        int cmd = code[ip];
        if (cmd & 0x30000) { //control
          switch(cmd) {
          case RET:
            return R[code[ip+1]];
            break;
          case JZ:
            if (!flag)
              ip += code[ip+1];
            else
              ip += 2;
            break;
          case JMP:
            ip += code[ip+1];
            break;
          }
          continue;
        } //if control
       
        int *target, source;
        if (cmd & 0x400) //target is pointer
          target = (int*)R[code[ip+1]];
        else  //target is register
          target = &R[code[ip+1]];

        switch(cmd & 0x300) { //source is..
        case 0x000://register
          source = R[code[ip+2]];
          break;
        case 0x100://pointer
          source = *(int*)R[code[ip+2]];
          break;
        case 0x200://value
          source = code[ip+2];
          break;
        }

        switch(cmd & 0xFF) {
        case ADD:
          *target += source;
          break;
        case MUL:
          *target *= source;
          break;
        ...
        case MOV:
          *target = source;
          break;
        case MOVB:
          *(BYTE*)target = source;
          break;
        default:
          ip -= 2;
          break;
        }
        ip += 3;
      }
      return -1;
    }

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

  9. Кодогенерация
  10. Хотя у нас уже есть свой ассемблер и мы можем писать программы для нашей ВМ, это довольно сложно, и много так не напишешь. Хотя бы из-за необходимости указывать точное смещение для команд перехода. Хорошо бы иметь что-то более высокоуровневое. Тут на помощь приходит язык Ruby, в котором можно легко записывать сложные древовидные структуры, содержащие не требующие предварительного описания символьные константы (symbol, аналог atom'ов в других языках). На том же Руби несложно написать компилятор из такого древовидного описания программы в наш ассемблер (для стековой машины компилятор умещается в 50 строк, для регистровой - около 200). Что важно, нам не нужно писать свой лексический и синтаксический парсеры, их мы получаем бесплатно, просто используя родные конструкции Руби.

    Рассмотрим этот процесс на примере кода, вычисляющего длину строки и сумму всех ее символов. Пусть он получает в регистре 1 адрес начала строки и сохраняет ее длину в регистре 2, а сумму символов в регистре 3. Тогда код в виде дерева может выглядеть так:

    [:do,
      [:var, :r0],
      [:var, :r1],
      [:var, :r2],
      [:var, :r3],
      [:set, :r0, 0],
      [:set, :r2, :r1],
      [:set, :r3, 0],
      [:while, [:less, :r0, [:getb, :p2]],
        [:do,
         [:inc, :r3, [:getb, :p2]],
         [:inc, :r2, 1]     
        ]
      ],
      [:set, :r2, [:sub, :r2, :r1]]
    ]
    Такой уровень описания все еще весьма низкоуровневый, но он уже достаточно структурированный, чтобы можно было составлять довольно сложные алгоритмы и не задумываться о длине переходов и временных регистрах. C точки зрения Руби это просто некий массив, некоторые элементы которого сами являются массивами и т.д. Теперь напишем компилятор из такой структуры в язык ассемблера нашей ВМ. Подобно Лиспу, здесь у нас каждый узел дерева состоит из команды и операндов и является выражением. Большинство выражений имеют значение, которое можно использовать в других выражениях. Операнды вроде :r2 обозначают регистры, а :p12 - указатели в соответствующих регистрах. Все значения по умолчанию 32-битные, поэтому для извлечения байтового значения по указателю используется отдельная операция :getb.

    Итак, наш компилятор будет читать такую структуру из файла, и выдавать ее перевод на ассемблер:

    if ARGV[0]
      tt = open(ARGV[0]) {|f| f.read } # читаем файл как строку
      prg = eval(tt)                   # превращаем ее в дерево
      s,r = comp(prg)                  # компилируем
      print s                          # выводим
    else
      print "Pass program tree filename as param\n"
    end

    Функция comp получает дерево выражения на вход и выдает два значения: строку с результатом компиляции и номер регистра, содержащий значение выражения (каждое дерево есть выражение, если помните). Внутри она выглядит примерно так:

    def comp(m)
      if m.class==Array && !m.empty?
        case m[0]                               # смотрим на первый элемент массива
          when :add                             # если это :add, компилируем арифм. операцию с командой ADD
            return arith("ADD", m[1], m[2])
          when :mul                             # и т.д.
            return arith("MUL", m[1], m[2])
          ...
          when :inc
            return compset("ADD", m[1], m[2])
          when :less
            return cmp("LE", m[1], m[2])
          when :equal
            return cmp("EQ", m[1], m[2])
          when :if                              # конструция :if
            action1, r1 = comp(m[2])            # компилируем обе ветки
            freereg(r1)                         # освобождая ненужные регистры
            action2, r2 = comp(m[3])            #
            freereg(r2)                         #
            dist1 = action1.count(",")+4        # считаем длину байткода обеих веток
            dist2 = action2.count(",")+2        # по числу запятых в коде, прибавляем команды перехода
            cond, rc = comp(m[1])               # компилируем условие
            return [cond + "JZ, #{dist1},\n" + action1 + "JMP, #{dist2},\n" + action2, -1]
          when :while
            cond, rc = comp(m[1])               # конструция :while
            cond_sz = cond.count(",")           # похожа на :if, даже проще
            action, r = comp(m[2])
            freereg(r)
            action_sz = action.count(",")
            back = cond_sz + action_sz + 2
            return  [cond + "JZ, #{action_sz+4},\n" + action + "JMP, #{-back},\n", -1]
          when :set:
            return compset("MOV", m[1], m[2])
          when :setb:
            return compset("MOVB", m[1], m[2])      
          when :get
            return compget("", m[1])
          when :getb
            return compget("B", m[1])
          when :do                              # :do - последовательность действий
            s = m[1..-1].map {|x|               
             s,r = comp(x)                      # компилируем каждое действие
             freereg(r)                         # освобождая регистры со значениями выражений
             s
            }.join("")                          # соединяем в строку и возвращаем
            return [s,-1]
          when :var
            if m[1].class==Symbol && m[1].to_s[0]==?r
              r = m[1].to_s[1..-1].to_i
            elsif m[1].class==Fixnum
              r = m[1]
            else
              print "Err: var #{m[1]}\n"
            end
            $vars[r] = true
            return ["",-1]
          ...
        end #case
      end
    end

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

    def arith(op, a1, a2)
      s1, m1, arg1 = adst(a1)
      s2, m2, arg2 = asrc(a2)
      freereg(arg2) if m2 != "V"
      s = s1+s2+"#{op}|#{m1}#{m2}, #{arg1}, #{arg2},\n"
      return [s, arg1]
    end

    Функции asrc и adst возвращают код вычисления соответствующего выражения, а также модификатор команды и номер регистра, содержащий значение выражения.

    def asrc(a) #returns [code, modif, atext]
      if a.class==Fixnum || a.class==Bignum
        return ["", "V", a]
      elsif a.class==Symbol && a.to_s[0]==?r
        r = a.to_s[1..-1].to_i
        return ["", "R", r]
      elsif a.class==Symbol && a.to_s[0]==?p
        r = a.to_s[1..-1].to_i
        return ["", "P", r]
      elsif a.class==Array
        s,r = comp(a)
        return [s, "R", r]
      else
        print "Err: unknown source arg #{a}\n"
        return ["","",""]
      end
    end

    def adst(a) #returns [code, modif, atext]
      if a.class==Fixnum
        r = newreg
        return ["MOV|RV, #{r}, #{a},\n", "R", r]
      elsif a.class==Symbol && a.to_s[0]==?r
        r = a.to_s[1..-1].to_i
        nr = newreg
        return ["MOV|RR, #{nr}, #{r},\n", "R", nr]
      elsif a.class==Symbol && a.to_s[0]==?p
        r = a.to_s[1..-1].to_i
        nr = newreg
        return ["MOV|RP, #{nr}, #{r},\n", "R", nr]
      elsif a.class==Array
        s,r = comp(a)
        return [s, "R", r]
      else
        print "Err: unknown dest arg #{a}\n"
        return ["","",""]
      end
    end

    newreg - функция, находящая свободный регистр. Когда требуется сохранить текущее значение выражения, эта функция выделяет регистр, помечая его как занятый. Когда регистр больше не нужен, он освобождается посредством вызова freereg, которая помечает его как свободный. В главной функции comp вы могли заметить, что конструкция :var помечает заданный регистр как используемый под переменную. Это нужно, чтобы компилятор не использовал под временные значения регистры, имеющие особую роль, вроде входных и выходных значений программы. Вот зачем в нашем примере были конструкции [:var, :r1], [:var, :r2] и т.д.

    Операции сравнения :less и :equal компилируются функцией cmp, очень похожей на arith. Функции compset и compget используются для компиляции конструкций чтения и записи в память, но также оказались удобны для операции :inc, увеличивающей регистр на нужную величину без использования промежуточных значений. compset весьма похожа на arith, а compget выглядит так:

    def compget(sz, a)
      s, m, r = asrc(a)
      nr = newreg
      if sz=="B"
        s << "MOV|RV, #{nr}, 0,\n"
        return [s+"MOVB|RP, #{nr}, #{r},\n", nr]
      else
        return [s+"MOV|RP, #{nr}, #{r},\n", nr]
      end
    end

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

    Приведенных примеров кода достаточно, чтобы читатель мог по аналогии дописать недостающие куски и получить готовый компилятор из древообразного представления в ассемблер ВМ. Наш пример с подсчетом длины строки и суммы символов будет скомпилирован в

    MOV|RV, 0, 0,
    MOV|RR, 2, 1,
    MOV|RV, 3, 0,
    MOV|RV, 4, 0,
    MOVB|RP, 4, 2,
    LE|RR, 0, 4,
    JZ, 16,
    MOV|RV, 4, 0,
    MOVB|RP, 4, 2,
    ADD|RR, 3, 4,
    ADD|RV, 2, 1,
    JMP, -23,
    MOV|RR, 4, 2,
    SUB|RR, 4, 1,
    MOV|RR, 2, 4,
    

  11. Компиляция через выполнение
  12. На данный момент у нас есть возможность писать программы для нашей ВМ на структурированном лиспообразном языке, но он все еще очень низкоуровневый, и тексты программ получаются весьма объемными, а синтаксис с этими скобками и запятыми оставляет желать лучшего. Поэтому стоит сделать еще один шаг и получить более высокоуровневый язык, а вышеописанные деревья использовать в компиляторе для промежуточного представления. И опять на помощь приходит Ruby с его гибким синтаксисом, блоками и переопределением операторов. Мы опять не будем писать парсеры, предоставив эту работу самому Руби. По сути, это будет internal (или embedded) DSL. Наш пример со строкой на новом миниязыке будет выглядеть так:
    str = _pbyte 1
    len = _r 2
    sum = _r 3
    sum <= 0
    (p = _pbyte).point str
    zero = _int 0
    _while(zero < p) {
      sum <= sum + p
      p.inc
    }
    len <= p - str
    Это уже гораздо удобней. Синтаксис, хоть и корявый, но вполне терпимый, а объем программ (исходников) примерно втрое меньше, чем в случае с деревом. Плюс, доступны более высокоуровневые приемы, о которых позже.

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

    $stk = []
    $curblock = []

    class E #base expression
      attr_reader :ast  
      def initialize(x = nil) @ast = x end    
      def pop() $curblock.delete self end
    end

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

    def ret(x) $curblock << x; x  end

    class ENumber < E
      def initialize(x) @ast = x end  
      def <=(a) oper(a, :set) end  # assignment
      def +(a) oper(a, :add) end
      def -(a) oper(a, :sub) end
      def *(a) oper(a, :mul) end
      def %(a) oper(a, :mod) end
      def /(a) oper(a, :div) end
      def ^(a) oper(a, :xor) end
      def <(a) oper(a, :less) end
      def eq(a) oper(a, :equal) end
      def add(a) oper(a, :inc) end
      def inc() add(1) end
      
    private  
      def oper(a, opsym) pop; ret ENumber.new([opsym, @ast, to_ast(a)]) end  
    end #of ENumber

    Каждая операция создает соответствующее ей поддерево и добавляет его к текущему блоку (curblock).

    Функция _r создает переменную, привязанную к данному регистру:

    def _r(regnum)
      nr = regnum
      rsym = "r#{nr}".to_sym
      $curblock << E.new([:var, rsym])
      ENumber.new(rsym)  
    end

    А функция to_ast превращает свой параметр в дерево (путем обращения к атрибуту ast для выражений и возвращения своего параметра для констант). Соответственно, если мы рассмотрим строки из приведенного выше примера

    sum = _r 3
    sum <= 0
    то увидим, что первая строка в результате выполнения добавит в массив curblock поддерево [:var, :r3] и создаст переменную sum типа ENumber с символом :r3 в качестве атрибута ast, а вторая строка вызовет у sum переопределенную операцию <=, которая создаст поддерево [:set, :r3, 0] и добавит его к curblock.

    Кроме численных выражений есть еще операции с указателями и соответствующие переменные. Они описываются классами EIntPointer (код не привожу) и EBytePointer, наследующимися от EPointer:

    class EPointer < ENumber
      def <(a) pntoper(a, :less) end
      def eq(a) pntoper(a, :equal) end

      def pntoper(a, opsym)
        pop
        if a.is_a? EPointer
          ret E.new([opsym, rsym, a.rsym])
        else
          ret E.new([opsym, rsym, to_ast(a)])
        end
      end

      def point(a) pntoper(a, :set) end
      
      def rsym #makes :r12 from :p12
        ("r"+@ast.to_s[1..-1]).to_sym
      end

    private
      def add(a) pop; ret E.new([:inc, rsym, to_ast(a)]) end  
    end # of EPointer

    class EBytePointer < EPointer
      def initialize(x) @ast = x end
      def <=(a) oper(a, :setb) end  # assignment
      def +(a) pop; ret ENumber.new([:add, rsym, to_ast(a)]) end
      def -(a)
        pop
        if a.is_a? EPointer
          ret ENumber.new([:sub, rsym, a.rsym])
        else
          super(a)
        end
      end
      
      def inc() add(1) end
    end

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

    str = _pbyte 1
    (p = _pbyte).point str
    Функция _pbyte по аналогии с _r создает переменную типа EBytePointer и связывает ее с заданным регистром (либо со свободным регистром, если параметр не задан). Первая строчка тут создает переменную-указатель str, связанную с регистром 1. Вторая создает указатель p и присваивает ему значение указателя str. В строках
    zero = _int 0
    _while(zero < p) {
    функция _int создаcт переменную типа ENumber со значением 0, сохраняя соответствующий объект как zero. Выражение zero < p при выполнении создаст поддерево, в котором значение по указателю (не сам указатель) будет сравниваться с нулем. А _while - это тоже функция:

    def makedo(ar)
      ar = ar.map! {|x| x.ast}
      return ar[0] if ar.size==1
      ar = [:do]+ar if ar.size>1
      ar
    end

    def proc_block
      $stk << $curblock
      $curblock = []
      nr = $nextreg
      yield
      r = makedo($curblock)
      $curblock = $stk.pop
      $nextreg = nr
      r
    end

    def _while(expr, &block)
      east = expr.ast
      expr.pop
      bast = proc_block(&block)
      ret E.new([:while, east, bast])
    end

    При вызове _while выражение-условие в скобках в результате выполнения создает соответствующее дерево и превращается в объект-выражение. Его-то и получает _while в качестве параметра, запрашивая у него дерево. То, что после _while в фигурных скобках - это блок кода Руби. Его _while передает в функцию proc_block, которая его выполняет в рамках нового curblock, при необходимости трансформируя его в дерево :do, и возвращает обратно дерево, получившееся в результате выполнения-компиляции блока. В итоге у _while есть дерево условия и дерево тела цикла, из них она создает дерево :while, добавляя его к текущему конструируемому блоку.

    Примерно так же компилируются _if и _for.

    Основной код фронтенда нашего компилятора выглядит так:

    if ARGV[0]
      load(ARGV[0])
      s,d = print_tree makedo($curblock)
      print s
    else
      print "Pass program filename as param\n"
    end

    Он читает из файла код на нашем миниязыке, исполняет его, заполняя массив поддеревьев curblock, превращает его в дерево :do, после чего выводит полученное дерево. Это дерево потом поступит на вход кодогенератору (бэкенду) из предыдущего раздела, в итоге получится код на ассемблере нашей ВМ.

  13. Бонусы
  14. Поскольку миниязык, на котором мы пишем для ВМ, есть лишь DSL в Ruby, нам доступны все возможности Руби для генерации кода. Например, нигде выше мы не занимались компиляцией объявлений и вызовов функций, потому что это и не нужно: достаточно объявить функцию на уровне самого Руби и из нужных мест к ней обращаться. Каждое обращение из компилируемого-путем-выполнения кода к такой функции приведет к компиляции ее кода с учетом ее параметров и подстановке его в место вызова. Это приведет к некоторому дублированию кода, но для наших целей - запутывания взломщика и усложнения модификации - это как раз то что надо.

    Кроме того, можно описать несколько сложных функций со сложной логикой и еще одну, которая случайным образом будет вызывать одну из них. Каждый вызов этой функции будет добавлять в код кучу лишней логики, которая при этом не будет захламлять исходник. Более того, ее вызов можно добавить в сам компилятор в места вроде makedo, _while и _if, тогда лишний запутывающий код будет добавляться сам.


© Dee Mon, 2004-2010.