actual JNIC/AOT reversing notes
this post is still a W.I.P :p
the function ptr and string decryption have been reworked with recent versions after a lot of downtime from the developers, it marks a shift in JNIC's priorities, focusing more on obfuscation than support and transpilation with new competitive competition (JNT)
Obvious terms for not so obvious reasons
- jni -> java native interface
- jvm -> java virtual machine (used interchangeably to describe java functionality)
- ptr -> pointer
- dynamic reversing -> reversing at runtime
- static reversing -> reversing without running the binary
- mixin -> java version of user hooks/reflection
- disasm -> disassembly view, the assembly instructions
- insn(s) -> instruction(s)
- decomp -> decompiler view, the pseudo code predicted from the instruction
- aot - ahead of time compilation, interpreted code compiled into machine code usually for performance
- transpiler -> converting one language src/machine code into another language’s src/machine code
i highly recommend reading aprl.pet/reversing-jni first <3
Why
- No one really does writeups on reversing cheats clientside protection/authentication systems, and there are especially no good jni resources (i had to use android jni reversing notes to learn)
- The minecraft community is ridden with malware, naturally written in java so as a prevention mechanism a lot of cheats and malware crutch native transpiler (also seen in il2cpp for unity games); relying on people not knowing how read asm
- The masses want to learn how to reverse beloved minecraft malware and i will give them what little knowledge i have (you would be surprised how many dms i get)
Intro
This post will be primarily focusing on JNIC v3.5.11 as that is what version the sample was when analysed (even at time of analysis this was a slightly outdated version).
update: an updated version of this post will follow with analysis of the new techniques on the most recent version
The sample is a fake “dupetoolkit” mod that has spread across the mc cheating community with views totaling over 1mil, and the owner (known by mutuals) has apparently over 500 clients on their c2.
This individual has inspired many like-minded very employed individuals to spread positivity and love with their own shitty native transpiled stealers, what a great use of time!3
That being said, we will not be going into detail for the stages after or anything else relating to this sample but the JNIC library.
These malware variants are always the same and is not the purpose of this blog post.
JNIC, despite what it sells itself as, is not really a “native obfuscator” but a native transpiler with very simple anti reversal techniques to prevent entry level reversers from just using the decompiler.
Version 3.6.0 does shift in perspective though, and i think the main selling point of JNIC is its compatability and support with already obfuscated binaries pre-transpilation.
As with any AOT binaries, once reversed it is far easier to understand the codebase and logic since it gives you a pseudo higher level language through the jni/jvm calls; also see in csharp aot/native.
The best jni libs ive seen use a mix of both jvm and native api functions.
e.g - dont use java sockets if your transpiling your communication to native
In my opinion, it is way better to write certain components natively even when using a AOT; unfortunately to my knowledge, JNIC does not allow modification to the native src or it is at least against best practice.
Prestige client balances this well with a good, seemingly custom, packer but many parts look llm generated (stinky) and there will be a writeup on that client itself as the native lib is technical and interesting with many areas to expand upon.
e.g - they do their communication through native wsa calls and not jvm calls like every other jni lib.
Prestige client also has a interesting way of using the same native library across loader and client with a sort of handoff.
Minecraft client devs should focus on learning proper client <-> server communication, server authority, protection and drm; proactively and reactively instead of obscurity.
If you want to learn how to reverse and crackmes are boring, get random cheat loaders in a vm; spoofers are always the worst protection (keyauth :3)
Dropper/Loader stage
exclusive to sample not JNIC
The dropper is a simple fabric mod with a fake embedded dependency (stage 2) that actually contains the malware, and subsequently the JNIC native.
The 2nd stage embedded is also a fabric mod, the details of which aren’t relevant for the topic but the main logic is only present in ExampleMod with 2 mixins that dont seem used and also look autofilled from the fabric mod template.
Although one mixin is of interest — MinecraftServer.loadWorld() is marked as native.
This is primarily why i chose this sample for a writeup, a small codebase with very few natives (because this native is again, used as another dropper).
Initialisation / keystream | JNI_OnLoad
JNI_OnLoad is the “java” entrypoint, what advertises JNIC, the version used and initialises the keystream used to decrypt the strings in the native lib.
jint JNI_OnLoad(JavaVM *vm, void *reserved) {}
The keystream is a ptr to the java ByteBuffer initialised within the JNICLoader class in java. The keystream is initialised with certain int numbers, used in the ChaCha204 algorithm.
Java Initialisation:
public class JNICLoader extends InputStream {
public static ByteBuffer z;
...
if (var0.contains("win") && var1.equals("aarch64")) {
// offsets for loading the native library for each platform
var2 = 506880L;
var4 = 970240L;
// the nonce values
z.putInt(-325934867);
z.putInt(-1796564512);
z.putInt(86162358);
z.putInt(137883887);
z.putInt(1625553484);
z.putInt(1963368029);
z.putInt(922207258);
z.putInt(-1772791943);
}
if (var0.contains("win") && (var1.equals("x86_64") || var1.equals("amd64"))) {
// win_x86_64 starts at 0 to file offset 506880
var2 = 0L;
var4 = 506880L;
// the nonce values
z.putInt(-1200696756);
z.putInt(-592176884);
z.putInt(-1136343657);
z.putInt(-1621046469);
z.putInt(1821978264);
z.putInt(-1370484180);
z.putInt(1196660769);
z.putInt(849921994);
}
Native Counterpart:
(*(*vm)->GetEnv)(vm,&env,0x10006);
JNICLoaderClass = (*(*env)->FindClass)(env,"dev/JNIC/GAoMnN/JNICLoader");
bytebufFieldID = (*(*env)->GetStaticFieldID)(env,JNICLoaderClass,"z","Ljava/nio/ByteBuffer;");
buf = (*(*env)->GetStaticObjectField)(env,JNICLoaderClass,bytebufFieldID);
keystreamBuf = (*(*env)->GetDirectBufferAddress)(env,buf);
state_s0_const0 = *(uint *)PTR_s_JNIC.dev_v3.7.0_18007e000;
state_s1_const1 = *(uint *)(PTR_s_JNIC.dev_v3.7.0_18007e000 + 4);
state_s2_const2 = *(uint *)(PTR_s_JNIC.dev_v3.7.0_18007e000 + 8);
state_s3_const3 = *(uint *)(PTR_s_JNIC.dev_v3.7.0_18007e000 + 0xc);
state_s4s5_key0 = 0xfa592a0b679ac6c7;
state_s6s7_key1 = 0xff854713ec933cfc;
state_s8s9_key2 = 0x9720746395e4d3b4;
state_s10s11_key3 = 0x4a49a780b08235cd;
state_s12_counter = 0;
nonce0_raw = *(uint *)((longlong)keystreamBuf + 0x20);
state_s14_nonce1 = *(uint *)((longlong)keystreamBuf + 0x24);
state_s15_nonce2 = *(uint *)((longlong)keystreamBuf + 0x28);
local_88 = 0x40;
state_s13_nonce0 = nonce0_raw;
init_s0 = state_s0_const0;
init_s1 = state_s1_const1;
init_s2 = state_s2_const2;
init_s3 = state_s3_const3;
init_s14_nonce1 = state_s14_nonce1;
init_s15_nonce2 = state_s15_nonce2;
keystreamBuf = malloc(0x9772);
block_byte_idx = 0x40;
counter_lo = 0;
lVar4 = 0;
JNICKeystream = keystreamBuf;
do {
/* ChaCha20 logic */
...
return 0x10006;
In summary it:
- retrieves the nonce/init values from the Java side (through the already populated bytebuf shared reference)
- builds the ChaCha20 initial state
s[0..3] = " JNIC.dev v3.7.0" (custom constant, replaces "expand 32-byte k")
s[4..7] = chacha20_key_lo (256-bit hardcoded key, low 128 bits)
s[8..11] = chacha20_key_hi (256-bit hardcoded key, high 128 bits)
s[12] = counter (0, incremented per 64-byte block)
s[13] = nonce[0] (from ByteBuffer+0x20)
s[14] = nonce[1] (from ByteBuffer+0x24)
s[15] = nonce[2] (from ByteBuffer+0x28)
- generates the keystream from 10 double-rounds
- return JNI_VERSION_1_6 (0x10006)
The stack variable containing the keystream ptr can be found directly above the first cmp instruction, find the operand in the disasm and the value written is the ptr to the keystream; or in the decomp it will inline the ptr above the first/topmost do while loop.
To dump the keystream at runtime dump this memory region and load it as a segment from file and set the pointer in .data to point to the base of the keystream segment, or just reverse it from a full dump.
I have developed Ghidra plugins to transform and help the JNIC reversing process that may be released on my gitea.
Including a script to fully generate the keystream statically, although it is easier to dump dynamically.
Before i developed the keystream script i purposely chose to make this the only dynamic part of the reversing process; of course with any reversing you should do a mix of both dynamic and static reversing but i wanted a “challenge” from the legendary “obfuscation” that nef resold for ~3 years
I have plans to try make a tool to only dynamically execute the
JNI_OnLoad and nothing else while automatically dumping the keystream for the user; update soon:tm: i already have a tool to statically recreate the keystream, although not a bad premise to safely automate the execution of the entrypoint to dump the keystream
Codebase
Most jvm functions are in wrapper/helper subroutines.
It’s checked if the classes reference is already initialised - if not, the class and all that classes methods used anywhere in the binary are retrieved through jni calls.
Each are stored as a global/static reference; a generic lazy singleton initialisation with a mutex, so all “standard library” jvm, other methods and classes are only resolved once.
These are present at the top of every native method counterpart.
To reverse these functions, find what class and methods it is initialising, then rename the function and each reference, this is an integral component to understanding the flow of the native method.
Once done, it will make the reversing trivial.
Again each one of these functions initialise one class and all used methods, its 1-1 to each class used, these “ref helper” methods are the first functions you should focus on.
e.g - a natively transpiled method uses System.exec()5, -> the mutex lock and function call that returns jclass at the top of this native method will be the resolve function, it will check if java/lang/Runtime already has a global reference initialised.
If not it will use env->FindClass and env->NewGlobalRef to reference the class and all the methods used in the library.
A suitable name for this function might be java_lang_Runtime_RefInit, if you change the label for all the globals in this function the native method will become incredibly easy to reverse.
I also recommend using the FunctionStringAssociate plugin or an alternative, to automatically comment function calls with the strings within that function (method names in this case), for readability.
jclass getMainClass(JNIEnv *env)
{
jclass mainClassGlobal;
jclass tmp;
bool initialised;
ulonglong classname;
ulonglong classname2;
ushort classname3;
jclass existing;
mainClassGlobal = g_MainClass;
if (g_MainClass == (jclass)0x0) {
classname = *(ulonglong *)((longlong)JNICKeystream + 0x8f43) ^ 0x5fe6a794d6de8d4d;
classname2 = *(ulonglong *)((longlong)JNICKeystream + 0x8f4b) ^ 0x888a166165b8a00d;
classname3 = *(byte *)((longlong)JNICKeystream + 0x8f53) ^ 0xd0;
mainClassGlobal = (*(*env)->FindClass)(env,(char *)&classname);
mainClassGlobal = (*(*env)->NewGlobalRef)(env,mainClassGlobal);
tmp = (jclass)0x0;
LOCK();
initialised = g_MainClass != (jclass)0x0;
existing = mainClassGlobal;
if (initialised) {
tmp = g_MainClass;
existing = g_MainClass;
}
g_MainClass = existing;
UNLOCK();
if (initialised) {
(*(*env)->DeleteGlobalRef)(env,mainClassGlobal);
mainClassGlobal = tmp;
}
}
return mainClassGlobal;
}
Following the same practice, all the native functions for a given user defined java class are initialised in the same way, compared to initialising them each in their respective Java_classname exports as seen in usual jni libraries.
Expect to see excessive exception checks and locks for stability, especially when it comes to obfuscated binaries. Native Obfuscator (writeup soon:tm:) utilises exception checks on almost every single jni statement.
JNIC seems to improve on this by opportunistically placing exception handlers on known problematic calls therefore likely improving its pre-transpiled obfuscation support.
nativeobf excep handler example
Strings
; certain things have been renamed even when they shouldnt
; e.g it loads the keystream into param1 which contains the jni env
; after the decryption, the jni env is copied back into param1
; (*env)->NewString
1000133d 48 8b 06 MOV RAX,qword ptr [RSI]
10001340 48 8b 80 MOV RAX,qword ptr [RAX + 0x518]
18 05 00 00
; cipher text, constant values
10001347 c7 44 24 MOV dword ptr [RSP + cipher_text],0xd4bb5a7b
69 7b 5a
bb d4
1000134f 66 c7 44 MOV word ptr [RSP + cipher_textb],0x9116
24 6d 16 91
10001356 c6 44 24 MOV byte ptr [RSP + cipher_text9],0x0 ; null terminator
6f 00
1000135b 31 c9 XOR i,i
1000135d 48 8b 15 MOV keystream,qword ptr [KEYSTREAM_PTR]
54 ed 00 00
string_xor_loop XREF[1]: 10001375(j)
10001364 44 8a 44 MOV R8B,byte ptr [keystream + i*0x1 + 0x1d]
0a 1d ; key for this cipher text is at offset 0x1d
10001369 44 30 44 XOR byte ptr [RSP + i*0x1 + cipher_text+0x1],R8B
0c 69 ; R8b is the byte from keystream
1000136e 48 ff c1 INC i
10001371 48 83 f9 06 CMP i,0x6 ; length of the string
10001375 75 ed JNZ string_xor_loop ; jump/loop again if i is not 6
; call (*env)->NewString with the decrypted string
1000137c 48 89 f1 MOV i,RSI
1000137f 41 b8 03 MOV R8D,0x3 ; length of the string
00 00 00 ; (sometimes it decrypts more than needed and substrings)
10001385 ff d0 CALL RAX ; (*env)->NewString was moved into RAX earlier :3
As you can see the string decryption is the boilerplate stack string xor decryption.
It loads the cipher text onto the stack from the constant values, copies the keystream_ptr into param2 and
then xors each cipher byte with a corresponding byte from the keystream for the length of the string, in this case 6 bytes/chars.
The offset into they keystream for the key being 0x1d in this case.
It initialises this as a string with the jvm with the call NewString6.
pseudo code
cipher_text = 0xd4bb5a7b;
cipher_textb = 0x9116;
cipher_text9 = 0;
i = 0;
do {
*(byte *)(cipher_text + i) =
*(byte *)(cipher_text + i) ^ *(byte *)(KEYSTREAM_PTR + 0x1d + i);
i = i + 1;
} while (i != 6); // length of the string
Function pointers
jni functions are often loaded into the stack as such:
CallStaticObjectMethod = (*param_1)->CallStaticObjectMethod;
Just rename these.
User defined native functions are slightly different.
Each class that uses native methods has a jni export of that name, as is usual practice for jni libraries.
Within these functions, also the same with normal jni practice, RegisterNatives7 calls are performed to register each native method with the jvm.
The obvious indicator is the autotyped JNINativeMethod struct, if you have typed the JNIEnv these will also be typed.
// string decryption for the method name
do {
m_name[i + 4] = m_name[i + 4] ^ *(byte *)(KEYSTREAM_PTR + 0x4 + i);
i = i + 1;
} while (i != 0x12);
methods[0].name = (char *)(m_name + 4);
// string decryption for the sig
do {
m_sig[i] = m_sig[i] ^ *(byte *)(KEYSTREAM_PTR + 0x16 + i);
i = i + 1;
} while (i != 3);
methods[0].signature._0_5_ = SUB85(m_sig,0);
methods[0].signature._5_3_ = (undefined3)((ulonglong)m_sig >> 0x28);
// ignore the string manipulation here
// ghidra has just slightly messed up, its actually just a cast to a uint64 ptr
const uint64_t sig = *(uint64_t *) m_sig;
methods[0].signature = sig;
methods[0].fnPtr._0_5_ = 0x10001000; // memory address of the function, yes really its just here
methods[0].fnPtr._5_3_ = 0;
// decomp moment again its just
// methods[0].fnPtr = 0x10001000;
(*(*env)->RegisterNatives)(env,clazz,&methods,1); // num of methods
Footnotes
-
Some excerpts are from the latest version at the time of writing — JNIC v3.7.0 ↩
-
SRC: larp masters ↩
-
The same 3 samples using native now float through minecraft exploit communities, the abuse reports? unseen. ↩
-
https://docs.oracle.com/javase/8/docs/api/java/lang/Runtime.html#exec-java.lang.String- ↩
-
https://docs.oracle.com/javase/8/docs/technotes/guides/jni/spec/functions.html#NewString ↩
-
https://docs.oracle.com/javase/8/docs/technotes/guides/jni/spec/functions.html#RegisterNatives ↩