关于Java调试的一些笔记

Published: 2021年10月22日

In Reverse.

记得之前到他们在研究有回显的代码注入时,一般两种思路:(1)利用系统特性,如Linux的特殊文件传回数据。(2)利用特定框架获取Response对象写数据。当时有考虑过能否找到一种通用的方法,直接获取JVM中所有的Socket实例,再从中筛选出本次连接的Socket,将输出写入从而实现通杀型回显,这里面最大的问题就是如何找到内存中所有的Socket对象,首先Jvm本身是知道这些信息的,至少从GC角度看它必须管理这些信息,另外对象是有特征的,遍历内存也能找到,为了通用必须编写Java代码,但是Java代码是无法直接操作内存的,那退一步用C呢?然后...

image.png

编译JVM

分析java虚拟机动态调试不可避免,因此需要自己编译JVM,说实话这在Linux下毫无难度,但我之前在Windows下编译确实踩了很多坑,所以记录一下,总的来说,OpenJDK使用Makefile指导项目编译,Makefile里是各种编译命令,在Windows下需要安装make及Makefile里调用的各种工具,此处可使用cygwin,若是Win10也可使用wsl,而编译工具链根据建议是使用VC套装。

一般大家会喜欢用经典的Jdk8,这个版本现在编译起来坑挺多的,至少我选的Jdk1.8.180不够新:

  • 获取java源码:git clone git@gitee.com:mirrors/openjdk.git,之所以用它是你觉得太难还可以换新版本(逃)。
  • 下载freetype解压并将win64改为lib
  • 安装DirectXSDK 8.0以上,并设置ALT_DXSDK_PATH环境变量
  • 安装jdk并且创建软链接到/NO_BOOTDIR,不要在环境变量里添加JAVA_HOME,这里的java叫boot_jdk,它一定一定要是比正在编译的jdk低一个版本或同样版本,它的作用是编译jdk中的java库,太低了新特性支持不了,太高了新编的jdk运行不了它。
  • 安装VC工具链和SDK,安装Cygwin及make,tar等工具,如果不知道需要啥可以先不装,后续报错就知道了(🤪)
  • 添加环境变量,如:
set VS2010DIR=E:/BUILDSDK/VC/VC/
set WINDOWSSDKDIR=E:/BUILDSDK/v7.0A/
set ALT_DXSDK_PATH=E:/BUILDSDK/DXSDK/
set FREE_TYPE=E:/BUILDSDK/freetype-2.9.1/
set CYGWINDIR=E:/cygwin64/
set ALT_BOOTDIR=E:/BUILDSDK/OpenJDK7/

:: set DXSDK
:: set ALT_DXSDK_LIB_PATH=%ALT_DXSDK_PATH%Lib\x64
:: SET DXSDK_INCLUDE_PATH=%ALT_DXSDK_PATH%Include
SET DXSDK_DIR=%ALT_DXSDK_PATH%

:: set freetype
set ALT_FREETYPE_HEADERS_PATH=%FREE_TYPE%include
set ALT_FREETYPE_LIB_PATH=%FREE_TYPE%lib

set INCLUDE=%VS2010DIR%include;%FREE_TYPE%include;%WINDOWSSDKDIR%Include;%ALT_DXSDK_PATH%Include
set LIB=%VS2010DIR%lib\amd64;%WINDOWSSDKDIR%Lib\x64

set VS100COMNTOOLS=%VS2010DIR%../Common7/Tools/
set Path=%VS2010DIR%bin\amd64;%ALT_DXSDK_PATH%Utilities\Bin\x64;%WindowsSdkDir%bin\NETFX 4.0 Tools;%CYGWINDIR%bin;%PATH%
  • 开始编译并准备修BUG,一般来讲都会遇到各种错误,这些错误可分析日志手动修复,例如中文版获取信息的表达式无法匹配,gcc版本比较代码错误等
bash ./configure --with-boot-jdk=C:\\"Program Files"\\Java\\jdk1.8.0_202 --with-target-bits=64 --enable-debug-symbols --with-debug-level=slowdebug --with-freetype=C:/BUILD/freetype-2.9.1 OBJCOPY=gobjcopy

bash ./configure --with-target-bits=64 --enable-debug-symbols --with-debug-level=slowdebug OBJCOPY=gobjcopy

