Обзор способов защиты программных продуктов на Java

 

 

 

Введение

 

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

В случае с Java дела обстоят достаточно плохо. Дело в том, что в отличии от C или C++, Java компилятор не создает конечный машинный код, а всего лишь его платформенно независимое представление. Полученный байт-код содержит очень много осмысленной информации, которая может помочь разобраться взломщику программы в принципе её работы.

При создании программы, в подовляющем большенстве, вводятся сомодостаточные названия для классов и их методов. Например, класс ConfigMgr скорее всего бдует представлять сервис для управления конфигруцией программного продукта. А его метод getRootCataloog – будет возвращать корневой коталог, где физически расположена программа. Это, конечно, существенно облегчает понимание программы для программиста, но и одновременно помогает злоумышленнику, ведь при декомпиляции Java байт-кода получаются реальные названия классов и их методов.

 

Стратегии победы декомпиляторов

 

1)      Использование флагов компиляции

2)      Написание двух версий программного продукта

3)      “Затемнение” кода

4)      Изменение байт-кода

5)      Использование JNI

6)      Выставление исходников программы по более высокой цене

7)      Хранение методов в атрибутах

8)      Применение глухих классов

 

1. Использование флагов декомпиляции

 

По существу нас интересуют только 3 флага компиляции, это –g, –O и без флага.

Флаг –g говорит компилятору добавлять номер строки и имя локальных переменных в конечный байт код.

Без флага – теряются имена локальных переменных, но сохраняютья номера строк.

Флаг –O – дополнительно удаляются номера строк.

 

Коментарии

 

Данный метод был приведен для общности картины и может являться лишь первым шагом на пути защиты java кода от взлома. Единственный вывод отсюда можно сделать, что всегда необходимо компилировать программу тольк с ключом компиляции –O.

 

2. Написание двух версий программного продукта

 

Эта достаточно хороший способ защиты. Например если необходимо выпустить демо версию, то берётся полная верися, из неё вырезается например сохранение в файл и затем раздаётся всем бесплатно. Таким образом нет особой нужды защищать программу. Если же клиент покупает программный продукт, то ему выдаётся полная версия программы.

Но этот подход не полностью может удовлетворть потребности в защите, например, если необходимо, чтобы даже полная версия работала определённый период времени (так называемая “временная” лицензия).

Коментарии

 

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

3. “Затемнение” кода

 

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

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

Например был исходный текст :

 

private void loadPixie( URL url ) throws Exception {

URLConnection connection = url.openConnection();

connection.setUseCaches( true );

in = new DataInputStream( connection.getInputStream() );

// Verify file format via magic numbers and version number.

if (in.readInt() != Constants.MAGIC)

throw new Exception( "Bad Pixie header" );

int v = in.readShort();

if (v != FILE_VERSION)

throw new Exception( "Bad Pixie version " + v );

pixieWidth = readUnsignedVInt();

pixieHeight = readUnsignedVInt();

// Skip unused fields.

readUnsignedVInt();

readUnsignedVInt();

readUnsignedVInt(); // Frame table size always 1.

frame = readUnsignedVInt();

readUnsignedVInt(); // Skip end of control commands.

// Object table size always 0.

readUnsignedVInt();

// Skip unused fields.

readUnsignedVInt();

readUnsignedVInt();

// Block-read the rest.

int byteCount = readUnsignedVInt();

pixieBody = new byte[ byteCount ];

int maxBlockSize = byteCount/20+1;

flushVInt();

Graphics fg = getGraphics();

int bytesDone = 0;

while (bytesDone < byteCount) {

int blockSize = byteCount-bytesDone;

if (blockSize > maxBlockSize)

blockSize = maxBlockSize;

in.readFully( pixieBody, bytesDone, blockSize );

bytesDone += blockSize;

// Update progress monitor.

if (fg != null) {

progress = bytesDone / (float) byteCount;

fg.setColor( getForeground() );

fg.fillRect( 0, size().height-4,

(int)(progress * size().width), 4 );

}

}

if (fg != null)

fg.dispose();

in.close();

setMessage( null );

}

 

после “затемнения” он принял вид:

 

private void _mth015E(void 867 % static 931)

