Complex systems involving polyglot programming introduce unique issues for security professionals because of the interactions between different languages. To better understand such a system and its security mechanisms, it is often required for security professionals to trace call flows through the language boundaries of polyglot code. Unfortunately, the language boundaries rarely involve a one-to-one mapping between the languages on either side, and often require some level of translation. This translation can complicate some of the simplest of manual call flow traces. However, it makes it even more difficult to develop reusable call flow tracing tools to aid security professionals.

This post discusses one solution to this problem for Java-based systems that use the JavaScript interpreter Rhino1 and demonstrates Rhino Tracker,2 a proof-of-concept demonstrating this solution. The output of Rhino Tracker is a call graph that captures the call flows through the Rhino interpreter without the runtime complexities that the Rhino interpreter introduces. Effectively, Rhino Tracker bridges the call flows from Java to Javascript to Java, creating call graphs that can be used to develop further analysis tools.

Designing Rhino Tracker

The problem of tracing call flows from Java to JavaScript to Java can be subdivided into two separate problems: 1) tracing code from JavaScript to Java and 2) tracing code from Java to JavaScript. Below is a discussion of these problems, how to solve them, and how to combine their output to obtain the desired call graph that eliminates the Rhino interpreter runtime complexities.

JavaScript to Java Code Tracing - First Attempt

To tackle the problem of tracing Java calls from JavaScript code through the Rhino interpreter, a first attempt was made making use of Rhino’s ability to compile JavaScript to Java class files. When generating a call graph for Java code, static analysis frameworks (e.g. SootUp3) can use a class file containing standard bytecode invocation statements of the Java methods in the JavaScript to replace calls to the Rhino interpreter. The class conversion functionality can be found in the compileToClassFiles method of the org.mozilla.javascript.optimizer.ClassCompiler class of Rhino. An example of how to call this method can be found in the stringToClassFile method of the com.snc.secres.tool.dynamic.Tools class found in the Rhino Tracker source code.4

Unfortunately, the class file compileToClassFiles produces is not particularly useful for our purposes. For example, take the sample JavaScript code found below that makes a number of calls to Java standard library methods.

Sample JavaScript

var now = java.time.LocalDateTime.now();
now.toString();
now.minusHours(48);
now.getDayOfYear();
var then = now.minusDays(10);
var thenInstant = then.toInstant(java.time.ZoneOffset.UTC);
var thenDate = new java.util.Date.from(thenInstant);
var nowDate = new java.util.Date();
nowDate.compareTo(thenDate);
thenDate.compareTo(nowDate);

A simple bit of JavaScript that makes a number of different types of Java methods calls. The file can also be found in the ServiceNow SecurityResearch GitHub repo.5

When this JavaScript is run through the compileToClassFiles method of Rhino and then decompiled into standard Java, the following code is produced.

Decompiled Class File of JavaScript Compiled Using Rhino