make JOBS=8 CONF=windows-x86_64-normal-server-slowdebug

如果没有版本要求,这里推荐Jdk-15+35,因为我是JetBrains全家桶用户,C语言也喜欢用Clion,在它支持Makefile时,就以openjdk作为官方样例,跟着它的步骤走会省去很多麻烦,要注意只有较新的版本才支持该方式,不信YouTryTry。

编译好后就可以直接使用VS/Clion启动来调试JVM了。

JPDA

为了方便调试,Java提出了JPDA(Java Platform Debug Architecture),它包含三个部分: image.png JVMTI(Java Virtual Machine Tool Interface):Java虚拟机工具接口(规范),它通过在虚拟机里埋桩来提供各种虚拟机内部信息,用户通过这些桩提供的API,就可以监控与修改虚拟机的行为,比如通过它实现调试功能,对于调试一般它会和后端连接,后端通过JDWP与调试器通信。

JDWP(Java Debug Wire Protocol):Java调试线协议,规定了调试器与虚拟机间的通信协议格式,用户可自行选择传输实现方式(共享内存/网络套接字/Unix套接字等)。

JDI(Java Debug Interface):Java调试接口,这是调试的纯Java封装,使用它用户可以直接通过Java代码调用这些接口实现调试工具。 ​

在高级语言调试架构中,一般有两种方式,debuggee监听等待debugger连接与debugger监听等待debuggee连接,其中python和php一般是采取后者,而Java两种方式都可用。在调试Java/Class时,若能直接在IDE里已调试的方式运行那当然是最简单不过的,但是很多应用不便与以这种方式运行,如某WEB应用需要由它的WEB容器执行它,对此只要把调试参数传入给JVM即可,如:

-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=9090 # 旧版 
-agentlib:jdwp=transport=dt_socket,server=y,address=9090 # 新版,在更新的版本中需要用*:9090指定绑定地址

这两种参数都是让JVM加载jdwp这个agent来代理调试请求,官方文档提到前者会使用旧的JVMDI接口,而后者会用到新的JVMTI接口。

注:需要区分JDWP协议和jdwp这个agent的不同,后者是一个动态链接库,它调用jvmti提供的调试接口,也实现了JDWP所定义的协议,因此它能用于连接调试器与被调试的JVM。

JVMTI

JDWP和JDI主要用于实现调试器这点我不关心,它最底层的调试功能由JVMTI实现,在Hotspot中该协议实现为jvmti模块,不过由于其功能的特殊性它会插入其他模块/核心里,可根据它的命名空间jvmti_进行区分,由于它只是一些接口,我们需要编写一个动态库_(也可以将静态库直接链接到JVM上)_,将动态库注入到JVM里,并由该动态库调用jvmti导出的函数,Java将这个动态库称作agent,例如上面提到的jdwp就是一个agent,另一个常见的是instrument,使用它就可以通过纯Java代码调用jvmti的部分功能(javaagent)。 ​

JVMTI接口

为了兼容性JVMTI接口是使用C编写的(所有对外的接口都需要用C,否则需要处理很多问题,更多可见C++逆向里关于COM组件的说明),它提供的功能可见文档,它描述了使用方法与可用时机:

类型 说明
线程 读写线程与线程组相关的信息,如暂停与恢复执行,获取所有线程的信息,读写LTS等
获取栈和栈帧信息,局部变量等
对堆进行管理,如想要获取所有实例需要遍历堆
对类操作,如获取类加载器,获取类的签名,文件,方法,字段等信息
对象 对实例操作
... ...

JVMTI基于事件回调,在JVM执行时,它的桩代码会检测是否有注册的回调函数,若有就会被调用,它支持如下等事件:

typedef struct {
    jvmtiEventVMInit VMInit; // JVM初始化完成时,此时可以调用所有JNI和JVMTI
    jvmtiEventVMDeath VMDeath;  // JVM终止时
    jvmtiEventThreadStart ThreadStart;  // 创建新线程时发生,这在新线程初始化之前
    jvmtiEventThreadEnd ThreadEnd;  // 线程终止时
    jvmtiEventClassFileLoadHook ClassFileLoadHook;  // 类数据被读取但是还未被解析为Class,或者类被修改了
    jvmtiEventClassLoad ClassLoad;  // 第一次加载类时(原生类/数组类不会发生),注意此时类还未完成加载
    jvmtiEventClassPrepare ClassPrepare;  // 类准备完成时,还未执行任何代码,但是字段方法可用
    jvmtiEventVMStart VMStart;  // JVM启动时,此时可调用JNI和该阶段可用的部分JVMTI
    jvmtiEventException Exception;  // 当检测到异常时
    jvmtiEventExceptionCatch ExceptionCatch;  // 当捕获异常时
    jvmtiEventSingleStep SingleStep; 
    jvmtiEventFramePop FramePop;  // 弹出一个栈帧时,如运行返回或向上抛出异常
    jvmtiEventBreakpoint Breakpoint;  // 在运行到SetBreakpoint指定的位置时发生
    jvmtiEventFieldAccess FieldAccess;  // 访问某个域时,如watch
    jvmtiEventFieldModification FieldModification;  // 修改某个域时
    jvmtiEventMethodEntry MethodEntry;  // 进入Java/Native方法时
    jvmtiEventMethodExit MethodExit;  // 退出方法时,包括正常或异常返回
    jvmtiEventNativeMethodBind NativeMethodBind;  // 当Native方法被绑定时,如调用RegisterNatives或第一次调用Native方法
    //...
} jvmtiEventCallbacks;

在使用时,使用SetEventNotificationMode来使能对应的事件,使用SetEventCallbacks注册回调。

agent

上面已经提到用户使用agent来调用jvmti的功能,agent一般是动态链接库(之后默认是动态库),一般有C/C++编写,开发时包含如下头文件:

#include <jvmti.h>

之后按需实现下面的三个函数:

JNIEXPORT jint JNICALL 
Agent_OnLoad(JavaVM *vm, char *options, void *reserved); // 启动时调用

JNIEXPORT jint JNICALL 
Agent_OnAttach(JavaVM* vm, char *options, void *reserved); // 以attach加载时调用

JNIEXPORT void JNICALL 
Agent_OnUnload(JavaVM *vm); // 卸载时调用

其中options是-agentlib:agentfile[=<args,>]处的参数。开发完成后,该agent需要被加载到目标JVM里,有两种方法可实现这种目的,一种是在启动时通过-agentlib:xx.so<=args...>指定,如上面的调试时指定jdwp,另一种是在目标运行时直接attach上去,寒泉子的attach机制的原理解释了hotspot在linux下如何实现该功能的,attach时必须要在同一台机器上使用同一个用户(即使是root也无法attach到其他用户的Jvm进程上),因此俺们可以调试线上环境,事实上arthas就是使用这种方式。

Instrument

Instrument Agent也叫JPLISAgent(Java Programming Language Instrumentation Services Agent),通过它可以实现仅使用Java修改自身的类代码,即javaagent,这其实是接触比较多的东西,很多盗版软件,如JetBrain家的盗版就是用的Javaagent做的:

image.png

编写javaagent时,需要实现premain或agentmain接口,前者在启动时被调用而后者在attach时被调用,它们签名如下:

public static void premain(String agentArgs, Instrumentation inst);
public static void premain(String agentArgs);

public static void agentmain(String agentArgs, Instrumentation inst);
public static void agentmain(String agentArgs);

可见它们有两个重载,instrument会先尝试获取第一个函数,若没有再获取第二个,一般都是使用带inst参数的实现,这样就可以将编写的tranformer添加到inst,从而实现运行时类修改,transformer把类文件当作字节数组,我们可以把它视作数据进行修改,再返回新的字节数组,修改时一般会使用javassist,它提供了高级的代码生成功能。开发完javaagent后还需要在manifest里指定实现入口方法(premain/aganetmain)的类以及权限:

Premain-Class:me.betamao.instrument.StartUpTest
Agent-Class:me.betamao.instrument.AttachTest
Can-Redefine-Classes:true
...

寒泉子在JVM源码分析之javaagent原理完全解读agent机制中对它的底层进行了完整的解读,此处做点自己的笔记,它的入口位于src/java.instrument/share/native/libinstrument/InvocationAdapter.c,若启动时使用了-javaagent:jarfile=...则会调用到如下函数,指定几次调用几次:

JNIEXPORT jint JNICALL
DEF_Agent_OnLoad(JavaVM *vm, char *tail, void * reserved) { // DEF_Agent_OnLoad宏展开就是Agent_OnLoad
    ...
    /* 创建JPLISAgent,它里面会保存一些数据,如要运行的函数名,一些参数,一些权限信息等 */
    initerror = createNewJPLISAgent(vm, &agent);
    /* 解析参数,获取javaagent文件 */
    parseArgumentTail(tail, &jarfile, &options)
    /* 解析javaagent里的属性信息,下面会提 */
    attributes = readAttributes(jarfile);
    /* 根据javaagent里的属性信息和剩余的参数信息设置JPLISAgent */
    premainClass = getAttribute(attributes, "Premain-Class");
    agent->mJarfile = jarfile;
    premainClass = strdup(premainClass);
    bootClassPath = getAttribute(attributes, "Boot-Class-Path");
    appendBootClassPath(agent, jarfile, bootClassPath);
    convertCapabilityAttributes(attributes, agent);
    initerror = recordCommandLineData(agent, premainClass, options);
    ...
}

直接看似乎很疑惑,它创建完JPLISAgent再设置一些信息后程序就结束了,这个JPLISAgent不会被返回也没有看到被调用,而且还没有被销毁,其实它是在createNewJPLISAgent里的initializeJPLISAgent函数中就注册了:

JPLISInitializationError
initializeJPLISAgent(   JPLISAgent *    agent,
                        JavaVM *        vm,
                        jvmtiEnv *      jvmtienv) {
    jvmtiError      jvmtierror = JVMTI_ERROR_NONE;
    jvmtiPhase      phase;
    /* 把agent存入mNormalEnvironment并将mNormalEnvironment存入jvmtienv,这样之后就能重新获取了*/
    agent->mJVM                                      = vm;
    agent->mNormalEnvironment.mJVMTIEnv              = jvmtienv;
    agent->mNormalEnvironment.mAgent                 = agent;
    //...

    jvmtierror = (*jvmtienv)->SetEnvironmentLocalStorage(
                                            jvmtienv,
                                            &(agent->mNormalEnvironment));
    /* 查看当前的阶段,只有在启动时指定的才会继续,而且-javaagent要早于-jar */
    jvmtierror = (*jvmtienv)->GetPhase(jvmtienv, &phase);
    /* can be called from any phase */
    jplis_assert(jvmtierror == JVMTI_ERROR_NONE);
    if (phase == JVMTI_PHASE_LIVE) {
        return JPLIS_INIT_ERROR_NONE;
    }

    /* 这里注册了一个VMInit的回调事件 */
    if ( jvmtierror == JVMTI_ERROR_NONE ) {
        jvmtiEventCallbacks callbacks;
        memset(&callbacks, 0, sizeof(callbacks));
        callbacks.VMInit = &eventHandlerVMInit;

        jvmtierror = (*jvmtienv)->SetEventCallbacks( jvmtienv,
                                                     &callbacks,
                                                     sizeof(callbacks));
        check_phase_ret_blob(jvmtierror, JPLIS_INIT_ERROR_FAILURE);
        jplis_assert(jvmtierror == JVMTI_ERROR_NONE);
    }
    //...
}

再看看VMInit事件发生时的回调函数如下:

void JNICALL
eventHandlerVMInit( jvmtiEnv *      jvmtienv,
                    JNIEnv *        jnienv,
                    jthread         thread) {
    //...
    /* 获取之前存储的agent */
    environment = getJPLISEnvironment(jvmtienv);
    JPLISAgent * agent = environment->mAgent;
    /* 把agent的文件路径添加到类路径里 */
    appendClassPath(agent, agent->mJarfile)
    /* 开始执行agent */
    success = processJavaStart( environment->mAgent, jnienv);
}

继续跟入processJavaStart