{

void short + = 867 % static 931.openConnection();

short +.setUseCaches(true);

private01200126013D = new DataInputStream(short +.getInputStream());

if(private01200126013D.readInt() != 0x5daa749)

throw new Exception("Bad Pixie header");

void do const throws = private01200126013D.readShort();

if(do const throws != 300)

throw new Exception("Bad Pixie version " + do const throws);

_fld015E = _mth012B();

for = _mth012B();

_mth012B();

_mth012B();

_mth012B();

short01200129 = _mth012B();

_mth012B();

_mth012B();

_mth012B();

_mth012B();

void |= = _mth012B();

_fld013D013D0120import = new byte[|=];

void void = |= / 20 + 1;

private = false;

void = = getGraphics();

for(void catch 11 final = 0; catch 11 final < |=;)

{

void while if = |= - catch 11 final;

if(while if > void)

while if = void;

private01200126013D.readFully(_fld013D013D0120import, catch 11 final, while if);

catch 11 final += while if;

if(= != null)

{

const = (float)catch 11 final / (float)|=;

=.setColor(getForeground());

=.fillRect(0, size().height - 4, (int)(const * size().width), 4);

}

}

}

 

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

Одним из наиболее распространённых средств по проведению подобой операции является Creama (http://falconet.inria.fr/~java/tools/crema/). Crema рапространяется бесплатно (с незначительными ограничениями), а ее аналог интегрирован в Jbuilder начиная с версии 2.0.

 

Коментарии

 

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

4. Изменение байт-кода

 

Основным недостатком предыдущего метода является то, что “затемнённый” код все равно может быть успешно компилирован, после декомпиляции. Против простеньких декомпиляторов можно побороться следующим способом. Всавлять лишние инструкции в байт-код. Например если после return в методе класса всавить java инструкцию pop, то многие декомпиляторы воспримут это как ошибку и не смогут коректно декомпилировать этот код. Хотя при этом JVM его сможет исполнять без ошибок.

Этот метод требует знания структуры файла классов и неэфиктивен против некоторых декомпиляторов. Эту функцию замечательно выполняет, например, инструментальное средство DashO (http://www.preemptive.com/products.html).

 

Пример исходного файла:

 

void parse_fig_pointline( DataInputStream f,

int npoints, int xpoints[], int ypoints[] ) {

if (debug) editor.consoleMessage(

"parse_fig_pointline: npoints= " + npoints );

String line = null;

StringTokenizer st;

int n_tokens = 0; /* number of tokens in current line */

int i =0; /*current index into point arrays */

try {

while( i < npoints ) {

line = f.readLine();

if (false)

editor.consoleMessage( "parse_fig_pointline: " + line );

st = new StringTokenizer( line, " \n\r\t" );

n_tokens = st.countTokens();

for( int j=0; j < n_tokens; j+=2) {

xpoints[i] = fig_scale( Integer.parseInt(

st.nextToken() ));

ypoints[i] = fig_scale( Integer.parseInt(

st.nextToken() ));

i++;

}

line_number++;

}

} catch( IOException e ){

editor.consoleMessage( "Error: Not a valid FIG3.1 file

(pointline)" );

if (line != null)

editor.consoleMessage( "on line " + line_number + ": " +line );

n_errors++;

}

 

После применения DashO и декомпиляции JAD’ом получился следующий код:

 

void a(DataInputStream datainputstream, int i1, int ai[], int ai1[])

{

String s1;

int k1;

if(a)

d.a("parse_fig_pointline: npoints= " + i1);

s1 = null;

boolean flag = false;

k1 =0;

goto _L1

_L6:

StringTokenizer stringtokenizer;

int j1;

int l1;

s1 = datainputstream.readLine();

stringtokenizer = new StringTokenizer(s1, " \n\r\t");

j1 = stringtokenizer.countTokens();

l1 =0;

goto _L2

_L4:

ai;

k1;

int i2 = Integer.parseInt(stringtokenizer.nextToken());

h <30 ?(i2 +1)*g :i2 *g;

;

ai1;

k1;

i2 = Integer.parseInt(stringtokenizer.nextToken());

h <30 ?(i2 +1)*g :i2 *g;

;

k1++;

l1 += 2;

_L2:

if(l1 < j1) goto _L4; else goto _L3

_L3:

c++;

_L1:

if(k1 < i1) goto _L6; else goto _L5

_L5:

return;

JVM INSTR pop ;

d.a("Error: Not a valid FIG3.1 file (pointline)");

if(s1 != null)

d.a("on line "+c +":"+s1);

b++;

return;

}

 

Подобный код уже не откомпилируется.

 

Коментарии

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

 

5. Использование JNI

 

Этот может принести ощутимый эффект, например в следующем случае. У нас есть определённая программа. Часть её функционального кода без которого она не будет работать переводится на C. А затем использую JNI API эта часть кода связывается с остальным java байт-кодом.

 

Пример ReadFile.java:

import java.util.*;
 
class ReadFile {
//Native method declaration
  native byte[] loadFile(String name);
//Load the library
  static {
    System.loadLibrary("nativelib");
  }
 
  public static void main(String args[]) {
    byte buf[];
//Create class instance
    ReadFile mappedFile=new ReadFile();
//Call native method to load ReadFile.java
    buf=mappedFile.loadFile("ReadFile.java");
//Print contents of ReadFile.java
    for(int i=0;i<buf.length;i++) {
      System.out.print((char)buf[i]);
    }
  }
}
 

Далее запускается следующая команда:

 

javah -jni ReadFile

 

Создаётся заголовок для программы на C:

 

/*
   * Class:     ReadFile
   * Method:    loadFile
   * Signature: (Ljava/lang/String;)[B
   */
  JNIEXPORT jbyteArray JNICALL Java_ReadFile_loadFile
    (JNIEnv *, jobject, jstring);
 
где JNIEnv * - указатель на JNI окружение. Точнее это указатель на поток исполняющий данную программу на виртуальной машине.
jobject – ссылка на метод который вызвал native метод
jstring – ссылка на параметр передаваемы в native меод
 
далее реализуется метод Java_ReadFile_loadFile:
 
JNIEXPORT jbyteArray JNICALL Java_ReadFile_loadFile
  (JNIEnv * env, jobject jobj, jstring name) {
    caddr_t m;
    jbyteArray jb;
    jboolean iscopy;
    struct stat finfo;
    const char *mfile = (*env)->GetStringUTFChars(
                env, name, &iscopy);
    int fd = open(mfile, O_RDONLY);
 
    if (fd == -1) {
      printf("Could not open %s\n", mfile);
    }
    lstat(mfile, &finfo);
    m = mmap((caddr_t) 0, finfo.st_size,
                PROT_READ, MAP_PRIVATE, fd, 0);
    if (m == (caddr_t)-1) {
      printf("Could not mmap %s\n", mfile);
      return(0);
    }
    jb=(*env)->NewByteArray(env, finfo.st_size);
    (*env)->SetByteArrayRegion(env, jb, 0, 
                    finfo.st_size, (jbyte *)m);
    close(fd);
    (*env)->ReleaseStringUTFChars(env, name, mfile);
    return (jb);
}
 

Компилируем программу:

 

Linux:

gcc  -o libnativelib.so -shared -Wl,-soname,libnative.so  
     -I/export/home/jdk1.2/include 
     -I/export/home/jdk1.2/include/linux nativelib.c  
     -static -lc
 
Gnu C++/Linux with Xbase
g++ -o libdbmaplib.so -shared -Wl,-soname,libdbmap.so  
    -I/export/home/jdk1.2/include 
    -I/export/home/jdk1.2/include/linux 
    dbmaplib.cc -static -lc -lxbase

 

 

Win32:

cl -Ic:/jdk1.2/include 
   -Ic:/jdk1.2/include/win32 
   -LD nativelib.c -Felibnative.dll

 

Ззапускаем:

 

Linux:

LD_LIBRARY_PATH=`pwd`
  export LD_LIBRARY_PATH
java ReadFile
 
Win32:
set PATH=%path%;.
java Readfile

 

Коментарий

 

Некоторые декомпиляторы автоматически коментируют вызовы native методов, поэтому перенося важную часть функционала вы огораживаете себя от декомпиляции Java кода. Для взлома программы злоумышленнику придётся так же проводить реинженеринг машинных кодов, что является задачей на порядок более сложной. Так что в native метод удобно встраивать проверку лицензий и целостности java байт-кода.

Единственное, что не рекомендуется – это очень частые конструирование классов с native методами, это снижает производительность системы.

6. Выставление исходников программы по более высокой цене

 

Этот способ защиты от декомпиляции, по-моему, совршенно не требует разяснений J

 

Коментарии

 

7. Хранение классов в атрибутах

 

Кратко этот способ можно описать так. У нас есть определённая область памяти где у нас храниться зашифрованный байт-код некоторых классов. Для того чтобы получить экземпляр класса – нам предварительно нужно расшифровать этот класс, а затем при помощи метода, схожего с ClassLoader, создать экземпляр класса.

Например, мне представляется хорошим сценарий вызыва native метода, который возвращает расшифрованный байт-код  класса (если лицензия коректна). А затем мы можем работать с ним как с обычным классом. Это получается своего рода аналог ClassLoader. Но при этом нужно обязательно проверить подписчика класса, а то слишком умный взломщик и вызывающий класс сожет подменить, тем самым получить недостающий ему байт-код. Так же нужно проверять не запущена ли JVM  с возможностью отладки, иначе существует риск того, что может быть получен класс напрямую из памяти.

 

Коментарии

 

Этот метод можно считать достаточно новым, так как я не встретил фактически не одно более или менее нормальной реализации. В сочетании с ипользование JNI это достаточно эфективный метод борьбы с декомпиляцией.

 

8. Применение глухих классов

 

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

 

Создаем глухой класс FakeClass.java:

public class FakeClass {
    public FakeClass() {
    }
    public boolean getGuard() {
        return true;
    }
}

Дале в своей программе вставляем следующий код:

static boolean guard = false;

……

if (!guard) {

FakeClass fc = new FakeClass();
guard = fc.getGuard();

}

Ну например:

public class A {
    static boolean guard = false;
    public static void main(String[] args) {
        guard = true;
        System.out.println("Hello, World!\n");
        if (!guard) {
            FakeClass fc = new FakeClass();
            guard = fc.getGuard();
        }
    }
}

Далее FakeClass запаковывается, например,  fake.jar. Этот архив прописывается в CLASSPATH и затем компилируется A.java. После этого можно со спокойной душой отдавать A.class заказчику. Он, конечно же, без проблем его декомпилирует, но вот скомпилировать его он уже не сможет для этого необходимо иметь fake.class. В приведенном примере, конечно же очевидно что условие if (!guard) никогда не выполнится, но при доле смекалки можно достаточно зорошо спрятать это условие, да и вызов класса FakeClass сделать пострашнее, чтобы злоумышленник не сразу смекнул, что это только защита кода. А если таких классов сделать пару десятков, то задача компиляции может стать достаточно сложной.

 

Коментарии

 

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

 

Вывод

 

Из всех вышеперечисленных методов противостояния взломщику ни один не предоставляется мне достаточным для предотвращения нелегального ипользования программного продукта написанного на Java. Думаю что только комплекс этих мер способен реально защитить программу от взлома. Из наиболее эфективных хочется отметить:

1)      Затемнение кода

2)      Применение native методов

3)      Хранение классов в атрибутах

 


Сценарий защиты

 

Подводя итог хотел бы вкратце обрисовать сценарий достаточно эфективой борьбы против декомпиляции:

1)      Предварительно в готовый java код несколько глухих классов.

