zoukankan      html  css  js  c++  java
  • @Java Web 程序员,我们一起给程序开个后门吧:让你在保留现场,服务不重启的情况下,执行我们的调试代码

    一、前言

     这篇算是类加载器的实战第五篇,前面几篇在这里,后续会持续写这方面的一些东西。

    实战分析Tomcat的类加载器结构(使用Eclipse MAT验证)

    还是Tomcat,关于类加载器的趣味实验

      

    进入正文,不知道你有没有这样的时候,在线上或者测试环境,报了个bug。这个 bug 可能是:
    • 从数据库、redis取了些数据,做了一些运算后,没抛异常,但是就是结果不对
    • 抛了个空指针异常,但是看代码,感觉没问题,是取出来就是空,还是中间什么函数把它改坏了
    • 发现导致一个bug的原因是用了JVM缓存,但是怎么清理呢?难道重启? 
    • redis 数据不对,能不能悄咪咪重新拉一下
    • 好想把某个全局变量打出来看一下?好想执行一个数据库查询,看看他么的结果对不对?
    • 。。。

     

    哎,程序员的世界,从来没有容易二字。 说实话,我们这次要开的后门就是做上面这些事情的,我刚鼓捣出这个时,我感觉这个还挺shock,为啥大佬们不去弄呢,后来我偶然想到,在 周志明大佬的那本 《Java 虚拟机:JVM高级特性与最佳实践》书里,提到过类似的解决思路。就在书的 9.3 节,如下图,这里就提到了类似的需求,就是要在不停服务情况下,动态执行代码,方案其实一直都有:将自己的调试代码写到JSP里,丢到服务器上,然后访问该JSP。

    我们要做的事情,其实有点类似JSP,比它好的地方在于:不用把文件手动丢到服务器上,直接上传class就行了。 也正是因为这次的折腾,我才知道,JSP原来还是能做很多事情的。但是笔者在毕业时,JSP应用基本就很少了,大学学了点皮毛而已,工作后更是没用到,但它的类加载器的思想还是值得我们学习的。

    二、大体思路与展示

    1、思路

    我们的目标是,针对一个 spring mvc 开发的部署在tomcat 上的 war 包应用,不重启的情况下,动态执行一些我们的调试代码,调试代码中,只要是原项目能用的东西,我们都可以用。具体的方式是,在项目中 增加一个Controller,该Controller 的接口,主要是接收客户端传过来的调试类的 class 文件,或者去指定的 url 加载调试类的 class,然后用自定义类加载器加载该 class,new出对象,并执行我们指定的方法。

    下面我先简单介绍下演示项目:

    应用是 spring MVC + spring(演示用,就没有db层),内部有一个测试用的 Controller:

     1 // TestController.java
     2 
     3 package com.remotedebug.controller;
     4 
     5 import com.remotedebug.service.IRedisCacheService;
     6 import org.springframework.beans.factory.annotation.Autowired;
     7 import org.springframework.web.bind.annotation.RequestMapping;
     8 import org.springframework.web.bind.annotation.RequestParam;
     9 import org.springframework.web.bind.annotation.RestController;
    10 
    11 /**
    12  * desc:
    13  * 测试接口,模拟从redis中获取缓存。当然,实际场景下,看缓存可以直接用工具的,这里就是举个栗子
    14  * @author : caokunliang
    15  * creat_date: 2019/6/18 0018
    16  * creat_time: 10:13
    17  **/
    18 @RestController
    19 public class TestController {
    20 
    21     @Autowired
    22     private IRedisCacheService iRedisCacheService;
    23 
    24     /**
    25      * 缓存获取接口
    26      * @param cacheKey
    27      */
    28     @RequestMapping("getCache.do")
    29     public String getCache(@RequestParam String  cacheKey){
    30         String value = iRedisCacheService.getCache(cacheKey);
    31         System.out.println(value);
    32 
    33         return value;
    34     }
    35 }
     1 // IRedisCacheServiceImpl.java
     2 package com.remotedebug.service.impl;
     3 
     4 import com.remotedebug.service.IRedisCacheService;
     5 import lombok.extern.slf4j.Slf4j;
     6 import org.springframework.stereotype.Service;
     7 
     8 import java.util.List;
     9 
    10 /**
    11  * desc:
    12  *
    13  * @author : caokunliang
    14  * creat_date: 2019/6/18 0018
    15  * creat_time: 10:17
    16  **/
    17 @Service
    18 @Slf4j
    19 public class IRedisCacheServiceImpl implements IRedisCacheService {
    20 
    21     @Override
    22     public String getCache(String cacheKey) {
    23         String target = null;
    24         // ----------------------前面有复杂逻辑--------------------------
    25         String count = getCount(cacheKey);
    26         // ----------------------后面有复杂逻辑,包括对 count 进行修改--------------------------
    27         if (Integer.parseInt(count) > 1){
    28             target = "abc";
    29         }else {
    30             // 一些业务逻辑,但是忘记给 target 赋值
    31             // .....
    32         }
    33 
    34         return target.trim();
    35     }
    36 
    37     @Override
    38     public String getCount(String cacheKey){
    39         // 假设是从redis 读取缓存,这里简单起见,假设value的值就是cacheKey
    40         return cacheKey;
    41     }
    42 }

    注意上面的实现类,getCache 方法,就是简单地去调用了 getCount 方法,然后做了一些复杂计算,在 else 分支,我们没给  target  赋值,所以 在 34 行调用 target.trim 时会抛NPE。我们这时候排查问题时,如果能够调用 getCount 看到返回的值是多少,就好了!

    知道了getCount 返回值,我们就可以接着看到底是返回的值有问题,还是是因为后面的逻辑有问题了。 常规情况下,我们是没办法的,只能肉眼看了,或者本地调试,但本地调试,取到的数据又不是真实环境的,很可能不能复现。

     

    我们现在就可以写一段下面这样的代码,放到服务器上执行,就可以将我们需要的信息打出来了:

    import com.remotedebug.service.IRedisCacheService;
    import com.remotedebug.utils.SpringContextUtils;
    import lombok.extern.slf4j.Slf4j;
    
    
    @Slf4j
    public class RemoteDebugTest {
        public void debug(){
            IRedisCacheService bean = SpringContextUtils.getBean(IRedisCacheService.class);
            String value = bean.getCount("user.count.userIdxxx");
    
            log.info("value:{}", value );
        }
    
        public static void main(String[] args) {
            new RemoteDebugTest().debug();
        }
    }

    ps:这里的 SpringContextUtils 只是一个简单的工具类,spring 容器会把自己赋值给  SpringContextUtils 中一个静态变量,方便我们在一些不被spring 管理的bean中获取 bean。

     

    那要怎么才能让服务器执行我们的  RemoteDebugTest  的 debug 方法呢,你可能想到了,我们再加一个 Controller 就行了:

      1 package com.remotedebug.controller;
      2 
      3 import com.remotedebug.utils.LocalFileSystemClassLoader;
      4 import com.remotedebug.utils.MyReflectionUtils;
      5 import com.remotedebug.utils.UploadFileStreamClassLoader;
      6 import lombok.extern.slf4j.Slf4j;
      7 import org.springframework.stereotype.Controller;
      8 import org.springframework.web.bind.annotation.RequestMapping;
      9 import org.springframework.web.bind.annotation.RequestParam;
     10 import org.springframework.web.bind.annotation.ResponseBody;
     11 import org.springframework.web.multipart.MultipartFile;
     12 
     13 import java.io.InputStream;
     14 
     15 /**
     16  * desc:
     17  * 原理:自定义类加载器,根据入参加载指定的调试类,调试类中需要引用webapp中的类,所以需要把webapp的类加载器作为parent传给自定义类加载器。
     18  * 这样就可以执行 调试类中的方法,调试类中可以访问 webapp中的类,所以通过 spring 容器的静态引用来获取spring中的bean,然后就可以执行很多业务方法了。
     19  * 比如获取系统的一些状态、执行service/dao bean中的方法并打印结果(如果方法是get类型的操作,则可以获取系统状态,或者模拟取redis/mysql库中的数据,如果
     20  * 为update类型的service 方法,则可以用来改变系统状态,在不用重启的情况下,进行一定程度的热修复。
     21  * @author : caokunliang
     22  * creat_date: 2018/10/19 0019
     23  * creat_time: 14:02
     24  **/
     25 @Controller
     26 @Slf4j
     27 public class RemoteDebugController {
     28 
     29 
     30     /**
     31      * 远程debug,读取参数中的class文件的路径,然后加载,并执行其中的方法
     32      */
     33     @RequestMapping("/remoteDebug.do")
     34     @ResponseBody
     35     public String remoteDebug(@RequestParam String className ,@RequestParam String filePath, @RequestParam String methodName) throws Exception {
     36         /**
     37          * 获取当前类加载器,当前类肯定是放在webapp的web-inf下的classes,这个类所以是由 webappclassloader 加载的,所以这里获取的就是这个
     38          */
     39         ClassLoader webappClassloader = this.getClass().getClassLoader();
     40         log.info("webappClassloader:{}",webappClassloader);
     41 
     42 
     43         /**
     44          * 用自定义类加载器,加载参数中指定的filePath的class文件,并执行其方法
     45          */
     46         log.info("开始执行:{}中的方法:{}",className,methodName);
     47         LocalFileSystemClassLoader localFileSystemClassLoader = new LocalFileSystemClassLoader(filePath, className, webappClassloader);
     48         Class<?> myDebugClass = localFileSystemClassLoader.loadClass(className);
     49         MyReflectionUtils.invokeMethodOfClass(myDebugClass, methodName);
     50 
     51         log.info("结束执行:{}中的方法:{}",className,methodName);
     52 
     53         return "success";
     54 
     55     }
     56 
     57 
     58     /**
     59      * 远程debug,读取参数中的class文件的路径,然后加载,并执行其中的方法
     60      */
     61     @RequestMapping("/remoteDebugByUploadFile.do")
     62     @ResponseBody
     63     public String remoteDebugByUploadFile(@RequestParam String className, @RequestParam String methodName, MultipartFile file) throws Exception {
     64         if (className == null || file == null || methodName == null) {
     65             throw new RuntimeException("className,file,methodName must be set");
     66         }
     67 
     68         /**
     69          * 获取当前类加载器,当前类肯定是放在webapp的web-inf下的classes,这个类所以是由 webappclassloader 加载的,所以这里获取的就是这个
     70          */
     71         ClassLoader webappClassloader = this.getClass().getClassLoader();
     72         log.info("webappClassloader:{}",webappClassloader);
     73 
     74         /**
     75          * 用自定义类加载器,加载参数中指定的class文件,并执行其方法
     76          */
     77         log.info("开始执行:{}中的方法:{}",className,methodName);
     78         InputStream inputStream = file.getInputStream();
     79         UploadFileStreamClassLoader myClassLoader = new UploadFileStreamClassLoader(inputStream, className, webappClassloader);
     80         Class<?> myDebugClass = myClassLoader.loadClass(className);
     81         MyReflectionUtils.invokeMethodOfClass(myDebugClass, methodName);
     82         log.info("结束执行:{}中的方法:{}",className,methodName);
     83 
     84 
     85         return "success";
     86 
     87     }
     88 
     89 
     90     /**
     91      * 远程debug,读取参数中url指定的class文件的路径,然后加载,并执行其中的方法
     92      */
     93     @RequestMapping("/remoteDebugByURL.do")
     94     @ResponseBody
     95     public String remoteDebugByURL(@RequestParam String className,@RequestParam String url, @RequestParam String methodName) throws Exception {
     96         if (className == null || url == null || methodName == null) {
     97             throw new RuntimeException("className,url,methodName must be set");
     98         }
     99 
    100         /**
    101          * 获取当前类加载器,当前类肯定是放在webapp的web-inf下的classes,这个类所以是由 webappclassloader 加载的,所以这里获取的就是这个
    102          */
    103         ClassLoader webappClassloader = this.getClass().getClassLoader();
    104         log.info("webappClassloader:{}",webappClassloader);
    105 
    106         /**
    107          * 用自定义类加载器,加载参数中指定的class文件,并执行其方法
    108          */
    109         log.info("开始执行:{}中的方法:{}",className,methodName);
    110         UploadFileStreamClassLoader myClassLoader = new UploadFileStreamClassLoader(url, className, webappClassloader);
    111         Class<?> myDebugClass = myClassLoader.loadClass(className);
    112         MyReflectionUtils.invokeMethodOfClass(myDebugClass, methodName);
    113         log.info("结束执行:{}中的方法:{}",className,methodName);
    114 
    115 
    116         return "success";
    117     }
    118 }
    View Code

    在这个 Controller 中,一共提供了三种方式,先说最直接的,就是通过上传 class 文件,这个很简单,只要有一个接口工具(如 postman)就可以。 Controller 中会 用自定义类加载器,去加载 文件流 代表的class,然后 new出对象,调用方法就行了。

    2.效果展示

    我的应用部署在 192.168.19.13上,Tomcat 端口为 8081,如下:

    [root@localhost apache-tomcat-8.0.41]# ll webapps/
    total 9336
    drwxr-xr-x. 14 root root    4096 Jun 19 11:39 docs
    drwxr-xr-x.  6 root root    4096 Jun 19 11:39 examples
    drwxr-xr-x.  5 root root    4096 Jun 19 11:39 host-manager
    drwxr-xr-x.  5 root root    4096 Jun 19 11:39 manager
    drwxr-xr-x.  4 root root    4096 Jun 19 13:48 remotedebug
    -rw-r--r--.  1 root root 9531510 Jun 19 13:47 remotedebug.war
    drwxr-xr-x.  3 root root    4096 Jun 19 11:39 ROOT

    我们在本地写好一个测试文件,(可以直接在 工程 里面写,这样才方便引用工程的类,不然还要自己敲 import 路径,那也太傻了),写好后,右键 执行下 main,触发编译操作。

    执行main,肯定会报错,这是不用说的,但我们只需要 class 而已:

    我们去 target 目录下,找到编译出来的 class,然后用 接口工具调用,如下:

    下面我们看看执行结果:

     然后我再改下测试类的debug方法:

        public void debug(){
            IRedisCacheService bean = SpringContextUtils.getBean(IRedisCacheService.class);
            String value = bean.getCount("123456789");
    
            log.info("value:{}", value );
        }

    再次执行:

    三、源码解析

    代码我放在交友网站了,欢迎fork。 

    https://github.com/cctvckl/remotedebug

    类结构如下:

    我们重点分析 remoteDebugByUploadFile :

     1 /**
     2      * 远程debug,读取参数中的class文件的路径,然后加载,并执行其中的方法
     3      */
     4     @RequestMapping("/remoteDebugByUploadFile.do")
     5     @ResponseBody
     6     public String remoteDebugByUploadFile(@RequestParam String className, @RequestParam String methodName, MultipartFile file) throws Exception {
     7         if (className == null || file == null || methodName == null) {
     8             throw new RuntimeException("className,file,methodName must be set");
     9         }
    10 
    11         /**
    12          * 获取当前类加载器,当前类肯定是放在webapp的web-inf下的classes,这个类所以是由 webappclassloader 加载的,所以这里获取的就是这个
    13          */
    14         ClassLoader webappClassloader = this.getClass().getClassLoader();
    15         log.info("webappClassloader:{}",webappClassloader);
    16 
    17         /**
    18          * 用自定义类加载器,加载参数中指定的class文件,并执行其方法
    19          */
    20         log.info("开始执行:{}中的方法:{}",className,methodName);
    21         InputStream inputStream = file.getInputStream();
    22         UploadFileStreamClassLoader myClassLoader = new UploadFileStreamClassLoader(inputStream, className, webappClassloader);
    23         Class<?> myDebugClass = myClassLoader.loadClass(className);
    24         MyReflectionUtils.invokeMethodOfClass(myDebugClass, methodName);
    25         log.info("结束执行:{}中的方法:{}",className,methodName);
    26 
    27 
    28         return "success";
    29 
    30     }

    其中,14,15行,主要获取当前的 webappclassloader 加载器,该加载器,通俗来讲,就是加载应用目录下的 web-inf/lib 和 web-inf/classes。 21 行,主要获取文件流; 22行,将流、要加载的class的类名、webappclassloader 作为参数,来生成 自定义的类加载器,其中 webappclassloader 将作为 我们自定义类加载器的 双亲加载器。 23行,用自定义类加载器加载我们的类; 24行,用加载类反射,生成对象,并执行 methodName指定的方法。

     

    重点代码在 UploadFileStreamClassLoader,我们看一下:

     1 package com.remotedebug.utils;
     2 
     3 import lombok.extern.slf4j.Slf4j;
     4 
     5 import java.io.ByteArrayOutputStream;
     6 import java.io.IOException;
     7 import java.io.InputStream;
     8 import java.io.UnsupportedEncodingException;
     9 import java.net.URL;
    10 import java.net.URLConnection;
    11 
    12 /**
    13  * desc:
    14  *
    15  * @author : caokunliang
    16  * creat_date: 2019/6/13 0013
    17  * creat_time: 10:19
    18  **/
    19 @Slf4j
    20 public class UploadFileStreamClassLoader extends ClassLoader {
    21     /**
    22      * 要加载的class的类名
    23      */
    24     private String className;
    25     /**
    26      * 要加载的调试class的流,可以通过客户端文件上传,也可以通过传递url来获取
    27      */
    28     private InputStream inputStream;
    29 
    30     /**
    31      *
    32      * @param inputStream 要加载的class 的文件流
    33      * @param className 类名
    34      * @param parentWebappClassLoader 父类加载器
    35      */
    36     public UploadFileStreamClassLoader(InputStream inputStream, String className, ClassLoader parentWebappClassLoader) {
    37         super(parentWebappClassLoader);
    38         this.className = className;
    39         this.inputStream = inputStream;
    40     }
    41 
    42 
    43 
    44     @Override
    45     protected Class<?> findClass(String name) throws ClassNotFoundException {
    46         byte[] data = getData();
    47         try {
    48             String s = new String(data, "utf-8");
    49 //            log.info("class content:{}",s);
    50 
    51         } catch (UnsupportedEncodingException e) {
    52             e.printStackTrace();
    53         }
    54         return defineClass(className,data,0,data.length);
    55     }
    56 
    57     private byte[] getData(){
    58         try {
    59             ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
    60             byte[] bytes = new byte[2048];
    61             int num = 0;
    62             while ((num = inputStream.read(bytes)) != -1){
    63                 byteArrayOutputStream.write(bytes, 0,num);
    64             }
    65 
    66             return byteArrayOutputStream.toByteArray();
    67         } catch (Exception e) {
    68             log.error("read stream failed.{}",e);
    69             throw new RuntimeException(e);
    70         }
    71     }
    72 }

    重点关注 46 行和 54行,46行主要是 从流中读取字节,转为字节数组; 54行主要是将字节数组代表的 class 加载到虚拟机中。另外,这里我们只覆盖了 findClass,是遵循双亲委派模型的,可以注意到,我们的测试类中,import了一些工程的类,比如:

     1 import com.remotedebug.service.IRedisCacheService;
     2 import com.remotedebug.utils.SpringContextUtils;
     3 import lombok.extern.slf4j.Slf4j;
     4 
     5 
     6 @Slf4j
     7 public class RemoteDebugTest {
     8     public void debug(){
     9         IRedisCacheService bean = SpringContextUtils.getBean(IRedisCacheService.class);
    10         String value = bean.getCount("123456789");
    11 
    12         log.info("value:{}", value );
    13     }
    14 
    15 }

    在加载这些类时,我们自定义的类加载器会先委托给父类加载器加载,而且,我们自定义的类加载器自身也加载不了这些类。这里有个关键点在于,我们为什么要把 应用的当前类加载器传入作为自定义加载器的父加载器呢,因为不同类加载器加载出来的 class,不能互转,所以我们必须用 同一个类加载器实例。 

    四、使用说明

    上面详细讲述了代码实现,这里,汇总一下,我们这边一共提供了三个接口:

    • remoteDebug.do 参数:@RequestParam String className ,@RequestParam String filePath, @RequestParam String methodName

    该接口,主要是从本地文件系统加载 filepath 指定的文件,所以这个接口,需要先把class 文件 上传到 服务器的某个路径下。

    • remoteDebugByUploadFile.do 参数: @RequestParam String className, @RequestParam String methodName, MultipartFile file

    该接口,可以直接上传class文件,要支持文件上传,需要进行以下配置:

        <bean
              class="org.springframework.web.multipart.commons.CommonsMultipartResolver">
            <property name="defaultEncoding" value="utf-8" />
            <property name="maxUploadSize" value="10485760000" />
            <property name="maxInMemorySize" value="40960" />
        </bean>

     

    同时,我这边的环境不知道为啥,还需要修改web.xml(我们其他项目中都没配这个,尴尬):

        <servlet>
            <servlet-name>DispatcherServlet</servlet-name>
            <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
            <init-param>
                <param-name>contextConfigLocation</param-name>
                <param-value>classpath*:/remotedebug-servlet.xml</param-value>
            </init-param>
            <load-on-startup>1</load-on-startup>
            <multipart-config>
                <location></location>
                <max-file-size>20848820</max-file-size>
                <max-request-size>418018841</max-request-size>
                <file-size-threshold>1048576</file-size-threshold>
            </multipart-config>
        </servlet>
    • remoteDebugByURL  @RequestParam String className,@RequestParam String url, @RequestParam String methodName

    该接口,可接受一个网络url,从url 去加载指定的class。

    五、总结

    一开始没想鼓捣这个,只是后边学了类加载器后,感觉是不是可以利用其来做点什么,于是想到了这个。因为热替换,是不可能在同一个类加载器实例中重复加载同一个类的,所以目前的热替换都是连根拔起,将类加载器一起换掉。在 web 应用中,web-inf下的classes和lib 都由唯一的一个类加载器加载,要替换其中的单个类,暂时没想到什么办法,但是我就感觉,可以用一个单独的类加载器去加载指定的一个位置(不同于 web-inf的位置),然后每次不用这个类,就把加载器一起丢了就行。然后一开始不知道可行,直到做出来试了后,发现确实没有问题,理论上也能解释。 后来,我在和同事讨论的过程中,感觉我做的这个东西,和JSP很像,然后又想到 在周志明的那本书里,好像有过类似的案例,去看了下,果然如此。。。

    哈哈,好吧,我还以为是很新鲜的东西,原来大佬早就玩过了,JSP更是出现了不知道多少年了,只是以前没怎么玩过JSP。 

    这个方法,也是适用于 spring boot 的,只是需要稍微修改一下,后续我再稍微改改,发个spring boot 的版本出来。类加载器这个东西还是挺有用,后续我会继续更新这方面的文章,包括 SPI、osgi(皮毛),各类框架中 类加载器的应用等,也希望和大家多多交流,共同交流才能一起进步嘛。

    源码再发一下,在这里: https://github.com/cctvckl/remotedebug

    不同于之前的文章,这次排版改了下,比如字体变大了,有些段落换了颜色,大家觉得比默认的好看还是不好看?

  • 相关阅读:
    内部类
    Tomcat 配置安装
    Eclipse转idea改设置
    MyEclipse增强代码补全
    06、自动挂载+超级守护进程+时间同步+tcpwrapper+软硬链接+日志管理
    05、ip划分+网络配置+虚拟化基础+基本路由
    04、rpm+yum+tar解压
    03、磁盘管理+swap分区创建+磁盘配额+自动挂载
    02、用户管理
    01、Linux基础命令
  • 原文地址:https://www.cnblogs.com/grey-wolf/p/11051427.html
Copyright © 2011-2022 走看看