Java Agent 与 Attach API
到目前为止,我们已经熟悉了字节码instrumentation的一些基本原理,但是上面提供的示例仍然不够灵活——我们需要将额外的逻辑嵌入到应用程序中,或者需要部署其他应用程序instrument所需的类字节码。让我们更进一步,探索如何将instrumenting应用程序与instrumented应用程序(上面使用的Java应用程序)解耦。这种概念在JVM中已经存在了一段时间,称为Java代理。Java agent是一种以特定方式捆绑的应用程序,通常作为一个独立的JAR文件(它可能还需要额外的依赖项)交付,它包含instrumentation逻辑的实现,并且可以为了instrumentation而附加到Java应用程序。
有两种方法可以启动Java代理并将其加载到instrumented JVM(更多信息,请参阅java.lang.instrument包上的文档,例如,java.lang.instrument(Java Platform SE 8 ):
-
在JVM启动期间启动代理,也称为静态加载。这是通过使用附加的JVM参数“-javaagent”来实现的,并将代理的JAR文件的位置指定为该参数的值(如果代理接受任何参数或选项,它们也可以作为参数值的一部分传递):-javaagent:jarpath[=options]。可以使用这种方法加载多个代理——必须指定参数“-javaagent”的几个记录,每个记录引用单个加载的代理。这样,代理将按在JVM参数列表出现的顺序加载。这种方法的优点是,代理代码是在JVM调用Java应用程序的main()之前加载的。在instrumented应用程序之前加载instrumentation可以确保Java应用程序在JVM整个的运行生命周期中是instrumented的。缺点是,它不可能动态instrument已经运行的Java应用程序,如果由于某种原因,初始启动Java应用程序之前没有指定有效参数“-javaagent”,就需要重启JVM(例如,必须重新启动服务器节点),以加载agent,使instrumentation生效
-
在JVM启动后启动代理,并将其附加到已经运行的JVM上,也称为动态加载。这是通过使用Attach API实现的,它是现代jvm公开的诊断接口之一。这种方法的思想是,在Java应用程序执行的任意时刻,使用JVM的Attach API,我们可以连接到JVM(附加在它上面),并从特定的JAR文件加载有效的代理,其中包含必要的可选参数。对运行中JVM的连接可以通过其中运行的Java应用程序触发,但它也可以由外部JVM进程发起—这为我们提供了一种可能,我们可以开发一个外部应用程序附加到正在运行的JVM进程,给它加载一个代理(当然,相应的安全问题必须考虑)。
这样的动态代理加载机制解决了前面描述的方法的主要缺点——即instrumentJava应用程序时,如果事先没有指定参数“-javaagent”,需要重新启动JVM,使用这种方法,不再需要指定像“-javaagent”这样的JVM属性。这种方法也有一个缺点:由于实现instrumentation的代理是在Java应用程序之后启动的,一些Java应用程序类可能已经被类加载器加载,使用的是原始的(non-instrumented)字节码版本——导致缺乏对早期执行的应用程序逻辑的instrumentation,以及对受影响(已加载)类的重加载/卸载的管理。为了更好地了解哪些类已经加载,可以用JVM参数" - verbose:class "来启用类加载日志,然后从日志检查,判断一个instrumented类在代理加载之前是否已经加载到JVM,谁为被加载的类提供字节码(请不要在生产环境、集群或日志超负荷时启用该参数)。有关Attach API的详细信息,请参阅官方文档—例如,Attach API. 相应的API实现位于库tools.jar,它可以在JDK发行版中找到。
JVM提供了使用上述任一方法加载Java代理的简便方法,但是它没有提供卸载Java代理的便捷方法。原因是,Java代理本身是一组特定的类,这些类在Java代理启动期间使用类加载机制加载到JVM中。而且,正如前面提到的,JVM不提供类卸载的通用机制。这意味着,如果不仅需要加载Java代理,还需要卸载Java代理,则需要开发类卸载逻辑。
你可能已经碰到使用Java代理对SAP应用程序服务器基于java的系统( 如PI/PO, EP, CE)作系统和性能监控的情况—一个不错的例子是Wily Introscope Agent,这是Wily Introscope 基础设施的一部分—事实上的工具集,用于对SAP Application Server Java组件和在其上运行的应用程序性能进行连续实时和回顾性的监视和分析,并提供JVM、Application Server和正在运行的应用程序的宝贵信息,这些信息都是收集到的metrics和遥测信息。
-
在这里,我不详细介绍Java代理规范—对Java .lang.instrument包,JDK文档中有很好的描述。我只强调几个重点:
Java agent 主类必须实现将在代理启动期间触发的相应方法:方法premain()用于在JVM启动期间启动代理,方法agentmain()用于动态加载代理。如果一个代理需要支持上述两种Java代理启动方式,那它可以实现两种方法; -
代理类并没有真正实现任何特定的Java接口,但是实现的方法premain() / agentmain()必须符合预期的方法声明;
-
方法premain() / agentmain()通过添加自定义字节码/类文件转换器来实现instrumentation/字节码操作逻辑的触发;
-
Java代理被组装在一个JAR文件中;
-
组装的JAR文件包含Java代理类、所需的其他类和依赖项,还必须包含manifest文件,manifest文件至少需要指定包含实现方法premain() / agentmain()的相应类(可以是相同的类,也可以是不同的类),这些方法将在代理启动时调用。
在下面的示例中,我开发了一个Java代理,它可以以上述任何一种方式启动,并实现与前面示例中相同的instrumentation逻辑。
类DemoAgent包含方法premain()和agentmain()的实现:
package vadim.demo.jvm.agent;
import java.lang.instrument.Instrumentation;
public class DemoAgent {
public static void premain(String args, Instrumentation instrumentation) {
System.out.println("[Agent] Start agent during JVM startup using argument '-javaagent'");
instrumentation.addTransformer(new DemoClassFileTransformer());
}
public static void agentmain(String args, Instrumentation instrumentation) {
System.out.println("[Agent] Load agent into running JVM using Attach API");
instrumentation.addTransformer(new DemoClassFileTransformer());
}
}
类DemoClassFileTransformer被代理主类调用,实现类文件转换器逻辑/字节码instrumentation:
package vadim.demo.jvm.agent;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.IllegalClassFormatException;
import java.security.ProtectionDomain;
import javassist.CannotCompileException;
import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtMethod;
import javassist.expr.ExprEditor;
public class DemoClassFileTransformer implements ClassFileTransformer {
@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
String instrumentedClassName = "vadim.demo.jvm.app.Text";
String instrumentedMethodName = "display";
byte[] bytecode = classfileBuffer;
try {
ClassPool cPool = ClassPool.getDefault();
CtClass ctClass = cPool.makeClass(new ByteArrayInputStream(bytecode));
CtMethod[] ctClassMethods = ctClass.getDeclaredMethods();
for (CtMethod ctClassMethod : ctClassMethods) {
if (ctClassMethod.getDeclaringClass().getName().equals(instrumentedClassName)
&& ctClassMethod.getName().equals(instrumentedMethodName)) {
ctClassMethod.insertBefore("System.out.println(\"[Instrumentation] Entering method\");");
ctClassMethod.insertAfter("System.out.println(\"[Instrumentation] Exiting method\");");
ctClassMethod.insertAt(24, true,
"text = \"Original text was replaced with instrumentation by agent\";");
ExprEditor instrumentationExpressionEditor = new DemoExpressionEditor();
ctClassMethod.instrument(instrumentationExpressionEditor);
bytecode = ctClass.toBytecode();
}
}
} catch (IOException e) {
throw new IllegalClassFormatException(e.getMessage());
} catch (RuntimeException e) {
throw new IllegalClassFormatException(e.getMessage());
} catch (CannotCompileException e) {
throw new IllegalClassFormatException(e.getMessage());
}
return bytecode;
}
}
由于我们的目标是复制前面示例中使用的相同的instrumentation逻辑,所以我还复制了类DemoExpressionEditor (借助Javassist库实现表达式编辑器用于更复杂的字节码修改):
package vadim.demo.jvm.agent;
import javassist.CannotCompileException;
import javassist.expr.ExprEditor;
import javassist.expr.MethodCall;
public class DemoExpressionEditor extends ExprEditor {
@Override
public void edit(MethodCall method) throws CannotCompileException {
if (method.getMethodName().contains("sleep")) {
System.out.println("[Instrumentation] Suppressing sleep for " + method.getClassName() + "."
+ method.getMethodName() + " called from " + method.getEnclosingClass().getName());
method.replace("{}");
}
}
}
Manifest文件 MANIFEST.MF:
Manifest-Version: 1.0
Premain-Class: vadim.demo.jvm.agent.DemoAgent
Agent-Class: vadim.demo.jvm.agent.DemoAgent
在开发代理之后,我们编译它,构建项目并导出到JAR文件中,将其命名为“DemoAgent.jar”。
我们还将Java应用程序demo还原到原始版本,删除我们随后嵌入其中的所有instrumentation逻辑,instrumentation仅由代理完成。
首先,让我们在JVM启动时使用JVM参数“-javaagent”启动这个代理。Java应用程序demo的JVM参数采用如下:
image.png
现在,我再次运行应用程序Demo-这是控制台输出:
[Agent] Start agent during JVM startup using argument '-javaagent'
[Application - Main] Start application
[Instrumentation] Suppressing sleep for java.lang.Thread.sleep called from vadim.demo.jvm.app.Text
[Application - Main] Value passed to text display: Demonstration of Java bytecode manipulation capabilities
[Instrumentation] Entering method
[Application - Text display] Text display is going to sleep for 1000 ms
[Application - Text display] Text display wakes up
[Application - Text display] Text display sleep time: 0 ms
[Application - Text display] Output: Original text was replaced with instrumentation by agent
[Instrumentation] Exiting method
[Application - Main] Complete application
你可能会注意到,代理的确是在应用程序Demo的main()方法调用之前启动的。
另一种将字节码instrumentation灵活性提高到新的层次的技术是代理的动态加载。为了使演示更有趣,我们启动一个纯粹的Java应用程序Demo(没有嵌入式instrumentation或与JVM一起启动的Java代理),然后从另一个进程连接到JVM(即另一个Java应用程序,Java代理加载器),加载一个实现字节码instrumentation逻辑的Java代理。要做到这一点,Java代理加载程序必须与运行java程序Demo的Jvm进程在同一主机启动——这样它才可以识别运行的JVM并连接上它。
我们再次恢复Java应用程序Demo到原始状态,删除前面介绍的JVM参数“-javaagent”。唯一的小变化是给Java应用程序Demo加一个等待时间——只是几秒钟——在其开始执行的时候,这样在演示Java应用程序启动并完成工作之前,就有时间运行Java agent loader应用程序:
package vadim.demo.jvm.app;
public class DemoApplication {
public static void main(String[] args) {
System.out.println("[Application - Main] Start application");
suspend(5000);
String value = "Demonstration of Java bytecode manipulation capabilities";
Text text = new Text();
System.out.println("[Application - Main] Value passed to text display: " + value);
text.display(value);
System.out.println("[Application - Main] Complete application");
}
private static void suspend(long sleepTime) {
try {
Thread.sleep(sleepTime);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
还有另一个应用程序—Java代理加载器,它将获得本地运行的JVM的列表,确定哪个在执行Java应用程序Demo,使用Attach API,加载一个Java代理(我将使用先前的例子中的java代理),然后将目标JVM与特定类的instrumented字节码分离:
package vadim.demo.jvm.agent.loader;
import java.io.File;
import java.util.List;
import com.sun.tools.attach.VirtualMachine;
import com.sun.tools.attach.VirtualMachineDescriptor;
public class DemoAgentLoader {
public static void main(String[] args) {
String agentFilePath = "D:/tmp/DemoAgent.jar";
String jvmAppName = "vadim.demo.jvm.app.DemoApplication";
String jvmPid = null;
List<VirtualMachineDescriptor> jvms = VirtualMachine.list();
for (VirtualMachineDescriptor jvm : jvms) {
System.out.println("Running JVM: " + jvm.id() + " - " + jvm.displayName());
if (jvm.displayName().equals(jvmAppName)) {
jvmPid = jvm.id();
}
}
if (jvmPid != null) {
File agentFile = new File(agentFilePath);
if (agentFile.isFile()) {
String agentFileName = agentFile.getName();
String agentFileExtension = agentFileName.substring(agentFileName.lastIndexOf(".") + 1);
if (agentFileExtension.equalsIgnoreCase("jar")) {
try {
System.out.println("Attaching to target JVM with PID: " + jvmPid);
VirtualMachine jvm = VirtualMachine.attach(jvmPid);
jvm.loadAgent(agentFile.getAbsolutePath());
jvm.detach();
System.out.println("Attached to target JVM and loaded Java agent successfully");
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
} else {
System.out.println("Target JVM running demo Java application not found");
}
}
}
我们现在准备进行另一项测试。我首先运行Java应用程序demo,然后立即切换运行Java agent loader应用程序。下面是各个控制台的输出:
Java应用程序Demo:
[Application - Main] Start application
[Agent] Load agent into running JVM using Attach API
[Instrumentation] Suppressing sleep for java.lang.Thread.sleep called from vadim.demo.jvm.app.Text
[Application - Main] Value passed to text display: Demonstration of Java bytecode manipulation capabilities
[Instrumentation] Entering method
[Application - Text display] Text display is going to sleep for 1000 ms
[Application - Text display] Text display wakes up
[Application - Text display] Text display sleep time: 0 ms
[Application - Text display] Output: Original text was replaced with instrumentation by agent
[Instrumentation] Exiting method
[Application - Main] Complete application
Java代理加载器:
Running JVM: 10712 -
Running JVM: 8904 - vadim.demo.jvm.app.DemoApplication
Running JVM: 12156 - vadim.demo.jvm.agent.loader.DemoAgentLoader
Attaching to target JVM with PID: 8904
Attached to target JVM and loaded Java agent successfully
可以注意到,我们再次成功地实现了所需类的instrumentation,但这一次,正如输出所描述的,Java agent是在demo Java application启动后加载的。应当指出的是,对该类的字节码instrumentation成功了,这是因为它不但被类加载器加载到JVM中,还在instrumented类第一次被访问和加载(发生在类的一个对象实例创建时)之前加载了java代理,实现了字节码instrumentation。
如果Java代理是在将该类的原始字节码加载到JVM之后加载的,那么结果会有所不同。这可以通过把线程sleep调用放到Java应用程序Demo后面的代码块中轻松验证,例如,在创建了新的类Text实例之后:
package vadim.demo.jvm.app;
public class DemoApplication {
public static void main(String[] args) {
System.out.println("[Application - Main] Start application");
String value = "Demonstration of Java bytecode manipulation capabilities";
Text text = new Text();
suspend(5000);
System.out.println("[Application - Main] Value passed to text display: " + value);
text.display(value);
System.out.println("[Application - Main] Complete application");
}
private static void suspend(long sleepTime) {
try {
Thread.sleep(sleepTime);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
现在让我们重新测试。下面的控制台输出:
Java应用程序Demo:
[Application - Main] Start application
[Agent] Load agent into running JVM using Attach API
[Application - Main] Value passed to text display: Demonstration of Java bytecode manipulation capabilities
[Application - Text display] Text display is going to sleep for 1000 ms
[Application - Text display] Text display wakes up
[Application - Text display] Text display sleep time: 1000 ms
[Application - Text display] Output: Demonstration of Java bytecode manipulation capabilities
[Application - Main] Complete application
java代理加载器:
Running JVM: 9156 - vadim.demo.jvm.app.DemoApplication
Running JVM: 10712 -
Running JVM: 11740 - vadim.demo.jvm.agent.loader.DemoAgentLoader
Attaching to target JVM with PID: 9156
Attached to target JVM and loaded Java agent successfully
正如所看到的,Java代理已经加载到JVM中,但instrumentation生效太晚了——instrumented类已经加载了原始字节码版本。
这种技术的用途在于,在相同的JVM中执行的应用程序可以将Java代理动态加载到运行的JVM中。例如,一些定制开发的程序可以被SAP PI/PO系统,Java Scheduler作业、WebDynpro / SAPUI5用户界面、HTTP servlet / JSP或其他组件调用。
结尾
希望本博客中提供的几个示例能够说明JVM中字节码instrumentation和Java代理/Attach API的主要功能。当然,这不是PI开发人员或NetWeaver技术顾问在日常活动中经常使用的技术,但是值得关注。对于安全团队来说,熟悉这种技术及其使用的可能后果,并采取预防措施以保护Java系统的安全性,无疑是值得的。由于未授权的组件连接到正在运行的服务器节点JVM,动态代理加载时携带恶意instrumentation,显然是存在安全隐患的,可能会导致应用程序甚至整个系统受损。因此,强烈建议,进行任何字节码instrumentation尝试都要谨慎,并且需要评估其对部署的应用程序、JVM /服务器节点甚至整个系统的影响。
java达人
ID:drjava
(长按或扫码识别)
image
网友评论