Java
JavaDay 17

JARファイルの難デコンパイル化について頑張ってみた話

More than 1 year has passed since last update.

これは、「Java Advent Calendar 2016」17日目の記事です。


はじめに


  • 難読化ではなく難デコンパイル化です

  • ソースコードが汚いのは許して

  • 例外処理は全くしてないのでお好みで(IDEA様に身を任せた結果、素敵なcatch文が出来上がったよ)

  • 全く同じ内容のものを自分のブログにも書くかも


事の始まり

ある方曰く、


  • MinectaftにはSpigotというカスタムサーバがある

  • そのプラグインはJavaで書かれていてAPIも公開されている

  • Javaだからデコンパイルされやすくてクソコードを全世界に公開するの恥ずかしい


こう考えた

Javaのclassファイルを暗号化しそれを復号化・動的ロードするローダから起動すれば良いのでは。

classファイル自体が暗号化されているので素直にデコンパイラに入れただけじゃコードを表示できないはず。


こんなことをやってみた


暗号化されたclassファイルを含むJARファイルの生成


classファイルの暗号化

適当にこんな感じで

private Key key;// KeyGenerator#generateKey()で適当に

void encrypt(File file) {
byte[] inByte = null;
try {
inByte = FileUtils.readFileToByteArray(file);

Cipher cipher = Cipher.getInstance("AES");
cipher.init(Cipher.ENCRYPT_MODE, key);
byte[] encrypted = cipher.doFinal(inByte);

FileUtils.writeByteArrayToFile(file, encrypted);
decryptTest(file, key, inByte);
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
} catch (NoSuchPaddingException e) {
e.printStackTrace();
} catch (InvalidKeyException e) {
e.printStackTrace();
} catch (BadPaddingException e) {
e.printStackTrace();
} catch (IllegalBlockSizeException e) {
e.printStackTrace();
}
}


mainClassの書き換え

Spigotはプラグインのメインクラスを設定ファイル(plugin.yml)から読み込むのでそれをローダのクラスのものに書き換え

Yaml yaml = new Yaml();

try {
String str = FileUtils.readFileToString(new File(appTmpDir, "plugin.yml"), "utf-8");
Map map = yaml.loadAs(str, Map.class);
map.put("main", getPackageName(getMainClass()) + ".PluginLoader");
str = yaml.dumpAsMap(map);
FileUtils.writeStringToFile(new File(appTmpDir, "plugin.yml"), str, "utf-8");
} catch (IOException e) {
e.printStackTrace();
}


classファイルの復号化&動的ロード


復号化

ファイルからbyte列に

private byte[] read(InputStream inputStream) {

byte[] buf = new byte[1024];
int len;
BufferedInputStream bufferedInputStream = new BufferedInputStream(inputStream);
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();

try {
while ((len = bufferedInputStream.read(buf)) > 0) {
byteArrayOutputStream.write(buf, 0, len);
}
inputStream.close();
} catch (IOException e) {
e.printStackTrace();
}

return byteArrayOutputStream.toByteArray();
}

復号化(KeyはObjectInputStreamで読み込んでるよ)

private byte[] decrypt(byte[] bytes, Key key) {

byte[] inByte = null;
try {

if (key == null) throw new IllegalArgumentException("Keyがnullです。");
Cipher cipher = Cipher.getInstance("AES");
cipher.init(Cipher.DECRYPT_MODE, key);
inByte = cipher.doFinal(bytes);

} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
} catch (NoSuchPaddingException e) {
e.printStackTrace();
} catch (InvalidKeyException e) {
e.printStackTrace();
} catch (BadPaddingException e) {
e.printStackTrace();
} catch (IllegalBlockSizeException e) {
e.printStackTrace();
}
return inByte;
}


動的ロード

byte列をリフレクションを使ってうまいこと読み込む

ClassLoaderのdefineClass0メソッドがだいじ

private void loadClass(byte[] bytes, String name) throws ClassFormatError {

try {
String packageName = name.replaceAll("/", ".").substring(0, name.length() - 6);
Method define0Method = ClassLoader.class.getDeclaredMethod("defineClass0", new Class[]{String.class, byte[].class, int.class, int.class, ProtectionDomain.class});
define0Method.setAccessible(true);
Class loadedClass = (Class) define0Method.invoke(getClassLoader(), packageName, bytes, 0, bytes.length, null);
if (packageName.equals(mainClassName)) {
this.mainClass = loadedClass;
}
} catch (NoSuchMethodException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (InvocationTargetException e) {
Throwable cause = e.getCause();
if (cause instanceof ClassFormatError) {
throw (ClassFormatError) cause;
}
}
stub.add(name);
}


こうなった

サーバ内での各プラグインの管理はパッケージ名+クラス名で行っているためローダのパッケージ名・クラス名が衝突して難デコンパイル化プラグインが2つ以上あると2つ目以降がロードに失敗する。

スタンドアロンなJavaプログラムとして配布する場合は問題ない。


解決策

JAR難デコンパイル化プログラム(仮)本体にローダのソースコードを持たせてパッケージ名を動的に変更しその都度コンパイルするようにした。

getTaskの引数は試行錯誤して決めた、多分正常に動いてるはず

private void addLoader(String packageName, File target, File bukkitJar) {

try {
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
String pluginLoaderJava = FileUtils.readFileToString(new File(ClassLoader.getSystemResource("PluginLoader.java").getFile()), "utf-8");
JavaFileObject file = new JavaSourceFromString("PluginLoader", pluginLoaderJava.replace("{{package}}", "package " + packageName + ";"));

String[] compileOptions = new String[]{"-d", target.getAbsolutePath(), "-classpath", bukkitJar.getAbsolutePath()};
Iterable<String> compilationOption = Arrays.asList(compileOptions);
Iterable<? extends JavaFileObject> compilationUnits = Arrays.asList(file);

JavaCompiler.CompilationTask task = compiler.getTask(
null,
null,
null,
compilationOption,
null,
compilationUnits);

task.call();
} catch (IOException e) {
e.printStackTrace();
} catch (Exception e) {
e.printStackTrace();
}
}


気に食わない点

ローダを難デコンパイル実行時にコンパイルするためJDKが必要になってしまう点。

(classファイルを直接いじればJDKなくてもできそう?)

大きな企業とかもオープンソース化してる時代なのに、ソースコードを隠すのってそもそもどうなんだろう?