2)      Откомпелировать их и запихать в отделный архив

3)      Затем “затемнить” java код используя вспомогательные средства, например, Crema.

4)      Отдельно реализовать классы которые будут храниться в native методе и получаться при помощи механизма схожего с ClassLoader

5)      Откомпелировать их

6)      Откомпелировать остальной java код

7)      Применить метод изменения байт-кода

8)      Запаковать остальной java код

9)      Зашифровать классы которые храниться в native методе, используя любой алгоритм, например RSA и поместить его в native код

10)  Реализовать в native методе проверку целостности архива с java кодом

11)  Откомпелировать native код

12)  Удалить архив с глухими классами

13)  Создать лицензию

14)  Теперь всё это можно запускать

 

Примерный вид ClassLoader:

 

import java.util.*;

 

public class QSecureLoader extends ClassLoader {

 

  private byte [] classBuffer;

  private Hashtable classCache;

 

  private native byte [] loadClassData(String target_name);

 

  static {

    System.loadLibrary("native_method");

  }

 

  public QSecureLoader() {

    super();

    this.classCache=new Hashtable();

  }

 

  public Class loadClass(String name)

  throws ClassNotFoundException {

    Class newClass=(Class)this.classCache.get(name);

    if (newClass!=null) {

      return (newClass);

    }

    try {

            newClass=super.loadClass(name);

            } catch (ClassNotFoundException ce) {

                        this.classBuffer=this.loadClassData(name+".class");

                        newClass=defineClass(name,this. classBuffer,0,this.classBuffer.length);

            this.classCache.put(name,newClass);

            }

    return(newClass);

  }

 

