Обзор способов защиты программных продуктов на Java
В природе не существует
природе не существует программных продуктов, которые не поддавались бы взлому.
И тут совершенно не важно на каком языке программирования написанна эта
программа, будь то Java или C.
Просто затраты на взлом могут быть различными и поэтому все основные действия
по защите от нелегального использования должны быть направлены на затруднение
декомпиляции.
В случае с Java дела обстоят
достаточно плохо. Дело в том, что в отличии от C или C++, Java компилятор не создает конечный машинный код, а
всего лишь его платформенно независимое представление. Полученный байт-код
содержит очень много осмысленной информации, которая может помочь разобраться
взломщику программы в принципе её работы.
При создании программы, в
подовляющем большенстве, вводятся сомодостаточные названия для классов и их
методов. Например, класс ConfigMgr скорее всего бдует представлять сервис для
управления конфигруцией программного продукта. А его метод getRootCataloog – будет
возвращать корневой коталог, где физически расположена программа. Это, конечно,
существенно облегчает понимание программы для программиста, но и одновременно
помогает злоумышленнику, ведь при декомпиляции Java байт-кода получаются реальные названия классов и
их методов.
1)
Использование
флагов компиляции
2)
Написание
двух версий программного продукта
3)
“Затемнение”
кода
4)
Изменение
байт-кода
5)
Использование
JNI
6)
Выставление
исходников программы по более высокой цене
7)
Хранение
методов в атрибутах
8)
Применение
глухих классов
По существу нас
интересуют только 3 флага компиляции, это –g, –O и без флага.
Флаг –g говорит компилятору
добавлять номер строки и имя локальных переменных в конечный байт код.
Без флага – теряются
имена локальных переменных, но сохраняютья номера строк.
Флаг –O – дополнительно
удаляются номера строк.
Данный метод был приведен
для общности картины и может являться лишь первым шагом на пути защиты java кода от взлома.
Единственный вывод отсюда можно сделать, что всегда необходимо компилировать
программу тольк с ключом компиляции –O.
Эта достаточно хороший
способ защиты. Например если необходимо выпустить демо версию, то берётся
полная верися, из неё вырезается например сохранение в файл и затем раздаётся
всем бесплатно. Таким образом нет особой нужды защищать программу. Если же
клиент покупает программный продукт, то ему выдаётся полная версия программы.
Но этот подход не полностью может удовлетворть потребности в защите, например, если необходимо, чтобы даже полная версия работала определённый период времени (так называемая “временная” лицензия).
Данный метод имеет
несравненный плюс при предоставлении кому-либо демонстрации, фактически
вручается полурабочий образец, который сложно будет применять в каких либо
других целях, кроме как ознакомление с программным продуктом. Да и в добавок в
нем будет отсутствовать достаточно важная часть кода, которую невозможно будет
получить даже при помощи декомпиляции.
Этот способ является на
данный момент самым популярным среди методов защиты программ от декомпиляции.
На практике, если проект
переходит определённый предел сложности, то разобраться в логике программы
можно только с использованием коментариев в коде и технической подержки. На
этом и оновывается данный метод. Во всем проекте происходит замена
декларативных названий полей классов и методов на абстрактные, которые не несут
какой-либо смысловой нагрузки.
Например был исходный
текст :
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). Компромисом может
служить не тотальное переименование классов и методов, а только частичное, но
для этого необходимо изначально проектировать систему с учётом дальнейшего её
“затемнения”.
Основным недостатком
предыдущего метода является то, что “затемнённый” код все равно может быть
успешно компилирован, после декомпиляции. Против простеньких декомпиляторов
можно побороться следующим способом. Всавлять лишние инструкции в байт-код.
Например если после 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;
}
Подобный код уже не откомпилируется.
Этот метод не универсален
и это собственно его единственный недостаток, а в остальном он может бэффективно
применяться наряду с другими методами защиты программных продуктов.
Этот может принести
ощутимый эффект, например в следующем случае. У нас есть определённая
программа. Часть её функционального кода без которого она не будет работать
переводится на 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 методами, это
снижает производительность системы.
Этот способ защиты от
декомпиляции, по-моему, совршенно не требует разяснений J
Кратко этот способ можно
описать так. У нас есть определённая область памяти где у нас храниться
зашифрованный байт-код некоторых классов. Для того чтобы получить экземпляр
класса – нам предварительно нужно расшифровать этот класс, а затем при помощи
метода, схожего с ClassLoader, создать экземпляр класса.
Например, мне
представляется хорошим сценарий вызыва native метода, который возвращает расшифрованный
байт-код класса (если лицензия
коректна). А затем мы можем работать с ним как с обычным классом. Это
получается своего рода аналог ClassLoader. Но при этом нужно обязательно проверить
подписчика класса, а то слишком умный взломщик и вызывающий класс сожет
подменить, тем самым получить недостающий ему байт-код. Так же нужно проверять
не запущена ли JVM с возможностью отладки, иначе
существует риск того, что может быть получен класс напрямую из памяти.
Этот метод можно считать
достаточно новым, так как я не встретил фактически не одно более или менее
нормальной реализации. В сочетании с ипользование JNI это достаточно эфективный метод борьбы с
декомпиляцией.
Этот метод прежде всего
нацелен на то, чтобы препятствовать повторной компиляции програмы. Преведу
пример, который поможет понять всю прелесть идеи.
Создаем глухой класс 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);
}
·
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