Deobfuscating PairIP: Reverse Engineering VM protector
Google PAIRIP (Protected Android Integrity Protection) is a native protection mechanism used in some Android applications to make reverse…
Deobfuscating PairIP: Reverse Engineering VM protector
Google PAIRIP (Protected Android Integrity Protection) is a native protection mechanism used in some Android applications to make reverse engineering and tampering significantly more difficult. It combines multiple defensive techniques, including code virtualization, control-flow obfuscation, integrity verification, and anti-analysis mechanisms, to protect sensitive application logic from static and dynamic analysis.
The goal of this Article is to document the internal architecture of PAIRIP from a reverse engineering and educational perspective. It focuses on understanding the VM architecture, dispatcher design, instruction format, execution model, and analysis methodology.
Why is PAIRIP difficult to break?
PAIRIP combines several protection techniques instead of relying on a single obfuscation layer:
- PAIRIP employs multiple protection techniques that work together to resist reverse engineering. Protected routines are virtualized into proprietary bytecode and executed by a custom virtual machine, while Control Flow Obfuscation (CFO) replaces the original control-flow graph with a dispatcher-driven execution model. The VM bytecode is protected using RC4-based encryption, decrypted at runtime with NEON-accelerated routines, and further obscured through opcode permutation, preventing static instruction mapping. During execution, the runtime performs inline FNV-1a integrity hashing, stack canary validation, dynamic unpacking, runtime string construction, JNI bridging, and dynamic library resolution (
dlopen).
Many of you tried to hook with frida , xposed , ptrace etc etc . but applicaiton crashed , dont worry i ll explain why its and you can bypass it to custom kerenel requried . (see here)

What is Protects Actually ?
- Dex : DEX Stripping and Hiding. When the developer builds the app, the packer surgically removes the most sensitive functions (like license verification or game logic) from the public
classes.dexfile. It takes those removed functions, compiles them into a hidden DEX file, encrypts it with RC4, and stuffs it deep inside the.iap(or.pai) payload file. - Strings : it protect important strings like license verification , api end points and most of the other strings
(Note : In latest Update in other libraries of the application it had DT_NEED libpairipcore.so . Dont panic , this is for anti debugging . Re write elf header it’ll solve)
Where its Stored ?

in /assets the encrypted vm bytecode is stored
Lets Reverse IT ! ;)
Before going into Java or Native side . There are so many methods i know to break protection . But if your goal is unpack the vm bytecode 1. Find boot ( 1st encrypted byte code) 2. Identify the VM Dispatcher 3. Identify the Opcodes 4. Script Devirtualizer
Java-Side Architecture
The Java layer primarily serves as a thin interface between the Android runtime and the native virtual machine. Its responsibilities include loading the native library, supplying encrypted VM bytecode, invoking the native execution engine, and handling the returned Java objects.
1. PairIP Classes
The PairIP package contains the Java classes responsible for interacting with the native protection framework. These classes provide the entry points used to initialize and communicate with the virtual machine.

2. VMRunner
VMRunner is the central component of the Java layer. The class consists primarily of static fields, static methods, and a static initializer.
The static initializer executes immediately when the class is loaded by the JVM. During initialization, it performs two critical tasks:
- Loads the native library.
- Registers the native
executeVm()method through JNI.
The executeVm() method serves as the primary interface between the Java runtime and the native virtual machine. It transfers encrypted VM bytecode and any required Java objects to the native execution engine and ultimately returns a jobject containing the execution result.
Note : Rather than instrumenting
executeVm()through conventional runtime hooks, an alternative approach is to inject an object logger into the application's VDEX/ODEX after installation. This enables observation of returned Java objects while avoiding common hook-based detection mechanisms.

3. HelperMethods
HelperMethods is responsible for loading secondary encrypted VM bytecode from the application's assets directory and supplying it to the native virtual machine for execution.

4. StartupLauncher
StartupLauncher identifies the initial VM program that is executed during application startup. A representative example is:
startupProgramName = "awjety29psyo42jc";
The value corresponds to the first encrypted VM bytecode file that is passed to executeVm(). This initial bytecode performs essential VM initialization and establishes the execution environment for subsequent protected programs.