  public static void main (String [] args) {

    try {

      QSecureLoader loader=new QSecureLoader();

      Class c=loader.loadClass("ProtectMe");

      ProtectMe p=(ProtectMe)c.newInstance();

      System.out.println(p.getString());

    } catch (Exception e) {

      System.out.println(e.toString());

    }

  }

}

Примерный вид native метода на C:

 

#include <stdio.h>

#include "QSecureLoader.h"

 

JNIEXPORT jbyteArray JNICALL Java_QSecureLoader_loadClassData

(JNIEnv *env, jobject jobj, jstring name) {

 

            FILE *file;                                            //File where stores class

            int file_length=0;                       //Length of class file

            jbyteArray buffer;                     //Result byte array

            jbyte *bufferElements;  //Element of result byte array

            const char *filename;    //Name of class

            fpos_t pos;

 

            //Get filename from java String

            filename=(*env)->GetStringUTFChars(env, name, 0);

 

            //Open a class file

            printf("Open %s class file\n",filename);

            file=fopen(filename,"rb");

            if (file==NULL) {

                        return (NULL);

            }

 

            //Get them size

            fseek(file,0,SEEK_END);

            fgetpos(file,&pos);

            file_length=pos;

            fseek(file,0,SEEK_SET);

 

            //Create new byte array in java

            buffer=(*env)->NewByteArray(env,file_length);

 

            //Get pointer to array

            bufferElements=(*env)->GetByteArrayElements(env,buffer,0);

 

            //Put content of class to byte array

            fread(bufferElements,sizeof(char),file_length,file);

 

            //Release content of buffer

            (*env)->ReleaseByteArrayElements(env,buffer,bufferElements,0);

 

            //Release filename

            (*env)->ReleaseStringUTFChars(env,name,filename);

 

            //Close class file

            fclose(file);

 

            //Return class data

            return (buffer);

}

 

