Привязка программы к свойствам среды исполнения

В качестве примера работы со свойствами, описанными в предыдущих разделах, рассмотрим востребованную задачу привязки MQL-программы к аппаратному окружению, чтобы защитить её от копирования. Когда программа распространяется через MQL5 Маркет, привязку обеспечивает сам сервис. Однако если программа разрабатывается в заказном порядке, привязать её можно либо к номеру счета, либо к имени клиента, либо к доступным свойствам терминала (компьютера). Первое не всегда удобно, потому что многие трейдеры имеют несколько реальных счетов (вероятно, у разных брокеров), не говоря уже о демо-счетах с ограниченным сроком существования. Второе может быть вымышленным или слишком расхожим. Поэтому мы реализуем прототип алгоритма для привязки программы к выбранному набору свойств окружения. Более серьезные схемы защиты, вероятно, могли бы использовать DLL и напрямую читать аппаратные метки устройств из системы Windows, однако не каждый клиент согласится запускать потенциально небезопасные библиотеки.

Наш вариант защиты представлен в скрипте EnvSignature.mq5. Его суть заключается в том, чтобы вычислить хеши от заданных свойств среды и создать на их основе уникальную сигнатуру (оттиск).

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

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

Предположим, одно из свойств среды описано строкой: "TERMINAL_LANGUAGE=Russian". Его можно получить простой инструкцией вроде следующей (упрощенно):

string language = EnumToString(TERMINAL_LANGUAGE) +
            "=" + TerminalInfoString(TERMINAL_LANGUAGE);

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

string signature = Hash(language);

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

string properties[];
// заполняем строки свойств по своему желанию
// ...
string signature;
for(int i = 0i < ArraySize(properties); ++i)
{
   signature += properties[i];
}
return Hash(signature);

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

Затем разработчик передаст строку валидации пользователю, и тот сможет запускать программу, указав эту строку в параметрах.

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

Для самого разработчика можно предусмотреть несколько режимов запуска: с сигнатурой, но без строки валидации (чтобы сгенерировать последниюю), или с сигнатурой и строкой валидации (здесь программа подпишет сигнатуру заново и сравнит с заданной строкой валидации — просто для проверки).

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

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

Screen

1920x1080

1536x864

1440x900

1366x768

800x600

RAM

21%

7%

5%

10%

4%

4Gb    20%

4.20

1.40

1.00

2.0

0.8

8Gb    20%

4.20

1.40

1.00

2.0

0.8

16Gb  15%

3.15

1.05

0.75

1.5

0.6

32Gb  10%

2.10

0.70

0.50

1.0

0.4

64Gb    5%

1.05

0.35

0.25

0.5

0.2

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

Для хеширования воспользуемся встроенной функцией CryptEncode (она будет описана в разделе Криптография), поддерживающей метод хеширования SHA256. Согласно его имени, он производит хеш длиной 256 бит, то есть 32 байта. Если бы нам потребовалось показать его пользователю, то при переводе в текст шестандцатеричного представления мы получили бы строку длиной 64 символа.

Чтобы сделать сигнатуру короче, мы преобразуем её с помощью кодировки Base64 (она также поддерживается функцией CryptEncode и парной ей CryptDecode), что даст строку длиной 44 символа. В отличие от односторонней операции хеширования, кодирование в Base64 обратимо, то есть из него можно восстановить исходные данные.  

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

class EnvSignature
{
private:
   string data;
protected:
   virtual string pepper() = 0;
public:
   bool append(const ENUM_TERMINAL_INFO_STRING e)
   {
      return append(EnumToString(e) + pepper() + TerminalInfoString(e));
   }
   bool append(const ENUM_MQL_INFO_STRING e)
   {
      return append(EnumToString(e) + pepper() + MQLInfoString(e));
   }
   bool append(const ENUM_TERMINAL_INFO_INTEGER e)
   {
      return append(EnumToString(e) + pepper()
        + StringFormat("%d"TerminalInfoInteger(e)));
   }
   bool append(const ENUM_MQL_INFO_INTEGER e)
   {
      return append(EnumToString(e) + pepper()
        + StringFormat("%d"MQLInfoInteger(e)));
   }

Для добавления в объект произвольной строки имеется универсальный метод append, который вызывается в вышеприведенных методах.

   bool append(const string s)
   {
      data += s;
      return true;
   }

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

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

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

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