jboolean
processJavaStart(   JPLISAgent *    agent,
                    JNIEnv *        jnienv) {
    jboolean    result;

    /* 创建InstrumentationImpl对象实例,就是普通JNI调用,并把实例和相关的方法绑定到agent上 */
    result = createInstrumentationImpl(jnienv, agent);
    /* 在这一步,它监听了ClassFileLoadHook事件,代码和上面的监听类似,它实际做的是当类加载前/修改时,执行注册的transformer链 */
    result = setLivePhaseEventHandlers(agent);
    /* 这又是一个JNI调用,它调用了PreMain方法,传入的参数是上面创建的InstrumentationImpl实例 */
    result = startJavaAgent(agent, jnienv,
                            agent->mAgentClassName, agent->mOptionsString,
                            agent->mPremainCaller);
    //...
}

而使用attach机制时,由于已经初始化完成,它不再通过VMInit回调事件执行,另外它执行的入口也变成了agentmain了:

JNIEXPORT jint JNICALL
DEF_Agent_OnAttach(JavaVM* vm, char *args, void * reserved) {
        /* 与Onload类似 */
        convertCapabilityAttributes(attributes, agent);
        /* 此后它不再是使用事件回调(因为已经初始化完了),而是直接调用 */
        success = createInstrumentationImpl(jni_env, agent);
        success = setLivePhaseEventHandlers(agent);
        /* 注意这里的参数不再是PreMain而是AgentMain了 */
        success = startJavaAgent(agent,
                                 jni_env,
                                 agentClass,
                                 options,
                                 agent->mAgentmainCaller);
        //...
}

在之前调试时,其实很想知道是否可在运行时开启调试(不使用启动参数),毕竟插桩这种类似的功能是可以这样的,它们都用了agent,搜了下似乎不大行,之后好好研究下...

调试无行号表文件

一般来说我们遇到的class都是有行号的,所以能够正常调试,但是有时也会遇到行号错误或者根本没有行号的情况,这时断点就无法生效:

image.png

因此需要重建行号表,最直观的是先把class反编译为java再回编译,这时编译选项可控当然可以用javac生成行号,但是反再回终究不让人放心,例如在int,bool,char,byte等的类型转换,容易导致伪代码需要修复才能编译,而且重编译也怕和真实环境不一致,因此想要的还是不操作指令,直接生成行号表。

获取行号表映射

由于我们在调试时打开的是class而idea显示的是源码,而且我们的断点也是打在idea上的,因此最好使用idea的反编译器,阅读其代码发现它在反编译过程中已经实现了行号的映射(org/jetbrains/java/decompiler/main/collectors/BytecodeSourceMapper.java),因此只需要将其提取修正再写回class即可。 通过动态调试发现它在org/jetbrains/java/decompiler/struct/ContextUnit.java:save()里反编译并且保存文件,因此修改此处的代码: image.png 需要注意的是内部类会编译成独立的文件,而在源码中它们是在同一个文件中,因此行号规则也是连续递增的(下图),如上图它只会解析外部的类,在解析此类时会解析其内部类等,因此在解析完根类时就可以获得其内部类的行号表了: image.png

修改class文件

获取到行号表就可以写回class文件了,这里用bcel库解析class:

public static void fixLineNumber(String inputFilePath, String outputFilePath, HashMap<String, ArrayList<Pair<Integer, Integer>>> lineNumberMapping) {
    ClassParser classParser = new ClassParser(inputFilePath);
    JavaClass javaClass = null;
    try {
      javaClass = classParser.parse();
    } catch (IOException e) {
      e.printStackTrace();
      return;
    }

    // 1. 先获取LineNumberTable的位置,若没有就增加一个常量来存放
    Constant[] constantPoolArr = javaClass.getConstantPool().getConstantPool();
    int lineTableConstIndex = 0;
    for (; lineTableConstIndex < constantPoolArr.length; lineTableConstIndex++) {
      Constant constant = constantPoolArr[lineTableConstIndex];
      if (constant instanceof ConstantUtf8 && ((ConstantUtf8) constant).getBytes().equals("LineNumberTable")) {
        break;
      }
    }
    if (lineTableConstIndex == constantPoolArr.length) {
      Constant[] constants = new Constant[lineTableConstIndex + 1];
      System.arraycopy(constantPoolArr, 0, constants, 0, constantPoolArr.length);
      constants[lineTableConstIndex] = new ConstantUtf8("LineNumberTable");
      org.apache.bcel.classfile.ConstantPool newConsTantPool = new org.apache.bcel.classfile.ConstantPool(constants);
      javaClass.setConstantPool(newConsTantPool);
    }
    org.apache.bcel.classfile.ConstantPool constantPool = javaClass.getConstantPool();
    // 2. 将符号存入表中
    for (Method method : javaClass.getMethods()) {
      String qualifyName = method.getName() + ' ' + method.getSignature();
      if (!lineNumberMapping.containsKey(qualifyName)) {
        continue;
      }
      ArrayList<Pair<Integer, Integer>> lines = lineNumberMapping.get(qualifyName);
      LineNumber[] lineNumbers = new LineNumber[lines.size()];
      int j = 0;
      for (Pair<Integer, Integer> row : lines) {
        lineNumbers[j++] = new LineNumber(row.getKey(), row.getValue());
      }
      LineNumberTable lineNumberTable = new LineNumberTable(lineTableConstIndex, lineNumbers.length * 4 + 2, lineNumbers, constantPool);
      Attribute[] attributes = method.getCode().getAttributes();
      int i = 0;
      // 若之前有行号则直接覆盖
      for (; i < attributes.length; i++) {
        if (attributes[i] instanceof LineNumberTable) {
          attributes[i] = lineNumberTable;
          break;
        }
      }
      // 否则新增行号属性
      if (i == attributes.length) {
        Attribute[] newAttributes = new Attribute[attributes.length + 1];
        System.arraycopy(attributes, 0, newAttributes, 0, attributes.length);
        newAttributes[attributes.length] = lineNumberTable;
        method.getCode().setAttributes(newAttributes);
      }
    }
    try {
      javaClass.dump(outputFilePath);
    } catch (IOException e) {
      e.printStackTrace();
    }
  }

再把它插入合适的位置。

替换原文件

将生成好的class文件直接替换原jar里的class即可,有签名的去掉签名,然后就可以调试了:

image.png
image.png

调试JSP

JSP和Java调试会有不同,尽管JVM和Java似乎天生一对,但事实上JVM虚拟机运行的是符合虚拟机规范的Class文件,因此其他语言只要编译为这种规范的Class即可在JVM上运行,jsp会被转化为Java语言的servlet再编译为class文件,上面已经说明了JVM本身的源码调试是依赖与Class文件中的行号表的,由于Class是从Java生成,因此在JSP中打的断点是无法与Class文件对应,为此JSR045提出了其他文件到Java再到Class文件间的映射表规范: image.png 在每次转换过程中,它会生成一个SMAP文件,该文件记录了转换前后的行号对应关系,通过分析这个文件就能将JSP里的断点打到class的对应位置了。当可以在Jetbrain里启动WEB服务时,可使用如下方法配置JSP调试,此时Jetbrain就可以自动加载SMAP文件:

For remote JSP debugging (which also applies to localhost) you'll need to install the JSR45 ㄧ support plugin. Please note this feature is only supported in the Ultimate edition of IntelliJ, not the community edition. 1. Go to Preferences > Plugins, search for the JSR45 plugin, and enable it.

  1. Create a run configuration: Run > Run Configuration > click the + button, and pick JSR45 Compatible Server, and then in the dialog that opens, select Remote, and set server host and port. Setting Application Server: Generic should work fine.

  2. Make sure you set the correct port in Startup/Configuration > Debug.

  3. Open the module settings (F3 on the project folder), and add a Web Facet under Facets, and under Web Resource Directories specify your JSP root folder.

  4. Click the Configuration... button, and select the folders with the beans, classes and libraries that your JSPs depend on.

Now JSP breakpoints should work, provided that you started your server with the proper debug arguments.

FROM:https://stackoverflow.com/questions/33739/jsp-debugging-in-intellij-idea

但是很多时候进行这种配置会很复杂,我们想要的是通过远程attach上去,并能调试JSP,此时就需要两点:

  1. 让JSP在编译时生成SMAP文件
  2. 本地能实时获取该文件,且调试器要能识别该文件

很久没碰Java Web了懒得看,等以后遇到了再研究具体怎么实施吧...

参考

  1. JVM源码分析之javaagent原理完全解读--寒泉子
  2. 字节码增强技术探索--赵泽恩
  3. Java 动态调试技术原理及实践--胡健