使用Java/Kotln编写的JVM字节码执行引擎

JAVA Bytecode Execution Engine

Traditional Java dynamic debugging can only be based on the source code level, and cannot be dynamically debugged without source code or obfuscated Java class files.

The running of Java programs is based on the JVM virtual machine, which executes bytecode. We have constructed a JVM bytecode execution engine using Kotlin, which allows us to debug Java programs at the bytecode level using modern IDEs such as IntelliJ IDEA to observe program behavior during execution.

Attention, this project is only for studying and researching the operation principle of JVM and analyzing malicious programs. It is strictly prohibited to use it for illegal purposes.

Prerequisite Knowledge Foundation

Before using this project, please make sure that you have the following knowledge foundation.

  1. Understanding the format of Java class files
  2. Understanding the role and meaning of each bytecode in JVM

Debugging at the bytecode level using IDEA

git clone https://github.com/vlinx-io/vlx-vmengine-jvm.git

Open this project using IDEA (requires JDK 17) and navigate to TestCases

There are two test cases in TestCases, one for executing static methods and one for executing instance methods, respectively named executeStaticMethod and executeVirtualMethod.

On the corresponding method, fill in the information such as classPath, className, methodName, and methodSignature. The detailed information of the class file can be viewed using ClassViewer.

Run directly

Using the compiled class file of the following code as an example

public class Hello {

    public void hello() {
        System.out.println("hello");
    }

    public static void main(String[] args) {
        System.out.println("Hello World!");
    }
}

Execute executeVirtualMethod, run the hello method of this class.

val classPath = "your-classpath"
val className = "Hello"
val methodName = "hello"
val methodSignature = "()V"
val args = listOf<Any?>()

val url = File(classPath).toURI().toURL()
val urls = arrayOf(url)
val loader = VlxClassLoader(urls)

val clazz = loader.loadClass(className)

val method = ClassUtils.getMethod(clazz, methodName, methodSignature, loader)
val instance = clazz.getDeclaredConstructor().newInstance()


val thread = VMThread(VMEngine.instance, loader)
thread.execute(instance, method!!, args, true, 0)

You can get the following output on the console.

2023-05-21 17:51:10 [DEBUG] Execute method: public void Hello.hello()
2023-05-21 17:51:10 [DEBUG] Receiver: Hello@3daf7722
2023-05-21 17:51:10 [DEBUG] Args: []
2023-05-21 17:51:11 [DEBUG] LocalVars: [kotlin.Unit]
2023-05-21 17:51:11 [DEBUG] "L0: GETSTATIC"
2023-05-21 17:51:11 [DEBUG] "#7"
2023-05-21 17:51:11 [DEBUG] public static final java.io.PrintStream java.lang.System.out
2023-05-21 17:51:11 [DEBUG] "push" org.gradle.internal.io.LinePerThreadBufferingOutputStream@6aa3a905
2023-05-21 17:51:11 [DEBUG] "L3: LDC"
2023-05-21 17:51:11 [DEBUG] "#13"
2023-05-21 17:51:11 [DEBUG] "hello"
2023-05-21 17:51:11 [DEBUG] "push" "hello"
2023-05-21 17:51:11 [DEBUG] "L5: INVOKEVIRTUAL"
2023-05-21 17:51:11 [DEBUG] "#15"
2023-05-21 17:51:11 [DEBUG] "class java.io.PrintStream, NameAndType(name='println', type='(Ljava/lang/String;)V')"
2023-05-21 17:51:11 [DEBUG] public void java.io.PrintStream.println(java.lang.String)
2023-05-21 17:51:11 [DEBUG] "pop" "hello"
2023-05-21 17:51:11 [DEBUG] "pop" org.gradle.internal.io.LinePerThreadBufferingOutputStream@6aa3a905
2023-05-21 17:51:11 [DEBUG] 	Execute method: public void org.gradle.internal.io.LinePerThreadBufferingOutputStream.println(java.lang.String)
2023-05-21 17:51:11 [DEBUG] 	Receiver: org.gradle.internal.io.LinePerThreadBufferingOutputStream@6aa3a905
2023-05-21 17:51:11 [DEBUG] 	Args: [org.gradle.internal.io.LinePerThreadBufferingOutputStream@6aa3a905, hello]
2023-05-21 17:51:11 [ERROR] Can't parse class class org.gradle.internal.io.LinePerThreadBufferingOutputStream
hello
2023-05-21 17:51:11 [DEBUG] "L8: RETURN"

The console output displays all the bytecode instructions of this method, the changes in the stack during instruction execution, and the results of each bytecode instruction.

Debugging

If you need to debug bytecode instructions, you can set breakpoints before and after the execute() method in VMExecutor.

 fun execute() {
    while (true) {
        try {
            pc = sequence.index()
            val opcode = sequence.readUnsignedByte().toShort()
            if (execute(opcode)) {
                break
            }
        } catch (vme: VlxVmException) {
            Logger.FATAL(vme)
            exitProcess(1)
        } catch (t: Throwable) {
            handleException(t)
        }

    }
}

Debugging submethod bytecode

By default, the virtual engine only interprets and executes the bytecode of the specified method. The sub-methods called within the specified method still run in the JVM to avoid significant performance overhead from multiple layers of calls. If you want all methods to be interpreted and executed by the virtual engine, please modify io.vlinx.vmengine.Options and change handleSubMethod to true.