Алгоритм проверки лицензии и шифрования класса не рассматривается в рамках данного исследования.

            В случае если декомпиляци пройдёт успешно, то в результате получатся затемнённые исходные тексты с абстрактными названиями методов и классов и в добавок часть классов не будет представленна вообще, так как они будут храниться зашифрованными в native методе. Если пользователь попытается вырезать native метод, то он навсегда потеряет связь с классами которые считываются в случае правильной лицензии.  Глухие классы воспрепятствую нормальной компиляции. Да и после компиляции пользователь не сможет получить доступ к зашифрованным классам, так как архив с программой потеряет свою подпись и следовательно ликвидность. Единственное что может помочь пользователю – это приступить к реинженерингу native методов J


Ссылки

·        http://togethercrack.nm.ru/index.html

·        http://www.preemptive.com/products.html

·        http://www.4thpass.com/purchase/price.html

·        http://www.meurrens.org/ip-Links/java/codeEngineering/obfusc.htm

·        http://falconet.inria.fr/~java/tools/crema/

·        http://java.sun.com/docs/books/vmspec/html/ClassFile.doc.html

·        \\artq\webfiles\library\docs\JavaProtection\

·        http://java.sun.com/docs/books/tutorial/native1.1

 

Не вошедшее:

·        http://java.sun.com/products/plugin/1.3/docs/rsa_signing.html

·        http://personal.vsnl.com/sureshms/javasign1.html

 

 

Используются технологии uCoz