During devirtualization, this bootstrap bytecode becomes particularly important because it frequently contains initialization routines, dispatcher setup, and other logic that defines the VM’s execution state.
5. Signature Verification
The Java layer performs an APK signature verification prior to VM execution. While this provides an initial integrity check, it should not be considered the primary anti-tampering mechanism.

The native library implements considerably more sophisticated integrity verification and anti-repackaging routines, many of which are only observable through native analysis and VM devirtualization.
Summary
The Java layer contains relatively little protection logic. Its primary responsibilities are to:
- Load the native library.
- Load encrypted VM bytecode from the application’s assets.
- Invoke the native execution engine via
executeVm(). - Return the resulting Java objects to the Android runtime.
The core virtual machine implementation, bytecode interpreter, and the majority of the protection mechanisms reside within the native library.
Native Side
Open libpairipcore.so in Ida
Finding the Core: How to Locate and Reverse executeVM
When you’re dealing with advanced Android protectors like PairIP, finding the main engine is half the battle. The developers are smart — they don’t just leave their most important functions lying around in plain sight. Instead, they use dynamic method registration (like RegisterNatives) to bridge the gap between Java and their protected C/C++ code.

Now, there are plenty of dynamic ways to find the executeVM method. You could use Frida (Stalker), ptrace, or even eBPF. But honestly? I suggest we keep it simple and look statically at the library's entry point: JNI_OnLoad.
Step 1: JNI_OnLoad Jungle
When you first open JNI_OnLoad in your decompiler, you're going to see a lot of junk. The developers intentionally stuff this function full of meaningless arithmetic to waste your time.
My advice: Don’t overthink it. Ignore the math. All you need to do is look for the string "executeVM". In almost all of these samples, the label right next to that string contains the exact offset we need. v84 is our executeVm offset.

To show you what’s actually happening under all that noise, here is a simplified, cleaned-up version of the JNI_OnLoad code:
C++
#include <jni.h>
#include <unistd.h>JavaVM* g_vm = nullptr;
jclass g_contextClass = nullptr;
jmethodID g_startActivity = nullptr;
jmethodID g_sendBroadcast = nullptr;
jmethodID g_executeVM = nullptr;
jmethodID g_invoke = nullptr;
jmethodID g_getContext = nullptr;
bool g_isInitialized = false;
jint JNI_OnLoad(JavaVM* vm, void* reserved) {
if (g_isInitialized) {
return JNI_VERSION_1_6;
}
g_vm = vm;
JNIEnv* env = nullptr;
if (vm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6) != JNI_OK) {
return JNI_ERR;
}
// Grab the Android Context so the VM can interact with the app
jclass localContextClass = env->FindClass("android/content/Context");
if (localContextClass != nullptr) {
g_contextClass = (jclass)env->NewGlobalRef(localContextClass);
g_startActivity = env->GetMethodID(g_contextClass, "startActivity", "(Landroid/content/Intent;)V");
g_sendBroadcast = env->GetMethodID(g_contextClass, "sendBroadcast", "(Landroid/content/Intent;)V");
env->DeleteLocalRef(localContextClass);
}
if (env->ExceptionCheck()) env->ExceptionClear();
// The Golden Target: Finding our PayloadLoader
jclass payloadClass = env->FindClass("com/target/PayloadLoader");
if (payloadClass != nullptr) {
g_executeVM = env->GetStaticMethodID(payloadClass, "executeVM", "([B[Ljava/lang/Object;)Ljava/lang/Object;");
g_invoke = env->GetStaticMethodID(payloadClass, "invoke", "([B[Ljava/lang/Object;)Ljava/lang/Object;");
g_getContext = env->GetStaticMethodID(payloadClass, "getContext", "()Landroid/content/Context;");
}
if (env->ExceptionCheck()) env->ExceptionClear();
g_isInitialized = true;
return JNI_VERSION_1_6;
}
Step 2: The executeVM Wrapper
Once you track down the executeVM function, you'll realize it’s actually just a middleman.