private static Object _c_script_0(test testVar, Context context, Scriptable global, Scriptable scriptable, Object[] objArr) {
    ScriptRuntime.initScript(testVar, scriptable, context, global, false);
    Object obj = Undefined.instance;
    ScriptRuntime.setName(ScriptRuntime.bind(context, global, "now"), OptRuntime.callProp0(ScriptRuntime.getObjectProp(ScriptRuntime.getObjectProp(ScriptRuntime.name(context, global, "java"), "time", context, global), "LocalDateTime", context, global), "now", context, global), context, global, "now");
    OptRuntime.callProp0(ScriptRuntime.name(context, global, "now"), "toString", context, global);
    OptRuntime.call1(ScriptRuntime.getPropFunctionAndThis(ScriptRuntime.name(context, global, "now"), "minusHours", context, global), ScriptRuntime.lastStoredScriptable(context), _k0, context, global);
    OptRuntime.callProp0(ScriptRuntime.name(context, global, "now"), "getDayOfYear", context, global);
...

The decompiled class file of the sample JavaScript from above. The class file was compiled using Rhino. Notice how it is essentially a dump of Rhino’s AST, and it is only possible to determine the Java methods being invoked at runtime. The class file can also be found in the ServiceNow SecurityResearch GitHub repo.6

Expand for Full Code
package sample;

import org.mozilla.javascript.Context;
import org.mozilla.javascript.NativeFunction;
import org.mozilla.javascript.Script;
import org.mozilla.javascript.ScriptRuntime;
import org.mozilla.javascript.Scriptable;
import org.mozilla.javascript.Undefined;
import org.mozilla.javascript.optimizer.OptRuntime;

/* loaded from: 2024-07-17_13-24-53__sample.test__.class */
public class test extends NativeFunction implements Script {
    private int _id = 0;
    private static Integer _k0 = 48;
    private static Integer _k1 = 10;

    public static void main(String[] strArr) {
        OptRuntime.main(new test(), strArr);
    }

    public final Object exec(Context context, Scriptable scriptable) {
        Object call = call(context, scriptable, scriptable, null);
        context.processMicrotasks();
        return call;
    }

    public final Object call(Context context, Scriptable scriptable, Scriptable scriptable2, Object[] objArr) {
        return !ScriptRuntime.hasTopCall(context) ? ScriptRuntime.doTopCall(this, context, scriptable, scriptable2, objArr, false) : _c_script_0(this, context, scriptable, scriptable2, objArr);
    }

    public int getLanguageVersion() {
        return 0;
    }

    public String getFunctionName() {
        return "";
    }

    public int getParamCount() {
        return 0;
    }

    public int getParamAndVarCount() {
        return 5;
    }

    public String getParamOrVarName(int i) {
        switch (i) {
            case 1:
                return "then";
            case 2:
                return "thenInstant";
            case 3:
                return "thenDate";
            case 4:
                return "nowDate";
            default:
                return "now";
        }
    }

    public boolean getParamOrVarConst(int i) {
        switch (i) {
            case 1:
                return false;
            case 2:
                return false;
            case 3:
                return false;
            case 4:
                return false;
            default:
                return false;
        }
    }

    protected boolean isGeneratorFunction() {
        return false;
    }

    public boolean hasRestParameter() {
        return false;
    }

    private static Object _c_script_0(test testVar, Context context, Scriptable global, Scriptable scriptable, Object[] objArr) {
        ScriptRuntime.initScript(testVar, scriptable, context, global, false);
        Object obj = Undefined.instance;
        ScriptRuntime.setName(ScriptRuntime.bind(context, global, "now"), OptRuntime.callProp0(ScriptRuntime.getObjectProp(ScriptRuntime.getObjectProp(ScriptRuntime.name(context, global, "java"), "time", context, global), "LocalDateTime", context, global), "now", context, global), context, global, "now");
        OptRuntime.callProp0(ScriptRuntime.name(context, global, "now"), "toString", context, global);
        OptRuntime.call1(ScriptRuntime.getPropFunctionAndThis(ScriptRuntime.name(context, global, "now"), "minusHours", context, global), ScriptRuntime.lastStoredScriptable(context), _k0, context, global);
        OptRuntime.callProp0(ScriptRuntime.name(context, global, "now"), "getDayOfYear", context, global);
        ScriptRuntime.setName(ScriptRuntime.bind(context, global, "then"), OptRuntime.call1(ScriptRuntime.getPropFunctionAndThis(ScriptRuntime.name(context, global, "now"), "minusDays", context, global), ScriptRuntime.lastStoredScriptable(context), _k1, context, global), context, global, "then");
        ScriptRuntime.setName(ScriptRuntime.bind(context, global, "thenInstant"), OptRuntime.call1(ScriptRuntime.getPropFunctionAndThis(ScriptRuntime.name(context, global, "then"), "toInstant", context, global), ScriptRuntime.lastStoredScriptable(context), ScriptRuntime.getObjectProp(ScriptRuntime.getObjectProp(ScriptRuntime.getObjectProp(ScriptRuntime.name(context, global, "java"), "time", context, global), "ZoneOffset", context, global), "UTC", context, global), context, global), context, global, "thenInstant");
        ScriptRuntime.setName(ScriptRuntime.bind(context, global, "thenDate"), ScriptRuntime.newObject(ScriptRuntime.getObjectProp(ScriptRuntime.getObjectProp(ScriptRuntime.getObjectProp(ScriptRuntime.name(context, global, "java"), "util", context, global), "Date", context, global), "from", context, global), context, global, new Object[]{ScriptRuntime.name(context, global, "thenInstant")}), context, global, "thenDate");
        ScriptRuntime.setName(ScriptRuntime.bind(context, global, "nowDate"), ScriptRuntime.newObject(ScriptRuntime.getObjectProp(ScriptRuntime.getObjectProp(ScriptRuntime.name(context, global, "java"), "util", context, global), "Date", context, global), context, global, ScriptRuntime.emptyArgs), context, global, "nowDate");
        OptRuntime.call1(ScriptRuntime.getPropFunctionAndThis(ScriptRuntime.name(context, global, "nowDate"), "compareTo", context, global), ScriptRuntime.lastStoredScriptable(context), ScriptRuntime.name(context, global, "thenDate"), context, global);
        return OptRuntime.call1(ScriptRuntime.getPropFunctionAndThis(ScriptRuntime.name(context, global, "thenDate"), "compareTo", context, global), ScriptRuntime.lastStoredScriptable(context), ScriptRuntime.name(context, global, "nowDate"), context, global);
    }
}

The generated code is much more complex than expected, and does not directly contain invocations to any of the Java methods mentioned in the sample JavaScript code. Indeed, all Java method calls from JavaScript are wrapped in calls to the Rhino interpreter. The actual invocation target of these Java methods calls is not determined until evaluation at runtime. In fact, what the compileToClassFiles method actually does when producing a class file is simply dump the AST it generates of the JavaScript code, the same AST that would be used to run the JavaScript code at runtime. Unfortunately, this AST, and in turn the class file, determines the invocation target of method calls just before a method is invoked at runtime.

As a result, the class files produced by Rhino cannot directly be used to solve the problem of tracing Java calls from JavaScript code through the Rhino interpreter. This is because static call graph generation algorithms (e.g. SootUp3) are not able to resolve runtime call stacks. These algorithms have a similar limitation for calls using the more well known Java reflection API. However, the class files do provide a concrete means to invoke the JavaScript code from Java. This allows for the capture of the Java methods being called from JavaScript at runtime.

JavaScript to Java Code Tracing - Instrumenting Rhino

Even though it is not possible to directly use the class files produced in the last section to construct a full call graph, it is possible to run the class files and capture the Java methods being invoked. These runtime method captures can then be used with static analysis tools (e.g. SootUp3) to construct a full call graph. While this call graph is not entirely generated through static analysis tooling, it still eliminates the Rhino interpreter runtime complexities while requiring minimal runtime analysis and overhead.

To instrument Rhino at runtime and capture the Java methods called from JavaScript, Rhino Tracker uses a combination of Byte Buddy’s7 method interceptor APIs, Byte Buddy’s advice APIs, and Java reflection APIs. These are all combined together into a Java Agent that uses the premain entry method to initialize all instrumentation before the server runs.

Java Agent Stub Making Use of Byte Buddy’s API

public static void premain(String agentArgs, Instrumentation inst) {
    AgentBuilder agentBuilder = new AgentBuilder.Default()
        .with(new AgentBuilder.Listener.StreamWriting(loggingPrintStream).withTransformationsOnly())
        .with(new AgentBuilder.InstallationListener.StreamWriting(loggingPrintStream));
...

Start of a example of a Java agent premain method that uses Byte Buddy’s API. The actual premain method for Rhino Tracker can be found in the ServiceNow SecurityResearch GitHub repo.8

The remaining subsections will contain discussions on how Rhino methods were identified as instrumentation targets, how to ensure the capture of only the methods invoked directly by the JavaScript being sampled, and how to disable the SecurityManager during the capture.

Identifying Code to Instrument

In order to identify the Java methods being called from JavaScript, it must first be known what areas of Rhino hold enough context to describe these Java methods. This was manually determined by making use of Byte Buddy’s Advice API to hook and dump stack traces from known Java methods called from JavaScript code. The code below provides an example on how to hook the now method of LocalDateTime and have it dump a stack trace once it is finished executing.

Instrumenting now Method of LocalDateTime Using Advice API

agentBuilder
    .type(ElementMatchers.named("java.time.LocalDateTime"))
    .transform(new AgentBuilder.Transformer() {
...
        public DynamicType.Builder<?> transform(DynamicType.Builder<?> builder, 
                TypeDescription typeDescription, ClassLoader classLoader, 
                JavaModule module, ProtectionDomain protectionDomain) {
            return builder.visit(Advice.to(WhoCalledMeAdvice.class).on(ElementMatchers.named("now")));
        }
...

This code adds the code from the WhoCalledMeAdvice class to the entry/exit points of the java.time.LocalDateTime now method. Note: To hook the constructors of classes using Byte Buddy, replace ElementMatchers.named("now") with ElementMatchers.isConstructor(). The methods selected for hooking by Byte Buddy can be changed by making use of other methods in the ElementMatchers class.

Expand for Full Code
agentBuilder
    .type(ElementMatchers.named("java.time.LocalDateTime"))
    .transform(new AgentBuilder.Transformer() {
        // This is needed because of a bug in bytebuddy
        @SuppressWarnings("unused")
        public DynamicType.Builder<?> transform(DynamicType.Builder<?> builder, 
        TypeDescription typeDescription, ClassLoader classLoader, JavaModule module) {
            return transform(builder, typeDescription, classLoader, module, null);
        }
        @Override
        public DynamicType.Builder<?> transform(DynamicType.Builder<?> builder, 
                TypeDescription typeDescription, ClassLoader classLoader, 
                JavaModule module, ProtectionDomain protectionDomain) {
            return builder.visit(Advice.to(WhoCalledMeAdvice.class).on(ElementMatchers.named("now")));
        }
    }).installOn(instrumentation);

Code Run Using Advice API on Exit of now Method of LocalDateTime

public class WhoCalledMeAdvice {
    @Advice.OnMethodExit
    public static void exit() throws Exception {
        Exception e = new RuntimeException("Foo bar");
        StackTraceElement[] elements = e.getStackTrace();
        Logging.warn(Arrays.toString(elements));
    }
}

Code that is appended to the exit of the now method. The code dumps the current stack trace and makes no other modifications. To append code to the entry of the now method a separate method must be defined with the annotation @Advice.OnMethodEnter.

Expand for Full Code
package com.snc.secres.tool.dynamic;
import java.util.Arrays;
import net.bytebuddy.asm.Advice;

public class WhoCalledMeAdvice {
    @Advice.OnMethodExit
    public static void exit() throws Exception {
        Exception e = new RuntimeException("Foo bar");
        StackTraceElement[] elements = e.getStackTrace();
        Logging.warn(Arrays.toString(elements));
    }
}

Using this technique on a number of different types of Java methods, it was clear from the stack traces that Rhino uses Java’s Reflection API to invoke Java methods from JavaScript. In fact, all calls using reflection take place in the methods of the org.mozilla.javascript.MemberBox class. Unfortunately, while this class stores all the information needed to determine the Java methods being called, it does not contain any information about the JavaScript from which the calls originate. Such information is required to ensure the capture of only those methods invoked directly in the JavaScript being sampled. To obtain this information, Rhino Tracker hooks methods that are one or two steps back from the methods in MemberBox on the call stack. These methods contain both access to a MemberBox object describing the Java method that will be invoked and access to an object of the org.mozilla.javascript.Context class that can be used to gain the origin information needed.

The methods in Rhino we hook are the call methods of the org.mozilla.javascript.NativeJavaConstructor, org.mozilla.javascript.FunctionObject, and org.mozilla.javascript.NativeJavaMethod classes, along with the constructSpecific method of the org.mozilla.javascript.NativeJavaClass. The code below illustrates how Rhino Tracker uses Byte Buddy’s Method Interceptor API to hook the call method of FunctionObject to record calls from JavaScript to Java. Further information on how these are implemented can be found in the com.snc.secres.tool.dynamic.Agent and com.snc.secres.tool.dynamic.JavaMethodInterceptor classes of Rhino Tracker’s source code.89

Instrumenting call Method of FunctionObject Using Method Interceptor API

agentBuilder
    .type(ElementMatchers.named("org.mozilla.javascript.FunctionObject"))
    .transform(new AgentBuilder.Transformer() {
...
        public DynamicType.Builder<?> transform(DynamicType.Builder<?> builder, 
                TypeDescription typeDescription, ClassLoader classLoader, 
                JavaModule module, ProtectionDomain protectionDomain) {
            return builder.method(ElementMatchers.named("call").and(ElementMatchers.takesArguments(5)))
                        .intercept(MethodDelegation.withDefaultConfiguration()
                            .filter(ElementMatchers.named("interceptFunctionObject"))
                            .to(JavaMethodInterceptor.class)
                        );
        }
...

This code in the agent replaces calls to the call method in FunctionObject that takes exactly 5 arguments with the code in interceptFunctionObject of JavaMethodInterceptor.

Expand for Full Code
agentBuilder
    .type(ElementMatchers.named("org.mozilla.javascript.FunctionObject"))
    .transform(new AgentBuilder.Transformer() {
        // This is needed because of a bug in bytebuddy
        @SuppressWarnings("unused")
        public DynamicType.Builder<?> transform(DynamicType.Builder<?> builder, 
                TypeDescription typeDescription, ClassLoader classLoader, JavaModule module) {
            return transform(builder, typeDescription, classLoader, module, null);
        }
        @Override
        public DynamicType.Builder<?> transform(DynamicType.Builder<?> builder, 
                TypeDescription typeDescription, ClassLoader classLoader, 
                JavaModule module, ProtectionDomain protectionDomain) {
            return builder.method(ElementMatchers.named("call").and(ElementMatchers.takesArguments(5)))
                        .intercept(MethodDelegation.withDefaultConfiguration()
                            .filter(ElementMatchers.named("interceptFunctionObject"))
                            .to(JavaMethodInterceptor.class)
                        );
        }
    }).installOn(instrumentation);

Code Run When The call Method Of FunctionObject Is Intercepted

public static Object interceptFunctionObject(@AllArguments Object[] allArguments,
                                @SuperCall Callable<Object> callableMethod, 
                                @This Object thiz) throws Exception {
    if(testCaptureEnabled(allArguments[0])) {
        try {
            Field f = thiz.getClass().getDeclaredField("member");
            f.setAccessible(true);
            Object ctor = f.get(thiz); //MemberBox
            String result = Tools.memberBoxToString(ctor);
...

The code that is run when call is intercepted. allArguments[0] is the Context object while the member field of FunctionObject is a MemberBox object. Data in the Context object is used to test/set/unset capturing, while data in the MemberBox object is converted to a string and written to a file. Regardless of what happens, the original code for the call method is always called. Note: Rhino Tracker uses reflection to make all references to Rhino code to avoid loading the Rhino library twice with different class loaders. Dual loading of the same library can cause conflicts at runtime.

Expand for Full Code
public static Object interceptFunctionObject(@AllArguments Object[] allArguments,
                                @SuperCall Callable<Object> callableMethod, 
                                @This Object thiz) throws Exception {
    if(testCaptureEnabled(allArguments[0])) {
        try {
            Field f = thiz.getClass().getDeclaredField("member");
            f.setAccessible(true);
            Object ctor = f.get(thiz); //MemberBox
            String result = Tools.memberBoxToString(ctor);
            JavaMethodInterceptor.writeToFile(result, allArguments[0]);
        } catch(Throwable e) {
            Logging.error("Exception in FunctionObject intercept.", e);
        }
        // Prevent call tracing after a js -> java call is made
        boolean preValue = setCapture(allArguments[0], false);
        Object ret = callableMethod.call();
        setCapture(allArguments[0], preValue);
        return ret;
    } else {
        return callableMethod.call();
    }
}

Restricting Captured Java Methods

Without any restrictions, the methodology mentioned above would capture all Java methods called from the Rhino interpreter during the duration the agent is active. For multi-threaded systems where more than one JavaScript is being run simultaneously, this approach would capture Java methods not called from the sampled JavaScript. Additionally, if the Rhino interpreter were to be called to process JavaScript from a Java method in the original JavaScript, the Java methods in this secondary JavaScript would also be captured. To ensure the capture of only those Java methods invoked directly in the JavaScript being sampled, Rhino Tracker’s agent adds the field _capture to the Context class. The code below demonstrates how to add a field to an existing class using Byte Buddy and how to initialize it.

Instrumenting Context To Add Fields

agentBuilder
    .type(ElementMatchers.named("org.mozilla.javascript.Context"))
    .transform(new AgentBuilder.Transformer() {
...
        public DynamicType.Builder<?> transform(DynamicType.Builder<?> builder, 
                TypeDescription typeDescription, ClassLoader classLoader, 
                JavaModule module, ProtectionDomain protectionDomain) {
            Agent.getActiveAgent().orgClassLoader = classLoader;
            builder = builder.defineField("_capture", boolean.class, Visibility.PUBLIC);
            builder = builder.defineField("_capture_path", Path.class, Visibility.PUBLIC);
            builder = builder.visit(Advice.to(ContextAdvice.class).on(ElementMatchers.isConstructor()));
            return builder;
        }
...

This code adds fields to the existing Context class of Rhino. This is done using the defineField method of Byte Buddy which takes in three arguments: field name, field type, and field modifiers. To initialize the newly added fields, the Advice API is used to append code to the end of the existing constructors. Note: If the added fields are static, the value method of Byte Buddy could be used instead to initialize the field.

Expand for Full Code
agentBuilder
    .type(ElementMatchers.named("org.mozilla.javascript.Context"))
    .transform(new AgentBuilder.Transformer() {
        // This is needed because of a bug in bytebuddy
        @SuppressWarnings("unused")
        public DynamicType.Builder<?> transform(DynamicType.Builder<?> builder, 
                TypeDescription typeDescription, ClassLoader classLoader, JavaModule module) {
            return transform(builder, typeDescription, classLoader, module, null);
        }
        @Override
        public DynamicType.Builder<?> transform(DynamicType.Builder<?> builder, 
                TypeDescription typeDescription, ClassLoader classLoader, 
                JavaModule module, ProtectionDomain protectionDomain) {
            // Store the original class loader for use later
            Agent.getActiveAgent().orgClassLoader = classLoader;
            // Define the new field but can't use '.value' because field is not a static
            builder = builder.defineField("_capture", boolean.class, Visibility.PUBLIC);
            builder = builder.defineField("_capture_path", Path.class, Visibility.PUBLIC);
            builder = builder.visit(Advice.to(ContextAdvice.class).on(ElementMatchers.isConstructor()));
            return builder;
        }
    }).installOn(instrumentation);

Initializing New Fields In The Context Class

public class ContextAdvice {
    @Advice.OnMethodExit
    public static void exit(@Advice.FieldValue(value = "_capture", readOnly = false) boolean _capture, 
            @Advice.FieldValue(value = "_capture_path", readOnly = false) Path _capture_path) 
            throws Exception {
        _capture = false;
        _capture_path = null;
    }
}

The code that is appended to the end of all Context constructors in order to initialize the new fields. The @Advice.FieldValue annotation directs Byte Buddy to assign method arguments as references to class fields. As such, any changes to the values of these arguments will be reflects in the associated fields.

Expand for Full Code
package com.snc.secres.tool.dynamic;
import java.nio.file.Path;
import net.bytebuddy.asm.Advice;

public class ContextAdvice {
    @Advice.OnMethodExit
    public static void exit(@Advice.FieldValue(value = "_capture", readOnly = false) boolean _capture, 
            @Advice.FieldValue(value = "_capture_path", readOnly = false) Path _capture_path) 
            throws Exception {
        _capture = false;
        _capture_path = null;
    }
}

The added _capture field of the Context class is a boolean that indicates if the hooked Rhino methods when invoked should be capturing Java method calls. By default, this field is set to false to indicate that capturing should not take place. However, when sampling a piece of JavaScript, Rhino Tracker sets the field to true before running the script through the Rhino interpreter. As shown in the code for the call method of FunctionObject (here), all hooked methods of Rhino have access to the same Context object created when the script is invoked. As such, the method testCaptureEnabled can be used to determine if capturing is enabled by querying the _capture field. Additionally, the method setCapture can be used to disable the capture of Java methods just before the current Java method is invoked and restore the _capture field to its previous value after the current Java method is invoked. Such a setup prevents the capture of any Java methods except those directly invoked in the JavaScript. All methods referenced here can be found in JavaMethodInterceptor class of Rhino Tracker’s source code.9

Preventing Security Managers From Loading

Often in systems more complex than the sample server provided with Rhino Tracker, a Java SecurityManager class may be registered. Security managers can cause issues when reading/writing to files or other actions that do not fall into the realm of normal server behavior for an instrumentation target. To prevent interference from security managers, Rhino Tracker stops the registration of new security managers by instrumenting the setSecurityManager method of java.lang.System and replacing the entire method with a no-op.

The process for instrumenting Java standard library methods is more complicated than other methods as these classes are loaded by the bootloader. This is especially true for System as it is always one of the first classes loaded. To instrument the System class, Rhino Tracker makes use of the Byte Buddy setup shown below.

Instrumenting System To Prevent Security Manager Additions

private void sackSecurityManager(Instrumentation instrumentation, 
        PrintStream loggingPrintStream) throws IOException {
    File temp = Files.createTempDirectory("tmp").toFile();
    temp.deleteOnExit();
    ClassInjector.UsingInstrumentation.of(temp, ClassInjector.UsingInstrumentation.Target.BOOTSTRAP, 
        instrumentation).inject(Collections.singletonMap(
            new TypeDescription.ForLoadedType(SecurityManagerInterceptor.class), 
            ClassFileLocator.ForClassLoader.read(SecurityManagerInterceptor.class)
        )
    );
...

To instrument a method in System, the following additions must be made: 1) The bootloader must be made aware of the code that will replace it. 2) Any classes already loaded (e.g. System) must be redefined in terms of the new class loader provided by Byte Buddy. 3) Byte Buddy must be told not to ignore classes in the standard Java library when determining instrumentation targets.

Expand for Full Code
private void sackSecurityManager(Instrumentation instrumentation, 
        PrintStream loggingPrintStream) throws IOException {
    // Ensure our MethodDelegation class is available to the bootstrap bootloader
    // ClassInjector requires a directory when instrumenting but the code we are using is already
    // available so just use a empty temp directory
    File temp = Files.createTempDirectory("tmp").toFile();
    temp.deleteOnExit();
    ClassInjector.UsingInstrumentation.of(temp, ClassInjector.UsingInstrumentation.Target.BOOTSTRAP, 
        instrumentation).inject(Collections.singletonMap(
            new TypeDescription.ForLoadedType(SecurityManagerInterceptor.class), 
            ClassFileLocator.ForClassLoader.read(SecurityManagerInterceptor.class)
        )
    );
    // Instrument the setSecurityManager method
    new AgentBuilder.Default()
        // Add loggers so you can see failures with bytebuddy transforming
        .with(new AgentBuilder.Listener.StreamWriting(loggingPrintStream).withTransformationsOnly())
        .with(new AgentBuilder.InstallationListener.StreamWriting(loggingPrintStream))
        // Have to redefine the already loaded std Java lib classes
        .with(RedefinitionStrategy.RETRANSFORMATION)
        .with(InitializationStrategy.NoOp.INSTANCE)
        .with(AgentBuilder.TypeStrategy.Default.REDEFINE)
        // Needed to reset the default ignore which includes std Java libs
        .ignore(new AgentBuilder.RawMatcher.ForElementMatchers(
                ElementMatchers.nameStartsWith("net.bytebuddy.")
                .or(ElementMatchers.isSynthetic()), ElementMatchers.any(), ElementMatchers.any()
            )
        )
        .type(ElementMatchers.named("java.lang.System"))
        .transform(new AgentBuilder.Transformer() {
            // This is needed because of a bug in bytebuddy
            @SuppressWarnings("unused")
            public DynamicType.Builder<?> transform(DynamicType.Builder<?> builder, 
                    TypeDescription typeDescription, ClassLoader classLoader, JavaModule module) {
                return transform(builder, typeDescription, classLoader, module, null);
            }
            @Override
            public DynamicType.Builder<?> transform(DynamicType.Builder<?> builder, 
                    TypeDescription typeDescription, ClassLoader classLoader, 
                    JavaModule module, ProtectionDomain protectionDomain) {
                return builder.method(ElementMatchers.named("setSecurityManager"))
                        .intercept(MethodDelegation.to(SecurityManagerInterceptor.class));
            }
        }).installOn(instrumentation);
}

Java to JavaScript Code Tracing

To acquire a call graph that allows for tracing call flows from a given entry point method in Java through the Rhino interpreter’s handling of some JavaScript, Rhino Tracker requires a view of the Java call flows that can be manipulated to substitute in the data gathered at runtime. To gain this functionality and generate the call graph, Rhino Tracker makes use of the static analysis framework SootUp.3 SootUp is the new version of Soot.10 SootUp has much of the same functionality of Soot but has been rewritten to modernize and improve performance. It has a number of uses that aid in performing static analysis. These include manipulation of compiled or un-compiled Java code, code generation, CFG generation and manipulation, and call graph generation and manipulation.

The remaining subsections discuss how to initialize SootUp from compiled Java code, how to use SootUp to construct a call graph, how to limit the scope of the call graph, and how to modify the call graph to add additional data (e.g. the runtime generated data from the previous section).

Initializing SootUp

To initialize and use SootUp, a JavaView must be created from the class path of the code that is to be analyzed. A JavaView is the container for the code being analyzed by SootUp. It is responsible for the loading of code and mapping code back to files. Essentially, all data in SootUp maps back to a JavaView in some way. A JavaView is created from a list of AnalysisInputLocation classes, which are various ways of specifying where to find the code that is to be analyzed. It is possible, but not required, to specify a list of BodyInterceptor objects when creating a AnalysisInputLocation object. A BodyInterceptor processes the method bodies of classes when they are loaded by the JavaView and transforms the loaded code such as to improve performance, human readability, and analyzability. Additionally, it is possible to provide a SourceType when creating a AnalysisInputLocation. The available source types are Application and Library where the former’s code is traversed during call graph generation, while the latter’s code is not.

The code below shows how a JavaView is created in Rhino Tracker. The JavaView created is actually a child type known as a MutableJavaView. A MutableJavaView allows for the addition of generated classes and methods to an already existent JavaView.

Creating a JavaView in SootUp

public static MutableJavaView makeJavaView(String classPath) {
    List<BodyInterceptor> bodyInterceptors = Collections.unmodifiableList(Arrays.asList(
            new UnreachableCodeEliminator(),
            new Aggregator(),
...
    List<AnalysisInputLocation> inputLocations = new ArrayList<>();
    inputLocations.add(new JavaClassPathAnalysisInputLocation(classPath, SourceType.Application, 
            bodyInterceptors));
    return new MutableJavaView(inputLocations);
}

Code that creates a MutableJavaView from a given class path. It uses a custom list of BodyInterceptor objects to avoid unstable ones in the default list. However, a predefined list of BodyInterceptor classes can be found at BytecodeBodyInterceptors.Default.getBodyInterceptors().

Expand for Full Code
public static MutableJavaView makeJavaView(String classPath) {
    // This list was constructed and organized by combining the original soot with SootUp
    //A default list can be found here BytecodeBodyInterceptors.Default.getBodyInterceptors()
    List<BodyInterceptor> bodyInterceptors = Collections.unmodifiableList(Arrays.asList(
            new UnreachableCodeEliminator(),
            new Aggregator(),
            new EmptySwitchEliminator(), 
            new CastAndReturnInliner(), 
            new ConstantPropagatorAndFolder(), 
            new UnusedLocalEliminator(),
            new LocalNameStandardizer(),
            new CopyPropagator(),
            new UnusedLocalEliminator(),
            new LocalPacker(),
            new NopEliminator(),
            new UnreachableCodeEliminator(),
            new LocalNameStandardizer())
    );
    List<AnalysisInputLocation> inputLocations = new ArrayList<>();
    inputLocations.add(new JavaClassPathAnalysisInputLocation(classPath, SourceType.Application, 
            bodyInterceptors));
    return new MutableJavaView(inputLocations);
}

Constructing A Call Graph

Once a JavaView is obtained, it is possible to construct a call graph. Constructing a call graph in SootUp requires two additional inputs, a call graph building algorithm and an entry point method. SootUp currently supports two types of call graph algorithms, Class Hierarchy Analysis (CHA) and Rapid Type Analysis (RTA). Both are similar in that they rely on the class hierarchy to resolve interface and super class method calls. However, RTA sacrifices some completeness for precision by only considering instantiated implementers of interfaces and super classes when resolving method calls. As such, RTA must be supplied with a entry point method that leads to the instantiation of all classes being analyzed. Typically this method is the main method of a Java application but any method may be given. CHA has no limitations on the entry point it is supplied with, but will ultimately be much less precise than RTA.

Rhino Tracker supports the use of both the CHA and RTA call graph algorithms. However, the use of RTA is the default. As shown in the sample code below, the construction of a call graph using either algorithm is similar. First, a MethodSignature of the entry point method must be obtained from the JavaView. These can be obtained in a number of ways but the easiest is by supplying a string representation and calling view.getIdentifierFactory().parseMethodSignature(sig). Here sig is formatted as follows <com.snc.secres.sample.DummyMain: void main(java.lang.String[])>. Next, a CallGraphAlgorithm must be created and the initialize method must be called and supplied the entry point method. At this point a GraphBasedCallGraph is created which represents the Java call graph of the given entry point. This call graph can then be manipulated further to remove unwanted edges. This will be discussed later.

Constructing A Call Graph Using SootUp

MethodSignature entryMethodSignature = view.getIdentifierFactory().parseMethodSignature(
    config.getEntryPointMethodSig()
);
MethodSignature mainMethodSignature = view.getIdentifierFactory().parseMethodSignature(
    config.getMainMethodSig()
);
CallGraphAlgorithm cga = new RapidTypeAnalysisAlgorithm(view);
CallGraphWrapper callGraph = new CallGraphWrapper(
    (GraphBasedCallGraph)cga.initialize(Collections.singletonList(mainMethodSignature))
);
callGraph.applyFilter(cgFilter, view);

Code to create a call graph using either the RTA or CHA algorithms. Note: The entry method is the method used as an entry point for the analysis and may or may not be the entry point supplied to the call graph generation algorithm. Additionally, CallGraphWrapper and applyFilter are a custom class and method designed to allow manipulation of the call graph, a feature which SootUp does not support out of the box.

Expand for Full Code
MethodSignature mainMethodSignature;
MethodSignature entryMethodSignature;
CallGraphAlgorithm cga;
CallGraphWrapper callGraph;
switch(config.getCallGraphAlgo().toLowerCase()) {
    case "cha":
        entryMethodSignature = parseMethodSignature(view, config.getEntryPointMethodSig(), "entry point");
        if(entryMethodSignature == null)
            return null;
        mainMethodSignature = null;
        cga = new ClassHierarchyAnalysisAlgorithm(view);
        callGraph = new CallGraphWrapper((GraphBasedCallGraph)cga.initialize(Collections.singletonList(entryMethodSignature)));
        callGraph.applyFilter(cgFilter, view);
        break;
    case "rta":
        entryMethodSignature = parseMethodSignature(view, config.getEntryPointMethodSig(), "entry point");
        if(entryMethodSignature == null) {
            return null;
        }
        mainMethodSignature = parseMethodSignature(view, config.getMainMethodSig(), "main");
        if(mainMethodSignature == null)
            return null;
        cga = new RapidTypeAnalysisAlgorithm(view);
        callGraph = new CallGraphWrapper((GraphBasedCallGraph)cga.initialize(Collections.singletonList(mainMethodSignature)));
        callGraph.applyFilter(cgFilter, view);
        break;
    default:
        System.err.println(CN + ": Unsupported call graph algorithm given '" + config.getCallGraphAlgo() + "''.");
        return null;
}

While CHA appears to be more suited to constructing call graphs for APIs, for multi-entry programs, and other systems where a main method does not accurately capture call flow, RTA can be used. By creating a dummy main method that instantiates all classes used by a particular entry point, a sound and precise call graph can be constructed using RTA. The sample code provided with Rhino Tracker contains a DummyMain class11 that performs such an operation. However, it is not required that this dummy main method be created ahead of time and included with the code being analyzed by SootUp. SootUp can be used to generate such a method during the analysis process if desired.

Limiting Call Graph Scope

As it stands, the call graph may contain paths to segments of code that are unrelated to the analysis being performed. These paths could increase analysis time, result in noise in the output data, or increase the manual effort involved in the analysis. As such, reducing the scope of the call graph further may be desired. To do this, we created CallGraphFilter. CallGraphFilter allows a user to specify specific class methods whose outgoing edges are to be removed from the call graph. This effectively makes these methods end nodes in the graph. Its use can be seen in the call graph construction above (here). For Rhino Tracker, the filter is defined in the yaml config file.

Example Of A Call Graph Filter Definition

filter_default_policy: deny
filter:
  - type: class_path
    pattern: 'com\.snc\.secres\.sample.*'
    policy: allow

This filter limits the call graph to only those classes/methods defined in the sample server code. In other words, all methods not in the package com\.snc\.secres\.sample.* are treated as end nodes. Note: The policy field of the entry denotes whether this entry should allow the classes specified in the call graph or deny them. The default policy of the call graph filter is specified by filter_default_policy.

Adding Runtime Data To Call Graph

Once a initial call graph has been generated, the runtime dump of the Java methods called from the sampled JavaScript can be added to it. To do this Rhino Tracker first iterates through the call graph to locate any calls to a given sink. The sink should be the point at which Rhino is called to evaluate the sampled JavaScript. For the sample code, the sink was defined as <org.mozilla.javascript.Context: java.lang.Object evaluateString(org.mozilla.javascript.Scriptable, java.lang.String, java.lang.String, int, java.lang.Object)>, but this can be changed based on the system being analyzed. Next, SootUp is used to generate a class with a single empty method. The empty method is used as a wrapper in the call graph for the Java method calls recorded at runtime from the sampled Java script. The code used to generate the class with an empty method is shown below.

Generating A Empty Method And Class Using SootUp

public static JavaSootClass makeClassWithEmptyMethod(MutableJavaView view, String fullClassName, 
        String methodName) {
    JavaClassType classType = view.getIdentifierFactory().getClassType(fullClassName);
    JavaSootMethod method = makeEmptyMethod(classType, methodName);
    InMemoryJavaAnalysisInputLocation memInputLocation = new InMemoryJavaAnalysisInputLocation(
...

Code that shows how to generate an empty method and class using SootUp. As shown, SootUp uses a builder structure for the majority of this construction. It should be noted that SootUp does not currently contain a means to define in-memory source locations (i.e. sources for classes without files). To add this functionality, we created the InMemoryJavaAnalysisInputLocation class.

Expand for Full Code
public static JavaSootClass makeClassWithEmptyMethod(MutableJavaView view, String fullClassName, String methodName) {
    JavaClassType classType = view.getIdentifierFactory().getClassType(fullClassName);
    JavaSootMethod method = makeEmptyMethod(classType, methodName);
    InMemoryJavaAnalysisInputLocation memInputLocation = new InMemoryJavaAnalysisInputLocation(
        classType, 
        view.getIdentifierFactory().getClassType("java.lang.Object"),
        Collections.emptySet(),
        null,
        Collections.emptySet(),
        new HashSet<>(Arrays.asList(method)),
        NoPositionInformation.getInstance(),
        EnumSet.of(ClassModifier.PUBLIC, ClassModifier.FINAL),
        Collections.emptySet(),
        Collections.emptySet(),
        Collections.emptySet(),
        SourceType.Application, 
        Collections.emptyList()
    );
    OverridingJavaClassSource source = memInputLocation.getClassSource();
    JavaSootClass sc =  new JavaSootClass(source, SourceType.Application);
    view.addClass(sc);
    return sc;
}

public static JavaSootMethod makeEmptyMethod(JavaClassType classType, String name) {
    MethodSignature methodSig = new MethodSignature(classType, name, Collections.emptyList(), VoidType.getInstance());
    Set<MethodModifier> mods = EnumSet.of(MethodModifier.PUBLIC, MethodModifier.STATIC, MethodModifier.FINAL);
    return (JavaSootMethod)JavaSootMethod.builder()
            .withSource(new OverridingBodySource(methodSig, makeEmptyBody(methodSig, mods)))
            .withSignature(methodSig)
            .withModifier(mods)
            .build();
}

public static Body makeEmptyBody(MethodSignature methodSig, Set<MethodModifier> mods) {
    return Body.builder().setMethodSignature(methodSig).setModifiers(mods).build();
}

Finally, the call graph is modified to replace the calls to the sink method with calls to the generated empty method. Edges are then added to the call graph from the empty method to all Java methods recorded during the runtime sampling of the JavaScript.

Running The Sample Code Through Rhino Tracker

The Rhino Tracker source code and sample code can be found at the ServiceNow SecurityResearch GitHub repo.12 Download the source code and run the following from the a-rhino-of-a-problem directory, which will assemble all the necessary JAR files:

gradle build

Rhino Tracker uses two config files to specify input for the two phases. The default config file for the first phase (i.e. dynamic instrumentation) is shown below.

Default Yaml Config File For Dynamic Instrumentation Phase

log_dir_path: 'work/dynamic_logs'
out_dir_path: 'work/java_call_traces'
js_file_path: 'sample/sample.test.js'
js_full_class_name: 'sample.test'
instance_url: 'http://127.0.0.1:8080'
connect_tries: 30
connect_timeout: 120000
time_between_connect_attempts: 10000
compiled_js_dir_path: 'work/compiled_javascript'

The default config file for the dynamic instrumentation phase. The fields in the config file largely specify the input/output files/directories and configuration options for connecting to the server. The sample JavaScript mentioned in this config file is the same as the one at the top of this post. This yaml file can also be found in the ServiceNow SecurityResearch GitHub repo.13

To run the dynamic instrumentation of the sample server using the default config file path, run the following command:

gradle runDynamic

This command will start the sample server with the instrumentation agent attached to it. Once all the instrumentation has been loaded, the agent will spawn a separate thread before exiting. The thread waits until it is able to make a connection to the sample server. Once it can connect, it submits the sample script to the server for processing and waits on the results before exiting. Once the script has finished running, the thread will dump the Java method calls to a file in the directory java_call_traces. The file is named timestamp + '__' + js_full_class_name + '__.txt'. The Rhino compiled JavaScript class file can also be found in the compiled_javascript directory. The output should look like the list of methods below.

Methods Invoked From Sample JavaScript

<java.time.LocalDateTime: java.time.LocalDateTime now()>
<java.time.LocalDateTime: java.lang.String toString()>
<java.time.LocalDateTime: java.time.LocalDateTime minusHours(long)>
<java.time.LocalDateTime: int getDayOfYear()>
<java.time.LocalDateTime: java.time.LocalDateTime minusDays(long)>
<java.time.chrono.ChronoLocalDateTime: java.time.Instant toInstant(java.time.ZoneOffset)>
<java.util.Date: java.util.Date from(java.time.Instant)>
<java.util.Date: void <init>()>
<java.util.Date: int compareTo(java.util.Date)>
<java.util.Date: int compareTo(java.util.Date)>

Output of the Rhino Tracker’s dynamic instrumentation phase when run on the sample JavaScript. This contains the list of Java methods directly invoked in the sample JavaScript. This file can also be found in the ServiceNow SecurityResearch GitHub repo.14

The default config file for the second phase (i.e. static analysis and call graph building) is shown below.

Default Yaml Config File For Static Analysis Phase

call_graph_algo: rta
class_path: sample/build/libs/sample.jar
entry_point_method_sig: '<com.snc.secres.sample.RhinoServlet: void doPost(jakarta.servlet.http.HttpServletRequest, jakarta.servlet.http.HttpServletResponse)>'
main_method_sig: '<com.snc.secres.sample.DummyMain: void main(java.lang.String[])>'
output_dir_path: work/cg
runtime_trace_file_path: work/java_call_traces/2024-07-17_13-24-53__sample.test__.txt
sink_method_sig: '<org.mozilla.javascript.Context: java.lang.Object evaluateString(org.mozilla.javascript.Scriptable, java.lang.String, java.lang.String, int, java.lang.Object)>'
filter_default_policy: deny
filter:
  - type: class_path
    pattern: 'com\.snc\.secres\.sample.*'
    policy: allow

The default config file for the static analysis phase. The config file defines the call graph algorithm, a path to the sample server JAR, a path to the Java methods runtime dump being used, output directory, important Java method signatures, and a call graph filter. The file can be found in the ServiceNow SecurityResearch GitHub repo15 as well.

To run the static analysis using the default config file path, run the following command:

gradle runStatic

This command will first initialize soot with the given class paths. Then it will build the call graph using the given algorithm, entry point, and main method information. Any filters will then be applied to the call graph. Next, the call graph is searched for occurrences of the given sink method. Calls to this method in the call graph are replaced with a call to a generated method containing outgoing edges to the methods in the runtime trace file. Finally, the call graph is dumped to a dot file in the output directory. More information about this process can be found in the previous section. The output should look like the dot file below.

Generated Dot File Representing Java to JavaScript to Java Call Graph

...
"<com.snc.secres.sample.RhinoServlet: void doPost(jakarta.servlet.http.HttpServletRequest,jakarta.servlet.http.HttpServletResponse)>" -> "<com.snc.secres.sample.RhinoServlet: java.lang.String setupAndDoScriptEval(java.lang.String)>";
"<com.snc.secres.sample.RhinoServlet: java.lang.String setupAndDoScriptEval(java.lang.String)>" -> "<com.snc.secres.sample.RhinoServlet: java.lang.Object doScriptEval(java.lang.String,org.mozilla.javascript.Scriptable,org.mozilla.javascript.Context)>";
"<com.snc.secres.sample.RhinoServlet: java.lang.Object doScriptEval(java.lang.String,org.mozilla.javascript.Scriptable,org.mozilla.javascript.Context)>" -> "<sample.test20240717132453: void runtimeSimulator()>";
"<sample.test20240717132453: void runtimeSimulator()>" -> "<java.time.chrono.ChronoLocalDateTime: java.time.Instant toInstant(java.time.ZoneOffset)>";
"<sample.test20240717132453: void runtimeSimulator()>" -> "<java.util.Date: void <init>()>";
...

The dot file created from the Java to JavaScript to Java call graph. This file can also be found in the ServiceNow SecurityResearch GitHub repo.16 Note: The dot files name and the generated method’s class name are based on the name of the runtime trace file. So given a file named 2024-07-17_13-24-53__sample.test__.txt, it will produce a sample.test20240717132453.dot file with the generated method <sample.test20240717132453: void runtimeSimulator()>.

Expand for Full Code
strict digraph ObjectGraph {
    "<com.snc.secres.sample.DummyMain: void main(java.lang.String[])>" -> "<jakarta.servlet.GenericServlet: void <clinit>()>";
    "<com.snc.secres.sample.DummyMain: void main(java.lang.String[])>" -> "<jakarta.servlet.http.HttpServlet: void <clinit>()>";
    "<com.snc.secres.sample.DummyMain: void main(java.lang.String[])>" -> "<com.snc.secres.sample.RhinoServlet: void <clinit>()>";
    "<com.snc.secres.sample.DummyMain: void main(java.lang.String[])>" -> "<com.snc.secres.sample.RhinoServlet: void <init>()>";
    "<com.snc.secres.sample.DummyMain: void main(java.lang.String[])>" -> "<com.snc.secres.sample.RhinoServlet: void doPost(jakarta.servlet.http.HttpServletRequest,jakarta.servlet.http.HttpServletResponse)>";
    "<com.snc.secres.sample.RhinoServlet: void <clinit>()>" -> "<jakarta.servlet.GenericServlet: void <clinit>()>";
    "<com.snc.secres.sample.RhinoServlet: void <clinit>()>" -> "<jakarta.servlet.http.HttpServlet: void <clinit>()>";
    "<com.snc.secres.sample.RhinoServlet: void <clinit>()>" -> "<com.snc.secres.sample.RhinoServlet: void <clinit>()>";
    "<com.snc.secres.sample.RhinoServlet: void <init>()>" -> "<org.mozilla.javascript.Context: void <clinit>()>";
    "<com.snc.secres.sample.RhinoServlet: void <init>()>" -> "<org.mozilla.javascript.Context: org.mozilla.javascript.Context enter()>";
    "<com.snc.secres.sample.RhinoServlet: void <init>()>" -> "<org.mozilla.javascript.Context: void exit()>";
    "<com.snc.secres.sample.RhinoServlet: void <init>()>" -> "<jakarta.servlet.http.HttpServlet: void <init>()>";
    "<com.snc.secres.sample.RhinoServlet: void doPost(jakarta.servlet.http.HttpServletRequest,jakarta.servlet.http.HttpServletResponse)>" -> "<java.io.PrintWriter: void println(java.lang.String)>";
    "<com.snc.secres.sample.RhinoServlet: void doPost(jakarta.servlet.http.HttpServletRequest,jakarta.servlet.http.HttpServletResponse)>" -> "<com.snc.secres.sample.RhinoServlet: java.lang.String setupAndDoScriptEval(java.lang.String)>";
    "<com.snc.secres.sample.RhinoServlet: java.lang.Object doScriptEval(java.lang.String,org.mozilla.javascript.Scriptable,org.mozilla.javascript.Context)>" -> "<sample.test20240717132453: void runtimeSimulator()>";
    "<com.snc.secres.sample.RhinoServlet: java.lang.String setupAndDoScriptEval(java.lang.String)>" -> "<org.mozilla.javascript.Context: void <clinit>()>";
    "<com.snc.secres.sample.RhinoServlet: java.lang.String setupAndDoScriptEval(java.lang.String)>" -> "<org.mozilla.javascript.Context: org.mozilla.javascript.Context enter()>";
    "<com.snc.secres.sample.RhinoServlet: java.lang.String setupAndDoScriptEval(java.lang.String)>" -> "<org.mozilla.javascript.Context: void exit()>";
    "<com.snc.secres.sample.RhinoServlet: java.lang.String setupAndDoScriptEval(java.lang.String)>" -> "<org.mozilla.javascript.Context: org.mozilla.javascript.ScriptableObject initStandardObjects()>";
    "<com.snc.secres.sample.RhinoServlet: java.lang.String setupAndDoScriptEval(java.lang.String)>" -> "<org.mozilla.javascript.Context: java.lang.String toString(java.lang.Object)>";
    "<com.snc.secres.sample.RhinoServlet: java.lang.String setupAndDoScriptEval(java.lang.String)>" -> "<jakarta.servlet.GenericServlet: void <clinit>()>";
    "<com.snc.secres.sample.RhinoServlet: java.lang.String setupAndDoScriptEval(java.lang.String)>" -> "<jakarta.servlet.http.HttpServlet: void <clinit>()>";
    "<com.snc.secres.sample.RhinoServlet: java.lang.String setupAndDoScriptEval(java.lang.String)>" -> "<java.lang.Object: java.lang.Class getClass()>";
    "<com.snc.secres.sample.RhinoServlet: java.lang.String setupAndDoScriptEval(java.lang.String)>" -> "<java.io.PrintWriter: void <init>(java.io.Writer)>";
    "<com.snc.secres.sample.RhinoServlet: java.lang.String setupAndDoScriptEval(java.lang.String)>" -> "<java.io.PrintWriter: void print(java.lang.String)>";
    "<com.snc.secres.sample.RhinoServlet: java.lang.String setupAndDoScriptEval(java.lang.String)>" -> "<org.mozilla.javascript.RhinoException: void printStackTrace(java.io.PrintWriter)>";
    "<com.snc.secres.sample.RhinoServlet: java.lang.String setupAndDoScriptEval(java.lang.String)>" -> "<com.snc.secres.sample.RhinoServlet: void <clinit>()>";
    "<com.snc.secres.sample.RhinoServlet: java.lang.String setupAndDoScriptEval(java.lang.String)>" -> "<com.snc.secres.sample.RhinoServlet: java.lang.Object doScriptEval(java.lang.String,org.mozilla.javascript.Scriptable,org.mozilla.javascript.Context)>";
    "<com.snc.secres.sample.RhinoServlet: java.lang.String setupAndDoScriptEval(java.lang.String)>" -> "<org.mozilla.javascript.ScriptableObject: void defineFunctionProperties(java.lang.String[],java.lang.Class,int)>";
    "<com.snc.secres.sample.RhinoServlet: java.lang.String setupAndDoScriptEval(java.lang.String)>" -> "<java.io.StringWriter: void <init>()>";
    "<com.snc.secres.sample.RhinoServlet: java.lang.String setupAndDoScriptEval(java.lang.String)>" -> "<java.io.StringWriter: java.lang.String toString()>";
    "<sample.test20240717132453: void runtimeSimulator()>" -> "<java.time.chrono.ChronoLocalDateTime: java.time.Instant toInstant(java.time.ZoneOffset)>";
    "<sample.test20240717132453: void runtimeSimulator()>" -> "<java.util.Date: void <init>()>";
    "<sample.test20240717132453: void runtimeSimulator()>" -> "<java.util.Date: int compareTo(java.util.Date)>";
    "<sample.test20240717132453: void runtimeSimulator()>" -> "<java.util.Date: java.util.Date from(java.time.Instant)>";
    "<sample.test20240717132453: void runtimeSimulator()>" -> "<java.time.LocalDateTime: int getDayOfYear()>";
    "<sample.test20240717132453: void runtimeSimulator()>" -> "<java.time.LocalDateTime: java.time.LocalDateTime minusDays(long)>";
    "<sample.test20240717132453: void runtimeSimulator()>" -> "<java.time.LocalDateTime: java.time.LocalDateTime minusHours(long)>";
    "<sample.test20240717132453: void runtimeSimulator()>" -> "<java.time.LocalDateTime: java.time.LocalDateTime now()>";
    "<sample.test20240717132453: void runtimeSimulator()>" -> "<java.time.LocalDateTime: java.lang.String toString()>";
}

Using Graphviz, the graph can be rendered into an image. Below is a portion of the rendered graph.

Rendered Call Graph Representing Java to JavaScript to Java

Rendered Call Graph

Graph rendered using the command dot -O -Tpdf sample.test20240717132453.dot. The full rendered graph can be found in the ServiceNow SecurityResearch GitHub repo.17

More information about how to run Rhino Tracker and other available options can be found in the readme file in the ServiceNow SecurityResearch GitHub repo.18

Conclusion

While Rhino Tracker requires both static and dynamic analysis to produce a call graph of a polyglot Rhino server environment, the dynamic data required is minimal and reusable. Rhino Tracker requires only the JavaScript call flows be gathered dynamically. Such an approach is still subject to the same limitations of completeness as all dynamic analysis approaches. However, a good fuzzing environment will likely be able to run through all code paths in a given JavaScript codebase. As such, so long as all available code paths are hit during runtime, a complete dump of the JavaScript call flows will be produced by Rhino Tracker. Moreover, a purely static analysis-based approach to the call graph generation would not be guaranteed to be complete. Using static analysis for call graph generation would require parsing Rhino’s abstract syntax tree (AST). As much of the call targets of the Rhino AST are determined at runtime from customizable mappings that are also runtime generated, there is no simple way to statically map calls through the Rhino interpreter without runtime generated data.

The hybrid analysis approach of Rhino Tracker allows it to create complete call graphs that capture the call flows through the Rhino interpreter without the runtime complexities that the Rhino interpreter introduces. This allows Rhino Tracker to bridge the call flows from Java to JavaScript to Java. Not only can the call graphs produced by Rhino Tracker be used to aid manual review of polyglot Java/JavaScript code, but they can also but built on to develop more complex analysis tools.

  1. https://github.com/mozilla/rhino 

  2. https://github.com/ServiceNow/SecurityResearch/tree/main/rhino-tracker 

  3. https://soot-oss.github.io/SootUp/develop/  2 3 4

  4. https://github.com/ServiceNow/SecurityResearch/tree/main/rhino-tracker/tool/src/main/java/com/snc/secres/tool/dynamic/Tools.java 

  5. https://github.com/ServiceNow/SecurityResearch/tree/main/rhino-tracker/sample/sample.test.js 

  6. https://github.com/ServiceNow/SecurityResearch/tree/main/rhino-tracker/sample/2024-07-17_13-24-53__sample.test__.class 

  7. https://bytebuddy.net/#/ 

  8. https://github.com/ServiceNow/SecurityResearch/tree/main/rhino-tracker/tool/src/main/java/com/snc/secres/tool/dynamic/Agent.java  2

  9. https://github.com/ServiceNow/SecurityResearch/tree/main/rhino-tracker/tool/src/main/java/com/snc/secres/tool/dynamic/JavaMethodInterceptor.java  2

  10. https://github.com/soot-oss/soot 

  11. https://github.com/ServiceNow/SecurityResearch/tree/main/rhino-tracker/sample/src/main/java/com/snc/secres/sample/DummyMain.java 

  12. https://github.com/ServiceNow/SecurityResearch/tree/main/rhino-tracker/sample 

  13. https://github.com/ServiceNow/SecurityResearch/tree/main/rhino-tracker/sample/dynamic_config.yaml 

  14. https://github.com/ServiceNow/SecurityResearch/tree/main/rhino-tracker/sample/2024-07-17_13-24-53__sample.test__.txt 

  15. https://github.com/ServiceNow/SecurityResearch/tree/main/rhino-tracker/sample/static_config.yaml 

  16. https://github.com/ServiceNow/SecurityResearch/tree/main/rhino-tracker/sample/sample.test20240717132453.dot 

  17. https://github.com/ServiceNow/SecurityResearch/tree/main/rhino-tracker/sample/sample.test20240717132453.pdf 

  18. https://github.com/ServiceNow/SecurityResearch/tree/main/rhino-tracker/Readme.md