Skip to content

Support Java Native Method Hook

Core Feature 1: Support Java Native Method Hook

OK-RASP is currently the only RASP that supports hooking native methods, stemming from our continuous technological exploration

The fundamental principle of RASP implementation is to modify the bytecode of target methods, inserting detection logic at method entry, return, and exception throwing points.

Among Java methods, there is a special category that lacks method bodies, such as interface methods, abstract methods, and native methods. Native methods serve as interfaces for Java to call non-Java code, typically being core JVM methods like command execution native methods. Monitoring calls to these methods would eliminate attack bypass issues.

1. Basic Principles

  • Native methods and C++ implementation resolution rules

Let’s use command execution methods as an example. First, examine the native method for command execution and its local implementation.

Source location: jdk11/src/java.base/unix/classes/java/lang/ProcessImpl.java

private native int forkAndExec(int mode, byte[] helperpath,
byte[] prog,
byte[] argBlock, int argc,
byte[] envBlock, int envc,
byte[] dir,
int[] fds,
boolean redirectErrorStream)
throws IOException;

The corresponding native method implementation (HotSpot) is as follows:

Source location: jdk11/src/java.base/unix/native/libjava/ProcessImpl_md.c

JNIEXPORT jint JNICALL
Java_java_lang_ProcessImpl_forkAndExec(JNIEnv *env,
jobject process,
jint mode,
jbyteArray helperpath,
jbyteArray prog,
jbyteArray argBlock, jint argc,
jbyteArray envBlock, jint envc,
jbyteArray dir,
jintArray std_fds,
jboolean redirectErrorStream){
// Not the focus of this article, code omitted...
}

We can see that the implementation name of a native method is composed of the Java class’s package name and method name. This rule is called standard resolution.

  • Setting prefixes for native method resolution

Bytecode modification primarily relies on methods like addTransformer and retransformClasses in the java.lang.instrument.Instrumentation API interface. Delving deeper into the Instrumentation API, we also notice the following method:

Source location: jdk11/src/java.instrument/share/classes/java/lang/instrument/Instrumentation.java

void setNativeMethodPrefix(ClassFileTransformer transformer, String prefix);

From the method’s comments, we can see that when standard resolution fails, the corresponding native method implementation can be found by adding a prefix to the Java name. This feature is disabled by default and needs to be configured in the JavaAgent package’s MANIFEST.MF file: Can-Set-Native-Method-Prefix: true. An example MANIFEST.MF is shown below:

Manifest-Version: 1.0
Premain-Class: com.okrasp.example.agent.Agent
Agent-Class: com.okrasp.example.agent.Agent
Can-Redefine-Classes: true
Can-Retransform-Classes: true
Can-Set-Native-Method-Prefix: true

The above MANIFEST.MF file not only configures the JavaAgent entry class but also enables three switches: Can-Redefine-Classes, Can-Retransform-Classes, and Set-Native-Method-Prefix. The Instrumentation API method isNativeMethodPrefixSupported can be used to check if this feature is enabled in the Java Agent.

  • Example of native method resolution

Suppose we have a native method like this, with its standard resolution implementation.

native boolean foo(int x); ====> Java_somePackage_someClass_foo(JNIEnv* env, jint x);

Adding a ClassTransformer to the JVM with setNativeMethodPrefix set to wrapped_, when standard resolution fails, the method resolution rule becomes:

native boolean wrapped_foo(int x); ====> Java_somePackage_someClass_foo(JNIEnv* env, jint x);

Figure 1 Two resolution rules for native methods

Figure 1 Resolution rules for native methods

Method linking can occur in two ways: explicit resolution using JNI function RegisterNatives and normal automatic resolution. For RegisterNatives, the JVM will attempt this association:

method(foo) -> nativeImplementation(foo)

When this fails, the specified prefix will be prepended to the method name for correct resolution:

method(wrapped_foo) -> nativeImplementation(foo)

For automatic resolution, the JVM will attempt:

method(wrapped_foo) -> nativeImplementation(wrapped_foo)

If this fails, the specified prefix will be removed from the implementation name and resolution will be retried:

method(wrapped_foo) -> nativeImplementation(foo)

If either of these associations is found, execution proceeds. Otherwise, if no suitable resolution is found, the process fails.

  • Multiple transformers scenario

The JVM resolves transformers in the order they are added (i.e., the sequence in which addTransformer is called). Suppose three transformers are added with their respective prefixes: transformer1 with prefix1_, transformer2 with prefix2_, and transformer3 with prefix3_. The JVM’s resolution rule would be:

native boolean prefix3_prefix2_prefix1_foo(int x); ====> Java_somePackage_someClass_foo(JNIEnv* env, jint x);

2. Using ASM to Modify Native Methods

Since native methods cannot directly have instructions inserted (they lack bytecode), they must be wrapped with non-native methods that can. For example, consider this native method:

native boolean foo(int x);

We can transform the bytecode into:

boolean foo(int x) {
//... record entry to foo ...
return wrapped_foo(x);
}
native boolean wrapped_foo(int x);

And set the native method resolution rule as:

method(wrapped_foo) -> nativeImplementation(foo)

Thus, the native method hook strategy can be broken down into three steps:

  • Convert the original native method to a non-native method with a body;
  • Call the wrapped native method within the body;
  • Add the wrapped native method;

Below is a demo using ASM to modify command execution methods. Key code snippets:

The Agent startup class is shown below:

public class Agent {
public static void premain(String args, Instrumentation inst) {
main(args, inst);
}
public static void agentmain(String args, Instrumentation inst) {
main(args, inst);
}
public static void main(String args, Instrumentation inst) {
System.out.println(String.format("%s INFO [rasp] %s ",
new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.sss").format(new Date()), "enter agent"));
// Register ClassFileTransformer object
RaspClassFileTransformer raspClassFileTransformer = new RaspClassFileTransformer(inst);
inst.addTransformer(raspClassFileTransformer);
}
}

After loading the Agent, register a class transformation ClassFileTransformer object with JVM’s Instrumentation.

Here’s the implemented ClassFileTransformer code:

public class RaspClassFileTransformer implements ClassFileTransformer {
private final Instrumentation inst;
public RaspClassFileTransformer(Instrumentation inst) {
this.inst = inst;
}
@Override
public byte[] transform(ClassLoader loader, String className,
Class<?> classBeingRedefined,
ProtectionDomain protectionDomain,
byte[] classfileBuffer) throws IllegalClassFormatException {
// Match specified classes
if ("java/lang/UNIXProcess".equals(className) || "java/lang/ProcessImpl".equals(className)) {
final ClassReader cr = new ClassReader(classfileBuffer);
final ClassWriter cw = new ClassWriter(cr, COMPUTE_FRAMES | COMPUTE_MAXS);
cr.accept(new RaspClassVisitor(ASM9, cw, cr.getClassName(), inst, this), EXPAND_FRAMES);
return dumpClassIfNecessary(cr.getClassName(), cw.toByteArray());
}
return null;
}
// Dump modified bytecode to file if necessary
private static byte[] dumpClassIfNecessary(String className, byte[] data) {
final File dumpClassFile = new File("./rasp-class-dump/" + className + ".class");
final File classPath = new File(dumpClassFile.getParent());
if (!classPath.mkdirs() && !classPath.exists()) {
return data;
}
try {
FileUtils.writeByteArrayToFile(dumpClassFile, data);
} catch (IOException e) {
e.printStackTrace();
}
return data;
}
}

In the transform method, if command execution-related classes are matched, proceed with method modification. The bytecode modification logic resides in RaspClassVisitor:

public class RaspClassVisitor extends ClassVisitor {
private RaspMethod method = null;
private final String targetClassInternalName;
private final Instrumentation inst;
private RaspClassFileTransformer raspClassFileTransformer;
private final static String NATIVE_PREFIX = "$$OKRASP$$_"; // Native method prefix
public RaspClassVisitor(final int api, final ClassVisitor cv, String targetClassInternalName, Instrumentation inst,
RaspClassFileTransformer raspClassFileTransformer) {
super(api, cv);
this.targetClassInternalName = targetClassInternalName;
this.inst = inst;
this.raspClassFileTransformer = raspClassFileTransformer;
}
@Override
public MethodVisitor visitMethod(final int access, final String name, final String desc, final String signature, final String[] exceptions) {
if ("forkAndExec".equals(name)) { // Match specified method
// Set native method resolution prefix
if (inst.isNativeMethodPrefixSupported()) {
inst.setNativeMethodPrefix(raspClassFileTransformer, NATIVE_PREFIX);
} else {
throw new UnsupportedOperationException("Native Method Prefix Unspported");
}
// Modify method access modifier
// Change private native int forkAndExec to private int forkAndExec
int newAccess = access & ~Opcodes.ACC_NATIVE;
method = new RaspMethod(access, NATIVE_PREFIX + name, desc);
final MethodVisitor mv = super.visitMethod(newAccess, name, desc, signature, exceptions);
return new AdviceAdapter(api, new JSRInlinerAdapter(mv, newAccess, name, desc, signature, exceptions), newAccess, name, desc) {
@Override
public void visitEnd() {
// Call native method $OKRASP$$_forkAndExec within forkAndExec
loadThis();
loadArgs();
mv.visitMethodInsn(Opcodes.INVOKESPECIAL, targetClassInternalName, method.getName(), method.getDescriptor(), false);
returnValue();
super.visitEnd();
}
};
}
return super.visitMethod(access, name, desc, signature, exceptions);
}
@Override
public void visitEnd() {
if (method != null) {
// Add a new native method with prefix: $$OKRASP$$_forkAndExec
int newAccess = (Opcodes.ACC_PRIVATE | Opcodes.ACC_NATIVE | Opcodes.ACC_FINAL);
MethodVisitor mv = cv.visitMethod(newAccess, method.getName(), method.getDescriptor(), null, null);
mv.visitEnd();
}
super.visitEnd();
}
}

In visitMethod: If target methods are matched:

  1. First set the native method resolution prefix
  2. Then modify method access modifiers
  3. Remove ‘native’ keyword from native methods
  4. Call prefixed native methods within modified methods

In visitEnd: Add new prefixed native methods.

Now examine key configurations in agent project’s pom.xml - must include Can-Set-Native-Method-Prefix:true.

<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.2.2</version>
<configuration>
<archive>
<manifest>
<addClasspath>true</addClasspath>
</manifest>
<manifestEntries>
<Premain-Class>com.okrasp.example.agent.Agent</Premain-Class>
<Agent-Class>com.okrasp.example.agent.Agent</Agent-Class>
<Can-Redefine-Classes>true</Can-Redefine-Classes>
<Can-Retransform-Classes>true</Can-Retransform-Classes>
<!--Allow setting prefixes for native method resolution-->
<Can-Set-Native-Method-Prefix>true</Can-Set-Native-Method-Prefix>
</manifestEntries>
</archive>
</configuration>
</plugin>

3. Modified Native Methods

After compilation launch with premain mode:

java -javaagent:rce-agent-1.0-SNAPSHOT.jar -jar springboot.jar

Since command execution classes aren’t actively loaded during application startup unless triggered - once loaded their bytecode can be modified. rce-agent dumps modified bytecode files under rasp-class-dump directory after modification. Below shows java.lang.UNIXProcess’s forkAndExec before/after modification. Original bytecode:

private final native int forkAndExec(int var1, byte[] var2, byte[] var3, byte[] var4, int var5, byte[] var6, int var7, byte[] var8, int[] var9, boolean var10);

Modified bytecode:

// Modified access modifier with added body calling native method
private int forkAndExec(int var1, byte[] var2, byte[] var3, byte[] var4, int var5, byte[] var6, int var7, byte[] var8, int[] var9, boolean var10) throws IOException {
// Typically add detection logic here
return this.$$OKRASP$$_forkAndExec(var1, var2, var3, var4, var5, var6, var7, var8, var9);
}
// New prefixed native method
private final native int $$OKRASP$$_forkAndExec(int var1);

Now forkAndExec has a body where RASP detection logic can be inserted.

4. Limitations of Native Method Hooks

Not all native methods can be modified - certain restrictions exist. Methods defined in jdk11/src/hotspot/share/classfile/vmSymbols.hpp macros cannot be directly modified:

Figure 2 Non-modifiable methods

Figure 2 Non-modifiable methods To enhance native methods defined in VM_INTRINSICS_DO macros add these VM parameters:

-XX:+UnlockDiagnosticVMOptions
-XX:CompileCommand=dontinline;${ClassName}::${MethodName}
-XX:DisableIntrinsic=${MethodNameId}

Where ${ClassName} is fully qualified class name; {MethodName} is class method name; ${MethodNameId} is intrinsic id from first parameter of VM_INTRINSICS_DO macro definition.

Example for java.lang.System.currentTimeMillis enable hooking with these JVM parameters:

-XX:+UnlockDiagnosticVMOptions
-XX:CompileCommand=dontinline,java.lang.System::currentTimeMillis
-XX:DisableIntrinsic=_currentTimeMillis

5. Summary

OK-RASP now supports modifying native methods without additional configuration - users only need implement specific detection logic after authorization.