As you can see in the code below, it takes our encrypted VM bytecode array, allocates some space in memory using malloc, copies the payload over, and then passes it straight into the real beast: vmDispatcher.
C++
jobject JNICALL executeVM(JNIEnv* env, jclass clazz, jbyteArray payloadArray, jobjectArray args) {
jsize length = env->GetArrayLength(payloadArray);
// Make room in native memory
jbyte* nativeBuffer = (jbyte*)malloc(length);
memset(nativeBuffer, 0, length);
// Copy the encrypted bytecode over
env->GetByteArrayRegion(payloadArray, 0, length, nativeBuffer);
// Hand it off to the main engine!
jobject result = vmDispatcher(&nativeBuffer, args);
return result;
}
Step 3: Inside vmDispatcher (Don't Panic!)
This is usually the hardest part of the analysis, but don’t panic!

When you open vmDispatcher, it looks terrifying. It uses Control Flow Flattening (similar to OLLVM but customized) to turn the code into a massive, unreadable loop.
Here is the secret: As humans, we simply don’t have the computational power to read this. And we don’t have to! We can use Tuned AI (like LLMs) and Symbolic Execution (like Angr) to do the heavy lifting for us. We just need to figure out what the individual cases (the opcodes) are actually doing.
Let’s look at two great examples: Opcode 17 and Opcode 23. Once we clean up the messy math, we can see they are just doing basic arithmetic — but with a catch. After every operation, they compute a 64-bit FNV hash over the VM’s memory to make sure nobody is tampering with it!
case 17:
v545 = v972;
v19 = v972[2];
v546 = v972[3];
v547 = *v972;
v548 = *(*v972 + v546);
v972[3] = v546 + 4;
v23 = *(v547 + (v546 + 4));
v545[3] = v546 + 8;
v549 = v548 ^ ~v19;
v24 = *(v547 + (v546 + 8));
v545[3] = v546 + 16;
v550 = *(v547 + (v546 + 16));
v545[3] = v546 + 20;
v551 = *(v547 + (v546 + 20));
v545[3] = v546 + 22;
v27 = *(v547 + (v546 + 22));
v545[3] = v546 + 26;
v28 = *(v547 + (v546 + 26));
v545[3] = v546 + 30;
v552 = *(v547 + (v546 + 30)) ^ ~v19;
v545[3] = v546 + 34;
v553 = *(v547 + (v546 + 34));
v545[3] = v546 + 38;
v554 = *(v547 + v552 % v19) / *(v547 + v549 % v19);
v32 = 0xCBF29CE484222325LL;
*(v547 + (v553 ^ ~v19) % v19) = v554;
v31 = *v545;
if ( v551 )
{
v555 = 0;
v556 = 1;
v557 = v31 + (v550 ^ ~v19) % v19;
do
{
v558 = *(v557 + v555);
v555 = v556;
--v551;
++v556;
v32 = (0x100000001B3LL * v32) ^ v558;
}
while ( v551 );
}
goto LABEL_518;
case 23:
v451 = v972;
v19 = v972[2];
v452 = v972[3];
v453 = *v972;
v83 = *(*v972 + v452);
v972[3] = v452 + 4;
v84 = *(v453 + (v452 + 4));
v451[3] = v452 + 12;
v454 = *(v453 + (v452 + 12));
v451[3] = v452 + 16;
v455 = *(v453 + (v452 + 16));
v451[3] = v452 + 18;
v87 = *(v453 + (v452 + 18));
v451[3] = v452 + 22;
v88 = *(v453 + (v452 + 22));
v451[3] = v452 + 26;
v456 = *(v453 + (v452 + 26));
v451[3] = v452 + 30;
v457 = *(v453 + (v452 + 30));
v451[3] = v452 + 34;
v458 = *(v453 + (v452 + 34));
v451[3] = v452 + 38;
LODWORD(v452) = (v456 ^ ~v19) % v19;
v92 = 0xCBF29CE484222325LL;
*(v453 + v452) = *(v453 + (v457 ^ ~v19) % v19) + *(v453 + (v458 ^ ~v19) % v19);
v93 = *v451;
if ( v455 )
{
v459 = 0;
v460 = 1;
v461 = v93 + (v454 ^ ~v19) % v19;
do
{
v462 = *(v461 + v459);
v459 = v460;
--v455;
++v460;
v92 = (0x100000001B3LL * v92) ^ v462;
}
while ( v455 );
}
goto LABEL_306;
Opcode 17 — Division:
// Decode the VM's hidden operands
dst = decode(dstOperand);
src1 = decode(src1Operand);
src2 = decode(src2Operand);
hashStart = decode(hashOperand)
VM[dst] = VM[src1] / VM[src2];
FNV64(VM + hashStart, hashLength);
Opcode 23 — Addition:
// Decode the VM's hidden operands
dst = decode(dstOperand);
src1 = decode(src1Operand);
src2 = decode(src2Operand);
hashStart = decode(hashOperand);
VM[dst] = VM[src1] + VM[src2];
FNV64(VM + hashStart, hashLength);
Step 4: How the VmContext Works
Now that we know what the opcodes do, we need to understand how the VM keeps track of everything. It does this using a structure called VmContext.
This structure holds the base address of the encrypted bytecode, the total size, and the Virtual Instruction Pointer (VIP) which tracks our current location.
struct VmContext {
uint8_t* bytecode; // Base address of the VM memory / encrypted bytecode
uint32_t size; // Total size of the VM memory
uint32_t vip; // Virtual Instruction Pointer (current instruction)
};
Here is where the developers got really clever. Unlike a normal virtual machine, operands are not stored as direct memory addresses. If they were, reverse engineers could find them easily. Instead, every operand is obfuscated. To use an operand, the VM has to decode it on the fly using this awesome little formula: (value ^ ~(ctx->size)) % ctx->size; **For example see this**
struct VmContext {
uint8_t* bytecode; // Base address of the VM memory / encrypted bytecode
uint32_t size; // Total size of the VM memory
uint32_t vip; // Virtual Instruction Pointer (current instruction)
};
// Decode an encoded VM operand into a valid VM memory offset.
inline uint32_t decode_reg(VmContext* ctx, uint32_t offset) {
// Read the encoded operand from the current instruction.
uint32_t value = *(uint32_t*)(ctx->bytecode + ctx->vip + offset);
// Decode the operand into a VM memory address.
return (value ^ ~(ctx->size)) % ctx->size;
}
// VM memory access helpers.
#define VM_MEM(addr) (ctx->bytecode + (addr))
#define REG_U32(addr) *(uint32_t*)VM_MEM(addr)
#define REG_U64(addr) *(uint64_t*)VM_MEM(addr)
#define REG_F32(addr) *(float*)VM_MEM(addr)
#define REG_F64(addr) *(double*)VM_MEM(addr)
// Main VM execution loop.
while (true) {
// Fetch the next opcode.
opcode = get_next_opcode(ctx);
switch (opcode) {
// Integer Addition
case OP_ADD:
REG_U32(dst) = REG_U32(src1) + REG_U32(src2);
break;
// Integer Division
case OP_DIV:
REG_U32(dst) = REG_U32(src1) / REG_U32(src2);
break;
// Unconditional Jump
case OP_JMP:
ctx->vip = target;
continue;
}
// Advance to the next instruction.
ctx->vip += instruction_size;
}
Because of this setup, the VM behaves like a “typed memory machine.” The exact same chunk of memory can be treated as an integer, a float, or a double depending entirely on the opcode.
So, our main VM execution loop always looks like this: Fetch opcode → Decode operands → Access memory → Execute the math & hash it → Move to the next instruction. **SEE here for Simplified Opcode Cases**

calling Core Vm antiTamper2.c
The Plot Twist: The Second VM!
You might be wondering: “Okay, we have the opcodes and the bytecode, but why is the payload still largely encrypted?”
Well, because there is a plot twist! The vmDispatcher we just analyzed is the core engine, but there is a second Virtual Machine sitting in the background. After the bytecode goes into the dispatcher, this secondary VM wakes up. It acts as a late-stage decryption engine. Its entire job is to decrypt the rest of the bytecode, strings, and later assets just-in-time for the main VM to use them. Don't worry though—I’ve reconstructed this using Angr and AI too!

*(*Check out the full code in the repo antiTamper2.c).**
Now that we understand both the Decryption Engine and the Opcodes, we finally have everything we need. Next stop:
Writing the DeVirtualizer!

2 pillars SBox and permuation Opcode randomizer
Defeating Disassembler Desynchronization: The DFS Disassembler
How Pillar 1 (RC4 S-Box / Table 1) is used
Before your DeVirtualizer can translate any code, it has to be able to see the code. The raw .iap file on disk is encrypted. PairIP normally uses a secondary Just-In-Time (JIT) VM to decrypt things in memory only when needed, which makes static analysis impossible.
By extracting Pillar 1 (the fully initialized 256-byte RC4 state), you bypass the JIT VM entirely. You use Pillar 1 in the very first stage of your DeVirtualizer:
- Input: You feed the encrypted
.iappayload and the 256-byte Pillar 1 table into your DeVirtualizer. - Execution: Your script runs a standard RC4 PRGA (Pseudo-Random Generation Algorithm) loop over the payload, using Pillar 1 to generate the keystream.
- The Result: * The encrypted garbage becomes readable VM bytecode.
- The obfuscated string pointers now resolve to actual plaintext strings (e.g.,
"libart.so"or"verifyLicense"). - The hidden, stripped
**classes.dex** file is unpacked and can be dumped to disk.
Without Pillar 1: Your DeVirtualizer would be reading high-entropy garbage, and all string references would point to meaningless bytes.
How Pillar 2 (Opcode Permutation / Table 2) is used
Role in the DeVirtualizer: The Instruction Unscrambler
Once Pillar 1 has decrypted the bytecode, your DeVirtualizer needs to read it. However, PairIP randomizes its instruction IDs on every single build (Polymorphism). For example, OP_ADD might be 0x17 in this app, but 0x8F in another.
If your DeVirtualizer just reads the decrypted bytecode directly, it will misinterpret every instruction and immediately crash when calculating instruction sizes (Disassembler Desynchronization).
You use Pillar 2 inside the Bytecode Parser / Lifter stage of your DeVirtualizer:
- Input: Your script reads a byte from the decrypted bytecode (e.g.,
0x4A). - Execution (The Lookup): Before your script tries to figure out what
0x4Ameans, it passes it through Pillar 2:true_opcode = table_2[0x4A] - The Result: Pillar 2 translates that randomized byte back into the VM’s true, static internal ID (e.g., returning
23, which you mapped toOP_ADD). - Lifting: Now that your DeVirtualizer knows it is actually an
OP_ADD, it knows exactly how many bytes of operand data to decode and can successfully translate it into readable C or Python pseudo-code.
Without Pillar 2: Your DeVirtualizer would misidentify every instruction, assign the wrong byte sizes, fall into the packer’s dead-byte padding, and hallucinate fake execution paths.
Dumping Sboxes !
import os# --- CONFIGURATION ---
BINARY_PATH = "libpairipcore.so"
# Offsets from your decompilation: unk_F044 and unk_F174
TABLE_1_OFFSET = 0xF044
TABLE_2_OFFSET = 0xF174
def hexdump(src, length=16):
result = []
for i in range(0, len(src), length):
s = src[i:i+length]
hexa = ' '.join([f"{x:02X}" for x in s])
text = ''.join([chr(x) if 0x20 <= x < 0x7F else '.' for x in s])
result.append(f"0x{i:04X} {hexa:<{length*3}} {text}")
return '\n'.join(result)
def extract_and_decrypt(filepath, offset, name):
print(f"[*] Extracting {name} from offset {hex(offset)}...")
with open(filepath, "rb") as f:
# Jump directly to the unk_XXXX address in the file
f.seek(offset)
# Read exactly 0x108 (264) bytes as seen in the memcpy
raw_data = f.read(264)
if len(raw_data) < 264:
print(f"[-] Error: Could not read 264 bytes at {hex(offset)}. Is the offset correct?")
return
# The first 8 bytes are the XOR key
key = raw_data[:8]
# The next 256 bytes are the encrypted table
encrypted_payload = raw_data[8:]
print(f" -> Key found: {key.hex()}")
# The decryption loop you reversed: v3 = v2 ^ key[i & 7]
decrypted = bytearray()
for i in range(256):
decrypted_byte = encrypted_payload[i] ^ key[i % 8]
decrypted.append(decrypted_byte)
# Save to disk
filename = f"{name.lower().replace(' ', '_')}.bin"
with open(filename, "wb") as f:
f.write(decrypted)
print(f"[+] Saved to {filename}")
print(f"\n--- {name.upper()} PREVIEW ---")
print(hexdump(decrypted[:64])) # Print first 64 bytes to verify
print("-" * 50 + "\n")
if __name__ == "__main__":
if not os.path.exists(BINARY_PATH):
print(f"[-] Could not find {BINARY_PATH}. Please check the filename.")
else:
extract_and_decrypt(BINARY_PATH, TABLE_1_OFFSET, "Table 1")
extract_and_decrypt(BINARY_PATH, TABLE_2_OFFSET, "Table 2")
print("[*] All extraction finished. You beat the packer!")

If you try to read the VM bytecode linearly (byte 0, byte 1, byte 2), the packer’s injected “dead bytes” and massive operand padding will misalign your instruction pointer, causing you to read random garbage as if it were executable code.
Dumping Boot encryptedBytecode Operations: Code :
import os
TABLE_2_PATH = "table_2.bin"
PAYLOAD_PATH = "aWJeTy29psyo4ZJC"
KNOWN_OPCODES = {
200: "OP_JMP", 201: "OP_JZ", 202: "OP_JNZ",
2: "OP_ADD_INT32", 73: "OP_SUB_INT32", 75: "OP_MUL_INT32", 16: "OP_DIV_INT32", 77: "OP_MOD_INT32", 143: "OP_INC_INT32",
1: "OP_MUL_INT64", 29: "OP_ADD_INT64", 100: "OP_SUB_INT64", 20: "OP_DIV_INT64", 47: "OP_MOD_INT64",
54: "OP_AND_INT32", 161: "OP_OR_INT32", 58: "OP_OR_INT8", 66: "OP_XOR_INT64", 162: "OP_XOR_INT8", 126: "OP_NOT_AND_INT64", 79: "OP_SHL_INT32", 53: "OP_SHR_INT64", 142: "OP_SAR_INT32",
23: "OP_ADD_FLOAT", 151: "OP_ADD_DOUBLE", 163: "OP_MUL_FLOAT", 30: "OP_MUL_DOUBLE", 17: "OP_DIV_FLOAT", 122: "OP_DIV_DOUBLE", 128: "OP_FMOD_FLOAT", 33: "OP_FMOD_DOUBLE",
112: "OP_IS_ZERO_INT32", 131: "OP_IS_NOT_ZERO_INT32", 102: "OP_LOGICAL_NOT_CMP", 141: "OP_CMP_GT_INT32", 63: "OP_CMP_INT64", 104: "OP_CMP_GT_INT64", 42: "OP_CMP_DOUBLE",
111: "OP_MOV_INT8", 96: "OP_MOV_INT16", 105: "OP_MOV_INT16", 28: "OP_MOV_INT32", 46: "OP_MOV_INT32", 49: "OP_MOV_INT32", 117: "OP_MOV_INT32", 24: "OP_MOV_INT64", 123: "OP_MOV_INT64", 80: "OP_MOV_FLOAT_DOUBLE",
41: "OP_MEMCMP", 130: "OP_STRCMP", 68: "OP_MEMCPY",
10: "OP_CAST_MEM", 25: "OP_CAST_MEM", 78: "OP_CAST_MEM",
83: "OP_EXT_CALL",
255: "OP_EXIT",
19: "OP_NEON_DECRYPT", 146: "OP_NEON_DECRYPT", 127: "OP_NEON_DECRYPT", 57: "OP_NEON_DECRYPT",
103: "OP_FNV_HASH", 125: "OP_FNV_HASH", 144: "OP_FNV_HASH",
45: "OP_APK_REPACK_CHECK", 62: "OP_APK_REPACK_CHECK",
12: "OP_DLOPEN", 34: "OP_DLOPEN", 82: "OP_DLOPEN",
9: "OP_UNPACK_BUFFER", 76: "OP_UNPACK_BUFFER", 121: "OP_UNPACK_BUFFER", 137: "OP_UNPACK_BUFFER",
3: "OP_SSO_ALLOC", 37: "OP_SSO_ALLOC",
99: "OP_MUTEX_INIT", 150: "OP_MUTEX_INIT",
5: "OP_IO_POLL", 14: "OP_IO_POLL", 48: "OP_IO_POLL", 116: "OP_IO_POLL", 160: "OP_IO_POLL",
39: "OP_OBJ_DESTROY", 65: "OP_OBJ_DESTROY", 110: "OP_OBJ_DESTROY", 166: "OP_OBJ_DESTROY"
}
# The EXACT sizes pulled directly from your 435acvm.c file
KNOWN_SIZES = {
1: 40, 2: 40, 10: 44, 15: 44, 16: 40, 17: 40, 20: 40, 23: 40,
24: 36, 25: 44, 28: 36, 29: 40, 30: 40, 33: 40, 41: 40, 42: 44,
46: 36, 47: 40, 49: 36, 53: 40, 54: 40, 58: 40, 63: 40, 66: 40,
68: 40, 73: 40, 75: 40, 77: 40, 78: 44, 79: 40, 80: 44, 83: 36,
96: 36, 100: 40, 101: 28, 102: 40, 104: 40, 105: 36, 111: 36,
112: 36, 117: 36, 122: 40, 123: 36, 126: 40, 128: 40, 130: 40,
131: 36, 141: 40, 142: 40, 143: 32, 151: 40, 161: 40, 162: 40,
163: 40
}
def load_data():
with open(TABLE_2_PATH, "rb") as f:
opcode_map = f.read(256)
with open(PAYLOAD_PATH, "rb") as f:
raw_data = f.read()
return opcode_map, raw_data[16:]
def dfs_disassemble(bytecode, opcode_map, ip, depth, max_depth, path):
if depth == max_depth or ip >= len(bytecode):
return path
raw_byte = bytecode[ip]
real_opcode = opcode_map[raw_byte]
if real_opcode not in KNOWN_OPCODES:
return None # Dead end, we hit an operand byte
name = KNOWN_OPCODES[real_opcode]
# If it's a known math instruction, step forward by its exact size
if real_opcode in KNOWN_SIZES:
size = KNOWN_SIZES[real_opcode]
return dfs_disassemble(bytecode, opcode_map, ip + size, depth + 1, max_depth, path + [(ip, real_opcode, name, size)])
# If it's an UNKNOWN size (like OP_NEON_DECRYPT), try every possible even size
# and let the script validate which one leads to a valid future execution stream
for size in range(2, 54, 2):
res = dfs_disassemble(bytecode, opcode_map, ip + size, depth + 1, max_depth, path + [(ip, real_opcode, name, size)])
if res is not None:
return res # Found the path that keeps producing valid opcodes!
return None
def run():
opcode_map, bytecode = load_data()
# Based on your manual linear trace, the very first valid opcode was at offset 1
start_ip = 1
print("[*] Running DFS Disassembler...")
# Require 25 consecutive valid instructions to confirm a path
valid_path = dfs_disassemble(bytecode, opcode_map, start_ip, 0, max_depth=25, path=[])
if valid_path:
print("[+] Valid Execution Flow Found!\n")
print("IP | REAL | SIZE | INSTRUCTION")
print("-" * 45)
for ip, opcode, name, size in valid_path:
print(f"0x{ip:04X} | {opcode:<4} | {size:<4} | {name}")
else:
print("[-] DFS failed. Start IP might be slightly different.")
if __name__ == "__main__":
run()

Bingo ! You have Sucessfully dumped the Operations . Thats all In Next Article Ill be helping you to dump other vmByteCodes and string .
Special thanks to : Google AI for helping in Deobufscation . tim blazytko Videos
메타데이터
- post_id
- af8e18ea49ab
- slug
- deobfuscating-pairip-reverse-engineering-vm-protector-af8e18ea49ab
- url
- https://systemweakness.com/deobfuscating-pairip-reverse-engineering-vm-protector-af8e18ea49ab
- canonical_url
- https://systemweakness.com/deobfuscating-pairip-reverse-engineering-vm-protector-af8e18ea49ab
- author_url
- https://medium.com/@haxymad
- status
- ok
- fetched_at
- 2026-07-11 16:48:19