   string emit() const
   {
      uchar pack[];
      if(StringToCharArray(data + secret(), pack0,
         StringLen(data) + StringLen(secret()), CP_UTF8) <= 0return NULL;
   
      uchar key[], result[];
      if(CryptEncode(CRYPT_HASH_SHA256packkeyresult) <= 0return NULL;
      Print("Hash bytes:");
      ArrayPrint(result);
   
      uchar text[];
      CryptEncode(CRYPT_BASE64resultkeytext);
      return CharArrayToString(text);
   }

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

Полученный байтовый массив с хешем кодируется в строку с помощью Base64.

Теперь у нас на очереди самая главная функция класса — check. Именно она "подписывает" сигнатуру у разработчика и проверяет её у пользователя.

   bool check(const string sigstring &validation)
   {
      uchar bytes[];
      const int n = StringToCharArray(sig + secret(), bytes0,
         StringLen(sig) + StringLen(secret()), CP_UTF8);
      if(n <= 0return false;
      
      uchar key[], result1[], result2[];
      if(CryptEncode(CRYPT_HASH_SHA256byteskeyresult1) <= 0return false;
      
      /*
         ПРЕДУПРЕЖДЕНИЕ
         Следующий код должен присутствовать только в утилите разработчика.
         Программа, поставляемая пользователю, должна компилироваться без этого if.
      */
      #ifdef I_AM_DEVELOPER
      if(StringLen(validation) == 0)
      {
         if(CryptEncode(CRYPT_BASE64result1keyresult2) <= 0return false;
         validation = CharArrayToString(result2);
         return true;
      }
      #endif
      uchar values[];
      // the exact length is needed to not append terminating '0'
      if(StringToCharArray(validationvalues0,
         StringLen(validation)) <= 0return false;
      if(CryptDecode(CRYPT_BASE64valueskeyresult2) <= 0return false;
      
      return ArrayCompare(result1result2) == 0;
   }

При штатной работе (у пользователя) метод считает хеш от полученной сигнатуры, дополненной "секретом", и сравнивает его со значением из валидационной строки (её предварительно нужно раскодировать из Base64 в "сырое" бинарное представление хеша). Если два хеша совпали, проверка пройдена успешно: валидационная строка соответствует набору свойств. Очевидно, что пустая валидационная строка (или строка, введенная наобум) проверку не пройдет.

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

Для создания объекта необходим конкретный производный класс, в котором определены строки с "секретом" и "перчиком".

// ПРЕДУПРЕЖДЕНИЕ: измените макро на собственный набор случайных байтов
#define PROGRAM_SPECIFIC_SECRET "<PROGRAM-SPECIFIC-SECRET>"
// ПРЕДУПРЕЖДЕНИЕ: выберите свои символы для связки в парах name'='value
#define INSTANCE_SPECIFIC_PEPPER "=" // для демо выбран очевидный одиночный знак
// ПРЕДУПРЕЖДЕНИЕ: следующее макро нужно отключить в реальном продукте,
//                 оно должно быть только в утилите подписи
#define I_AM_DEVELOPER
#ifdef I_AM_DEVELOPER
#define INPUT input
#else
#define INPUT const
#endif
 
INPUT string Signature = "";
INPUT string Secret = PROGRAM_SPECIFIC_SECRET;
INPUT string Pepper = INSTANCE_SPECIFIC_PEPPER;
 
class MyEnvSignature : public EnvSignature
{
protected:
   virtual string secret() override
   {
      return Secret;
   }
   virtual string pepper() override
   {
      return Pepper;
   }
};

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

void FillEnvironment(EnvSignature &env)
{
   // порядок не важен, можно перемешать
   env.append(TERMINAL_LANGUAGE);
   env.append(TERMINAL_COMMONDATA_PATH);
   env.append(TERMINAL_CPU_CORES);
   env.append(TERMINAL_MEMORY_PHYSICAL);
   env.append(TERMINAL_SCREEN_DPI);
   env.append(TERMINAL_SCREEN_WIDTH);
   env.append(TERMINAL_SCREEN_HEIGHT);
   env.append(TERMINAL_VPS);
   env.append(MQL_PROGRAM_TYPE);
}

Теперь все готово для проверки нашей схемы защиты в функции OnStart. Но сначала обратим внимание на входные переменные. Поскольку одна и та же программа будет компилироваться в двух вариантах — для конечного пользователя и для разработчика — существует два набора входных переменных: для ввода регистрационных данных пользователем и для генерации этих данных на основе сигнатуры у разработчика. Входные переменные, предназначенные для разработчика, были описаны выше с помощью макроса INPUT. А для пользователя доступна только строка валидации.

input string Validation = "";

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

void OnStart()
{
   MyEnvSignature env;
    string signature;
   if(StringLen(Signature) > 0)
   {
     // ... здесь будет код для "подписи" автором
   }
   else
   {
      FillEnvironment(env);
      signature = env.emit();
   }
   
   if(StringLen(Validation) == 0)
   {
      Print("Validation string from developer is required to run this script");
      Print("Environment Signature is generated for current state...");
      Print("Signature:"signature);
      return;
   }
   else
   {
     // ... здесь нужно проверить строку валидации
   }
   Print("The script is validated and running normally");
   // ... фактический рабочий код здесь
}

Если переменная Validation заполнена, проверяем её соответствие сигнатуре и завершаем работу в случае неудачи.

   if(StringLen(Validation) == 0)
   {
      ...
   }
   else
   {
      validation = Validation// нужен non-const аргумент
      const bool accessGranted = env.check(Signaturevalidation);
      if(!accessGranted)
      {
         Print("Wrong validation string, terminating");
         return;
      }
      // успех
   }
   Print("The script is validated and running normally");
   // ... фактический рабочий код здесь
}

Если расхождений нет, алгоритм "проваливается" к рабочему коду программы.

На стороне разработчика (в той версии программы, что собрана с макросом I_AM_DEVELOPER) может быть введена сигнатура, и мы по ней восстанавливаем состояние объекта MyEnvSignature и рассчитываем строку валидации.

void OnStart()
{
   ...
   if(StringLen(Signature) > 0)
   {
      #ifdef I_AM_DEVELOPER
      if(StringLen(Validation) == 0)
      {
         string validation;
         if(env.check(Signaturevalidation))
           Print("Validation:"validation);
         return;
      }
      signature = Signature
      #endif
   }
   ...

Разработчик может указать не только сигнатуру, но и подпись: это приведет к продолжению исполнения кода, как у пользователя (в целях отладки).

При желании вы можете сымитировать изменение окружения, например, таким образом:

      FillEnvironment(env);
      // искусственно делаем изменение в среде (добавляем таймзону)
      // env.append("Dummy" + (string)(TimeGMTOffset() - TimeDaylightSavings()));
      const string update = env.emit();
      if(update != signature)
      {
         Print("Signature and environment mismatch");
         return;
      }

Рассмотрим несколько тестовых логов.

При первом запуске скрипта EnvSignature.mq5 "пользователь" увидит примерно следующий лог (значения будут отличаться из-за различий среды):

Hash bytes:
  4 249 194 161 242  28  43  60 180 195  54 254  97 223 144 247 216 103 238 245 244 224   7  68 101 253 248 134  27 102 202 153
Validation string from developer is required to run this script
Environment Signature is generated for current state...
Signature:BPnCofIcKzy0wzb+Yd+Q99hn7vX04AdEZf34hhtmypk=

Он "отправляет" сгенерированную сигнатуру "разработчику" (в ходе теста это мы сами, поэтому все роли "пользователя" и "разработчика" взяты в кавычки), который вводит её в утилиту подписи (скомпилированную с макросом I_AM_DEVELOPER), в параметр Signature. В результате, программа выдаст строку валидации:

Validation:YBpYpQ0tLIpUhBslIw+AsPhtPG48b0qut9igJ+Tk1fQ=

"Разработчик" "отправляет" её обратно "пользователю", и тот, введя её в параметр Validation, получит активированный скрипт:

Hash bytes:
  4 249 194 161 242  28  43  60 180 195  54 254  97 223 144 247 216 103 238 245 244 224   7  68 101 253 248 134  27 102 202 153
The script is validated and running normally

Для демонстрации действенности защиты давайте продублируем скрипт в виде сервиса: для этого скопируем файл в папку MQL5/Services/MQL5Book/p4/ и в исходном коде заменим строку:

#property script_show_inputs

на

#property service

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

Hash bytes:
147 131  69  39  29 254  83 141  90 102 216 180 229 111   2 246 245  19  35 205 223 145 194 245  67 129  32 108 178 187 232 113
Wrong validation string, terminating

Дело в том, что среди свойств окружения у нас использована строка MQL_PROGRAM_TYPE. Поэтому выписанная лицензия на один тип программы не подойдет для программы другого типа, даже если она выполняется на компьютере того же пользователя.